Skip to content

feat: colour, tone and motion options, with the playground controls to drive them - #7

Merged
lucasmarkes merged 26 commits into
mainfrom
feat/effect-controls
Jul 28, 2026
Merged

lucasmarkes merged 26 commits into
mainfrom
feat/effect-controls

Conversation

@lucasmarkes

Copy link
Copy Markdown
Owner

Gives the field a colour surface — background, ink and accent as real options — plus the tone and motion controls that go with them, the playground UI to drive them, and the docs. 26 commits, 38 files.

Core

  • Colour pipeline. background, ink, contrast, brightness and motion join the option surface. background accepts #rgb, #rrggbb, #rrggbbaa and transparent; the pipeline is premultiplied throughout, so a transparent background is just vec4(0) in the shader and the opaque case is arithmetically unchanged.
  • Ambient tone curve driven from the options, with the ambient ramp running from background to ink and the pointer driving it to accent.
  • prefers-reduced-motion freezes the ambient animation. The golden that covered this was replaced with a self-relative freeze check — a fixed image can't tell "frozen" from "rendering the same frame twice".
  • A diagnostic warning when ink and background are indistinguishable, which is the one way to configure an invisible field.
  • set() is atomic across colour parsing — a bad colour in a patch no longer leaves half the patch applied.
  • Golden-image look tests (test/look.browser.mjs), three baselines plus nine property assertions.

The golden rule is untouched: golden-rule.test.ts still pins cursor math to pointer.glsl, and it passes.

React

Forwards the new props and mirrors them in the test mock's DEFAULT_OPTIONS.

Playground

  • One palette control for background/ink/accent — three chips that select, one picker that edits whichever is selected, with tab semantics rather than radio.

  • Preset looks and the tone sliders, with a roving toolbar for the presets.

  • On-field tone (the last three commits). The effect page draws its title directly on the field, held legible by a wash. That wash was hardcoded near-black — invisible by construction back when the field always was, and a dark rectangle in the corner of the artwork once background shipped. Now fieldTone() derives the wash from the field's own colour, so it reads as the field thickening rather than a panel laid over it, and a light/dark verdict flips three --on-field* tokens that every piece of type on the field consumes. Every constant is measured against the actual tokens: the light ramp needed its own third step because --ink-3 measures 4.4545:1 against Paper and misses AA at micro size.

    A second, independent fault fixed in the same rule: the wash's 148deg gradient was clipped by its own box on the right and bottom edges, terminating at alpha 0.1–0.4. That was a hard line at every preset, only invisible on near-black. Pulling the last colour stop in can't fix it — on that box the bottom edge sits at 47.2% of the gradient's axis, so any stop low enough would end the wash above the description. The edges are now zeroed by two mask-image layers combined with mask-composite: intersect, which makes "alpha reaches zero inside the box on every edge" structural rather than a property of particular stop values.

  • Palette chip labels lifted to their x-height so they centre optically in the chip.

Docs

The colour, tone and motion options documented across the published READMEs and the React docs page.

Verification

pnpm build 3/3, pnpm typecheck 5/5, pnpm test 5/5 — core 71, react 15, playground 31. Core's browser diagnostics 8/8 and the look harness clean (3 baselines, 9 property assertions).

Not in this PR

No version bump and no tag. packages/core and packages/react stay at 0.1.3 here; releasing is a separate step once this lands.

Captured against unmodified main, before the colour pipeline changes, so
the premultiplied-alpha rewrite has something to be measured against.
test:look now runs pnpm build first, so a bundle stale relative to src/
can never produce a silent false pass against the golden images — the
exact failure mode the baseline exists to catch. Also ignore the
*.actual.png scratch files the harness writes on a mismatch, so a
careless `git add` of test/golden can't commit them.
Both parseHexColor and parseColorRGBA duplicated 3-digit shorthand
expansion and bit-shift RGB extraction. Extract expandBody and
rgbFrom so the two overlapping hex grammars can't drift apart.
…TIONS

The mock in packages/react/src/index.test.tsx hand-spells DEFAULT_OPTIONS
to keep the React tests hermetic and independent of the built core
package. It fell out of sync when Task 3 added five options to the real
DEFAULT_OPTIONS, breaking the root typecheck. Bring the mirror current.
The tone curve is applied before the shared pointer pass, so a flattened
field still takes the cursor at full strength. Asserted in golden-rule,
because nothing else would catch the order being swapped.
Golden-image comparison against the pre-change baseline passes, so the
default look is unchanged.
The golden cases only exercise defaults, so they prove the four uniforms
are uploaded but not that a non-default background/ink/contrast/brightness
reaches its uniform and moves the output the way main.frag predicts. Adds
five property assertions (direction + rough margin, not exact pixels) to
test:look, reusing its harness rather than inventing a new one.
Pointer reactivity stays live — WCAG 2.2.2 is about autoplaying motion,
and a response to the user's own cursor is not that.
…check

A golden image for a brand-new option only ever enshrines whatever the
current code produces, and comparing two independently-launched pages by
wall-clock screenshot timing made the check ~50% flaky (worst diffs of
126/255 against a 2/255 tolerance, from coincidental phase alignment).
Instead, capture two frames from one running instance's own rAF clock,
far apart, and require bit-exact equality: `u_time * u_speed` collapses
to 0 regardless of elapsed time once `u_speed` is truly 0, so nothing but
the freeze can explain identical pixels 117 frames apart.
Adds diagnoseContrast, a pure luminance-ratio check on the resolved
background/ink pair. Calibrated at 1.5:1 — far below WCAG's 4.5:1 — because
this is a decorative field, not body text, and the shipped default already
sits at ~4.7:1 on the bright end of its ramp while going much lower by
design at the dim end. Skipped for a non-opaque background, since what's
behind a transparent canvas isn't ours to judge. Wired into motes.ts's
existing once-per-instance diagnostics gate, alongside the DOM checks.
Three stacked pickers would have outgrown the panel, and would have
implied the three colours are unrelated. They are one ramp.
Selecting a chip doesn't choose a value the way a radio does — it
rebinds the one shared editor below to a different channel. That's
tab semantics, not radio: tablist/tab/aria-selected, roving tabIndex,
arrow-key navigation with wrapping plus Home/End, and a tabpanel wired
to the active tab via aria-labelledby/aria-controls.
packages/core/README.md and packages/react/README.md ship to npm verbatim
and still listed only the original nine options; apps/playground's React
docs page carried the same stale prose list. Extend all three with
background, ink, contrast, brightness and respectMotionPreference.
- look.browser.mjs: add the missing converse of the reduced-motion
  freeze assertion — under no-preference the field must still animate,
  which nothing in the suite previously proved (a regression that froze
  it unconditionally would have passed unchanged).
- Presets.tsx: sync the roving tabIndex on click, not just onFocus, so
  desktop Safari (which doesn't focus buttons on click) can't leave the
  toolbar's focus stop on a stale preset.
- Palette.tsx: reference DEFAULT_OPTIONS.background instead of a
  hardcoded '#050403' when un-checking "transparent".
set() reassigned `options` before parsing accent/ink/background, so a bad
hex left getOptions() reporting a value the renderer and parsed colour
locals never received, with no path back for a retry with the same good
value, and silently dropped any other field in the same patch. Colours
that changed are now parsed into locals before anything is committed, so a
throw leaves the instance untouched and the call is a clean no-op.

Also names the option in parseHexColor's error message (it was hardcoded
to "accent" even when parsing ink) and adds a browser property assertion
in test:look proving the atomicity fix, demonstrated to fail on the old
ordering.
@lucasmarkes
lucasmarkes merged commit 13ba86b into main Jul 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant