@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 +101 -0
- package/README.md +24 -10
- package/UIFX-RECIPE-GUIDE.md +3 -2
- package/UIFXController.d.ts +41 -1
- package/UIFXController.js +211 -17
- package/UIFXRecipes.d.ts +94 -53
- package/UIFXRecipes.js +669 -282
- package/llms.txt +33 -10
- package/package.json +1 -1
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
|
-
**
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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** | **
|
|
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
|
|
package/UIFX-RECIPE-GUIDE.md
CHANGED
|
@@ -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. **
|
|
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.
|
package/UIFXController.d.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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 (
|
|
184
|
-
throw new Error('mountUIFX: type must be UIType.BUTTON,
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|