/*
  Banded shaders — 2026-09-shader-token-field, as a standalone React component.

  GENERATED by tools/react/2026-09-shader-token-field.js from this folder's component.css,
  component.html and component.js. Do not edit it: change those and
  regenerate. The component is still those three files; this is an adapter
  that carries them into a React tree, and it depends on nothing but react.

  It plays the part demo.html plays: it loads the fonts (turn that off with
  loadFonts={false} if your app loads its own) and hands the component its
  tokens. Every prop lands on the component's own root as a custom property
  or modifier class component.css already defines; component.js watches that
  root and redraws.

    import BandedShaders from "./component.react.jsx";
    <BandedShaders palette="night" form="veins" />
    <BandedShaders tune warp={0.8} />

  `properties` describes every prop — type, label, range, default — for
  whatever panel a host builds, a Figma code layer's included. Palette and
  form are also accepted as their index in that list.
*/
import * as React from "react";

const CSS = "/*\n  2026-09-shader-token-field — self-contained, no globals, no imports.\n\n  An aesthetic study whose token block happens to be a set of shader uniforms.\n  Every value below is either a colour, a size, or a number `component.js`\n  hands to a fragment shader — and the second kind is the point. A GPU field\n  described as eight numbers is re-themeable from outside in exactly the way a\n  type scale is; described as a texture or as a literal in a JS object it is\n  not.\n\n  The block reads in four groups, and they are the four questions a field has\n  to answer: how coarse it is, how it moves, what colours gather in it, and how\n  light it is allowed to get. Override `--shader-token-field-scale` from a\n  parent and the whole field coarsens. Override `--shader-token-field-pool-a`\n  and both renderings change together, because both read this one declaration.\n\n  Both renderings: the stop list below draws the field with no script at all,\n  and the canvas draws it again on the GPU over the top. They are not a\n  component and its fallback — they are one recipe rendered two ways, and the\n  read-out says which of them you are looking at.\n\n  Everything the outside world says to this component, it says in the cascade,\n  including the two things that are not design decisions at all:\n  `--shader-token-field-run` stops the render loop (an animation-play-state\n  rule cannot reach a requestAnimationFrame loop) and\n  `--shader-token-field-buffer-scale` says how much the surface is being shrunk\n  on screen, so the drawing buffer is sized for the pixels that will actually\n  be shown. component.js watches this element's own `style` and `class`\n  attributes and re-reads the block when either moves; it never polls.\n*/\n\n.shader-token-field {\n  /* --- the field ------------------------------------------------------\n     How coarse it is and how far it is pushed around. Unitless on purpose:\n     these go to the GPU as floats, and `calc()` multiplies them happily.\n\n     --octaves is the one value that is clamped rather than trusted. GLSL\n     requires a compile-time loop bound, so the fbm loop runs to a constant 8\n     and breaks on this number — and component.js clamps it to 1..8 before it\n     gets there, because a token block is editable from outside by design and\n     an unbounded loop count is a hung GPU rather than a muddy field. */\n  --shader-token-field-scale: 2.4;        /* noise frequency, in field widths */\n  --shader-token-field-octaves: 4;        /* fbm depth, 1–8 */\n  --shader-token-field-warp: 0.42;        /* domain warp, 0–1 */\n  --shader-token-field-relief: 0.9;       /* directional lift, 0–1: see the band */\n  --shader-token-field-grain: 0.035;      /* per-pixel dither, added before the clamp */\n\n  /* Which generator draws it: 0 dunes, 1 veins, 2 terraces. The three\n     modifiers below set this AND restate the stop list, because a token the\n     GPU could read and the CSS could not would be the one place the two\n     renderings stopped being one declaration. */\n  --shader-token-field-form: 0;           /* 0 dunes, 1 veins, 2 terraces */\n\n  /* --- the motion -----------------------------------------------------\n     --flow is the only thing time does here: the noise is advected through\n     itself at this rate. The rest of the component's motion is CSS.\n\n     --run is not a design value. It is the pause: the index sends a card\n     `preview:pause` when the rail starts moving, and the CSS rule that\n     answers that for every other study — animation-play-state: paused — does\n     nothing at all to a render loop. So the loop answers a token instead, and\n     the preview writes it the same way it writes the buffer scale. */\n  --shader-token-field-flow: 0.05;        /* field widths per second */\n  --shader-token-field-settle: 900ms;     /* how fast the uniforms chase a change */\n  --shader-token-field-run: 1;            /* 0 stops the render loop */\n  --shader-token-field-gpu: 1;            /* 0 hands the surface to the stop list */\n  --shader-token-field-buffer-scale: 1;   /* × devicePixelRatio for the buffer */\n\n  /* --- the colours ----------------------------------------------------\n     Read by the stop list below AND parsed into vec3 uniforms, from this one\n     declaration, so the two renderings cannot drift apart. component.js\n     resolves each through a probe element rather than parsing it: an\n     unregistered custom property comes back from getComputedStyle as the\n     string it was authored as, so `oklch(...)` arrives as the seven\n     characters `oklch(`. Writing it to an element's `color` and reading the\n     computed value back hands the parsing to the engine.\n\n     All three sit inside the band declared below, which is what makes the\n     stop list safe as well as the shader: every sRGB blend of two colours has\n     each channel between theirs, and relative luminance rises with every\n     channel, so a blend of colours inside the band is inside the band. That\n     is the whole of the stop list, which is why it needs no clamp.\n\n     It is also why --relief exists. Blending alone cannot leave the band, so\n     a clamp over a blend is decoration — it can never fire. What leaves the\n     band is the shader's light, which is added rather than mixed. Measured\n     over the default plate: 25.7% of pixels sit outside the band before the\n     clamp and 0.00% after; on the night version, 8.7% and 0.00%. The two\n     versions exercise opposite ends of it — the light one overshoots the\n     ceiling (0.988 against 0.72), the night one bottoms out under the floor\n     (0.000 against 0.01) — which is the light and the shadow respectively. */\n  --shader-token-field-base: #d9d2c0;     /* Y 0.647 */\n  --shader-token-field-pool-a: #8fa38c;   /* Y 0.339 */\n  --shader-token-field-pool-b: #c39a62;   /* Y 0.356 */\n\n  /* --- the band -------------------------------------------------------\n     The contrast floor, declared rather than measured. The shader clamps\n     every fragment's WCAG relative luminance into this range before it\n     encodes, so the worst-case ratio against the ink is known from these two\n     numbers and does not depend on where the noise happened to land.\n\n     0.22 against this ink is 4.81:1 — above the 4.5 floor with a little\n     headroom, because the ratio models neither weight nor size and sitting on\n     the line is not the same as clearing it. Raising the floor lifts every\n     ratio on the field together; it also flattens the field, which is the\n     trade the number is making.\n\n     Only one ink goes on the field, and that is a consequence of this rather\n     than a style choice. A second, muted tier would need the ink itself to be\n     lighter, and against a floor of 0.22 anything lighter than about #2a2622\n     is already under 4.5:1 — so the field's hierarchy is size and tracking,\n     not colour. Muting into a clamped field is the one thing the clamp cannot\n     give you. */\n  --shader-token-field-lum-floor: 0.22;\n  --shader-token-field-lum-ceil: 0.72;\n  --shader-token-field-field-ink: #14120f;\n\n  /* --- the frame ------------------------------------------------------\n     The component's own surface, which is not the field: the read-out stands\n     on it, and its two text tiers are 15.9:1 and 6.8:1 — ordinary contrast,\n     because nothing is clamping anything here. */\n  --shader-token-field-surface: #f0ece2;\n  --shader-token-field-ink: #14120f;\n  --shader-token-field-ink-soft: #55504a;\n  --shader-token-field-line: rgba(20, 18, 15, 0.14);\n\n  --shader-token-field-font: \"Plus Jakarta Sans\", system-ui, sans-serif;\n  --shader-token-field-mono: \"JetBrains Mono\", ui-monospace, monospace;\n\n  --shader-token-field-width: 30rem;\n  --shader-token-field-pad: 1.25rem;\n  --shader-token-field-gap: 0.75rem;\n  --shader-token-field-radius: 0.875rem;\n  --shader-token-field-plate-aspect: 4 / 3;\n  --shader-token-field-band-aspect: 7 / 1;\n\n  --shader-token-field-kicker-size: 0.6875rem;\n  --shader-token-field-kicker-tracking: 0.18em;\n  --shader-token-field-line-size: 1.125rem;\n  --shader-token-field-row-size: 0.75rem;\n\n  --shader-token-field-lift: 2px;\n  --shader-token-field-transition: 260ms cubic-bezier(0.16, 1, 0.3, 1);\n  /* -------------------------------------------------------------------- */\n\n  display: grid;\n  gap: var(--shader-token-field-gap);\n  box-sizing: border-box;\n  width: var(--shader-token-field-width);\n  /* The width above is what it wants; this is what it will accept. */\n  max-width: 100%;\n  padding: var(--shader-token-field-pad);\n\n  background: var(--shader-token-field-surface);\n  color: var(--shader-token-field-ink);\n  /* The plate carries its own edge, in the same token the read-out's rules\n     take. Without it the surface is #f0ece2 on the index's #f3f2ef ground —\n     2.00 OK ΔE, which is an edge only in the sense that a number is not\n     zero — and the card had to be rested on its opposite variant to be seen\n     at all. An edge the component owns is the cheaper answer, and it is the\n     one the rest of the set already makes: `struck-tones` sits at 1.83 ΔE\n     against the same ground and reads as a plate, because it is bordered. */\n  border: 1px solid var(--shader-token-field-line);\n  border-radius: var(--shader-token-field-radius);\n\n  font-family: var(--shader-token-field-font);\n}\n\n/* --- the surfaces ------------------------------------------------------\n   Two of them, at two aspect ratios, and the reason is not shape variety: a\n   field that is a recipe rather than a picture has to resolve at whatever\n   proportion it is given. The band is the same three colours and the same\n   four numbers as the plate, 7:1 instead of 4:3.\n\n   The stop list is the background of the surface itself. Sized past the box\n   and positioned off-centre, so that --live can move each pool a different\n   distance: that is the whole of the no-script motion, and it is real motion\n   rather than a still frame standing in for one. */\n.shader-token-field__surface {\n  position: relative;\n  overflow: hidden;\n  border-radius: calc(var(--shader-token-field-radius) - var(--shader-token-field-pad) * 0.35);\n  max-width: 100%;\n\n  background-color: var(--shader-token-field-base);\n  background-image:\n    radial-gradient(68% 86% at 20% 26%, var(--shader-token-field-pool-a) 0%, transparent 62%),\n    radial-gradient(58% 76% at 78% 66%, var(--shader-token-field-pool-b) 0%, transparent 58%),\n    radial-gradient(92% 70% at 54% 100%, var(--shader-token-field-pool-a) 0%, transparent 70%);\n  background-repeat: no-repeat;\n  background-size: 150% 150%, 160% 150%, 150% 140%;\n  background-position: 26% 34%, 74% 62%, 50% 96%;\n\n  transition:\n    background-position 2400ms cubic-bezier(0.33, 0, 0.2, 1),\n    transform var(--shader-token-field-transition);\n}\n\n.shader-token-field__surface--plate { aspect-ratio: var(--shader-token-field-plate-aspect); }\n.shader-token-field__surface--band { aspect-ratio: var(--shader-token-field-band-aspect); }\n\n/* The GPU's copy, over the top. Transparent until component.js has drawn\n   into it, so the stop list is what shows while it has not — and what shows\n   for good if no script ever runs. */\n.shader-token-field__canvas {\n  position: absolute;\n  inset: 0;\n  display: block;\n  width: 100%;\n  height: 100%;\n  opacity: 0;\n  transition: opacity 420ms ease-out;\n}\n\n.shader-token-field__surface[data-shader-token-field-drawn] .shader-token-field__canvas {\n  opacity: 1;\n}\n\n/* --- what stands on the field -----------------------------------------\n   One ink, two sizes. See the band notes above for why there is no second\n   tier here. */\n.shader-token-field__inscription {\n  position: relative;\n  height: 100%;\n  box-sizing: border-box;\n  display: flex;\n  flex-direction: column;\n  justify-content: flex-end;\n  gap: 0.5rem;\n  padding: clamp(0.9rem, 4.5%, 1.4rem);\n  color: var(--shader-token-field-field-ink);\n}\n\n.shader-token-field__kicker {\n  margin: 0;\n  font-family: var(--shader-token-field-mono);\n  font-size: var(--shader-token-field-kicker-size);\n  letter-spacing: var(--shader-token-field-kicker-tracking);\n  text-transform: uppercase;\n}\n\n.shader-token-field__line {\n  margin: 0;\n  max-width: 34ch;\n  font-size: var(--shader-token-field-line-size);\n  font-weight: 500;\n  line-height: 1.45;\n  letter-spacing: -0.01em;\n}\n\n/* The rule under the kicker is the hover affordance, and it is CSS from end\n   to end — nothing about being looked at reaches the shader. */\n.shader-token-field__kicker::after {\n  content: \"\";\n  display: block;\n  height: 1px;\n  margin-top: 0.4rem;\n  width: 1.5rem;\n  background: currentColor;\n  transition: width var(--shader-token-field-transition);\n}\n\n/* --- the read-out ------------------------------------------------------\n   The token block, printed. component.js overwrites each value from\n   getComputedStyle, so the component states the numbers it is actually\n   running on rather than a copy of them; the text in the markup is what a\n   reader sees when no script runs, and it is the same set of defaults. */\n.shader-token-field__readout {\n  margin: 0.25rem 0 0;\n  display: grid;\n  grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr));\n  gap: 0.5rem 1rem;\n}\n\n.shader-token-field__row {\n  display: grid;\n  gap: 0.15rem;\n  padding-top: 0.45rem;\n  border-top: 1px solid var(--shader-token-field-line);\n  min-width: 0;\n}\n\n.shader-token-field__row dt {\n  font-family: var(--shader-token-field-mono);\n  font-size: 0.5625rem;\n  letter-spacing: 0.16em;\n  text-transform: uppercase;\n  color: var(--shader-token-field-ink-soft);\n}\n\n.shader-token-field__row dd {\n  margin: 0;\n  font-family: var(--shader-token-field-mono);\n  font-size: var(--shader-token-field-row-size);\n  font-variant-numeric: tabular-nums;\n  color: var(--shader-token-field-ink);\n  overflow-wrap: anywhere;\n}\n\n/* --- being looked at ---------------------------------------------------\n   Two separate things, deliberately. Hover is a pointer on the component and\n   is CSS only. --live is the state the index's read mark and the demo's own\n   wave put the component into: the field stirs, which means a larger warp and\n   a faster flow. component.js re-reads the block when the class lands and\n   eases the uniforms across --settle, so the GPU's copy arrives at the new\n   values on the same sort of curve the stop list transitions on rather than\n   snapping to them. */\n.shader-token-field:hover .shader-token-field__surface { transform: translateY(calc(var(--shader-token-field-lift) * -1)); }\n.shader-token-field:hover .shader-token-field__kicker::after { width: 3.25rem; }\n\n.shader-token-field--live {\n  --shader-token-field-warp: 0.72;\n  --shader-token-field-flow: 0.16;\n}\n\n.shader-token-field--live .shader-token-field__surface {\n  background-position: 36% 46%, 62% 50%, 44% 84%;\n}\n\n/* A finger cannot hover. The press reaches the same lift, and --live is the\n   state the thumbnail is put into anyway — on a coarse pointer the index\n   tells the card to perform rather than waiting for a hover that will never\n   fire. */\n@media (hover: none) {\n  .shader-token-field:active .shader-token-field__surface { transform: translateY(calc(var(--shader-token-field-lift) * -1)); }\n  .shader-token-field:active .shader-token-field__kicker::after { width: 3.25rem; }\n}\n\n/* Content that moves unasked is the whole of the preference. The stop list\n   stops transitioning and component.js draws one frame and never schedules\n   another — it reads the same query, because a render loop is not an\n   animation and no CSS rule can stop one. */\n@media (prefers-reduced-motion: reduce) {\n  .shader-token-field__surface,\n  .shader-token-field__canvas,\n  .shader-token-field__kicker::after { transition: none; }\n}\n\n/* --- the night version -------------------------------------------------\n   A whole second version of the component rather than a corrected palette,\n   which is why it is a modifier here and not something the preview hands in\n   from outside. The clamp inverts with it: in the light version the floor is\n   what guarantees the ratio, because the ink is dark and the darkest pixel is\n   the worst case; here the ink is light and the ceiling is. 0.14 against this\n   ink is 4.81:1 — the same number from the other end. */\n.shader-token-field--night {\n  --shader-token-field-base: #222623;     /* Y 0.018 */\n  --shader-token-field-pool-a: #3b5148;   /* Y 0.073 */\n  --shader-token-field-pool-b: #5a462e;   /* Y 0.067 */\n\n  /* The pools are LIGHTER than the base here, where in the light version\n     they are darker: a pool gathers colour, and on a dark ground colour\n     arrives as light. */\n  --shader-token-field-lum-floor: 0.01;\n  --shader-token-field-lum-ceil: 0.14;\n\n  /* More gain than the light version, for the reason given beside the light\n     in component.js: this band is a quarter as wide, so the same number\n     would barely move inside it. */\n  --shader-token-field-relief: 1.4;\n  --shader-token-field-field-ink: #f2efe8;\n\n  --shader-token-field-surface: #141614;\n  --shader-token-field-ink: #f2efe8;\n  --shader-token-field-ink-soft: #a8a39a;\n  --shader-token-field-line: rgba(242, 239, 232, 0.16);\n\n  --shader-token-field-grain: 0.032;\n}\n\n/* --- the other palettes -------------------------------------------------\n   Each is the same eleven declarations the root block makes, restated. Not a\n   patch over the default: a palette that inherited half its values would\n   leave the half it did not restate answering to whichever palette ran last,\n   and the band in particular has to move with the colours or the clamp is\n   holding the field to a range the new pools do not live in.\n\n   The band is the part to get right first, and it is chosen before the\n   colours rather than after. Every pool sits inside it, so the clamp is\n   holding the sheen and the shadow rather than fighting the palette, and the\n   worst case against the field ink clears 4.5:1 by construction — the ratio\n   is a property of the two band ends and the ink, not of where the noise\n   landed. Both of these leave more headroom than the two above them, which\n   were measured at 4.81:1. */\n\n/* Light, green. The counter-pool is a dry ochre rather than a second green:\n   two greens gather into one green, and the pools exist to be told apart. */\n.shader-token-field--verdant {\n  --shader-token-field-base: #cdd8b8;     /* Y 0.656 */\n  --shader-token-field-pool-a: #7d9a6b;   /* Y 0.285 */\n  --shader-token-field-pool-b: #b9a86a;   /* Y 0.394 */\n\n  --shader-token-field-lum-floor: 0.24;\n  --shader-token-field-lum-ceil: 0.70;\n  --shader-token-field-relief: 0.85;\n  --shader-token-field-field-ink: #10170f;   /* 5.04:1 against the floor */\n\n  --shader-token-field-surface: #eef0e6;\n  --shader-token-field-ink: #10170f;\n  --shader-token-field-ink-soft: #4d564a;\n  --shader-token-field-line: rgba(16, 23, 15, 0.14);\n\n  --shader-token-field-grain: 0.035;\n}\n\n/* Dark, warm. The second dark in the set, and it is here to show that the\n   night version is a palette rather than \"the dark one\" — the pools are a\n   burnt red and a bruised violet, which on a 0.012–0.13 band read as embers\n   in ash rather than as a tinted grey. */\n.shader-token-field--ember {\n  --shader-token-field-base: #2a1f1c;     /* Y 0.016 */\n  --shader-token-field-pool-a: #6b3a2c;   /* Y 0.063 */\n  --shader-token-field-pool-b: #4a3550;   /* Y 0.046 */\n\n  --shader-token-field-lum-floor: 0.012;\n  --shader-token-field-lum-ceil: 0.13;\n  --shader-token-field-relief: 1.5;\n  --shader-token-field-field-ink: #f6ece2;   /* 5.00:1 against the ceiling */\n\n  --shader-token-field-surface: #17120f;\n  --shader-token-field-ink: #f6ece2;\n  --shader-token-field-ink-soft: #ab9f94;\n  --shader-token-field-line: rgba(246, 236, 226, 0.16);\n\n  --shader-token-field-grain: 0.030;\n}\n\n/* --- the other forms ----------------------------------------------------\n   The palette says what the field is made of; the form says how it is put\n   together. They are independent on purpose — any palette above composes\n   with any form here — which is what a token block buys that a set of\n   finished pictures does not.\n\n   Each modifier states the form twice, because the study renders twice.\n   `--form` is the branch the fragment shader takes. The rule beneath it is\n   the stop list's answer to the same question, in the only vocabulary CSS\n   has for it: gradient geometry. They are not the same picture and were\n   never going to be — one is per-fragment noise and the other is three\n   ellipses — but they are the same ARGUMENT about the material, which is\n   the thing the two renderings are supposed to share. A form the GPU could\n   draw and the stop list could not would be the point where this study\n   stopped being one declaration read twice. */\n\n/* Filaments. Every octave is folded about its midpoint before it is summed,\n   so the creases branch instead of piling up. The stop list answers with\n   tighter, more eccentric ellipses and harder stops: the same move toward\n   something with edges. */\n.shader-token-field--veins {\n  --shader-token-field-form: 1;\n  --shader-token-field-scale: 2.0;\n  /* The fold halves the field's dynamic range — |2v-1| never reaches the\n     ends its input did — so the relief is raised to put the range back\n     rather than left to render a flatter version of the same palette. */\n  --shader-token-field-relief: 1.25;\n}\n\n.shader-token-field--veins .shader-token-field__surface {\n  background-image:\n    radial-gradient(38% 92% at 22% 30%, var(--shader-token-field-pool-a) 0%, transparent 46%),\n    radial-gradient(30% 86% at 74% 62%, var(--shader-token-field-pool-b) 0%, transparent 44%),\n    radial-gradient(52% 44% at 52% 98%, var(--shader-token-field-pool-a) 0%, transparent 52%);\n  background-size: 150% 150%, 160% 150%, 150% 140%;\n}\n\n/* Contours. The warped field is quantised into treads before anything reads\n   it, so the colour, the sheen and the shadow all land on the same steps.\n   The stop list answers with repeating-gradient bands over the pools —\n   CSS's own way of saying a continuous thing has been cut into levels. */\n.shader-token-field--terraces {\n  --shader-token-field-form: 2;\n  --shader-token-field-scale: 2.6;\n  --shader-token-field-warp: 0.30;\n}\n\n.shader-token-field--terraces .shader-token-field__surface {\n  background-image:\n    repeating-linear-gradient(\n      152deg,\n      rgba(0, 0, 0, 0.09) 0 6%,\n      rgba(255, 255, 255, 0.05) 6% 12%),\n    radial-gradient(64% 80% at 22% 28%, var(--shader-token-field-pool-a) 0%, transparent 60%),\n    radial-gradient(56% 72% at 76% 64%, var(--shader-token-field-pool-b) 0%, transparent 58%),\n    radial-gradient(90% 68% at 54% 100%, var(--shader-token-field-pool-a) 0%, transparent 70%);\n  background-size: 100% 100%, 150% 150%, 160% 150%, 150% 140%;\n  background-position: 0 0, 26% 34%, 74% 62%, 50% 96%;\n}\n";
const MARKUP = "<section class=\"shader-token-field\">\n\n  <div class=\"shader-token-field__surface shader-token-field__surface--plate\" data-shader-token-field-surface>\n    <canvas class=\"shader-token-field__canvas\" data-shader-token-field-canvas aria-hidden=\"true\"></canvas>\n    <div class=\"shader-token-field__inscription\">\n      <p class=\"shader-token-field__kicker\">Luminance band</p>\n      <p class=\"shader-token-field__line\">\n        The field cannot get light enough, or dark enough, to lose\n        this text.\n      </p>\n    </div>\n  </div>\n\n  <div class=\"shader-token-field__surface shader-token-field__surface--band\" data-shader-token-field-surface>\n    <canvas class=\"shader-token-field__canvas\" data-shader-token-field-canvas aria-hidden=\"true\"></canvas>\n  </div>\n\n  <dl class=\"shader-token-field__readout\">\n    <div class=\"shader-token-field__row\">\n      <dt>Frequency</dt>\n      <dd data-shader-token-field-value=\"scale\">2.4 &middot; 4 oct</dd>\n    </div>\n    <div class=\"shader-token-field__row\">\n      <dt>Warp</dt>\n      <dd data-shader-token-field-value=\"warp\">0.42</dd>\n    </div>\n    <div class=\"shader-token-field__row\">\n      <dt>Flow</dt>\n      <dd data-shader-token-field-value=\"flow\">0.05 / s</dd>\n    </div>\n    <div class=\"shader-token-field__row\">\n      <dt>Band</dt>\n      <dd data-shader-token-field-value=\"band\">0.22 &ndash; 0.72 Y</dd>\n    </div>\n    <div class=\"shader-token-field__row\">\n      <dt>Worst case</dt>\n      <dd data-shader-token-field-value=\"ratio\">4.81:1</dd>\n    </div>\n    <div class=\"shader-token-field__row\">\n      <dt>Drawn by</dt>\n      <dd data-shader-token-field-value=\"renderer\">Stop list</dd>\n    </div>\n  </dl>\n\n</section>";
const FONTS = "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400&family=Plus+Jakarta+Sans:wght@400;500;600&display=swap";
const P = "--shader-token-field-";

/* ---- component.js, verbatim but for its boot ------------------------- */
/*
  2026-09-shader-token-field — no framework, no build step, no dependency.

  What this file adds, and the order it matters in:

    1. It reads the token block. Every uniform the shader runs on comes from
       getComputedStyle on the component's own root — none of it is written
       twice. Re-theme the component from outside and the GPU's copy changes
       with the stop list, which is the test CLAUDE.md sets for a property
       block, applied to something that is not a colour.

    2. It draws the field on the GPU. One WebGL2 context for the whole
       document, blitted into every surface, because a browser caps live
       contexts and kills the oldest without saying so — and this page alone
       wants six.

    3. It prints the read-out, including the worst-case contrast ratio, which
       is computed from the ink token and the clamp token rather than
       asserted.

  Without it the component is still the component: the stop list is the same
  recipe from the same tokens, it moves under --live on its own, and the
  read-out in the markup states the same defaults. Script enhances; it does
  not constitute.
*/

function createShaderTokenField() {
  'use strict';

  var P = '--shader-token-field-';
  var MAX_OCTAVES = 8;          /* the fbm loop's compile-time bound */
  var MAX_BUFFER_PX = 2200000;  /* a card is not worth four megapixels */

  /* ---------------------------------------------------------------- colour

     An unregistered custom property comes back from getComputedStyle as the
     string it was authored as, so `oklch(60% .1 150)` arrives as text and
     `parseFloat` gets nothing. Writing it to an element's `color` and reading
     the computed value back hands the parsing to the engine, which returns a
     resolved rgb()/color() function whatever went in — hex, named, hsl,
     oklch. One probe, reused, and only touched when a token changes. */
  var probe = null;

  function probeEl() {
    if (probe) return probe;
    probe = document.createElement('span');
    probe.setAttribute('aria-hidden', 'true');
    probe.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;' +
      'clip-path:inset(50%);pointer-events:none';
    document.body.appendChild(probe);
    return probe;
  }

  function toLinear(c) {
    return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
  }

  /* -> [r, g, b] in linear light, which is the space the clamp has to work
     in: WCAG relative luminance is a linear-light quantity, and clamping it
     in gamma space clamps the wrong number. */
  function readColour(value, fallback) {
    var el = probeEl();
    el.style.color = '';
    el.style.color = value;
    var resolved = getComputedStyle(el).color;
    var parts = resolved.match(/[-\d.]+(?:e[-+]?\d+)?/gi);
    if (!parts || parts.length < 3) return fallback;

    var srgb = resolved.indexOf('color(') === 0
      ? [+parts[0], +parts[1], +parts[2]]
      : [parts[0] / 255, parts[1] / 255, parts[2] / 255];

    var out = [];
    for (var i = 0; i < 3; i++) {
      var c = srgb[i];
      out.push(toLinear(c < 0 ? 0 : c > 1 ? 1 : c));
    }
    return out;
  }

  function luminance(lin) {
    return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
  }

  /* ----------------------------------------------------------------- tokens */

  function readTokens(root) {
    var s = getComputedStyle(root);

    function num(name, fallback) {
      var v = parseFloat(s.getPropertyValue(P + name));
      return isFinite(v) ? v : fallback;
    }

    function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }

    var floor = clamp(num('lum-floor', 0.22), 0, 1);
    var ceil = clamp(num('lum-ceil', 0.72), 0, 1);
    if (ceil < floor) ceil = floor;

    return {
      /* The field. Clamped rather than trusted: the block is editable from
         outside by design, and an octave count is a loop bound. */
      scale: clamp(num('scale', 2.4), 0.05, 40),
      octaves: Math.round(clamp(num('octaves', 4), 1, MAX_OCTAVES)),
      /* Which of the three generators draws it. Clamped like --octaves and
         for the same reason: it selects a branch in the shader, so a value
         outside the set has to become one inside it rather than whatever the
         GPU does with an unmatched int. */
      form: Math.round(clamp(num('form', 0), 0, 2)),
      warp: clamp(num('warp', 0.42), 0, 4),
      relief: clamp(num('relief', 0.9), 0, 3),
      grain: clamp(num('grain', 0.035), 0, 0.5),
      flow: clamp(num('flow', 0.05), 0, 8),

      base: readColour(s.getPropertyValue(P + 'base'), [0.65, 0.62, 0.52]),
      poolA: readColour(s.getPropertyValue(P + 'pool-a'), [0.29, 0.38, 0.28]),
      poolB: readColour(s.getPropertyValue(P + 'pool-b'), [0.53, 0.32, 0.13]),

      lumFloor: floor,
      lumCeil: ceil,
      ink: readColour(s.getPropertyValue(P + 'field-ink'), [0.006, 0.006, 0.005]),

      /* --gpu is a design value and the other two are not. --gpu picks which
         of the two renderings is on screen, which is a thing worth being able
         to ask for: the stop list is the recipe without the light, not a
         degraded copy of it. --run is the pause and --buffer-scale is the
         device-pixel factor, both written onto this element from outside. */
      run: num('run', 1) !== 0,
      gpu: num('gpu', 1) !== 0,
      bufferScale: clamp(num('buffer-scale', 1), 0.1, 4),
      settle: Math.max(1, num('settle', 900))
    };
  }

  /* The guarantee the band is making, stated as the number a reader would
     measure. Dark ink on a light field: the darkest pixel allowed is the
     worst case, so the floor decides it. Light ink on a dark field: the
     lightest is, so the ceiling does. */
  function worstRatio(t) {
    var ink = luminance(t.ink);
    var lo = Math.min(ink, t.lumFloor);
    var hi = Math.max(ink, t.lumCeil);
    if (ink > t.lumCeil) { lo = t.lumCeil; hi = ink; }
    else if (ink < t.lumFloor) { lo = ink; hi = t.lumFloor; }
    else { return 1; }
    return (hi + 0.05) / (lo + 0.05);
  }

  /* -------------------------------------------------------------------- GPU

     One context per document, shared by every surface on it. A browser caps
     live WebGL contexts — Chromium at sixteen — and drops the oldest with no
     warning, which on this site would be a card in the rail going blank for
     no visible reason: the rail is eight previews and growing, quick look
     opens a ninth, and this demo page alone holds six surfaces. So the
     drawing is done once, off screen, and handed to each canvas as an
     ImageBitmap.

     If any piece of that is missing the function returns null and nothing
     else happens: the stop list is already on screen and is already the
     component. */
  var gpu;

  var VERT =
    '#version 300 es\n' +
    'void main() {\n' +
    '  vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));\n' +
    '  gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n' +
    '}\n';

  var FRAG =
    '#version 300 es\n' +
    'precision highp float;\n' +
    'uniform vec2 u_res;\n' +
    'uniform float u_phase;\n' +
    'uniform vec3 u_base, u_poolA, u_poolB;\n' +
    'uniform float u_scale, u_warp, u_grain, u_relief;\n' +
    'uniform float u_floor, u_ceil;\n' +
    'uniform int u_octaves, u_form;\n' +
    'out vec4 fragColor;\n' +

    'float hash(vec2 p) {\n' +
    '  p = fract(p * vec2(123.34, 456.21));\n' +
    '  p += dot(p, p + 45.32);\n' +
    '  return fract(p.x * p.y);\n' +
    '}\n' +

    'float vnoise(vec2 p) {\n' +
    '  vec2 i = floor(p), f = fract(p);\n' +
    '  vec2 u = f * f * (3.0 - 2.0 * f);\n' +
    '  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n' +
    '             mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);\n' +
    '}\n' +

    /* The loop bound is a compile-time constant because GLSL requires one;
       the token decides where it breaks. */
    'float fbm(vec2 p) {\n' +
    '  float a = 0.5, sum = 0.0, norm = 0.0;\n' +
    '  for (int i = 0; i < ' + MAX_OCTAVES + '; i++) {\n' +
    '    if (i >= u_octaves) break;\n' +
    '    float v = vnoise(p);\n' +
    /* FORM_VEINS folds each octave about its midpoint before it is summed.
       The fold is what makes the difference: |2v-1| is creased at v = 0.5,
       so every octave contributes a crease, and summing creased octaves
       gives filaments that branch instead of clouds that pile up. Folding
       the SUM instead would crease once and leave the material smooth
       either side of it.

       The square is not decoration either. The fold alone is smoother than
       what it replaced — |2v-1| never reaches the ends its input did, so the
       sum lands in a narrower band around the middle and rendered flatter
       than the dunes it was meant to contrast with. Squaring pushes the
       low end down and leaves the crest where it is, which is what turns a
       fold into a filament. */
    '  if (u_form == 1) { v = 1.0 - abs(v * 2.0 - 1.0); v *= v; }\n' +
    '    sum += a * v; norm += a;\n' +
    '    p *= 2.02; a *= 0.5;\n' +
    '  }\n' +
    '  return sum / max(norm, 1e-5);\n' +
    '}\n' +

    /* FORM_TERRACES quantises the field into risers and treads. The tread is
       floor(), the riser is a smoothstep across the middle third of each
       fraction rather than a hard edge — a hard edge is a step function on a
       value that varies per fragment, which aliases into stair-stepped jaggies
       the moment the field is drawn below its layout size, and the rail draws
       it at 0.7. */
    'float terrace(float v, float n) {\n' +
    /* fbm of a [0,1] noise is an average, so it clusters around the middle
       and barely reaches the ends: quantising it raw spends most of the
       treads on values the field never takes, and the two or three it does
       reach are too far apart to read as contours. Stretching the middle
       half across the whole range first is what puts every tread in play. */
    '  float s = clamp((v - 0.25) * 2.0, 0.0, 1.0) * n;\n' +
    '  return (floor(s) + smoothstep(0.42, 0.58, fract(s))) / n;\n' +
    '}\n' +

    'void main() {\n' +
    /* Both axes over the same number, so the field is never stretched — and
       that number is the width, so the 7:1 band is a horizontal slice of the
       same material the 4:3 plate shows rather than a finer one. Over the
       height it would have been the same recipe at a seventh of the size,
       which reads as a different material standing next to itself. */
    '  vec2 p = (gl_FragCoord.xy / u_res.x) * u_scale;\n' +
    '  vec2 q = vec2(fbm(p + vec2(0.0, u_phase)),\n' +
    '                fbm(p + vec2(5.2, 1.3) - vec2(u_phase, 0.0)));\n' +
    '  float f = fbm(p + u_warp * 4.0 * q);\n' +
    '  float g = fbm(p * 1.7 + 11.0 + q);\n' +

    /* The terrace is taken here, on the warped field and before anything
       reads it, so the colour mix, the ridge and the trough all land on the
       same treads. Terracing after the colour would band the hue and leave
       the light continuous, which reads as a printing fault rather than as
       contours. */
    '  if (u_form == 2) { f = terrace(f, 7.0); g = terrace(g, 5.0); }\n' +

    /* Linear light throughout. The pools gather, the base is what is left. */
    '  vec3 col = u_base;\n' +
    '  col = mix(col, u_poolA, smoothstep(0.30, 0.78, f));\n' +
    '  col = mix(col, u_poolB, smoothstep(0.42, 0.95, g) * 0.75);\n' +

    /* The light, and it is the reason the clamp is not decoration. Every
       line above is a convex blend, and relative luminance is linear, so a
       blend of colours inside the band is provably inside it: a clamp over a
       blend can never fire. This is not a blend. A narrow sheen sits on the
       ridge of the warped field and a shadow gathers in its troughs, and
       both SCALE the colour rather than mixing into it — which is what makes
       the field read as lit instead of as two colours poured together, and
       what walks it out of the band at both ends.

       Scaling rather than adding, because adding light is adding white.
       Added at the strength this needs it swamps the chroma of anything
       dark: the night version came out as grey plateaus, its palette gone.
       A scale preserves chromaticity exactly, so the field is the same
       colours lit harder. --relief is the gain, and the two versions do not
       share a value — a dark band is narrower, so the same gain moves a
       smaller fraction of it. */
    '  float ridge = 1.0 - abs(f * 2.0 - 1.0);\n' +
    '  col *= 1.0 + pow(ridge, 6.0) * u_relief;\n' +
    '  col *= 1.0 - smoothstep(0.42, 0.0, f) * u_relief * 0.45;\n' +

    /* Grain goes in BEFORE the clamp, so it cannot push a pixel out of the
       band it is the clamp's job to hold. */
    '  col += (hash(gl_FragCoord.xy + fract(u_phase)) - 0.5) * u_grain;\n' +
    '  col = clamp(col, 0.0, 1.0);\n' +

    /* The clamp, and the two halves are not symmetrical.

       Raising: mix toward white by (target - Y) / (1 - Y). Luminance is
       linear and the weights sum to one, so the result IS the target
       exactly, and a convex combination with white cannot leave the gamut.
       Scaling up would have been the obvious move and it is wrong: it clips a
       channel at 1.0, and the clip lands the fragment back under the floor —
       the guarantee fails precisely on the pixels that needed it.

       Lowering: scale. That cannot clip, and it hits the target exactly for
       the same reason. */
    '  float Y = dot(col, vec3(0.2126, 0.7152, 0.0722));\n' +
    '  if (Y < u_floor) {\n' +
    '    col = mix(col, vec3(1.0), (u_floor - Y) / max(1.0 - Y, 1e-5));\n' +
    '  } else if (Y > u_ceil) {\n' +
    '    col *= u_ceil / max(Y, 1e-5);\n' +
    '  }\n' +
    '  col = clamp(col, 0.0, 1.0);\n' +

    '  vec3 srgb = mix(col * 12.92,\n' +
    '                  1.055 * pow(max(col, 1e-5), vec3(1.0 / 2.4)) - 0.055,\n' +
    '                  step(vec3(0.0031308), col));\n' +
    '  fragColor = vec4(srgb, 1.0);\n' +
    '}\n';

  function compile(gl, type, src) {
    var sh = gl.createShader(type);
    gl.shaderSource(sh, src);
    gl.compileShader(sh);
    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
      gl.deleteShader(sh);
      return null;
    }
    return sh;
  }

  function getGpu() {
    if (gpu !== undefined) return gpu;
    gpu = null;

    if (typeof OffscreenCanvas !== 'function') return gpu;

    var off;
    try { off = new OffscreenCanvas(2, 2); } catch (err) { return gpu; }
    if (typeof off.transferToImageBitmap !== 'function') return gpu;

    var gl = off.getContext('webgl2', {
      alpha: false, antialias: false, depth: false, stencil: false,
      preserveDrawingBuffer: false, powerPreference: 'low-power'
    });
    if (!gl) return gpu;

    var vs = compile(gl, gl.VERTEX_SHADER, VERT);
    var fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
    if (!vs || !fs) return gpu;

    var prog = gl.createProgram();
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return gpu;

    gl.useProgram(prog);
    var u = {};
    ['u_res', 'u_phase', 'u_base', 'u_poolA', 'u_poolB', 'u_scale', 'u_warp',
      'u_grain', 'u_relief', 'u_floor', 'u_ceil', 'u_octaves',
      'u_form'].forEach(function (name) {
      u[name] = gl.getUniformLocation(prog, name);
    });

    gpu = { canvas: off, gl: gl, u: u, w: 0, h: 0 };
    return gpu;
  }

  /* ---------------------------------------------------------------- a root */

  function setup(root) {
    var surfaces = Array.prototype.slice
      .call(root.querySelectorAll('[data-shader-token-field-surface]'))
      .map(function (el) {
        var canvas = el.querySelector('[data-shader-token-field-canvas]');
        return canvas ? { el: el, canvas: canvas, ctx: null, w: 0, h: 0 } : null;
      })
      .filter(Boolean);

    var values = {};
    Array.prototype.slice
      .call(root.querySelectorAll('[data-shader-token-field-value]'))
      .forEach(function (el) {
        values[el.getAttribute('data-shader-token-field-value')] = el;
      });

    var calm = window.matchMedia
      ? window.matchMedia('(prefers-reduced-motion: reduce)')
      : null;

    var target = readTokens(root);
    var cur = JSON.parse(JSON.stringify(target));
    var phase = 0;
    var frame = 0;
    var last = 0;
    var drawing = getGpu() !== null;

    /* --- the read-out ------------------------------------------------- */

    function fixed(v, n) { return v.toFixed(n); }

    function paintReadout() {
      var t = target;
      function set(key, text) { if (values[key]) values[key].textContent = text; }

      set('scale', fixed(t.scale, 1) + ' · ' + t.octaves + ' oct');
      set('warp', fixed(t.warp, 2) + ' \u00b7 ' + fixed(t.relief, 2));
      set('flow', fixed(t.flow, 2) + ' / s');
      set('band', fixed(t.lumFloor, 2) + ' – ' + fixed(t.lumCeil, 2) + ' Y');
      set('ratio', fixed(worstRatio(t), 2) + ':1');
      set('renderer', drawing && target.gpu ? 'Shader' : 'Stop list');
    }

    /* --- sizing --------------------------------------------------------

       The buffer is sized for the pixels that will actually be shown, which
       is not the same as the pixels this document is laid out in. A card lays
       its preview out at 480 and shows it at 336, so every device pixel the
       shader draws is 0.7 of one — and the grain, which is one hash per
       fragment, is the part that notices. --buffer-scale carries that factor
       in from the index; anywhere else it is 1 and this is just the device
       ratio. */
    function measure(surface) {
      var rect = surface.el.getBoundingClientRect();
      if (!rect.width || !rect.height) return false;

      var dpr = (window.devicePixelRatio || 1) * cur.bufferScale;
      var w = Math.max(1, Math.round(rect.width * dpr));
      var h = Math.max(1, Math.round(rect.height * dpr));

      var px = w * h;
      if (px > MAX_BUFFER_PX) {
        var k = Math.sqrt(MAX_BUFFER_PX / px);
        w = Math.max(1, Math.round(w * k));
        h = Math.max(1, Math.round(h * k));
      }

      if (w === surface.w && h === surface.h) return true;
      surface.w = w;
      surface.h = h;
      surface.canvas.width = w;
      surface.canvas.height = h;
      return true;
    }

    /* --- drawing -------------------------------------------------------

       Surfaces are drawn smallest-first so that the shared buffer is resized
       once per distinct size rather than once per surface: this page holds
       three plates and three bands, which is two reallocations a frame
       instead of six. */
    function draw() {
      var g = getGpu();
      if (!g) return;
      if (!target.gpu) return;

      var order = surfaces.filter(measure).sort(function (a, b) {
        return (a.w * a.h) - (b.w * b.h);
      });

      var gl = g.gl;

      for (var i = 0; i < order.length; i++) {
        var s = order[i];

        if (!s.ctx) {
          try { s.ctx = s.canvas.getContext('bitmaprenderer'); } catch (err) { s.ctx = null; }
          if (!s.ctx) continue;
        }

        if (g.w !== s.w || g.h !== s.h) {
          g.canvas.width = s.w;
          g.canvas.height = s.h;
          g.w = s.w;
          g.h = s.h;
          gl.viewport(0, 0, s.w, s.h);
        }

        gl.uniform2f(g.u.u_res, s.w, s.h);
        gl.uniform1f(g.u.u_phase, phase);
        gl.uniform3fv(g.u.u_base, cur.base);
        gl.uniform3fv(g.u.u_poolA, cur.poolA);
        gl.uniform3fv(g.u.u_poolB, cur.poolB);
        gl.uniform1f(g.u.u_scale, cur.scale);
        gl.uniform1f(g.u.u_warp, cur.warp);
        gl.uniform1f(g.u.u_grain, cur.grain);
        gl.uniform1f(g.u.u_relief, cur.relief);
        gl.uniform1f(g.u.u_floor, cur.lumFloor);
        gl.uniform1f(g.u.u_ceil, cur.lumCeil);
        gl.uniform1i(g.u.u_octaves, cur.octaves);
        gl.uniform1i(g.u.u_form, cur.form);

        gl.drawArrays(gl.TRIANGLES, 0, 3);

        s.ctx.transferFromImageBitmap(g.canvas.transferToImageBitmap());
        if (!s.el.hasAttribute('data-shader-token-field-drawn')) {
          s.el.setAttribute('data-shader-token-field-drawn', '');
        }
      }
    }

    /* --- the loop -------------------------------------------------------

       Uniforms chase their target rather than snapping to it, so a --live
       class or a theme change arrives on the GPU's copy the way the stop
       list's own transition arrives: --settle is the time constant.

       What is advected is a phase this accumulates, not the clock. Passing
       raw time would make a change of --flow rewrite where the field already
       is, and the field would jump at the moment it was asked to speed up. */
    var SCALARS = ['scale', 'warp', 'relief', 'grain', 'flow', 'lumFloor', 'lumCeil'];
    var COLOURS = ['base', 'poolA', 'poolB'];

    function chase(dt) {
      var k = 1 - Math.exp(-dt / (target.settle / 1000));
      var moved = false;

      SCALARS.forEach(function (key) {
        var d = target[key] - cur[key];
        if (Math.abs(d) > 1e-4) { cur[key] += d * k; moved = true; }
        else cur[key] = target[key];
      });

      COLOURS.forEach(function (key) {
        for (var i = 0; i < 3; i++) {
          var d = target[key][i] - cur[key][i];
          if (Math.abs(d) > 1e-4) { cur[key][i] += d * k; moved = true; }
          else cur[key][i] = target[key][i];
        }
      });

      /* Snapped, not chased. An octave count and a form are selectors — a
         loop bound and a branch — and there is no halfway between two of
         them to render, so easing them would hand the shader 1.4 octaves and
         a form that is neither. The scalars above are quantities and ease;
         these are choices and switch. */
      cur.octaves = target.octaves;
      cur.form = target.form;
      cur.bufferScale = target.bufferScale;
      return moved;
    }

    function tick(now) {
      frame = 0;
      if (!root.isConnected) { last = 0; return; }
      var dt = last ? Math.min((now - last) / 1000, 0.1) : 0.016;
      last = now;

      chase(dt);
      phase += dt * cur.flow;
      draw();
      schedule();
    }

    /* Two things stop the loop, and neither is a CSS rule, because no CSS
       rule can reach one. --run is the index's pause, arriving as a token on
       this element. Reduced motion is the reader's, and it means one frame
       and no more — not a slower field.

       And a root that has left the document stops for good. Nothing on this
       site ever removes one, but a host that does — any framework unmounting
       it — would otherwise leave the loop running on a detached node for the
       life of the page, and worse than running: getComputedStyle on a
       detached element returns empty strings, every token falls back to its
       default, and --run reads as 1 again, so even a pause written on the way
       out is undone by the read it triggers. */
    function schedule() {
      if (frame || !drawing) return;
      if (!root.isConnected) return;
      if (!target.run || !target.gpu) return;
      if (calm && calm.matches) return;
      frame = requestAnimationFrame(tick);
    }

    function stop() {
      if (frame) { cancelAnimationFrame(frame); frame = 0; }
      last = 0;
    }

    /* Handing the surface back to the stop list. The canvas keeps whatever it
       last drew — there is no way to un-draw it — so what is taken away is the
       attribute the stylesheet fades it in on. */
    function yieldSurfaces() {
      surfaces.forEach(function (s) {
        s.el.removeAttribute('data-shader-token-field-drawn');
      });
    }

    /* A still frame still has to be a correct one: when the loop is not
       running, a token change or a resize snaps the uniforms and redraws
       once, so a paused card is the current component rather than a stale
       picture of the last one. */
    function still() {
      chase(10);
      SCALARS.forEach(function (k) { cur[k] = target[k]; });
      COLOURS.forEach(function (k) { cur[k] = target[k].slice(); });
      draw();
    }

    function refresh() {
      if (!root.isConnected) { stop(); return; }
      target = readTokens(root);
      paintReadout();

      if (!target.gpu) {
        stop();
        yieldSurfaces();
      } else if (!target.run || (calm && calm.matches)) {
        stop();
        still();
      } else {
        last = 0;
        schedule();
      }
    }

    /* Everything the outside world says to this component, it says in the
       cascade: --live lands as a class, the pause and the buffer scale land
       as inline custom properties. One observer catches all three, and
       nothing is read per frame. */
    if (window.MutationObserver) {
      new MutationObserver(refresh).observe(root, {
        attributes: true,
        attributeFilter: ['style', 'class']
      });
    }

    if (window.ResizeObserver) {
      new ResizeObserver(function () {
        if (frame || !drawing) return;
        still();
      }).observe(root);
    }

    if (calm) {
      var follow = function () { refresh(); };
      if (calm.addEventListener) calm.addEventListener('change', follow);
      else if (calm.addListener) calm.addListener(follow);
    }

    document.addEventListener('visibilitychange', function () {
      if (document.hidden) stop();
      else { last = 0; schedule(); }
    });

    paintReadout();
    if (!drawing || !target.gpu) return;
    if (calm && calm.matches) still();
    else schedule();
  }

  return setup;
}

/* ---------------------------------------------------------------------- */

// One factory per document: component.js draws every root through a single
// shared WebGL context, which is the point of it.
let setupRoot = null;

const useIsoLayoutEffect =
  typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;

function ensureFonts() {
  if (document.querySelector("link[data-shader-token-field-fonts]")) return;
  const link = document.createElement("link");
  link.rel = "stylesheet";
  link.href = FONTS;
  link.setAttribute("data-shader-token-field-fonts", "");
  document.head.appendChild(link);
}

// A colour prop may be any CSS colour string, or 0–1 / 0–255 channels.
function cssColour(c) {
  if (!c) return null;
  if (typeof c === "string") return c;
  const k = (v) => Math.round((v > 1 ? v / 255 : v) * 255);
  const a = c.a === undefined ? 1 : c.a;
  return "rgb(" + k(c.r) + " " + k(c.g) + " " + k(c.b) + " / " + a + ")";
}

const TUNED = {
  scale: "scale", octaves: "octaves", warp: "warp", relief: "relief",
  grain: "grain", flow: "flow", lumFloor: "lum-floor", lumCeil: "lum-ceil",
  base: "base", poolA: "pool-a", poolB: "pool-b"
};
const COLOURS = { base: 1, poolA: 1, poolB: 1 };

// The study's modifiers, one of each axis at a time. The first entry is the
// bare class — field and dunes are what .shader-token-field is on its own.
const PALETTES = ["field", "night", "verdant", "ember"];
const FORMS = ["dunes", "veins", "terraces"];

function pick(v, list) {
  if (typeof v === "number") return list[v] || list[0];
  return list.indexOf(v) >= 0 ? v : list[0];
}

function apply(root, p) {
  const palette = pick(p.palette, PALETTES);
  const form = pick(p.form, FORMS);
  root.className = "shader-token-field"
    + (palette !== PALETTES[0] ? " shader-token-field--" + palette : "")
    + (form !== FORMS[0] ? " shader-token-field--" + form : "")
    + (p.live ? " shader-token-field--live" : "");

  const s = root.style;
  s.setProperty(P + "width", p.width);
  s.setProperty(P + "gpu", p.stopList ? "0" : "1");
  s.setProperty(P + "run", p.play ? "1" : "0");

  // Untuned, each modifier keeps its own values — a palette carries its own
  // colours and band, a form its own scale, warp and relief, and the base
  // class's numbers written over them would break both. Tuning writes all
  // eleven, so the panel is one token block.
  for (const key in TUNED) {
    const name = P + TUNED[key];
    const v = p[key];
    if (!p.tune || v === undefined || v === null || v === "") {
      s.removeProperty(name);
    } else if (COLOURS[key]) {
      const c = cssColour(v);
      if (c) s.setProperty(name, c); else s.removeProperty(name);
    } else {
      s.setProperty(name, String(v));
    }
  }

  const kicker = root.querySelector(".shader-token-field__kicker");
  const line = root.querySelector(".shader-token-field__line");
  if (kicker) kicker.textContent = p.kicker;
  if (line) line.textContent = p.line;
}

export const defaults = {
  palette: "field",
  form: "dunes",
  stopList: false,
  live: false,
  play: true,
  kicker: "Luminance band",
  line: "The field cannot get light enough, or dark enough, to lose this text.",
  width: "100%",
  loadFonts: true,
  tune: false,
  scale: 2.4, octaves: 4, warp: 0.42, relief: 0.9, grain: 0.035, flow: 0.05,
  lumFloor: 0.22, lumCeil: 0.72,
  base:  { r: 0.851, g: 0.824, b: 0.753, a: 1 },
  poolA: { r: 0.561, g: 0.639, b: 0.549, a: 1 },
  poolB: { r: 0.765, g: 0.604, b: 0.384, a: 1 }
};

export default function BandedShaders(props) {
  const p = { ...defaults, ...props };
  const host = React.useRef(null);
  const rootRef = React.useRef(null);

  useIsoLayoutEffect(() => {
    const el = host.current;
    el.innerHTML = MARKUP;
    if (p.loadFonts) ensureFonts();
    const root = el.querySelector(".shader-token-field");
    rootRef.current = root;
    apply(root, p);                      // tokens first, so the first read is right
    if (!setupRoot) setupRoot = createShaderTokenField();
    setupRoot(root);
    // --run is the component's own pause; writing it stops the loop.
    return () => { root.style.setProperty(P + "run", "0"); };
  }, []);

  React.useEffect(() => {
    if (rootRef.current) apply(rootRef.current, p);
  });

  return (
    <div className={props.className} style={props.style}>
      <style>{CSS}</style>
      <div ref={host} />
    </div>
  );
}

export const properties = {
  palette:  { type: "string", label: "Palette", options: PALETTES, defaultValue: defaults.palette },
  form:     { type: "string", label: "Form", options: FORMS, defaultValue: defaults.form },
  stopList: { type: "boolean", label: "Drawn by stop list", defaultValue: defaults.stopList },
  live:     { type: "boolean", label: "Live", defaultValue: defaults.live },
  play:     { type: "boolean", label: "Play", defaultValue: defaults.play },
  kicker:   { type: "string", label: "Kicker", defaultValue: defaults.kicker },
  line:     { type: "string", label: "Line", defaultValue: defaults.line },
  tune:     { type: "boolean", label: "Tune tokens", defaultValue: defaults.tune },
  scale:    { type: "number", label: "Scale", control: "slider", min: 0.5, max: 8, step: 0.1, defaultValue: defaults.scale },
  octaves:  { type: "number", label: "Octaves", control: "slider", min: 1, max: 8, step: 1, defaultValue: defaults.octaves },
  warp:     { type: "number", label: "Warp", control: "slider", min: 0, max: 1, step: 0.01, defaultValue: defaults.warp },
  relief:   { type: "number", label: "Relief", control: "slider", min: 0, max: 2, step: 0.05, defaultValue: defaults.relief },
  grain:    { type: "number", label: "Grain", control: "slider", min: 0, max: 0.2, step: 0.005, defaultValue: defaults.grain },
  flow:     { type: "number", label: "Flow", control: "slider", min: 0, max: 1, step: 0.01, defaultValue: defaults.flow },
  lumFloor: { type: "number", label: "Luminance floor", control: "slider", min: 0, max: 1, step: 0.01, defaultValue: defaults.lumFloor },
  lumCeil:  { type: "number", label: "Luminance ceiling", control: "slider", min: 0, max: 1, step: 0.01, defaultValue: defaults.lumCeil },
  base:     { type: "color", label: "Base", defaultValue: defaults.base },
  poolA:    { type: "color", label: "Pool A", defaultValue: defaults.poolA },
  poolB:    { type: "color", label: "Pool B", defaultValue: defaults.poolB }
};
