2026-09 · Aesthetic
A gradient field written as a token block. Eight numbers and three colours declare what the field is, and the same declaration is read twice — by a stop list that needs no script, and by a WebGL2 fragment shader on the GPU. Every fragment’s luminance is clamped into a declared band, so the contrast ratio under the type is a property of the tokens rather than of where the noise landed, and the read-out states it.
Default — the block as declared
Luminance band
No pixel behind this line is lighter than the ceiling or darker than the floor. The ratio under a letter is a property of the field, not of the pixel that happened to land there.
Live — the state the index’s read mark puts a card into: a wider warp and three times the flow, eased across --settle
Luminance band
No pixel behind this line is lighter than the ceiling or darker than the floor. The ratio under a letter is a property of the field, not of the pixel that happened to land there.
Night — a second version of the component, with the clamp inverted: here the ceiling guarantees the ratio, because the ink is the light thing
Luminance band
No pixel behind this line is lighter than the ceiling or darker than the floor. The ratio under a letter is a property of the field, not of the pixel that happened to land there.
A gradient field written as a token block. Eight numbers and three colours declare what the field is — how coarse, how far it is pushed around, how hard it is lit, and how light it is allowed to get — and the same declaration is read twice: once by a stop list that needs no script, and once by a WebGL2 fragment shader that draws the same recipe on the GPU. A plate, a band and a read-out are what it is shown on; the read-out prints the numbers the field is actually running on, including the contrast ratio the block guarantees.
--shader-token-field-scale, -warp,
-relief, -octaves, -grain,
-flow and the three colours are custom properties on
the root class, and component.js reads every one of
them with getComputedStyle before it touches a
uniform. Override --shader-token-field-pool-a from a
parent and both renderings change together, because both read that
one declaration. The colours need a probe element to get out: 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.
--octaves is the one value clamped rather than
trusted: GLSL wants a compile-time loop bound, so the fbm loop runs
to a constant 8 and breaks on the token. A block editable from
outside by design is a block someone can put 500 in, and an
unbounded loop count is a hung GPU rather than a muddy field.
Every fragment’s WCAG relative luminance is clamped into
--lum-floor/--lum-ceil before it is
encoded, so the worst-case ratio against the ink is a property of
the two tokens and not of where the noise landed: 0.22 against
#14120f is 4.81:1, and the read-out computes it rather
than asserting it. A clamp over the colour mixing alone would never
fire — those are convex blends, relative luminance is linear,
so a blend of colours inside the band is provably inside it, which
is also why the no-script stop list needs no clamp of its own. What
leaves the band is --relief, the sheen on the
field’s ridges and the shadow in its troughs. Measured over
the default plate: 25.7% of pixels sit outside the band before the
clamp and 0.00% after; on the night version, 8.7% and 0.00% —
the light version overshooting the ceiling and the night one
bottoming out under the floor, which is the sheen and the shadow
respectively. The
relief scales the colour rather than adding to it, which
was a correction — added light is added white, and at this
strength it swamped the chroma of anything dark, turning the night
field into grey plateaus with its palette gone. The clamp’s
two halves are asymmetric for a related reason: lifting a fragment
to the floor is a mix toward white by
(floor − Y) / (1 − Y), which lands exactly
on the floor and cannot leave the gamut, where the obvious scale-up
clips a channel at 1.0 and drops the fragment back under the floor
— the guarantee failing precisely on the pixels that needed
it.
A second, muted tier needs a lighter ink, and against a floor of
0.22 anything lighter than about #2a2622 is already
under 4.5:1. So the hierarchy on the field is size and tracking
— an 11px kicker at 0.18em over a 15px line at 500 —
and not colour. Muting into a clamped field is the one thing the
clamp cannot give you, and it is worth knowing before the palette
is chosen rather than after the type goes on. Off the field, on the
component’s own surface, the read-out has ordinary tiers at
15.9:1 and 6.8:1, because nothing is clamping anything there.
A browser caps live contexts — Chromium at sixteen —
and drops the oldest with no warning, which on the index would be a
card going blank for no visible reason: the rail is eight previews
and growing, quick look opens a ninth, and this page alone holds
six surfaces. So one OffscreenCanvas draws every
surface and hands each one an ImageBitmap, and the
draw list is sorted by buffer size so the shared buffer is
reallocated once per distinct size rather than once per surface
— two a frame on this page instead of six. Where
OffscreenCanvas, bitmaprenderer or WebGL2
is missing, nothing happens at all and the stop list stands, which
is not a fallback so much as the other half of the study: the two
are one recipe rendered twice, and the read-out’s last row
says which of them you are looking at.
Every other study on the index answers preview:pause
with one global rule —
animation-play-state: paused !important — and
that rule does exactly nothing to
requestAnimationFrame. So the loop answers
--shader-token-field-run instead, and
preview:scale arrives the same way as
--shader-token-field-buffer-scale, which sizes the
drawing buffer for the pixels that will actually be shown rather
than the ones this document is laid out in. Both are written as
inline custom properties from outside, --live arrives
as a class, and one MutationObserver on
style and class catches all three:
nothing is read per frame. What is advected is a phase the loop
accumulates, not the clock, so raising --flow speeds
the field up from where it is instead of jumping it.
notes.md — the full record, including what was tried and dropped