@zakkster/lite-ui-fx 1.3.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,6 +5,55 @@ 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.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
+
8
57
  ## [1.3.0] -- 2026-09-07
9
58
 
10
59
  The recipe sweep (U3), first pass: zero per-frame allocation across all 50
@@ -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.3.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');
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)