@zakkster/lite-ui-fx 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,99 @@ All notable changes to `@zakkster/lite-ui-fx` are documented here.
5
5
  The format follows Keep a Changelog; this project adheres to Semantic
6
6
  Versioning.
7
7
 
8
- ## [1.2.0] -- unreleased
8
+ ## [1.4.0] -- 2026-09-07
9
+
10
+ The theming pass (U3b), completing U3's third finding (U-06). One option
11
+ convention across all 50 recipes; `themeable` is true for the first time. Every
12
+ default renders byte-for-byte as 1.3.0. Reduced motion (`motionSafe`) stays a
13
+ later pass.
14
+
15
+ ### Fixed
16
+
17
+ - U-06 (theming): all 50 recipes honour one reserved option set
18
+ `{ seed, colors, theme: { light, mid, dark }, text, font }`, resolved once in the
19
+ factory / `init`, never per frame. `theme` maps light/mid/dark onto each recipe's
20
+ accent/dim/surface roles; `colors` overrides positionally and wins over `theme`;
21
+ the array-palette recipes (Confetti, Firework, Aurora, RadioOrbit,
22
+ PasswordStrength, ReactionPicker) keep reading `colors` as their palette and are
23
+ seeded by `theme`. Hardcoded canvas labels (`'MAGNETIC'`, ...) became `text`,
24
+ resolved as `text ?? label ?? default`, so a mount that sets only the accessible
25
+ `label` drives the visible string (WCAG 2.5.3 label-in-name); fonts became the
26
+ `font` option. Every default is byte-identical to 1.3.0 (recording-context
27
+ draw-signature diff over all 50 recipes).
28
+
29
+ ### Added
30
+
31
+ - `RecipeOptions` (UIFXRecipes.d.ts) plus the shared cold helpers `resolveTheme`,
32
+ `pickText`, `pickFont`, `rgbaOf`, `rgbTriplet`; `MountOptions` gains the five
33
+ reserved keys.
34
+ - `test/theme.test.mjs`: 70 boundary tests (themeable flags, every recipe recolours
35
+ under a theme, colors-wins-over-theme, the legacy `colors`/`color` aliases, the
36
+ fail-closed validator matrix, label-in-name).
37
+ - t3 torture gates each recipe under a THEMED mount as well as its default
38
+ (`t3-scan.mjs` adds `tgrad`/`tcdist`; `t3-frame-alloc.mjs` judges both), proving
39
+ palette resolution stayed cold; t2 gains a label-in-name assertion.
40
+ - `tools/palettes.mjs` (not shipped): audits the shipped label colours for APCA
41
+ 0.1.9 contrast and emits example theme triples via `@zakkster/lite-hueforge`
42
+ (a dev-only tool, never a runtime dependency). Recorded in
43
+ `decisions/0002-recipe-options.md`.
44
+
45
+ ### Changed
46
+
47
+ - Controller: `KNOWN_OPTIONS` admits `seed` / `colors` / `theme` / `text` / `font`,
48
+ each validated fail closed -- a partial or extra-key `theme`, a non-array
49
+ `colors`, a non-string `text` / `font`, or a non-finite `seed` throws; an unknown
50
+ key still returns a did-you-mean.
51
+ - `RECIPE_META.themeable` is `true` for all 50 recipes; `motionSafe` stays `false`.
52
+ - The recording `Ctx2DStub` logs `fillText` / `strokeText` with their text argument.
53
+ - `UIFX-RECIPE-GUIDE.md`: the per-frame performance rules retire the pre-U3
54
+ "`splice()` is fine for < 100 particles" advice (it contradicts the t3 gate) and
55
+ add a rule to resolve theming in the factory / `init`, never in `tick`.
56
+
57
+ ## [1.3.0] -- 2026-09-07
58
+
59
+ The recipe sweep (U3), first pass: zero per-frame allocation across all 50
60
+ recipes (U-03) and geometry derived from state (U-05). The `zero-gc` keyword is
61
+ true for the first time. Theming (U-06) and the blueprint-doc rewrite are a
62
+ deferred follow-up; `themeable` / `motionSafe` stay `false`.
63
+
64
+ ### Fixed
65
+
66
+ - U-03 (zero-GC): every built-in recipe now runs its per-frame body -- idle and
67
+ under interaction -- without allocating. Per-frame `rgba(...,${alpha})` colour
68
+ strings became a const colour + `globalAlpha`; recipes whose RGB varies with a
69
+ value (FlameCounter, HeatMap, DayNightToggle) use a precomputed colour LUT;
70
+ particle `push`/`splice` pools became fixed preallocated pools with a live
71
+ flag; per-frame gradients moved to `init` (VolumeKnob / RingProgress build
72
+ once; LaserSlider / AuroraSlider build a reference gradient in `init` and scale
73
+ it to the fill, AuroraSlider cycling a bounded set of phase gradients); value
74
+ labels read a precomputed `PCT` table or a change-detection cache instead of
75
+ building `${n}%` / `String(n)` / `.toFixed()` per frame; the GlitchButton
76
+ per-tick closure and the CompassKnob / ReactionPicker per-frame array literals
77
+ were hoisted; `setLineDash([4,3])` uses one shared module-level array.
78
+ - U-05 (size-true): spawn and inset positions derive from `st.w` -- NeonPulse
79
+ ring origin (was `46`/`18`), SparkSlider and ScratchReveal spark spawn (was
80
+ `val * 200`), BubbleRating bubble gap (was `200 / 5`).
81
+
82
+ ### Added
83
+
84
+ - `test/torture/t3-frame-alloc.mjs` (+ `t3-scan.mjs`): the U-03 gate. It runs in
85
+ a child process under `--max-semi-space-size=1` and gates each recipe on
86
+ `major === 0`, zero gradient constructions after `init`, and a bounded count of
87
+ distinct fill/stroke colour strings (a per-frame colour template mints
88
+ hundreds; a const palette or LUT a few dozen). Wired into `torture.mjs`.
89
+
90
+ ### Changed
91
+
92
+ - Harness: the recording `Ctx2DStub` backs numeric context properties
93
+ (`globalAlpha`, `lineWidth`, ...) with a `Float64Array` so writing a
94
+ non-integer never boxes a HeapNumber -- the gate measures the recipe, not the
95
+ stub. `gcGate` gained a warmup phase and an optional zero-alloc churn driver.
96
+ - `mountUIFX` forwards the validated options to the recipe factory, so a recipe
97
+ can read its own config (a no-arg or wrapped factory ignores the argument).
98
+ This is inert until the U-06 theming pass uses it.
99
+
100
+ ## [1.2.0] -- 2026-09-06
9
101
 
10
102
  Recipes ship as code (U-13). The three GitHub-only recipe volumes are
11
103
  consolidated into one `UIFXRecipes.js` at the package root, exposed as the
@@ -181,8 +181,9 @@ if (state.focused) {
181
181
 
182
182
  ## Performance Rules
183
183
 
184
- 1. **Never allocate in `tick()`** -- pre-allocate TypedArrays in the factory closure or `init()`
185
- 2. **Use `splice()` sparingly** -- for small particle arrays (< 100) it's fine. For larger pools, use a dead-flag pattern
184
+ 1. **Never allocate in `tick()`** -- pre-allocate TypedArrays / fixed object pools in the factory closure or `init()`. This is not a guideline: the `t3-frame-alloc` torture tier FAILS any built-in recipe that assigns a fresh colour string, builds a gradient, or grows a pool on a hot frame (idle OR under interaction churn), and re-runs the same budget under a themed mount.
185
+ 2. **No `push`/`splice` on the hot path** -- a particle pool is a fixed-size array (or `Float64Array` lanes) with a `live`/`life` flag; spawn scans for a dead slot, death clears the flag. `splice()` allocates and shifts elements; it is a cold-path tool (mount/destroy) only, never per frame. (The pre-U3 "splice is fine for < 100 particles" advice is retired -- it contradicts the gate.)
186
186
  3. **Reset composite operation** -- if you set `ctx.globalCompositeOperation = 'screen'`, reset to `'source-over'` before returning
187
187
  4. **Reset shadow** -- `ctx.shadowBlur = 0` after drawing glowing elements
188
188
  5. **Use the `dt` parameter** -- all motion must be `value * dt`, not `value` per frame. This ensures consistent speed regardless of frame rate.
189
+ 6. **Resolve theming in the factory / `init()`, never in `tick()`** -- read `options.theme` / `colors` / `text` / `font` ONCE at construction (the built-ins use the shared `resolveTheme` / `pickText` / `pickFont` helpers), producing const colour strings and any value-LUT the hot body then reads. Alpha varies via `ctx.globalAlpha` over a const colour; a `rgba(...,${x})` or `` `${color}` `` built per frame trips t3. Defaults must reproduce the pre-theming output byte-for-byte.
@@ -59,6 +59,19 @@ export interface MountOptions {
59
59
  checked?: boolean;
60
60
  /** Disables the native element and sets state.disabled for recipes. */
61
61
  disabled?: boolean;
62
+ // -- Reserved recipe theming options (decisions/0002). Validated fail-closed,
63
+ // then forwarded to the recipe factory. All optional; omitting them keeps
64
+ // the recipe's shipped look byte-for-byte.
65
+ /** Seed for a recipe's deterministic RNG. */
66
+ seed?: number;
67
+ /** Positional palette override; wins over `theme`. */
68
+ colors?: string[];
69
+ /** Named theme roles: light -> accent, mid -> dim/muted, dark -> surface. */
70
+ theme?: { light: string; mid: string; dark: string };
71
+ /** Visible canvas label; falls back to `label`, then the recipe default. */
72
+ text?: string;
73
+ /** Canvas font string; falls back to the recipe's historical font. */
74
+ font?: string;
62
75
  }
63
76
 
64
77
  export interface UIFXInstance {
package/UIFXController.js CHANGED
@@ -22,7 +22,7 @@ import { Ticker } from '@zakkster/lite-ticker';
22
22
 
23
23
  // Three-place version sync: this constant, package.json "version", and the
24
24
  // VERSION line in llms.txt must always match. /release keeps them locked.
25
- export const VERSION = '1.2.0';
25
+ export const VERSION = '1.4.0';
26
26
 
27
27
  // ---------------------------------------------------------
28
28
  // SHARED TICKER (ref-counted, one RAF for all UI components)
@@ -84,7 +84,7 @@ function releaseSliderStyle() {
84
84
  // ---------------------------------------------------------
85
85
 
86
86
  const KNOWN_HOOKS = ['init', 'tick', 'onHover', 'onLeave', 'onClick', 'onToggle', 'onDrag', 'destroy'];
87
- const KNOWN_OPTIONS = ['width', 'height', 'padding', 'label', 'value', 'checked', 'disabled'];
87
+ const KNOWN_OPTIONS = ['width', 'height', 'padding', 'label', 'value', 'checked', 'disabled', 'seed', 'colors', 'theme', 'text', 'font'];
88
88
 
89
89
  // Levenshtein edit distance. Cold: only reached on the error path.
90
90
  function _editDistance(a, b) {
@@ -206,6 +206,33 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
206
206
  const padding = options.padding === undefined ? 40 : options.padding;
207
207
  const label = options.label === undefined ? '' : options.label;
208
208
 
209
+ // Reserved theming options (decisions/0002): all optional, validated fail
210
+ // closed here, then forwarded to the recipe factory which resolves them in
211
+ // init. This is cold mount code -- closures/allocation are fine here.
212
+ const _theme = options.theme;
213
+ if (_theme !== undefined) {
214
+ if (_theme === null || typeof _theme !== 'object' ||
215
+ typeof _theme.light !== 'string' || typeof _theme.mid !== 'string' ||
216
+ typeof _theme.dark !== 'string' || Object.keys(_theme).length !== 3) {
217
+ throw new Error('mountUIFX: option "theme" must be { light, mid, dark } of color strings');
218
+ }
219
+ }
220
+ const _colors = options.colors;
221
+ if (_colors !== undefined &&
222
+ (!Array.isArray(_colors) || _colors.some((c) => typeof c !== 'string'))) {
223
+ throw new Error('mountUIFX: option "colors" must be an array of color strings');
224
+ }
225
+ if (options.text !== undefined && typeof options.text !== 'string') {
226
+ throw new Error('mountUIFX: option "text" must be a string');
227
+ }
228
+ if (options.font !== undefined && typeof options.font !== 'string') {
229
+ throw new Error('mountUIFX: option "font" must be a string');
230
+ }
231
+ if (options.seed !== undefined &&
232
+ (typeof options.seed !== 'number' || !Number.isFinite(options.seed))) {
233
+ throw new Error('mountUIFX: option "seed" must be a finite number');
234
+ }
235
+
209
236
  // 3. recipeFactory
210
237
  if (typeof recipeFactory !== 'function') {
211
238
  throw new Error('mountUIFX: recipeFactory must be a function');
@@ -213,7 +240,10 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
213
240
 
214
241
  // 4. recipe object + hooks. Created now so a bad recipe throws BEFORE any
215
242
  // DOM/refcount side effect; .init is deferred to phase 2 (needs ctx).
216
- const recipe = recipeFactory();
243
+ // The validated options are forwarded so a recipe factory can read its
244
+ // own visual config and default a canvas `text` to the accessible
245
+ // `label`; a zero-arg or user-wrapped factory ignores the argument.
246
+ const recipe = recipeFactory(options);
217
247
  if (!recipe || typeof recipe !== 'object') {
218
248
  throw new Error('mountUIFX: recipe must be an object');
219
249
  }
package/UIFXRecipes.d.ts CHANGED
@@ -1,92 +1,118 @@
1
1
  import type { UIFXRecipe, UIFXInstance, MountOptions } from './UIFXController';
2
2
 
3
3
  // ===========================================================
4
- // RECIPE FACTORIES (all 50)
4
+ // RECIPE OPTIONS + FACTORIES (all 50)
5
5
  // ===========================================================
6
6
 
7
+ /**
8
+ * The reserved theming options every recipe factory accepts (decisions/0002).
9
+ * All optional; omitting them reproduces the recipe's shipped look byte-for-byte.
10
+ */
11
+ export interface RecipeOptions {
12
+ /** Seed for a recipe's deterministic RNG (particle bursts, jitter). */
13
+ seed?: number;
14
+ /**
15
+ * Positional palette override; wins over `theme`. For most recipes it maps
16
+ * over the role order (accent, dim, surface, ...); for an array-palette recipe
17
+ * (Confetti, Firework, Aurora, RadioOrbit, PasswordStrength, ReactionPicker)
18
+ * it IS the palette.
19
+ */
20
+ colors?: string[];
21
+ /** Named theme roles: `light` -> accent, `mid` -> dim/muted, `dark` -> surface. */
22
+ theme?: { light: string; mid: string; dark: string };
23
+ /**
24
+ * Visible canvas label. Falls back to the mount `label`, then the recipe's
25
+ * default string -- so setting only `label` keeps the visible text equal to the
26
+ * accessible name (WCAG 2.5.3 label-in-name). Ignored by recipes painting no text.
27
+ */
28
+ text?: string;
29
+ /** Canvas font string; falls back to the recipe's historical font literal. */
30
+ font?: string;
31
+ }
32
+
7
33
  // -- Vol.1: Toggles --
8
- export declare function SwarmToggle(options?: { seed?: number; count?: number }): UIFXRecipe;
9
- export declare function LiquidToggle(): UIFXRecipe;
10
- export declare function NeonPulseToggle(): UIFXRecipe;
34
+ export declare function SwarmToggle(options?: RecipeOptions & { count?: number }): UIFXRecipe;
35
+ export declare function LiquidToggle(options?: RecipeOptions): UIFXRecipe;
36
+ export declare function NeonPulseToggle(options?: RecipeOptions): UIFXRecipe;
11
37
 
12
38
  // -- Vol.1: Buttons --
13
- export declare function MagneticButton(options?: { maxPull?: number }): UIFXRecipe;
14
- export declare function ShatterButton(options?: { seed?: number }): UIFXRecipe;
15
- export declare function ConfettiButton(options?: { seed?: number; colors?: string[] }): UIFXRecipe;
16
- export declare function GlitchButton(options?: { seed?: number }): UIFXRecipe;
39
+ export declare function MagneticButton(options?: RecipeOptions & { maxPull?: number }): UIFXRecipe;
40
+ export declare function ShatterButton(options?: RecipeOptions): UIFXRecipe;
41
+ export declare function ConfettiButton(options?: RecipeOptions): UIFXRecipe;
42
+ export declare function GlitchButton(options?: RecipeOptions): UIFXRecipe;
17
43
 
18
44
  // -- Vol.1: Sliders --
19
- export declare function SparkSlider(options?: { seed?: number; color?: string }): UIFXRecipe;
20
- export declare function CosmicSlider(options?: { seed?: number; dustCount?: number }): UIFXRecipe;
21
- export declare function LaserSlider(): UIFXRecipe;
45
+ export declare function SparkSlider(options?: RecipeOptions & { color?: string }): UIFXRecipe;
46
+ export declare function CosmicSlider(options?: RecipeOptions & { dustCount?: number }): UIFXRecipe;
47
+ export declare function LaserSlider(options?: RecipeOptions): UIFXRecipe;
22
48
 
23
49
  // -- Vol.2: Toggles --
24
- export declare function PendulumToggle(): UIFXRecipe;
25
- export declare function CircuitToggle(options?: { seed?: number }): UIFXRecipe;
26
- export declare function LightningToggle(options?: { seed?: number }): UIFXRecipe;
27
- export declare function DNAToggle(): UIFXRecipe;
50
+ export declare function PendulumToggle(options?: RecipeOptions): UIFXRecipe;
51
+ export declare function CircuitToggle(options?: RecipeOptions): UIFXRecipe;
52
+ export declare function LightningToggle(options?: RecipeOptions): UIFXRecipe;
53
+ export declare function DNAToggle(options?: RecipeOptions): UIFXRecipe;
28
54
 
29
55
  // -- Vol.2: Buttons --
30
- export declare function HeartbeatButton(options?: { seed?: number }): UIFXRecipe;
31
- export declare function BreathingButton(): UIFXRecipe;
32
- export declare function InkSplashButton(options?: { seed?: number }): UIFXRecipe;
33
- export declare function PixelDissolveButton(options?: { seed?: number; cols?: number; rows?: number }): UIFXRecipe;
34
- export declare function FireworkButton(options?: { seed?: number }): UIFXRecipe;
56
+ export declare function HeartbeatButton(options?: RecipeOptions): UIFXRecipe;
57
+ export declare function BreathingButton(options?: RecipeOptions): UIFXRecipe;
58
+ export declare function InkSplashButton(options?: RecipeOptions): UIFXRecipe;
59
+ export declare function PixelDissolveButton(options?: RecipeOptions & { cols?: number; rows?: number }): UIFXRecipe;
60
+ export declare function FireworkButton(options?: RecipeOptions): UIFXRecipe;
35
61
 
36
62
  // -- Vol.2: Sliders --
37
- export declare function AuroraSlider(): UIFXRecipe;
38
- export declare function WaveSlider(options?: { seed?: number }): UIFXRecipe;
39
- export declare function ElasticBandSlider(): UIFXRecipe;
40
- export declare function GravitySlider(): UIFXRecipe;
63
+ export declare function AuroraSlider(options?: RecipeOptions): UIFXRecipe;
64
+ export declare function WaveSlider(options?: RecipeOptions): UIFXRecipe;
65
+ export declare function ElasticBandSlider(options?: RecipeOptions): UIFXRecipe;
66
+ export declare function GravitySlider(options?: RecipeOptions): UIFXRecipe;
41
67
 
42
68
  // -- Vol.2: Loaders --
43
- export declare function OrbitLoader(): UIFXRecipe;
44
- export declare function HelixLoader(): UIFXRecipe;
69
+ export declare function OrbitLoader(options?: RecipeOptions): UIFXRecipe;
70
+ export declare function HelixLoader(options?: RecipeOptions): UIFXRecipe;
45
71
 
46
72
  // -- Vol.2: Checkboxes --
47
- export declare function RippleCheck(): UIFXRecipe;
48
- export declare function MorphCheck(): UIFXRecipe;
73
+ export declare function RippleCheck(options?: RecipeOptions): UIFXRecipe;
74
+ export declare function MorphCheck(options?: RecipeOptions): UIFXRecipe;
49
75
 
50
76
  // -- Vol.2: Counters --
51
- export declare function FlameCounter(options?: { seed?: number }): UIFXRecipe;
52
- export declare function GlitchCounter(options?: { seed?: number }): UIFXRecipe;
77
+ export declare function FlameCounter(options?: RecipeOptions): UIFXRecipe;
78
+ export declare function GlitchCounter(options?: RecipeOptions): UIFXRecipe;
53
79
 
54
80
  // -- Vol.2: Rating --
55
- export declare function BubbleRating(options?: { seed?: number }): UIFXRecipe;
81
+ export declare function BubbleRating(options?: RecipeOptions): UIFXRecipe;
56
82
 
57
83
  // -- Vol.3: Knobs --
58
- export declare function VolumeKnob(): UIFXRecipe;
59
- export declare function CompassKnob(): UIFXRecipe;
84
+ export declare function VolumeKnob(options?: RecipeOptions): UIFXRecipe;
85
+ export declare function CompassKnob(options?: RecipeOptions): UIFXRecipe;
60
86
 
61
87
  // -- Vol.3: Progress --
62
- export declare function RingProgress(options?: { seed?: number }): UIFXRecipe;
63
- export declare function BatteryGauge(): UIFXRecipe;
64
- export declare function SignalMeter(): UIFXRecipe;
88
+ export declare function RingProgress(options?: RecipeOptions): UIFXRecipe;
89
+ export declare function BatteryGauge(options?: RecipeOptions): UIFXRecipe;
90
+ export declare function SignalMeter(options?: RecipeOptions): UIFXRecipe;
65
91
 
66
92
  // -- Vol.3: Controls --
67
- export declare function PillTabs(): UIFXRecipe;
68
- export declare function Stepper(): UIFXRecipe;
69
- export declare function RadioOrbit(): UIFXRecipe;
93
+ export declare function PillTabs(options?: RecipeOptions): UIFXRecipe;
94
+ export declare function Stepper(options?: RecipeOptions): UIFXRecipe;
95
+ export declare function RadioOrbit(options?: RecipeOptions): UIFXRecipe;
70
96
 
71
97
  // -- Vol.3: Indicators --
72
- export declare function PasswordStrength(): UIFXRecipe;
73
- export declare function WaterLevel(): UIFXRecipe;
74
- export declare function HeatMap(options?: { seed?: number }): UIFXRecipe;
98
+ export declare function PasswordStrength(options?: RecipeOptions): UIFXRecipe;
99
+ export declare function WaterLevel(options?: RecipeOptions): UIFXRecipe;
100
+ export declare function HeatMap(options?: RecipeOptions): UIFXRecipe;
75
101
 
76
102
  // -- Vol.3: Mood --
77
- export declare function DayNightToggle(options?: { seed?: number }): UIFXRecipe;
78
- export declare function ReactionPicker(): UIFXRecipe;
79
- export declare function NotificationBell(): UIFXRecipe;
103
+ export declare function DayNightToggle(options?: RecipeOptions): UIFXRecipe;
104
+ export declare function ReactionPicker(options?: RecipeOptions): UIFXRecipe;
105
+ export declare function NotificationBell(options?: RecipeOptions): UIFXRecipe;
80
106
 
81
107
  // -- Vol.3: Feedback --
82
- export declare function TypewriterField(): UIFXRecipe;
83
- export declare function SoundWaveBtn(): UIFXRecipe;
84
- export declare function UploadProgress(): UIFXRecipe;
108
+ export declare function TypewriterField(options?: RecipeOptions): UIFXRecipe;
109
+ export declare function SoundWaveBtn(options?: RecipeOptions): UIFXRecipe;
110
+ export declare function UploadProgress(options?: RecipeOptions): UIFXRecipe;
85
111
 
86
112
  // -- Vol.3: Fun --
87
- export declare function ScratchReveal(options?: { seed?: number }): UIFXRecipe;
88
- export declare function TimerCountdown(): UIFXRecipe;
89
- export declare function PullRefresh(): UIFXRecipe;
113
+ export declare function ScratchReveal(options?: RecipeOptions): UIFXRecipe;
114
+ export declare function TimerCountdown(options?: RecipeOptions): UIFXRecipe;
115
+ export declare function PullRefresh(options?: RecipeOptions): UIFXRecipe;
90
116
 
91
117
  // ===========================================================
92
118
  // BARREL OBJECTS (back-compat)