AGENTS.md@dither-kit · git:20261008.265d2f4 · 2026-10-08 · sha256 94b6ed70dca93007
AGENTS.md@dither-kit git:20261008.265d2f4A
Immutable. This exact content never changes and is served at /api/v1/blob/94b6ed70dca93007.
# dither-kit — the toolkit
## Purpose
Self-contained Vue 3 component library: charts, buttons, avatars, gradients and
images rendered through one ordered-dither (Bayer 4x4) engine. Interactive
surfaces use canvas; deterministic surfaces can use the dependency-free RGBA
compiler and a packaged image URL. This folder is the product; the `src/` app
is its showcase and editor.
## Ownership
- Owns every rendering primitive: palette seeds, Bayer matrix, bloom presets,
step-timing primitives, the sequence timeline algebra, the clock director,
the wipe engine (ordered-dither masks), the keyframe engine (container
units), container-query scales, chart roots/contexts, canvas painters,
and the public component set.
- Consumers import ONLY via `index.ts` (`@dither-kit` alias).
## Local Contracts
- `DitherCommand` is modal: Escape closes, Tab never leaves it (focus
returns to its input; the list is arrow-driven), focus restores to the
opener on close.
- Zero imports from `src/` — the kit must stay copy-out portable
(docs promise: "copy the folder, alias it"). Dependencies: vue, d3-scale,
d3-shape, tailwind classes only.
- `palette.ts` is the single source of color truth: 7 seeds, each resolving
fill/line/star hues. Swatch CSS vars in `src/app/styles.css` mirror it.
- `pixel.ts` owns BAYER4 and bloom presets; every dithered surface thresholds
against the same matrix.
- `timing.ts` is the pure temporal core: DOM-free and clock-free. `steps()` is
spec-locked to the css-easing-1 positions (jump-end/start/none/both),
`frameSteps` derives a `frame(fps)` cadence, `frameIndex` is the wall-clock
frame gate drivers paint through, and `cssSteps` serializes one config for
the CSS surface so JS and CSS can never disagree. Drivers pick cadence and
own the clock — the engine never reads time itself.
`tests/timing.spec.ts` holds the MDN step-graph parity table; change code
and table together.
- `sequence.ts` is the pure timeline algebra — the many-animations engine.
Four node kinds (`track` | `serial` | `parallel` | `stagger`) flatten once
via `planSequence` into scheduled tracks; `sampleSequence(plan, t)` maps any
moment to every track (`before`/`active`/`done` + eased `progress`, `raw`,
`cycle`). Clock-free like `timing.ts`; easing is the same `Easing` shape, so
`steps()` composes into a sequence. Stagger origins (`start`/`center`/`end`/
`edges`/index) live in the exported `staggerDelay`. Yoyo settling is
continuous with the active legs: odd `loop` ends at 1, even at 0 — never
jump a settled track. `Sequence.vue` is the shared driver: it snapshots
slot children at start (rebuild via `restartKey`), writes `--seq-p` (eased,
`toFixed(4)`) + `data-seq` per child with change-skipping, and owns an
rAF clock whose first frame is dt 0 and whose wake has no time jump —
`use-dither-background` semantics. IO gate and reduced-motion floor mirror
`AnimatedContent`; the `frameRate` gate mirrors the background runtime with
completion checked BEFORE the gate. `tests/sequence.spec.ts` (algebra) and
`tests/sequence-vue.spec.ts` (driver, manual-rAF fake clock — respect the
100ms dt cap when stepping fake time) pin both halves.
- `clock.ts` is the director — external ownership of time. Free-running,
every animated surface owns its own rAF loop on wall time. `seek(ms)`
directs them: the loops stand down and each surface paints exactly the
given moment, in any order (same seed + same time → same pixels);
`release()` hands time back. Surfaces subscribe with `onSeek` (a `null`
moment means released → resume through their own `wake`), request no
frames while `isDirected()`, and paint `directedTime()` when they start
or wake under direction. Directed semantics per surface:
`use-dither-background` sets `clock = ms/1000 · timeScale` (pure
renderers land on the exact frame) and passes `dt` = the step since the
last directed moment (simulations advance in capture order, so those
render with one worker); the chart canvases run the entrance from
composition time 0 and derive the sparkle tick from the moment;
`Sequence` samples its plan at the moment; spinner, skeleton, progress,
avatar, `CountUp` and `CurvedLoop` are pure functions of the moment;
`DecryptedText` runs its reveal at 60 frames per second of the moment with
a hashed glyph instead of `Math.random`. Pointer-driven motion (hover
lifts, cursors, `HoldAction`) stays free: a renderer has no input. CSS
keyframe and transition motion is not the clock's — HyperFrames seeks it
itself through WAAPI. `directFromHyperframes()` takes direction from
HyperFrames' `hf-seek` events (`detail.time` seconds): the contract the
Studio's video export and `src/pages/play` rely on. Pinned by
`tests/clock.spec.ts` and the directed case in `tests/sequence-vue.spec.ts`.
- `wipe.ts` is the wipe engine — ordered-dither reveal masks for any DOM.
`wipeStyle(progress, { cell, seed?, direction, band, width, height })`
turns a progress into CSS `mask-image/size/repeat/position` values: a
dissolve (`direction: "none"`) tiles an n·cell SVG whose cells show once
the threshold sweep passes them (one cell per step of the 8×8 Bayer
`BAYER8` / `bayerMatrix(n)`, or the kit's seeded 4×4 with `seed`); a
directional wipe (`right`/`left`/`down`/`up` = where the front travels)
is a box-sized SVG with a solid front and four dithered density bands
behind it (`band` cells wide), a dissolve until it knows the box. Pure
strings from pure numbers (cached by key): the same progress gives the
same mask, `null` at 1 (remove the mask), the 1×1 empty mask at 0.
`DitherReveal.vue` applies it to its slot: manual `progress` (a
timeline's — it runs no clock of its own), or a timed reveal
(`duration`/`delay`/`easing`, `restartKey` replays) on the IO-gated rAF
loop, the kit clock's moment when directed, shown at once under reduced
motion; `reverse` hides instead; `data-reveal` = hidden|playing|shown.
The Studio's reels cut between frames with it. Pinned by
`tests/wipe.spec.ts` and `tests/reveal-vue.spec.ts`.
- Browser contract: web platform only, every engine. The floor is Tailwind
v4's (Chrome 111, Safari 16.4, Firefox 128: `@layer`, `color-mix()` in
oklab, container queries and units, `:has()`, `inert`); anything newer
is feature-detected or degrades — `requestIdleCallback` (a timeout
fallback), `startViewTransition` (checked), WebGL2 → WebGL1 with
extensions (`gl.ts`, `world-gl.ts` leaves uvs unset there), `ResizeObserver`
and `IntersectionObserver` guarded (no gate → play), `document.fonts`
optional, `text-wrap`/`content-visibility`/`scrollbar-*` progressive,
masks through Vue's style prefixing (`DitherReveal` sets both spellings),
`-webkit-background-clip` beside the standard one. `lib.ts`'s `copyText`
is the one clipboard path: the async clipboard where it exists, else the
selection + `execCommand` route (plain http, older engines, automation),
returning whether it copied. No engine-specific APIs (`scrollIntoViewIfNeeded`,
`scheduler.postTask`, file pickers) anywhere.
- `keyframes.ts` + `DitherStage.vue` + `DitherLayer.vue` are the keyframe
engine in container units. `parseLength` turns px numbers and any CSS
length (`calc()` sums included) into unit terms, `lerpTerms` interpolates
term by term, `termsToCss` prints one unit plain or several as a
`calc()`, `termsToPx` resolves against a box (cq units from the box,
percentages by axis, viewport and font from an env); angles read in deg,
turn, rad or grad. A `KeyframeTrack` is keys over seconds or percentages
(`at`; unplaced keys spread evenly like CSS), `duration` (else the last
key's seconds, else 1), `delay`, `loop` (count or forever), `yoyo`, an
easing between keys that a key's own `easing` overrides for the segment
it starts (`EasingInput` or any `Easing` function, so `steps()`
quantizes); `compileTrack` resolves it once, `sampleKeyframes(track, t)`
gives state (before/active/done — `Infinity` is any track's settled
end), cycle, progress and every property: lengths (x, y, custom
lengths) as terms, numbers (angles, scales, opacity, custom numbers);
`keyframeTransform` prints the CSS transform with the units kept — the
browser measures the container — or in px against a box; `resolveSample`
gives every property as px/degrees (vertical names resolve percentages
against the height). `DitherStage` is a size query container
(`container-type: size`, `--cq-w/--cq-h`) owning one clock: the
visibility-gated rAF loop (dt capped, `frameRate` stop-motion, `speed`,
looping over `duration`), the kit clock's moment when directed, held
under reduced motion (layers then sit at their end); it provides `STAGE`
(time, box, reduced) and `data-stage` (playing/directed/paused/still).
`DitherLayer` samples its track at the stage's moment and writes
`transform` in the keyframes' own units, `opacity`, `--layer-p` and
`--layer-<name>` per property, `data-layer` state, and hands the slot
every property resolved to px/degrees plus progress, cycle, time, width,
height — the numbers a canvas painter needs; without a stage it holds
its first key. Pinned by `tests/keyframes.spec.ts` and
`tests/stage-vue.spec.ts` (fake rAF + ResizeObserver, directed seeks,
reduced motion).
- `containers.ts` + `DitherContainer.vue` are the container-query engine —
children answer to their OWN box, never the viewport (the responsive half
of the animation stack). The pure core resolves a content-box width
against a named `CqScale` with CSS range semantics (min inclusive / max
exclusive — a boundary belongs to the wider bucket; declaration order
irrelevant; active = highest matching min, `matches` = every overlapping
bucket, null/-1/[] when nothing matches or the width is non-finite), and
`quantize(value, step)` is the SPATIAL half of the step idea (`timing.ts`
quantizes time; step ≤ 0 is identity, output never negative). The wrapper
sets native `container-type: inline-size` + `container-name` (stylesheet
`@container` route), observes itself via ResizeObserver `contentRect`
(transform-safe, `use-chart-dimensions` precedent; silent without the
observer; a setTimeout(0) `clientWidth` guard fires only if nothing
reported — never overwrite a real observation with the zero read), and
publishes `data-cq` (active bucket) + `--cq-w`/`--cq-h` (content-box size,
raw or quantized together) + `--cq-i` (ordinal) + slot props
`{ width, height, size, index, matches }`. Its UNSCOPED style ships the
container-relative reference keyframes `dither-cq-traverse`
(translateX ±100cqw) and `dither-cq-rise` (translateY ±100cqh): motion
measured against the query container, small-viewport fallback when none —
never vw/vh, never the animated element's own box (`tests/cq-units.spec.ts`
guards the units and the viewport leak).
Unmount disconnects the observer and clears the timer (leak contract).
`tests/container.spec.ts` (boundaries, ordering, overlaps, quantize) and
`tests/container-vue.spec.ts` (observer, var/attr writes, slot state,
silent-observer fallback) pin both halves; the docs `v-slot` snippet rides
the `toSvelteCode` children-snippet rule (`tests/svelte-code.spec.ts`).
- Seed-generative contract: a `number` is a deterministic SEED everywhere a
visual input is accepted — `VariantInput` (texture via `textureFromSeed`),
`BloomInput`/`PixelBloomInput` (`bloomFromSeed`; pixel.ts mirrors the exact
PRNG+ranges, keep them in sync), `EasingInput` (`easingFromSeed`), and hue
colors. The dither MATRIX itself is seeded (`matrixFromSeed` /
`resolveMatrix`, mirrored as `pixelMatrixFromSeed`) — the threshold pattern
varies per seed. Luminance coefficients (alphaFloor/alphaRange/intensityLift)
live in `TextureConfig` and seed with the texture. The live-edge effect is
GENERATIVE, not a preset: `effectFromSeed` returns `EdgeEffectParams` (drift
x/y, gravity, twinkle, trail, spread, flow, burst) — a point in a continuous
motion space — and `glyphFromSeed` returns the particle SHAPE (a `Glyph`:
core + seeded rays → dot, plus, x, streak, asterisk). Seeds yield infinitely
many behaviors AND shapes (sparkle/rain/comet are just regions). ONE particle
loop stamps any glyph moving any way; add variety by widening the param
space or glyph generator, NEVER by adding a branch. The `effect` prop is a
NUMBER seed that pins the motion independent of the master seed (else master
seed, else a gentle default). Sparkle character (twinkle freq, star
brightness/burst, crosshair alpha) still seeds via `sparklesFromSeed` for the
crosshair; the master `seed` flows to the cartesian canvas through
`ChartContextValue.seed` so star positions AND render coefficients derive
from it. The ENTRANCE reveal is seeded (`revealFromSeed`): jitter=0 is a
clean sweep (optionally reversed), higher jitter dissolves the fill so it
develops out of order — half of all seeds stay a clean sweep.
Chart roots take a master `seed` prop deriving duration, delay,
easing, stagger, sparkle character, geometry, matrix, bloom (+ startAngle on
polar) with precedence: explicit prop > seed derivation > house default. All
seed fns live in `dither-paint.ts` (mulberry32, params clamped to usable
bands) — extend seeds there, never in per-component paint loops.
- `DitherSpinner` is generative like the charts: `spinnerFromSeed` samples three
axes — SHAPE (0 circle ring / 1 square box-ring / 2 bar; each cell gets a
path coord `t` walking that outline via `squareT` for squares), FLOW (0 sweep
comet / 1 pulse breathe / 2 travelling wave), and DETAIL (arc/taper/segments/
spokes/innerRatio). One seed → a rotating ring, a breathing square, dashes
racing a bar, a travelling-wave donut. ONE render loop resolves membership+t
by shape then brightness by flow — add variety by widening the axes, never by
branching per preset. Default (no seed) is a clean rotating circle arc.
- `FaultyTerminal` is a CRT glyph wall: `faulty-terminal.ts` lights a grid of
glyph cells with animated value-noise/fbm, then applies scanlines, glitch,
flicker, chromatic aberration, barrel curvature, tint and ordered dithering.
It is a WebGL-free reimplementation (canvas + Bayer only; `DitherShader` is
the one surface that uses WebGL, as a GLSL evaluator) — the `dither` prop
is the ordered-threshold intensity (0 smooth -> 1 hard 1-bit). Its root is
`relative h-full w-full` (self-sizing), NOT `absolute inset-0` like
DitherGradient, so it renders filled in Studio's generic widget renderer and
registers as an ordinary `COMPONENT_REGISTRY` entry instead of a bespoke kind;
pass `class="absolute inset-0"` to use it as a background layer.
- `Ferrofluid` is the same family: `ferrofluid.ts` fuses two fbm layers with a
smooth-max (metaball union tuned by `fluidity`), lights the iso-surface's
contour rim, tints it across the `colors` array by height, then shimmer-grains
and ordered-dithers it. Same WebGL-free, self-sizing, generic-registry rules
as FaultyTerminal; the pointer raises a magnetic spike eased by
`mouseDampening`, and `dpr` scales the backing resolution.
- `Aurora` (`aurora.ts`) is the same family: fbm drives a wavy curtain edge lit
into vertical rays and tinted across the `colors` ramp by width. All the
generative background surfaces (FaultyTerminal, Ferrofluid, Aurora, and the
ones that follow) share these rules: WebGL-free canvas + Bayer, a self-sizing
`relative h-full w-full` root, and an ordinary `COMPONENT_REGISTRY` entry.
- `world.ts` + `models.ts` + `world-gl.ts` + `DitherWorld.vue` are the 3D
surface. `models.ts` parses model files into a `World`: meshes (faces,
lines or points; positions in world space, or node space when animated;
palette indices for the material and, when the file colours faces or
vertices, per triangle — a world holds ≤ 255 colours, the rest snap),
nodes (animatable transforms with sampled tracks), directional lights,
the headlight flag, the first viewpoint, a bounding sphere at time 0 and
the cycle length. Formats: VRML97 / X3D classic and VRML 1.0 `.wrl` (one
lenient tokenizer + node grammar — DEF/USE, PROTO/EXTERNPROTO skipped,
ROUTEs collected, enums, bare children for 1.0's Separator state machine;
Transform/Group/Switch/LOD/Shape/Material/IndexedFaceSet (+ Color per
face or vertex)/IndexedTriangleSet/TriangleSet/IndexedLineSet/PointSet/
Box/Sphere/Cone/Cylinder/ElevationGrid/Viewpoint/DirectionalLight/
NavigationInfo; 1.0's Translation/Rotation/Scale/MatrixTransform/
Coordinate3/ShapeHints/Cube), X3D XML (`parseXml`, a small element +
attribute reader, mapped onto the same nodes by containerField), glTF 2.0
(`.glb` chunks or `.gltf` JSON with data: URIs; external buffers come
pre-fetched through `ParseOptions.resources`, listed by
`externalResources`; accessors with strides and normalized ints,
triangles/strips/fans/lines/points, baseColorFactor, doubleSided,
COLOR_0, node TRS or matrix, the FIRST animation's translation/rotation/
scale channels incl. STEP and CUBICSPLINE values), Wavefront OBJ (`v`/`f`
with slashes and negative indices, `l`, `p`, vertex colours, `usemtl`
groups coloured from the `mtllib` via `parseMtl`), STL (ASCII + binary,
two-sided, z-up swung to y-up by default), PLY (ASCII, binary little and
big endian; vertex and face colours, edges, point clouds) and OFF (face
colours). Animation: a VRML `TimeSensor` → `PositionInterpolator` /
`OrientationInterpolator` → `ROUTE … set_translation/rotation/scale`
chain (also X3D's `<ROUTE>`) makes that Transform a `WorldNode` whose
tracks hold keys in seconds (key × cycleInterval, loop, startTime);
everything static between nodes folds into the node's `pre` matrix and
static meshes stay baked. `world.ts` is the engine: column-major mat4 +
quaternion math (`composeTransform` = T·C·R·SR·S·-SR·-C), the VRML
primitives (y up, CCW outward), `addMesh` (bakes the matrix, reverses
rings for `ccw FALSE` or a mirroring matrix, fan-triangulates, keeps
polygon outline edges with their owning triangle, registers colours),
`sampleTrack` (lerp / step / shortest-path slerp, looping or clamped),
`nodeMatrices(world, t)` (cached per time), `posedPositions`,
`finishWorld`, `cameraOf` (orbit camera around the sphere: `yaw`/`pitch`
degrees, `zoom` 1 frames the sphere, eye never inside it, camera-space
lights) and the two-stage renderer: stage one fills a `WorldTarget`
(shade 0-1, depth from the eye, palette index + 1) — `rasterizeWorld` is
the CPU engine (edge-function rasterizer with a z-buffer, flat Lambert
from the headlight + the file's lights, back faces culled on solid
meshes, per-vertex fbm `meshGrain` interpolated into the shade) and
`world-gl.ts` (`createWorldGpu`) is the GPU engine: the same contract
through WebGL2 (WebGL1 with derivatives + uint indices), flat normals
from derivatives, the colour index as a vertex attribute (expanded
buffers for per-face colours), node poses as a per-mesh matrix uniform,
a 16-bit depth code read back into the target — on WebGL2 through its
own two-attachment framebuffer, so the texture coordinates come back too
(`packUv`'s encoding: 16-bit s, 16-bit t offset by one, 0 = no uvs);
WebGL1 leaves them unset. Stage two `paintTarget`
is shared and never knows the engine: fill mode thresholds the shade
against the Bayer cell (lit cells take the fill at full alpha, the rest
`shade` alpha), `material` does the same in the file's colour, `ramp`
(the kit's `sampleRgbGradient` palette engine) picks a band by lighting
and dithers between bands over an opaque silhouette with `dither` 0-1
blending to the smooth gradient, `fog` fades by depth, `texture` (a
RasterBuffer) wraps a raster onto the model through the target's uvs —
the texel under the cell (repeating), its alpha in the shade, meshes
without uvs keep the fill; then outlines
(`wire`, owning-triangle facing, never a fan's diagonals), line sets and
point sets draw depth-tested against the finished z-buffer. `paintWorld`
= CPU stage one + stage two; same world + time + view + style → same
bytes. `sampleWorld(seed)` writes the default content as real VRML97
text with its own TimeSensor/ROUTE animation (seed-generative like
everything else). The component loads `src` (fetch, format by name then
by sniffing, side files resolved relative to it) or inline `source`,
derives the initial pose from the file's Viewpoint when
`yaw`/`pitch`/`zoom` are unset, poses the file's animation at the kit
clock (`animate`, or a pinned `time`), picks the engine (`auto` = GPU
above ~40k triangles, CPU otherwise; a forced `gpu` that fails says why
and falls back), draws the bloom layer (`pixelBloomStyle`) in
`afterPaint`, orbits by pointer drag (`touch-action: pan-y`) and arrow
keys (focusable `role="img"`, `data-engine` says which engine drew), and
shows an honest note for loading/empty/error. `tests/world.spec.ts`,
`tests/vrml.spec.ts` and `tests/models.spec.ts` pin the engine, the
grammars and every format. Texture coordinates: `Geometry.uvs` (st per
point; (0, 0) is the texture's top-left) or per corner through
`faceUvs` — `addMesh` then bakes one vertex per (point, uv), so a seam
splits and equal corners share; the primitives carry theirs (box faces
whole and upright, sphere equirectangular, cylinder and cone sides
unrolled with planar caps, elevation by grid), the parsers read OBJ `vt`
(t flipped), glTF `TEXCOORD_0` (as is), VRML `TextureCoordinate` +
`texCoordIndex` (t flipped; X3D through the same builder) and PLY
`s`/`t`; `WorldMesh.uv` per vertex interpolates perspective-correct into
`WorldTarget.uv` (s = -1 where a mesh has none).
- The render graph: every surface on `use-dither-background` is a
`DitherSurface` — `raster()` (the last painted buffer), `pull(ms)`,
`version()`, `canvas()` — returned by the composable, exposed by
`DitherWorld`/`DitherShader` (`defineExpose({ surface })`) and reachable
from any kit canvas through `surfaceOf(canvas)`. `pull(ms)` is the edge
contract: under direction it paints the moment `ms` on demand when the
source has not painted it yet (a moment paints once — the clock's own
call then agrees), free-running it returns the latest raster; so a
consumer always reads a source at its own time stamp whatever order the
clock reaches them in, and a chain of surfaces seeks and renders as one.
Consumers: `DitherShader.channels` binds up to four sources as
`iChannel0..3` (a surface, a component instance, a kit canvas → its
surface; any other canvas, image or video → its current pixels, uploaded
upright with FLIP_Y, nearest-sampled, `iChannelResolution` set — all
through `gl.ts`'s `createChannels`), `DitherWorld.channels` skins the
model: without a shader the first channel is the texture (`rasterOf`
turns it into a raster — a surface's own at the moment, or an image,
canvas or video read through a 2D context at up to 512 px — and
`paintTarget`'s texture mode samples it per cell on either engine), and
`DitherWorld.shader` is a GLSL material over the finished target:
`wrapMaterial` wraps `mainMaterial(out vec4, in vec2)` (or a Shadertoy
`mainImage`) with `dk_cell/dk_shade/dk_depth/dk_covered/dk_color` over
the target packed by `packTarget` (R shade, G palette index + 1, B+A a
16-bit depth across the bounding sphere, empty cells far), the palette
as a 256x1 texture, and `dk_uv` (the cell's st, t flipped to GL's) /
`dk_textured` / `dk_texture` (iChannel0 at `dk_uv`) over the uv map
packed by `packUv` plus the material's own `iChannel0..3` (the
component's `channels`, uploaded after the three maps); `createWorldMaterial` runs it on the
component's context (shared with the GPU engine; each pass sets its own
GL state) and `paintMaterial` thresholds the readback's alpha against the
Bayer cell with the readback's rgb (zero stays clear, so a material may
ink outside the silhouette), then `drawOverlays` as usual. Pinned by
`tests/surface.spec.ts` (the pull contract under a fake 2D context) and
the material cases in `tests/world.spec.ts` / `tests/shader.spec.ts`.
- `gl.ts` is the kit's one piece of WebGL plumbing, shared by the GLSL
surface, the world's GPU engine and the material pass: `createGl` (an
offscreen WebGL2 or WebGL1 context, context-loss flag, `size`, `read`
back, `dispose`), `buildProgram` (the compiler's first line as the
error) and the channel binding: `ChannelInput` (a surface, a component
instance, a kit canvas → its surface; any canvas, image or video),
`bindChannel`, `createChannels` (the four textures of one program,
re-uploaded only when a surface painted, FLIP_Y upright,
`iChannelResolution` / `iChannelTime`) and `rasterOf` (the same input as
a raster for the CPU engines). The GPU is only ever an evaluator — every
surface still dithers on the CPU.
- `shader.ts` + `DitherShader.vue` are the GLSL surface. The pure half:
`wrapShader` builds a program around a user fragment shader — Shadertoy's
`mainImage` gets the Shadertoy uniforms plus the kit's (`iColor` the
component colour 0-1, `iSeed`, and `dk_bayer4(fragCoord)`, the 4x4 Bayer
threshold in closed form) and a `main` (GLSL ES 3.00 under WebGL2, 1.00
under WebGL1), a raw `main` compiles as written (default float precision
added when missing, `#version 300 es` honoured) — and
`ditherShaderPixels` turns the GPU readback (rows bottom-up) into the
raster: colour mode quantizes each channel to `levels` through the Bayer
cell (2 = the eight-colour look), mono thresholds luminance into the tint
with a `shade` floor, a `palette` ramp dithers luminance across its bands
(`sampleRgbGradient` for the smooth end of `dither`); the shader's alpha
carries. The component owns one `gl.ts` context at the cell resolution
(released on unmount), feeds `SHADER_UNIFORMS` (the Shadertoy set, the
kit's, and the glslsandbox / Book-of-Shaders aliases), reads back and
dithers each frame; `iTime` is the kit clock, so it seeks and renders
like every surface (HyperFrames' renderer has WebGL2 through SwiftShader
— verified by a real render). Without WebGL it shows "WebGL is not
available"; a compile error shows the compiler's first line.
`sampleShader(seed)` is the seeded default source. `tests/shader.spec.ts`
pins the pure half.
- `use-dither-background.ts` (`useDitherBackground`) is the single shared runtime
for that family: throttled rAF loop, backing buffer + upload, visibility gate,
resize, dpr, static/reduced-motion single frame, and the mount/restart/teardown
lifecycle. It returns the surface's `DitherSurface` (see the render
graph). `cell` is a number or a getter (a `cell` prop); `afterPaint`
runs after the upload with the canvas (bloom layers copy it). `paused` holds
the raster but never leaves it blank: the first frame, and the frame after
a restart, still paint before the loop stands down (so a paused surface
repaints when a `restart` source such as a drag changes). A still surface
(static mode, reduced motion) paints its one frame in `start()` and the
visibility wake never starts the loop behind it — it did once, and every
background animated under reduced motion as soon as it scrolled into view.
Its optional `frameRate` getter gates painting on
`timing.frameIndex` boundaries — between boundaries the raster is held
(stop-motion cadence, no upload), 0/undefined keeps the smooth ~30fps
throttle. Both cadence gates run BEFORE `measure()` — a held frame never
reads layout (gating the per-vsync `getBoundingClientRect`/`getContext`
cut script time 28% on three 8fps surfaces), and a boundary is consumed
only by a frame that actually paints.
EVERY background component exposes the matching `frameRate` prop
(default 0) and passes it through; the studio registry row and the docs API
table carry the same row — `scripts/frame-rate-codemod.ts` keeps all four
surfaces in step (strict anchors, idempotent, `--dry` to preview). A new
background is just an `engine.ts` `paint*` fn plus a thin `.vue`
that resolves props and passes a `render(buffer, clock, dt, elapsed)` callback —
never re-implement the loop per component. Per-frame extras stay in `render`:
page-load fade reads `elapsed`, eased pointers read `dt`.
- `noise.ts` is the single source for the 2D value-noise/fbm those surfaces use;
`sampleRgbGradient` in `palette.ts` is the single out-param colour-ramp
sampler they tint with. Add new generative backgrounds on top of both — never
re-derive hash/valueNoise/fbm or a per-pixel gradient lerp per component.
- Perf seams (measured on the docs page, 6x-throttled mobile):
`ScrollReveal` measures via `getBoundingClientRect` at most once per frame
(rAF-coalesced; its first measure lands before first paint — a direct
mount-time call measured the whole dirty document for ~2s), and
`DitherImage` defers its `src` fetch behind an IntersectionObserver
(400px rootMargin, eager fallback when the observer or element is missing) —
never pull the source at mount when the frame is offscreen.
- Loop visibility contract: self-scheduling rAF loops in the text/animation
family (CurvedLoop, ScrollVelocity, TextCursor, TextPressure,
VariableProximity, GhostCursor, BlobCursor, Antigravity) run through
`use-inview-loop.ts` — `start` only on the first IntersectionObserver hit,
`stop` when the host leaves, `start` idempotent, never an optimistic start
at mount (measured: ungated demos burned ~440 rAF/s offscreen on docs).
Without IO the loop is always-on — the old behaviour, which is what jsdom
tests get. A new looping component uses this composable, never a bare
mount-time `requestAnimationFrame` chain.
- Text animations (`GradientText`, `ShinyText`, `GlitchText`, `SplitText`,
`RotatingText`, `CountUp`, ...) are a separate family: pure DOM/CSS (or a small
rAF for counting), NOT canvas/Bayer. They still ship as `Dither*` exports,
register in `COMPONENT_REGISTRY`, and must honour `prefers-reduced-motion`
(CSS `@media` or `pixelPrefersReducedMotion`). Slot-based effects wrap arbitrary
text; char/number effects take a `text`/`to` prop. Docs live in `src/pages/docs/text/`.
- Interaction/motion effects (`AnimatedContent`, `FadeContent`, `Sequence`,
`Reveal`, `Stage` + `Layer`, `GradualBlur`, `StarBorder`,
`ElectricBorder`, `GlareHover`, `Magnet`, `ClickSpark`, ...) are
another DOM/CSS/pointer family: slot wrappers (reveal-on-view, animated
borders, hover glare), timeline-driven children (`Sequence` writes
`--seq-p`/`data-seq` — see its engine bullet), the container-query scope
(`DitherContainer` — `data-cq`/`--cq-*` + slot state; and
`AnimatedContent.distance` accepts CSS length strings — `"4cqw"` makes a
reveal measure its parent container, numbers stay px), or area wrappers
(cursor +
click effects on a canvas
overlay). Same rules: `Dither*` export, `COMPONENT_REGISTRY` entry (wrappers use
`slotText`), reduced-motion aware. Docs live in `src/pages/docs/animations/`.
- The layout family (`DitherShell`, `DitherRail`, `DitherConsole`,
`DitherCanvas`, `DitherGrid`) is pure DOM/CSS — slot-driven frames for
dashboards. `DitherRail` provides the sidebar's collapsed context so
`DitherSidebarItem` children fold automatically; `DitherConsole` follow-mode
pins the newest line and its caret stills under reduced motion. Docs live in
`src/pages/docs/components/LayoutDocs.vue`.
- `gesture.ts` owns swipe math (Apple-style `project`, `rubberband`,
`velocityFrom`) — any swipeable surface (drawer, sheet, future carousels)
uses these, never re-derives them. Gesture rules: 1:1 tracking with
setPointerCapture, rubber-band against the dismiss direction, velocity sign
decides a flick, projection decides a slow drag.
- All primary canvas contexts must be created with `{ willReadFrequently: true }`
because the kit uses `putImageData` for bulk pixel writes. Bloom canvases omit
the flag (they only use `drawImage`).
- Standalone components must defer their initial `getBoundingClientRect()` to
`requestAnimationFrame` to avoid forced reflow inside Vue's `flushJobs` when
many components mount simultaneously. `DitherButton` defers `init()` via RAF
with a `restartToken` guard. `DitherToggleGroup` defers `paintAll()` via RAF.
`paintToggleCanvas` (shared by DitherToggle and DitherToggleGroup) must use
`RasterBuffer` + `putRasterBuffer`, not per-pixel `fillRect`.
- `control.ts` is the internal source for native-control geometry, focus rings,
disabled states, elevated popovers, and `DitherField` context. Inputs, textarea,
select, number field, button, checkbox, switch, and modal controls reuse it;
keep native elements and public component APIs rather than adding a base class.
- `DitherField` supplies generated control/help IDs and error state to compatible
descendants. Explicit consumer `id`, `aria-describedby`, and invalid props win.
- Respect `prefers-reduced-motion` inside the kit (see
`pixelPrefersReducedMotion` / `prefersReducedMotion`) — consumers must not
need to opt in.
- Canvas visibility defaults to paused until IntersectionObserver reports the
element visible; do not start chart animation loops optimistically before the
first visibility observation. The IO callback must set `visible.value` before
calling `onWake` so `schedule()` sees the correct state.
- `precompile.ts` must remain browser/SSR safe: it returns raw RGBA buffers and
must not import Vue, DOM APIs, or a server-specific image encoder. Consumers
own encoding, caching, and invalidation of packaged assets.
- `precompiled` replaces only the dither plot/surface image; surrounding chart
composition remains available. `renderMode="static"` disables animation and
resize observation for standalone surfaces, auto-uses lower backing-resolution
caps (`STATIC_DEFAULT_MAX_COLS=320`, `STATIC_DEFAULT_MAX_ROWS=200`) unless
`maxCols`/`maxRows` props override, and defers initial paint through
`requestIdleCallback` (with `setTimeout` fallback) so decorative gradients
never block first contentful paint. Live mode uses `DEFAULT_MAX_COLS=960`,
`DEFAULT_MAX_ROWS=600` and paints immediately via `setTimeout(0)`.
`maxCols`/`maxRows` are configurable props on DitherGradient, DitherButton,
and DitherImage for per-instance tuning.
- Chart composition: root provides context (`cartesian-root` / `polar-root`),
children register series via contexts. Props are declared with explicit
runtime defaults — keep API tables in `src/pages/docs` in sync when defaults
change.
## Work Guidance
- A new time-driven surface subscribes to the clock: paint the moment on
seek, request no frames while directed, resume on release.
## Verification
- `npx vue-tsc --noEmit` (workspace-wide) must stay green.
- Visual: `npx vite build && npx vite preview` and eyeball `/#/docs` demos.
- Determinism: on the built site, `/play/#doc=…` driven by synthetic
`hf-seek` events must give identical bytes for identical times, in any
order, and request no animation frames while directed.
## Child DOX Index
- none (flat folder by design)