@zakkster/lite-ui-fx 1.3.0 → 1.5.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,107 @@ 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.5.0] -- 2026-09-07
9
+
10
+ New native element types (U4a, the first half of roadmap U4). Vol.3 faked
11
+ checkboxes as `role=switch` toggles and knobs/progress meters as sliders; U4a
12
+ promotes them to their true native elements (law 1). Additive: a bare mount of
13
+ any existing recipe is unchanged; 50 -> 53 recipes. Decorate mode is U4b.
14
+
15
+ ### Added
16
+
17
+ - Three `UIType`s, each wrapping the correct native element: `CHECKBOX`
18
+ (`<input type=checkbox>`, no `role=switch`; indeterminate via `setValue(null)`,
19
+ exposed as `state.indeterminate`), `PROGRESS` (native `<progress>`,
20
+ non-interactive, value written by `setValue`; opt-in `announce` adds a
21
+ visually-hidden `aria-live=polite` region updated at 10% steps), and `KNOB`
22
+ (`<input type=range>`, arrow keys native, canvas-side `knobMode`
23
+ `'rotate' | 'vertical'` pointer mapping).
24
+ - `instance.setValue(v)` / `instance.setChecked(b)`: one call syncs the native
25
+ element, `state`, any PROGRESS announcer, and fires the recipe hook
26
+ (`onDrag`/`onToggle`) exactly once (a programmatic write emits no native event).
27
+ - Options `knobMode` (KNOB-only) and `announce` (PROGRESS-only), both validated
28
+ fail-closed (presence on the wrong type throws).
29
+ - Three recipes, born themed and t3-gated: `TickDraw`, `IndeterminateScan`
30
+ (CHECKBOX, honouring `state.indeterminate`), `LiquidFill` (PROGRESS). Registered
31
+ in `RECIPES` / `RECIPE_META` / the default export / a new `UIFXRecipes4` barrel.
32
+ - `decisions/0003-element-types.md`; controller `npm test` coverage for the new
33
+ types; t2 gains the CHECKBOX-no-switch, PROGRESS-value, KNOB-arrows, and
34
+ `setValue`/`setChecked`-once contracts (A10-A13).
35
+
36
+ ### Changed
37
+
38
+ - Eight Vol.3 recipes re-homed onto their true types (rippleCheck/morphCheck ->
39
+ checkbox; volumeKnob/compassKnob -> knob; ringProgress/batteryGauge/signalMeter/
40
+ uploadProgress -> progress). Re-home is a `RECIPE_META.type` string change only
41
+ -- no recipe body touched -- so each renders byte-identical to 1.4.0 (proven by
42
+ `git diff`); new types keep the donor's default geometry (checkbox 64x36,
43
+ knob/progress 200x28).
44
+ - The mount type guard and `registerRecipe` both derive their valid-type set from
45
+ `UIType`, so the controller, the registry, and the d.ts cannot drift as types
46
+ are added; an unknown type still throws (fail closed).
47
+ - Torture: `makeChurn` drives the new types (checkbox like toggle + sweeps
48
+ indeterminate, knob like slider, progress sweeps value with no hook); t0/t5
49
+ synthetic batches iterate all six types; the t3 tier now gates 53 recipes
50
+ (default AND themed). `npm test` 164 pass; torture `gc major=0`, `alloc=0 B/op`.
51
+ - Docs (`llms.txt`, `README.md`, both `.d.ts`) updated to 53 recipes and the new
52
+ types / options / methods.
53
+
54
+ ### Fixed
55
+
56
+ - The Vol.3 semantic mis-mounts: a checkbox is no longer announced as a switch
57
+ (WCAG role match), and progress meters are non-interactive rather than
58
+ user-draggable sliders.
59
+
60
+ ## [1.4.0] -- 2026-09-07
61
+
62
+ The theming pass (U3b), completing U3's third finding (U-06). One option
63
+ convention across all 50 recipes; `themeable` is true for the first time. Every
64
+ default renders byte-for-byte as 1.3.0. Reduced motion (`motionSafe`) stays a
65
+ later pass.
66
+
67
+ ### Fixed
68
+
69
+ - U-06 (theming): all 50 recipes honour one reserved option set
70
+ `{ seed, colors, theme: { light, mid, dark }, text, font }`, resolved once in the
71
+ factory / `init`, never per frame. `theme` maps light/mid/dark onto each recipe's
72
+ accent/dim/surface roles; `colors` overrides positionally and wins over `theme`;
73
+ the array-palette recipes (Confetti, Firework, Aurora, RadioOrbit,
74
+ PasswordStrength, ReactionPicker) keep reading `colors` as their palette and are
75
+ seeded by `theme`. Hardcoded canvas labels (`'MAGNETIC'`, ...) became `text`,
76
+ resolved as `text ?? label ?? default`, so a mount that sets only the accessible
77
+ `label` drives the visible string (WCAG 2.5.3 label-in-name); fonts became the
78
+ `font` option. Every default is byte-identical to 1.3.0 (recording-context
79
+ draw-signature diff over all 50 recipes).
80
+
81
+ ### Added
82
+
83
+ - `RecipeOptions` (UIFXRecipes.d.ts) plus the shared cold helpers `resolveTheme`,
84
+ `pickText`, `pickFont`, `rgbaOf`, `rgbTriplet`; `MountOptions` gains the five
85
+ reserved keys.
86
+ - `test/theme.test.mjs`: 70 boundary tests (themeable flags, every recipe recolours
87
+ under a theme, colors-wins-over-theme, the legacy `colors`/`color` aliases, the
88
+ fail-closed validator matrix, label-in-name).
89
+ - t3 torture gates each recipe under a THEMED mount as well as its default
90
+ (`t3-scan.mjs` adds `tgrad`/`tcdist`; `t3-frame-alloc.mjs` judges both), proving
91
+ palette resolution stayed cold; t2 gains a label-in-name assertion.
92
+ - `tools/palettes.mjs` (not shipped): audits the shipped label colours for APCA
93
+ 0.1.9 contrast and emits example theme triples via `@zakkster/lite-hueforge`
94
+ (a dev-only tool, never a runtime dependency). Recorded in
95
+ `decisions/0002-recipe-options.md`.
96
+
97
+ ### Changed
98
+
99
+ - Controller: `KNOWN_OPTIONS` admits `seed` / `colors` / `theme` / `text` / `font`,
100
+ each validated fail closed -- a partial or extra-key `theme`, a non-array
101
+ `colors`, a non-string `text` / `font`, or a non-finite `seed` throws; an unknown
102
+ key still returns a did-you-mean.
103
+ - `RECIPE_META.themeable` is `true` for all 50 recipes; `motionSafe` stays `false`.
104
+ - The recording `Ctx2DStub` logs `fillText` / `strokeText` with their text argument.
105
+ - `UIFX-RECIPE-GUIDE.md`: the per-frame performance rules retire the pre-U3
106
+ "`splice()` is fine for < 100 particles" advice (it contradicts the t3 gate) and
107
+ add a rule to resolve theming in the factory / `init`, never in `tick`.
108
+
8
109
  ## [1.3.0] -- 2026-09-07
9
110
 
10
111
  The recipe sweep (U3), first pass: zero per-frame allocation across all 50
package/README.md CHANGED
@@ -21,26 +21,26 @@ https://cdpn.io/pen/debug/yyaPKpB
21
21
  ## Live Demo (UI-FX vol3.)
22
22
  https://cdpn.io/pen/debug/YPGEaYY
23
23
 
24
- **50 recipes** across UI element categories:
24
+ **53 recipes** across UI element categories:
25
25
 
26
26
  - **Toggles** -- Swarm, Liquid, Neon Pulse, Pendulum, Circuit, Lightning, DNA
27
27
  - **Buttons** -- Magnetic, Shatter, Confetti, Glitch, Heartbeat, Breathing, Ink Splash, Pixel Dissolve, Firework
28
28
  - **Sliders** -- Spark, Cosmic Void, Laser, Aurora, Wave, Elastic Band, Gravity
29
29
  - **Knobs** -- Volume dial, Compass needle
30
- - **Progress** -- Ring, Battery, Signal meter
30
+ - **Progress** -- Ring, Battery, Signal meter, Liquid Fill
31
31
  - **Controls** -- Pill tabs, Stepper, Radio orbit
32
32
  - **Indicators** -- Password strength, Water level, Heat map
33
33
  - **Mood** -- Day/night, Reaction picker, Notification bell
34
34
  - **Feedback** -- Typewriter, Sound wave, Upload progress
35
35
  - **Fun** -- Scratch reveal, Timer countdown, Pull refresh
36
- - **Checkboxes** -- Ripple, Morph (X to check)
36
+ - **Checkboxes** -- Ripple, Morph (X to check), Tick Draw, Indeterminate Scan
37
37
  - **Loaders** -- Orbit planets, DNA helix
38
38
  - **Counters** -- Flame heat, Glitch signal
39
39
  - **Rating** -- Bubble inflate
40
40
 
41
41
  Every recipe is zero-GC, uses `dt`-based animation, and includes accessibility indicators (focus rings, state labels).
42
42
 
43
- All 50 recipes ship in the package on the `./recipes` subpath -- versioned,
43
+ All 53 recipes ship in the package on the `./recipes` subpath -- versioned,
44
44
  typed, and tree-shakeable. With `sideEffects: false`, importing one recipe pulls
45
45
  in only that recipe, so a controller-only install stays tiny.
46
46
 
@@ -65,7 +65,7 @@ Part of the [@zakkster/lite-*](https://www.npmjs.com/org/zakkster) ecosystem.
65
65
  npm i @zakkster/lite-ui-fx
66
66
  ```
67
67
 
68
- > The 50 recipes ship in the same package on the `./recipes` subpath and
68
+ > The 53 recipes ship in the same package on the `./recipes` subpath and
69
69
  > tree-shake, so importing one adds only that one.
70
70
 
71
71
 
@@ -97,7 +97,7 @@ instance.destroy();
97
97
  // Controller (always needed)
98
98
  import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
99
99
 
100
- // All 50 recipes ship on the ./recipes subpath (tree-shakeable) -- import by name:
100
+ // All 53 recipes ship on the ./recipes subpath (tree-shakeable) -- import by name:
101
101
  import { SwarmToggle, MagneticButton, SparkSlider } from '@zakkster/lite-ui-fx/recipes';
102
102
  import { PendulumToggle, HeartbeatButton, RippleCheck } from '@zakkster/lite-ui-fx/recipes';
103
103
  import { VolumeKnob, WaterLevel, TimerCountdown } from '@zakkster/lite-ui-fx/recipes';
@@ -136,7 +136,7 @@ import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from
136
136
  | Parameter | Type | Description |
137
137
  |-----------|------|-------------|
138
138
  | `container` | `HTMLElement` | Parent to mount into |
139
- | `type` | `'button' \| 'toggle' \| 'slider'` | Determines native element type |
139
+ | `type` | `'button' \| 'toggle' \| 'slider' \| 'checkbox' \| 'progress' \| 'knob'` | Determines native element type |
140
140
  | `recipeFactory` | `() => Recipe` | Factory function (controller calls it) |
141
141
  | `options.width` | `number` | Element width (auto from type if omitted) |
142
142
  | `options.height` | `number` | Element height |
@@ -145,8 +145,18 @@ import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from
145
145
  | `options.value` | `number` | Slider initial value, 0..1 (default 0.5); out-of-range throws |
146
146
  | `options.checked` | `boolean` | Toggle initial state (default false) |
147
147
  | `options.disabled` | `boolean` | Disables the native element; sets `state.disabled` |
148
+ | `options.knobMode` | `'rotate' \| 'vertical'` | KNOB only: pointer-to-value mapping (default `'rotate'`); wrong type throws |
149
+ | `options.announce` | `boolean` | PROGRESS only: opt-in `aria-live` announcements at 10% steps; wrong type throws |
148
150
 
149
- Returns `{ el, canvas, wrapper, state, destroy() }`.
151
+ Recipe theming options (`seed`, `colors`, `theme`, `text`, `font`) are also
152
+ accepted and forwarded to the recipe -- see `llms.txt` for the full option surface.
153
+
154
+ Returns `{ el, canvas, wrapper, state, setValue(v), setChecked(b), destroy() }`.
155
+
156
+ - `setValue(v)` -- SLIDER/KNOB/PROGRESS: set `v` in `0..1` (updates the element +
157
+ `state.val`, fires `onDrag` once). CHECKBOX: `setValue(null)` sets indeterminate.
158
+ - `setChecked(b)` -- TOGGLE/CHECKBOX: set checked (updates the element +
159
+ `state.toggled`, fires `onToggle` once).
150
160
 
151
161
  ### Element Types
152
162
 
@@ -155,6 +165,9 @@ Returns `{ el, canvas, wrapper, state, destroy() }`.
155
165
  | `UIType.TOGGLE` | `<input type="checkbox" role="switch">` | `onToggle(checked)` | `state.toggled` |
156
166
  | `UIType.BUTTON` | `<button>` | `onClick(x, y, state)` | `state.active` |
157
167
  | `UIType.SLIDER` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0-1) |
168
+ | `UIType.CHECKBOX` | `<input type="checkbox">` (no `role=switch`) | `onToggle(checked)` | `state.toggled`, `state.indeterminate` |
169
+ | `UIType.PROGRESS` | `<progress>` (non-interactive) | (driven by `setValue`) | `state.val` (0-1) |
170
+ | `UIType.KNOB` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0-1) |
158
171
 
159
172
  ### State Object (provided to `tick()` every frame)
160
173
 
@@ -163,7 +176,8 @@ Returns `{ el, canvas, wrapper, state, destroy() }`.
163
176
  hover: boolean; // Pointer inside element
164
177
  active: boolean; // Pointer pressed
165
178
  focused: boolean; // Keyboard focus
166
- toggled: boolean; // Checkbox state
179
+ toggled: boolean; // Checkbox/toggle state
180
+ indeterminate: boolean; // CHECKBOX only: native indeterminate (setValue(null))
167
181
  disabled: boolean; // Disabled via options.disabled
168
182
  val: number; // Slider value (0-1)
169
183
  w: number; // Element width
@@ -180,7 +194,7 @@ Returns `{ el, canvas, wrapper, state, destroy() }`.
180
194
  | Framer Motion | ~45 KB | React HOC | 0 | Via React | `npm i framer-motion` |
181
195
  | GSAP | ~25 KB | Timeline | 0 | Manual | `npm i gsap` |
182
196
  | Lottie | ~55 KB | JSON animation | After Effects | Manual | `npm i lottie-web` |
183
- | **lite-ui-fx** | **< 5 KB** | **Canvas hijack** | **50 built-in** | **Native + visual** | **`npm i @zakkster/lite-ui-fx`** |
197
+ | **lite-ui-fx** | **< 5 KB** | **Canvas hijack** | **53 built-in** | **Native + visual** | **`npm i @zakkster/lite-ui-fx`** |
184
198
 
185
199
  ## Writing Custom Recipes
186
200
 
@@ -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.
@@ -1,11 +1,17 @@
1
1
  export declare const VERSION: string;
2
2
 
3
- export type UITypeValue = 'button' | 'toggle' | 'slider';
3
+ export type UITypeValue = 'button' | 'toggle' | 'slider' | 'checkbox' | 'progress' | 'knob';
4
4
 
5
5
  export declare const UIType: Readonly<{
6
6
  BUTTON: 'button';
7
7
  TOGGLE: 'toggle';
8
8
  SLIDER: 'slider';
9
+ /** Plain checkbox (no role=switch); indeterminate via setValue(null). */
10
+ CHECKBOX: 'checkbox';
11
+ /** Native <progress>, non-interactive; value driven by setValue. */
12
+ PROGRESS: 'progress';
13
+ /** <input type=range>; arrows native, pointer mapped by knobMode. */
14
+ KNOB: 'knob';
9
15
  }>;
10
16
 
11
17
  export interface UIFXState {
@@ -13,6 +19,8 @@ export interface UIFXState {
13
19
  active: boolean;
14
20
  focused: boolean;
15
21
  toggled: boolean;
22
+ /** CHECKBOX only: the native indeterminate state (set via setValue(null)). */
23
+ indeterminate: boolean;
16
24
  disabled: boolean;
17
25
  val: number;
18
26
  w: number;
@@ -59,6 +67,23 @@ export interface MountOptions {
59
67
  checked?: boolean;
60
68
  /** Disables the native element and sets state.disabled for recipes. */
61
69
  disabled?: boolean;
70
+ // -- Reserved recipe theming options (decisions/0002). Validated fail-closed,
71
+ // then forwarded to the recipe factory. All optional; omitting them keeps
72
+ // the recipe's shipped look byte-for-byte.
73
+ /** Seed for a recipe's deterministic RNG. */
74
+ seed?: number;
75
+ /** Positional palette override; wins over `theme`. */
76
+ colors?: string[];
77
+ /** Named theme roles: light -> accent, mid -> dim/muted, dark -> surface. */
78
+ theme?: { light: string; mid: string; dark: string };
79
+ /** Visible canvas label; falls back to `label`, then the recipe default. */
80
+ text?: string;
81
+ /** Canvas font string; falls back to the recipe's historical font. */
82
+ font?: string;
83
+ /** KNOB only: pointer-to-value mapping (default 'rotate'). Throws on any other type. */
84
+ knobMode?: 'rotate' | 'vertical';
85
+ /** PROGRESS only: opt-in aria-live announcements at 10% steps. Throws on any other type. */
86
+ announce?: boolean;
62
87
  }
63
88
 
64
89
  export interface UIFXInstance {
@@ -67,6 +92,21 @@ export interface UIFXInstance {
67
92
  wrapper: HTMLDivElement;
68
93
  state: UIFXState;
69
94
 
95
+ /**
96
+ * Set a valued control (SLIDER/KNOB/PROGRESS) to v in [0,1]: updates the
97
+ * native element, state.val, any PROGRESS announcer, and fires onDrag once.
98
+ * For a CHECKBOX, setValue(null) sets the indeterminate state. Throws on the
99
+ * wrong element type or an out-of-range value.
100
+ */
101
+ setValue(v: number | null): void;
102
+
103
+ /**
104
+ * Set a TOGGLE/CHECKBOX checked state: updates the native element,
105
+ * state.toggled, clears indeterminate, and fires onToggle exactly once.
106
+ * Throws on any other element type.
107
+ */
108
+ setChecked(b: boolean): void;
109
+
70
110
  destroy(): void;
71
111
  }
72
112
 
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.5.0';
26
26
 
27
27
  // ---------------------------------------------------------
28
28
  // SHARED TICKER (ref-counted, one RAF for all UI components)
@@ -84,7 +84,8 @@ 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', 'knobMode', 'announce'];
88
+ const KNOB_MODES = ['rotate', 'vertical'];
88
89
 
89
90
  // Levenshtein edit distance. Cold: only reached on the error path.
90
91
  function _editDistance(a, b) {
@@ -143,8 +144,15 @@ export const UIType = Object.freeze({
143
144
  BUTTON: 'button',
144
145
  TOGGLE: 'toggle',
145
146
  SLIDER: 'slider',
147
+ CHECKBOX: 'checkbox', // <input type=checkbox> WITHOUT role=switch (a check is not a switch)
148
+ PROGRESS: 'progress', // native <progress>, non-interactive, value driven programmatically
149
+ KNOB: 'knob', // <input type=range>, arrows native, canvas-side knobMode pointer map
146
150
  });
147
151
 
152
+ // The valid mount types, derived once from UIType so the controller guard, the
153
+ // recipe registry, and the d.ts never drift apart. Cold: read only at mount.
154
+ const _KNOWN_TYPES = new Set(Object.values(UIType));
155
+
148
156
 
149
157
  // =========================================================
150
158
  // UIFXController -- The Canvas Hijacker
@@ -177,11 +185,11 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
177
185
  throw new Error('mountUIFX: container must be a DOM element');
178
186
  }
179
187
 
180
- // 1b. type: exactly one of the three known element types. An unknown or
188
+ // 1b. type: exactly one of the known element types (UIType). An unknown or
181
189
  // undefined type is an Error here, never a silent default to a button
182
190
  // (fail closed -- the type selects the native element).
183
- if (type !== UIType.BUTTON && type !== UIType.TOGGLE && type !== UIType.SLIDER) {
184
- throw new Error('mountUIFX: type must be UIType.BUTTON, UIType.TOGGLE, or UIType.SLIDER');
191
+ if (!_KNOWN_TYPES.has(type)) {
192
+ throw new Error('mountUIFX: type must be one of UIType.BUTTON, TOGGLE, SLIDER, CHECKBOX, PROGRESS, KNOB');
185
193
  }
186
194
 
187
195
  // 2. options: unknown keys -> did-you-mean; value/checked/disabled
@@ -205,6 +213,55 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
205
213
  const height = options.height;
206
214
  const padding = options.padding === undefined ? 40 : options.padding;
207
215
  const label = options.label === undefined ? '' : options.label;
216
+ const knobMode = options.knobMode || 'rotate'; // KNOB pointer map; validated below
217
+ const announce = options.announce === true; // PROGRESS aria-live; validated below
218
+
219
+ // Reserved theming options (decisions/0002): all optional, validated fail
220
+ // closed here, then forwarded to the recipe factory which resolves them in
221
+ // init. This is cold mount code -- closures/allocation are fine here.
222
+ const _theme = options.theme;
223
+ if (_theme !== undefined) {
224
+ if (_theme === null || typeof _theme !== 'object' ||
225
+ typeof _theme.light !== 'string' || typeof _theme.mid !== 'string' ||
226
+ typeof _theme.dark !== 'string' || Object.keys(_theme).length !== 3) {
227
+ throw new Error('mountUIFX: option "theme" must be { light, mid, dark } of color strings');
228
+ }
229
+ }
230
+ const _colors = options.colors;
231
+ if (_colors !== undefined &&
232
+ (!Array.isArray(_colors) || _colors.some((c) => typeof c !== 'string'))) {
233
+ throw new Error('mountUIFX: option "colors" must be an array of color strings');
234
+ }
235
+ if (options.text !== undefined && typeof options.text !== 'string') {
236
+ throw new Error('mountUIFX: option "text" must be a string');
237
+ }
238
+ if (options.font !== undefined && typeof options.font !== 'string') {
239
+ throw new Error('mountUIFX: option "font" must be a string');
240
+ }
241
+ if (options.seed !== undefined &&
242
+ (typeof options.seed !== 'number' || !Number.isFinite(options.seed))) {
243
+ throw new Error('mountUIFX: option "seed" must be a finite number');
244
+ }
245
+
246
+ // Type-scoped options (U4a). knobMode belongs only to a KNOB; announce only
247
+ // to a PROGRESS. Presence on the wrong type is a mistake, not a silent
248
+ // ignore (fail closed). Both validated here, before any element exists.
249
+ if (options.knobMode !== undefined) {
250
+ if (type !== UIType.KNOB) {
251
+ throw new Error('mountUIFX: option "knobMode" is only valid for UIType.KNOB');
252
+ }
253
+ if (KNOB_MODES.indexOf(options.knobMode) === -1) {
254
+ throw new Error('mountUIFX: option "knobMode" must be "rotate" or "vertical"');
255
+ }
256
+ }
257
+ if (options.announce !== undefined) {
258
+ if (type !== UIType.PROGRESS) {
259
+ throw new Error('mountUIFX: option "announce" is only valid for UIType.PROGRESS');
260
+ }
261
+ if (typeof options.announce !== 'boolean') {
262
+ throw new Error('mountUIFX: option "announce" must be a boolean');
263
+ }
264
+ }
208
265
 
209
266
  // 3. recipeFactory
210
267
  if (typeof recipeFactory !== 'function') {
@@ -252,24 +309,37 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
252
309
 
253
310
  try {
254
311
  // -- Resolve dimensions --
255
- const w = width || (type === UIType.BUTTON ? 160 : type === UIType.SLIDER ? 200 : 64);
256
- const h = height || (type === UIType.BUTTON ? 48 : type === UIType.SLIDER ? 28 : 36);
312
+ // SLIDER/PROGRESS/KNOB share the 200x28 range geometry (so every vol.3
313
+ // re-home renders byte-identical to its slider era); CHECKBOX shares the
314
+ // 64x36 toggle geometry; BUTTON keeps 160x48.
315
+ const _rangeLike = type === UIType.SLIDER || type === UIType.PROGRESS || type === UIType.KNOB;
316
+ const w = width || (type === UIType.BUTTON ? 160 : _rangeLike ? 200 : 64);
317
+ const h = height || (type === UIType.BUTTON ? 48 : _rangeLike ? 28 : 36);
257
318
  let dpr = window.devicePixelRatio || 1;
258
319
 
259
320
  // -- Create native element (invisible, accessible, receives events) --
260
321
  let el;
261
- if (type === UIType.TOGGLE) {
322
+ if (type === UIType.TOGGLE || type === UIType.CHECKBOX) {
262
323
  el = document.createElement('input');
263
324
  el.type = 'checkbox';
264
- el.setAttribute('role', 'switch');
325
+ // TOGGLE is a switch; CHECKBOX is a plain checkbox. A check is not a
326
+ // switch -- U4a drops the role for CHECKBOX (the vol.3 mis-mount fix).
327
+ if (type === UIType.TOGGLE) el.setAttribute('role', 'switch');
265
328
  el.checked = checked; // coerced boolean; lands before frame 1
266
329
  if (label) el.setAttribute('aria-label', label);
267
- } else if (type === UIType.SLIDER) {
330
+ } else if (type === UIType.SLIDER || type === UIType.KNOB) {
268
331
  el = document.createElement('input');
269
332
  el.type = 'range';
270
333
  el.min = '0'; el.max = '100';
271
334
  el.value = value !== undefined ? String(value * 100) : '50'; // 0..1 -> 0..100
272
335
  if (label) el.setAttribute('aria-label', label);
336
+ } else if (type === UIType.PROGRESS) {
337
+ // Non-interactive: value is written programmatically (setValue) only,
338
+ // and exposed to assistive tech by the native <progress> element.
339
+ el = document.createElement('progress');
340
+ el.max = 1;
341
+ el.value = value !== undefined ? value : 0; // 0..1
342
+ if (label) el.setAttribute('aria-label', label);
273
343
  } else {
274
344
  el = document.createElement('button');
275
345
  el.textContent = label || 'Action';
@@ -289,7 +359,7 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
289
359
  // Slider thumb needs explicit sizing for hit area. One shared, ref-counted
290
360
  // <style> for all sliders (U-09): released in destroy() when the last slider
291
361
  // goes -- head child count nets to zero across mount/destroy.
292
- if (type === UIType.SLIDER) {
362
+ if (type === UIType.SLIDER || type === UIType.KNOB) {
293
363
  acquireSliderStyle();
294
364
  styleAcquired = true;
295
365
  el.classList.add('uifx-slider');
@@ -322,14 +392,32 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
322
392
  container.appendChild(wrapper);
323
393
  wrapperAppended = true;
324
394
 
395
+ // -- Optional aria-live announcer for PROGRESS (U4a). Opt-in via
396
+ // { announce: true }: a visually-hidden polite region that setValue
397
+ // updates at 10% steps. It lives in the wrapper, so wrapper.remove() in
398
+ // destroy() takes it with everything else -- no separate teardown. --
399
+ let announceRegion = null;
400
+ if (type === UIType.PROGRESS && announce) {
401
+ announceRegion = document.createElement('span');
402
+ announceRegion.setAttribute('aria-live', 'polite');
403
+ Object.assign(announceRegion.style, {
404
+ position: 'absolute', width: '1px', height: '1px',
405
+ overflow: 'hidden', clipPath: 'inset(50%)',
406
+ whiteSpace: 'nowrap', border: '0', padding: '0', margin: '-1px',
407
+ });
408
+ wrapper.appendChild(announceRegion);
409
+ }
410
+ let _lastAnnouncePct = -1; // last announced 10% step (cold: only setValue writes)
411
+
325
412
  // -- State (value/checked/disabled land here BEFORE frame 1) --
326
413
  const state = {
327
414
  hover: false,
328
415
  active: false, // pointer is down
329
416
  focused: false, // keyboard focus
330
417
  toggled: checked, // coerced boolean; element + state AGREE
418
+ indeterminate: false, // CHECKBOX only; set via setValue(null)
331
419
  disabled, // recipes can render a disabled look
332
- val: value !== undefined ? value : (type === UIType.SLIDER ? 0.5 : 0), // 0-1
420
+ val: value !== undefined ? value : (type === UIType.SLIDER || type === UIType.KNOB ? 0.5 : 0), // 0-1
333
421
  w, h, padding, dpr,
334
422
  };
335
423
 
@@ -339,6 +427,28 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
339
427
  // pointer event, then lazily filled once (see updatePointer).
340
428
  let rect = null;
341
429
 
430
+ // Apply a numeric value to a valued control (SLIDER/KNOB/PROGRESS): reflect
431
+ // it to the native element, update state, announce (PROGRESS), and fire
432
+ // onDrag exactly once when asked. A programmatic el.value write fires NO
433
+ // native 'input', so this explicit hook call is the only one -- no double
434
+ // fire. Cold path (setValue + knob drag), never a per-frame body.
435
+ function _applyVal(v, fireHook) {
436
+ state.val = v;
437
+ if (type === UIType.PROGRESS) {
438
+ el.value = v; // 0..1, native max=1
439
+ if (announceRegion) {
440
+ const pct = Math.round(v * 10) * 10;
441
+ if (pct !== _lastAnnouncePct) {
442
+ _lastAnnouncePct = pct;
443
+ announceRegion.textContent = pct + '%';
444
+ }
445
+ }
446
+ } else {
447
+ el.value = String(v * 100); // range 0..100
448
+ }
449
+ if (fireHook && recipe.onDrag) recipe.onDrag(v, pointer.vx, state);
450
+ }
451
+
342
452
  // -- Initialize recipe (already validated in phase 1: object, tick fn,
343
453
  // only known hooks). ctx exists now, so init can run. --
344
454
  if (recipe.init) recipe.init(ctx, w, h, padding);
@@ -390,9 +500,12 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
390
500
  el.addEventListener('focus', () => { state.focused = true; }, { signal });
391
501
  el.addEventListener('blur', () => { state.focused = false; }, { signal });
392
502
 
393
- // Toggle events
394
- if (type === UIType.TOGGLE) {
503
+ // Toggle + checkbox events (both are a native <input type=checkbox>)
504
+ if (type === UIType.TOGGLE || type === UIType.CHECKBOX) {
395
505
  el.addEventListener('change', () => {
506
+ // A user interaction resolves any indeterminate state (native does
507
+ // this too); keep state.indeterminate in agreement.
508
+ state.indeterminate = false;
396
509
  state.toggled = el.checked;
397
510
  if (recipe.onToggle) recipe.onToggle(state.toggled, state);
398
511
  }, { signal });
@@ -404,14 +517,51 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
404
517
  }, { signal });
405
518
  }
406
519
 
407
- // Slider events
408
- if (type === UIType.SLIDER) {
520
+ // Slider + knob value events. A range input fires 'input' on native drag
521
+ // (slider) AND on arrow keys (both) -- one path for keyboard on either type.
522
+ if (type === UIType.SLIDER || type === UIType.KNOB) {
409
523
  el.addEventListener('input', () => {
410
524
  state.val = el.value / 100;
411
525
  if (recipe.onDrag) recipe.onDrag(state.val, pointer.vx, state);
412
526
  }, { signal });
413
527
  }
414
528
 
529
+ // KNOB pointer remap (U4a). A range input maps value to horizontal thumb
530
+ // position; a knob maps a rotational or vertical drag instead. Arrow keys
531
+ // stay native (the 'input' handler above); for pointer we drive the value
532
+ // ourselves and preventDefault the native jump-to-pointer, restoring focus
533
+ // by hand. All cold: pointer handlers, no per-frame work.
534
+ if (type === UIType.KNOB) {
535
+ let knobActive = false;
536
+ let knobStartVal = 0;
537
+ let knobStartY = 0;
538
+ el.addEventListener('pointerdown', (e) => {
539
+ knobActive = true;
540
+ knobStartVal = state.val;
541
+ knobStartY = e.clientY;
542
+ refreshRect();
543
+ el.focus();
544
+ e.preventDefault(); // suppress the range's native jump-to-pointer
545
+ if (el.setPointerCapture) el.setPointerCapture(e.pointerId);
546
+ }, { signal });
547
+ el.addEventListener('pointermove', (e) => {
548
+ if (!knobActive) return;
549
+ let v;
550
+ if (knobMode === 'vertical') {
551
+ v = knobStartVal + (knobStartY - e.clientY) / 150; // 150px = full sweep
552
+ } else {
553
+ const cx = rect ? rect.left + rect.width / 2 : e.clientX;
554
+ const cy = rect ? rect.top + rect.height / 2 : e.clientY;
555
+ let a = Math.atan2(e.clientY - cy, e.clientX - cx); // -PI..PI
556
+ a = (a + Math.PI * 2.5) % (Math.PI * 2); // 0 at bottom, clockwise
557
+ v = a / (Math.PI * 2);
558
+ }
559
+ if (v < 0) v = 0; else if (v > 1) v = 1;
560
+ _applyVal(v, true); // reflect + fire onDrag once
561
+ }, { signal });
562
+ el.addEventListener('pointerup', () => { knobActive = false; }, { signal });
563
+ }
564
+
415
565
  // -- DPR re-read on display change (cold, feature-detected). Absent
416
566
  // matchMedia is a silent no-op: the canvas stays at mount DPR (fail
417
567
  // closed, never throw). Listener bound to signal for teardown. --
@@ -470,6 +620,50 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
470
620
  /** Current state (read-only reference). */
471
621
  state,
472
622
 
623
+ /**
624
+ * Programmatically set a valued control (SLIDER/KNOB/PROGRESS) to v in
625
+ * [0,1]: updates the native element, state.val, any PROGRESS announcer,
626
+ * and fires onDrag exactly once (a programmatic write emits no native
627
+ * event, so there is no second fire). For a CHECKBOX, setValue(null)
628
+ * sets the indeterminate state. Fail closed on wrong type / bad value.
629
+ */
630
+ setValue(v) {
631
+ if (destroyed) return;
632
+ if (type === UIType.CHECKBOX) {
633
+ if (v === null) {
634
+ el.indeterminate = true;
635
+ state.indeterminate = true;
636
+ return;
637
+ }
638
+ throw new Error('setValue: a checkbox takes setChecked(bool), or setValue(null) for indeterminate');
639
+ }
640
+ if (type !== UIType.SLIDER && type !== UIType.KNOB && type !== UIType.PROGRESS) {
641
+ throw new Error('setValue: only SLIDER, KNOB, and PROGRESS carry a numeric value');
642
+ }
643
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1) {
644
+ throw new Error('setValue: v must be a number in [0,1]');
645
+ }
646
+ _applyVal(v, true);
647
+ },
648
+
649
+ /**
650
+ * Programmatically set a TOGGLE/CHECKBOX checked state: updates the
651
+ * native element, state.toggled, clears indeterminate, and fires
652
+ * onToggle exactly once. Fail closed on the wrong type.
653
+ */
654
+ setChecked(b) {
655
+ if (destroyed) return;
656
+ if (type !== UIType.TOGGLE && type !== UIType.CHECKBOX) {
657
+ throw new Error('setChecked: only TOGGLE and CHECKBOX carry a checked state');
658
+ }
659
+ const nb = !!b;
660
+ el.indeterminate = false;
661
+ el.checked = nb;
662
+ state.indeterminate = false;
663
+ state.toggled = nb;
664
+ if (recipe.onToggle) recipe.onToggle(nb, state);
665
+ },
666
+
473
667
  /** Destroy everything. Idempotent. */
474
668
  destroy() {
475
669
  if (destroyed) return;
@@ -478,7 +672,7 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
478
672
  removeTick();
479
673
  if (recipe.destroy) recipe.destroy();
480
674
  releaseTicker();
481
- if (type === UIType.SLIDER) releaseSliderStyle();
675
+ if (type === UIType.SLIDER || type === UIType.KNOB) releaseSliderStyle();
482
676
  wrapper.remove();
483
677
  },
484
678
  };