@zakkster/lite-ui-fx 1.9.0 → 1.10.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 +69 -0
- package/README.md +29 -0
- package/UIFXController.d.ts +81 -0
- package/UIFXController.js +381 -1
- package/UIFXHeadless.d.ts +23 -0
- package/UIFXHeadless.js +23 -0
- package/UIFXRecipes.d.ts +30 -2
- package/UIFXRecipes.js +210 -1
- package/llms.txt +39 -3
- package/package.json +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,75 @@ 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.10.0] -- 2026-09-08
|
|
9
|
+
|
|
10
|
+
Enrichment session E1: skinHeadless, a fourth mount adapter that paints a
|
|
11
|
+
`@zakkster/lite-headless` primitive by observing the state attributes it paints.
|
|
12
|
+
Additive -- the three existing mount modes and the 57-recipe registry are
|
|
13
|
+
byte-identical.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `skinHeadless(handle, recipeFactory, options)` -- a fourth mount adapter, exported
|
|
18
|
+
from `.` and from the new `./headless` subpath. It places an overlay canvas over
|
|
19
|
+
the element a `@zakkster/lite-headless` primitive paints on and drives a recipe from
|
|
20
|
+
that primitive's painted state attributes via one `MutationObserver`, parsed at
|
|
21
|
+
event time (never per frame). It couples through the painted-attribute contract
|
|
22
|
+
only: `@zakkster/lite-headless` is imported nowhere and is not a dependency.
|
|
23
|
+
Structurally a decoration -- no native element, host byte-identical, one overlay
|
|
24
|
+
canvas + one observer removed on `destroy()`, the primitive handle never destroyed.
|
|
25
|
+
`setValue`/`setChecked` throw. See decisions/0008.
|
|
26
|
+
- The `./headless` subpath (`UIFXHeadless.js` + `UIFXHeadless.d.ts`) and four headless
|
|
27
|
+
skins: `SwitchSkin` (data-checked/aria-checked), `SliderSkin` (aria-valuenow/min/max
|
|
28
|
+
+ data-dragging), `ProgressSkin` (aria-valuenow/max + data-complete/data-loading),
|
|
29
|
+
`RatingSkin` (aria-valuenow/max). They live in a registry (`HEADLESS_SKINS`,
|
|
30
|
+
`SKIN_META`, `SKIN_NAMES`) separate from `RECIPES`/`RECIPE_META`, so the 57-recipe
|
|
31
|
+
count is unchanged. Each resolves theme in init and allocates zero bytes per frame,
|
|
32
|
+
gated by a new torture tier (t6); the existing alloc baseline is unchanged.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- `package.json` `description`: corrected the recipe count (was "50", now 57) and
|
|
37
|
+
named the three mount modes plus the headless-skin adapter.
|
|
38
|
+
- `llms.txt` and `UIFXRecipes.d.ts`: corrected a stale recipe count ("56" -> 57 in the
|
|
39
|
+
themeable and default-namespace notes) left over from the U7 addition of the 57th
|
|
40
|
+
recipe. Shipped-doc accuracy only; no code path changed.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
none
|
|
45
|
+
|
|
46
|
+
### Removed
|
|
47
|
+
|
|
48
|
+
none
|
|
49
|
+
|
|
50
|
+
## [1.9.1] -- 2026-09-07
|
|
51
|
+
|
|
52
|
+
Patch: a rendering fix for the ReactionPicker recipe.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
none
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- demo (not shipped in the package): the `passwordStrength` showcase input is
|
|
61
|
+
seeded with a weak value (`abc`) instead of an already-maximal one, so typing
|
|
62
|
+
visibly moves the strength meter. The recipe itself was unchanged and correct.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- ReactionPicker: the emoji faces inherited the circle's translucent `fillStyle`
|
|
67
|
+
(0.04 alpha at rest), so a colour glyph rendered at 4% opacity and the faces
|
|
68
|
+
were invisible until hover. An opaque fill (`colors[i]`) is now set before each
|
|
69
|
+
glyph; all five faces render at rest, and hover still inflates and highlights
|
|
70
|
+
the selected one. One precomputed array read per glyph -- the frame path stays
|
|
71
|
+
zero-allocation (torture alloc = 0.8759765625 B/op, unchanged).
|
|
72
|
+
|
|
73
|
+
### Removed
|
|
74
|
+
|
|
75
|
+
none
|
|
76
|
+
|
|
8
77
|
## [1.9.0] -- 2026-09-07
|
|
9
78
|
|
|
10
79
|
Grouped controls (roadmap U7): a third mount mode for a control that is N native
|
package/README.md
CHANGED
|
@@ -50,6 +50,7 @@ Three runtime dependencies, all zero-GC (`@zakkster/lite-ticker`, `lite-lerp`, `
|
|
|
50
50
|
- [mountUIFX](#mountuifxcontainer-type-recipefactory-options)
|
|
51
51
|
- [decorateUIFX](#decorateuifxel-recipefactory-options)
|
|
52
52
|
- [mountUIFXGroup](#mountuifxgroupcontainer-grouptype-recipefactory-options)
|
|
53
|
+
- [skinHeadless](#skinheadlesshandle-recipefactory-options)
|
|
53
54
|
- [The recipe registry](#the-recipe-registry)
|
|
54
55
|
- [Constants: UITypes, state, META](#constants-uitypes-state-meta)
|
|
55
56
|
- [Host clock and reduced motion](#host-clock-and-reduced-motion)
|
|
@@ -79,6 +80,7 @@ The alternative is a hand-rolled canvas threshold loop (no a11y, allocates freel
|
|
|
79
80
|
- **`mountUIFX(container, type, recipeFactory, options?)`** -- the hijack mount. Creates a real native element (invisible, accessible) under a DPR-scaled canvas and drives the recipe. Six element types: `TOGGLE`, `BUTTON`, `SLIDER`, `CHECKBOX`, `PROGRESS`, `KNOB`.
|
|
80
81
|
- **`decorateUIFX(el, recipeFactory, options?)`** -- the decorate mount. Places a canvas *around* an existing visible element (a live `<input>`), reading `state.text`/`state.valid` from the host's own events. The host is byte-identical before and after; `destroy()` removes only the overlay.
|
|
81
82
|
- **`mountUIFXGroup(container, groupType, recipeFactory, options)`** -- the group mount. N native elements + one canvas + one recipe: `RADIO`/`RATING` (a fieldset radiogroup), `TABS` (an APG tablist with roving tabindex), `STEPPER` (a spinbutton). The recipe reads `state.index`/`state.count`; selection and keyboard are the native elements' own.
|
|
83
|
+
- **`skinHeadless(handle, recipeFactory, options)`** -- the headless-skin adapter (on the `./headless` subpath). Paints a `@zakkster/lite-headless` primitive by observing the state attributes it paints -- never importing lite-headless, so it stays a compose-target, not a dependency.
|
|
82
84
|
- **57 built-in recipes** on the `./recipes` subpath, versioned, typed, and tree-shakeable. With `sideEffects: false`, importing one recipe drops the other 56. Families: Toggles (7), Buttons (9), Sliders (7), Knobs (2), Progress (4), Checkboxes (4), Loaders (2), Counters (2), Rating (1), Controls (4), Indicators (3), Mood (3), Feedback (3), Fun (3), Form decorations (3).
|
|
83
85
|
- **A registry for data-driven UIs** -- `RECIPES` (id -> factory, null-prototype), `RECIPE_META` (`{ id, name, type, family, themeable, motionSafe }`), `RECIPE_NAMES`, `registerRecipe(id, factory, meta)`, and `mountRecipe(container, id, options?)` which resolves the id fail-closed (did-you-mean on a typo) and mounts it as its declared type.
|
|
84
86
|
- **One option convention for theming** -- `{ colors, theme: { light, mid, dark }, text, font }` honoured by all 57 recipes, resolved once in `init` so a themed mount stays zero-GC and a bare mount is byte-identical to pre-theming.
|
|
@@ -205,6 +207,32 @@ tabs.destroy();
|
|
|
205
207
|
|
|
206
208
|
Built-in group recipes: `PillTabs`, `SegmentedSlide` (`TABS`), `RadioOrbit` (`RADIO`), `Stepper` (`STEPPER`), `BubbleRating` (`RATING`). The first four re-home from their vol.3 single-element fakes to real groups (so their arrow-key selection is finally correct); `mountRecipe(container, id, { items })` routes them here by `META.type`.
|
|
207
209
|
|
|
210
|
+
### `skinHeadless(handle, recipeFactory, options)`
|
|
211
|
+
|
|
212
|
+
The fourth mount adapter, on the `./headless` subpath: **skin a `@zakkster/lite-headless` primitive.** lite-headless ships ARIA-correct primitives that render nothing and paint a canonical set of state attributes; `skinHeadless` places a canvas over the element a primitive paints on and drives a recipe from those attributes -- lite-ui-fx paints, lite-headless behaves. It couples through the painted-attribute contract, never an import, so **lite-headless is never a dependency**. Structurally a decoration: the host is byte-identical, one overlay canvas + one `MutationObserver` are removed on `destroy()`, and the primitive `handle` is never destroyed (the caller owns it).
|
|
213
|
+
|
|
214
|
+
`options`: `host` (the element the primitive paints on, **required**) plus `padding`, `seed`, `colors`, `theme`, `text`, `font`, `ticker`, `driven`. `setValue`/`setChecked` throw -- a skin reflects the primitive, it does not drive it.
|
|
215
|
+
|
|
216
|
+
A skin is an ordinary recipe plus a descriptor -- `recipe.headless = { attrs, read(host, handle, state) }`. `skinHeadless` observes `attrs` and calls `read()` at **event time** (never per frame) to parse the painted state into preallocated slots. A painted attribute is truthy when present with any value but `"false"` (so both a boolean `data-disabled` and a value `data-checked="true"` work). The four E1 skins live in a registry (`HEADLESS_SKINS` / `SKIN_META`) separate from the 57 recipes.
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
import { skinHeadless, SwitchSkin } from '@zakkster/lite-ui-fx/headless';
|
|
220
|
+
|
|
221
|
+
// `sw` is your @zakkster/lite-headless primitive (e.g. createSwitch({ ... })).
|
|
222
|
+
// The skin never imports lite-headless -- it observes the attributes it paints.
|
|
223
|
+
const sw = null; // <- your lite-headless switch handle
|
|
224
|
+
const thumb = document.querySelector('[data-switch-thumb]');
|
|
225
|
+
|
|
226
|
+
const skin = skinHeadless(sw, SwitchSkin, {
|
|
227
|
+
host: thumb,
|
|
228
|
+
theme: { light: '#38bdf8', mid: '#3a3a4a', dark: '#0a0a12' },
|
|
229
|
+
});
|
|
230
|
+
// the overlay now tracks data-checked / aria-checked as the switch paints them
|
|
231
|
+
skin.destroy(); // host + handle left untouched
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
E1 skins: `SwitchSkin` (`data-checked`), `SliderSkin` (`aria-valuenow`/`min`/`max`), `ProgressSkin` (`aria-valuenow`/`max` + `data-complete`/`data-loading`), `RatingSkin` (`aria-valuenow`/`max`). See [0008](decisions/0008-headless-skins.md).
|
|
235
|
+
|
|
208
236
|
### The recipe registry
|
|
209
237
|
|
|
210
238
|
```js
|
|
@@ -348,6 +376,7 @@ Each is an ADR under [`decisions/`](decisions/):
|
|
|
348
376
|
- **[0005](decisions/0005-host-clock.md) -- Host clock, reduced motion, frame budget.** Three clock modes, `state.reducedMotion` as a flag the recipe reads, `state.budget` for graceful degradation -- all additive, default path byte-identical.
|
|
349
377
|
- **[0006](decisions/0006-docs-and-demo.md) -- Blueprint docs + a demo that consumes the package.** This README on the blueprint spine, and one demo generated from `RECIPE_META` that imports only public exports (no more inline reimplementation).
|
|
350
378
|
- **[0007](decisions/0007-group-contract.md) -- Grouped controls: one canvas, N native elements.** A third mount mode (`mountUIFXGroup`) for radio/tabs/stepper/rating; `onSelect` is a ninth, group-only hook and group state a superset of scalar state, so the single-element API is byte-identical (additive, 1.9.0).
|
|
379
|
+
- **[0008](decisions/0008-headless-skins.md) -- Headless skins: paint a lite-headless primitive.** `skinHeadless` couples through the painted-attribute contract (one `MutationObserver`, parsed at event time), never an import -- so lite-headless is a compose-target, never a dependency. The four skins live in a registry separate from the 57 recipes (additive, 1.10.0).
|
|
351
380
|
|
|
352
381
|
---
|
|
353
382
|
|
package/UIFXController.d.ts
CHANGED
|
@@ -232,6 +232,87 @@ export declare function decorateUIFX(
|
|
|
232
232
|
options?: DecorateOptions
|
|
233
233
|
): DecorateInstance;
|
|
234
234
|
|
|
235
|
+
// =========================================================
|
|
236
|
+
// skinHeadless -- paint a lite-headless primitive (decisions/0008, E1)
|
|
237
|
+
// =========================================================
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The descriptor that makes a recipe skinnable: the painted attributes to observe
|
|
241
|
+
* and how to parse them into recipe state. read() runs at EVENT time (init + every
|
|
242
|
+
* mutation), never per frame -- so it may read attributes freely.
|
|
243
|
+
*/
|
|
244
|
+
export interface HeadlessSkinDescriptor {
|
|
245
|
+
/** The painted state attributes to observe (MutationObserver attributeFilter). */
|
|
246
|
+
attrs: string[];
|
|
247
|
+
/** Parse the primitive's painted attributes into the preallocated state slots
|
|
248
|
+
* (state.val/toggled/disabled/complete/... ). `handle` is the lite-headless
|
|
249
|
+
* primitive handle for an optional signal fast path, or null. */
|
|
250
|
+
read(host: HTMLElement, handle: any, state: UIFXState): void;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** A recipe usable with skinHeadless: an ordinary recipe plus a `headless`
|
|
254
|
+
* descriptor. Additive to UIFXRecipe -- no hook is added or changed. */
|
|
255
|
+
export interface HeadlessSkinRecipe extends UIFXRecipe {
|
|
256
|
+
headless: HeadlessSkinDescriptor;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export type HeadlessSkinRecipeFactory = (options?: SkinOptions) => HeadlessSkinRecipe;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Options accepted by skinHeadless. The decorate subset PLUS `host` (the element
|
|
263
|
+
* the primitive paints on, REQUIRED). The hijack-only options are rejected.
|
|
264
|
+
*/
|
|
265
|
+
export interface SkinOptions extends HostClockOptions {
|
|
266
|
+
/** The element the lite-headless primitive paints on: the overlay + observer
|
|
267
|
+
* target (REQUIRED). */
|
|
268
|
+
host: HTMLElement;
|
|
269
|
+
/** Overlay padding around the host, in px (default 40). */
|
|
270
|
+
padding?: number;
|
|
271
|
+
seed?: number;
|
|
272
|
+
colors?: string[];
|
|
273
|
+
theme?: { light: string; mid: string; dark: string };
|
|
274
|
+
text?: string;
|
|
275
|
+
font?: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The instance returned by skinHeadless. Like DecorateInstance: no wrapper, and
|
|
280
|
+
* setValue/setChecked are hijack-only (a skin reflects the primitive, it does not
|
|
281
|
+
* drive it, so both throw).
|
|
282
|
+
*/
|
|
283
|
+
export interface SkinInstance {
|
|
284
|
+
/** The skinned host element (unchanged -- skinHeadless never mutates it). */
|
|
285
|
+
el: HTMLElement;
|
|
286
|
+
/** The overlay canvas (the only DOM node skinHeadless adds). */
|
|
287
|
+
canvas: HTMLCanvasElement;
|
|
288
|
+
state: UIFXState;
|
|
289
|
+
/** Drive one frame by hand (U5). Callable ONLY with { driven: true }. */
|
|
290
|
+
tick(dtMs: number): void;
|
|
291
|
+
/** Hijack-only. Throws in skin mode. */
|
|
292
|
+
setValue(v?: number | null): void;
|
|
293
|
+
/** Hijack-only. Throws in skin mode. */
|
|
294
|
+
setChecked(b?: boolean): void;
|
|
295
|
+
/** Remove the overlay + observer + every listener the skin added; the host AND
|
|
296
|
+
* the lite-headless handle are left untouched. Idempotent. */
|
|
297
|
+
destroy(): void;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Skin a @zakkster/lite-headless primitive: place a canvas over the element it
|
|
302
|
+
* paints on and drive a recipe from the primitive's PAINTED state attributes --
|
|
303
|
+
* lite-ui-fx paints, lite-headless behaves. Arm's-length: it couples through the
|
|
304
|
+
* painted-attribute contract (lite-headless docs/CSS_CONTRACT.md), NEVER an import,
|
|
305
|
+
* so lite-headless is never a dependency. Structurally a decoration (0004): no
|
|
306
|
+
* native element, host byte-identical, one overlay canvas + one MutationObserver
|
|
307
|
+
* removed on destroy; the `handle` is never destroyed (the caller owns it). The
|
|
308
|
+
* recipe MUST carry a `headless` descriptor. See decisions/0008.
|
|
309
|
+
*/
|
|
310
|
+
export declare function skinHeadless(
|
|
311
|
+
handle: any,
|
|
312
|
+
recipeFactory: HeadlessSkinRecipeFactory,
|
|
313
|
+
options: SkinOptions
|
|
314
|
+
): SkinInstance;
|
|
315
|
+
|
|
235
316
|
// =========================================================
|
|
236
317
|
// Grouped controls (U7, decisions/0007)
|
|
237
318
|
// =========================================================
|
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.10.0';
|
|
26
26
|
|
|
27
27
|
// ---------------------------------------------------------
|
|
28
28
|
// SHARED TICKER (ref-counted, one RAF for all UI components)
|
|
@@ -124,6 +124,10 @@ const KNOB_MODES = ['rotate', 'vertical'];
|
|
|
124
124
|
// ARE valid in decorate mode: a decoration wants host-clock control every bit as
|
|
125
125
|
// much as a hijack does. Cold: read only at mount.
|
|
126
126
|
const DECORATE_OPTIONS = ['padding', 'seed', 'colors', 'theme', 'text', 'font', 'ticker', 'driven'];
|
|
127
|
+
// skinHeadless (E1, decisions/0008) takes the decorate subset PLUS `host` -- the
|
|
128
|
+
// element the lite-headless primitive paints its state attributes on (REQUIRED; the
|
|
129
|
+
// overlay + MutationObserver target). The hijack-only keys throw, same as decorate.
|
|
130
|
+
const HEADLESS_OPTIONS = ['host', 'padding', 'seed', 'colors', 'theme', 'text', 'font', 'ticker', 'driven'];
|
|
127
131
|
|
|
128
132
|
// Levenshtein edit distance. Cold: only reached on the error path.
|
|
129
133
|
function _editDistance(a, b) {
|
|
@@ -1209,6 +1213,382 @@ export function decorateUIFX(el, recipeFactory, options = {}) {
|
|
|
1209
1213
|
}
|
|
1210
1214
|
}
|
|
1211
1215
|
|
|
1216
|
+
// =========================================================
|
|
1217
|
+
// skinHeadless -- paint a lite-headless primitive (decisions/0008, E1)
|
|
1218
|
+
// =========================================================
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Skin a @zakkster/lite-headless primitive: place a canvas over the element the
|
|
1222
|
+
* primitive paints on and drive a recipe from the primitive's PAINTED state
|
|
1223
|
+
* attributes -- lite-ui-fx paints, lite-headless behaves. Arm's-length: it
|
|
1224
|
+
* couples through the attribute contract (docs/CSS_CONTRACT.md), NOT an import, so
|
|
1225
|
+
* lite-headless is never a dependency. Structurally a decoration (0004): no native
|
|
1226
|
+
* element, host never reparented/restyled, one overlay canvas + its listeners + one
|
|
1227
|
+
* MutationObserver; on destroy the host is byte-identical and the handle untouched.
|
|
1228
|
+
*
|
|
1229
|
+
* The recipe MUST be a headless-skin recipe: it carries
|
|
1230
|
+
* recipe.headless = { attrs: string[], read(host, handle, state) }
|
|
1231
|
+
* where `attrs` is the MutationObserver attributeFilter and `read` parses the
|
|
1232
|
+
* painted attributes into preallocated `state` slots (val/toggled/disabled/...),
|
|
1233
|
+
* called once at init and on every mutation -- EVENT time, never per frame.
|
|
1234
|
+
*
|
|
1235
|
+
* @param {Object} handle The lite-headless primitive handle (duck-typed;
|
|
1236
|
+
* passed to read() for an optional signal fast path;
|
|
1237
|
+
* NEVER destroyed here). May be null.
|
|
1238
|
+
* @param {Function} recipeFactory (options) => Recipe with a `headless` descriptor.
|
|
1239
|
+
* @param {Object} options { host: Element (REQUIRED), padding=40, seed,
|
|
1240
|
+
* colors, theme, text, font, ticker, driven }.
|
|
1241
|
+
* @returns {{ el, canvas, state, tick, setValue, setChecked, destroy }}
|
|
1242
|
+
*/
|
|
1243
|
+
export function skinHeadless(handle, recipeFactory, options = {}) {
|
|
1244
|
+
// =====================================================================
|
|
1245
|
+
// PHASE 1 -- VALIDATION ONLY (fail closed; mirrors decorateUIFX). No
|
|
1246
|
+
// createElement, no observer, no ticker acquire, no recipe.init until
|
|
1247
|
+
// every check below has passed.
|
|
1248
|
+
// =====================================================================
|
|
1249
|
+
|
|
1250
|
+
// 1. handle: duck-typed. It is optional context for read()'s fast path and is
|
|
1251
|
+
// never destroyed by skinHeadless. Only a non-nullish non-object is a
|
|
1252
|
+
// mistake (a primitive handle is an object).
|
|
1253
|
+
if (handle !== null && handle !== undefined && typeof handle !== 'object') {
|
|
1254
|
+
throw new Error('skinHeadless: handle must be a lite-headless primitive handle (an object) or null');
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// 2. options: the decorate subset + `host`. Unknown key -> did-you-mean; a
|
|
1258
|
+
// hijack-only key -> a clear "not valid in skin mode". Both fail closed.
|
|
1259
|
+
for (const k in options) {
|
|
1260
|
+
if (!Object.prototype.hasOwnProperty.call(options, k)) continue;
|
|
1261
|
+
if (HEADLESS_OPTIONS.indexOf(k) === -1) {
|
|
1262
|
+
if (KNOWN_OPTIONS.indexOf(k) !== -1) {
|
|
1263
|
+
throw new Error('skinHeadless: option "' + k + '" is not valid in skin mode (hijack-only)');
|
|
1264
|
+
}
|
|
1265
|
+
throw new Error(_didYouMean('skinHeadless: unknown option', k, HEADLESS_OPTIONS));
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// 3. host: a live, attached DOM element -- the overlay is a sibling of it and
|
|
1270
|
+
// the observer watches it. A detached host has no parentNode to host the
|
|
1271
|
+
// canvas: an Error, never a silent no-op (mirrors decorate's el checks).
|
|
1272
|
+
const host = options.host;
|
|
1273
|
+
if (!host || typeof host.getBoundingClientRect !== 'function' ||
|
|
1274
|
+
typeof host.setAttribute !== 'function') {
|
|
1275
|
+
throw new Error('skinHeadless: options.host must be a DOM element (the element the primitive paints on)');
|
|
1276
|
+
}
|
|
1277
|
+
if (!host.parentNode || typeof host.parentNode.insertBefore !== 'function') {
|
|
1278
|
+
throw new Error('skinHeadless: options.host must be attached to the DOM (no parentNode to host the overlay)');
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const padding = options.padding === undefined ? 40 : options.padding;
|
|
1282
|
+
|
|
1283
|
+
// Theming options (decisions/0002): validated fail closed, forwarded to the
|
|
1284
|
+
// recipe factory which resolves them in init. Cold mount code.
|
|
1285
|
+
const _theme = options.theme;
|
|
1286
|
+
if (_theme !== undefined) {
|
|
1287
|
+
if (_theme === null || typeof _theme !== 'object' ||
|
|
1288
|
+
typeof _theme.light !== 'string' || typeof _theme.mid !== 'string' ||
|
|
1289
|
+
typeof _theme.dark !== 'string' || Object.keys(_theme).length !== 3) {
|
|
1290
|
+
throw new Error('skinHeadless: option "theme" must be { light, mid, dark } of color strings');
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
const _colors = options.colors;
|
|
1294
|
+
if (_colors !== undefined &&
|
|
1295
|
+
(!Array.isArray(_colors) || _colors.some((c) => typeof c !== 'string'))) {
|
|
1296
|
+
throw new Error('skinHeadless: option "colors" must be an array of color strings');
|
|
1297
|
+
}
|
|
1298
|
+
if (options.text !== undefined && typeof options.text !== 'string') {
|
|
1299
|
+
throw new Error('skinHeadless: option "text" must be a string');
|
|
1300
|
+
}
|
|
1301
|
+
if (options.font !== undefined && typeof options.font !== 'string') {
|
|
1302
|
+
throw new Error('skinHeadless: option "font" must be a string');
|
|
1303
|
+
}
|
|
1304
|
+
if (options.seed !== undefined &&
|
|
1305
|
+
(typeof options.seed !== 'number' || !Number.isFinite(options.seed))) {
|
|
1306
|
+
throw new Error('skinHeadless: option "seed" must be a finite number');
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Host clock (U5, decisions/0005) -- same three modes as decorate.
|
|
1310
|
+
const callerTicker = options.ticker;
|
|
1311
|
+
if (options.driven !== undefined && typeof options.driven !== 'boolean') {
|
|
1312
|
+
throw new Error('skinHeadless: option "driven" must be a boolean');
|
|
1313
|
+
}
|
|
1314
|
+
const driven = options.driven === true;
|
|
1315
|
+
if (callerTicker !== undefined) {
|
|
1316
|
+
if (driven) {
|
|
1317
|
+
throw new Error('skinHeadless: options "ticker" and "driven" are mutually exclusive');
|
|
1318
|
+
}
|
|
1319
|
+
if (!callerTicker || typeof callerTicker.add !== 'function') {
|
|
1320
|
+
throw new Error('skinHeadless: option "ticker" must be a ticker with an .add(fn) method');
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// 4. recipeFactory + recipe object + hooks (same 8-hook contract as decorate).
|
|
1325
|
+
if (typeof recipeFactory !== 'function') {
|
|
1326
|
+
throw new Error('skinHeadless: recipeFactory must be a function');
|
|
1327
|
+
}
|
|
1328
|
+
const recipe = recipeFactory(options);
|
|
1329
|
+
if (!recipe || typeof recipe !== 'object') {
|
|
1330
|
+
throw new Error('skinHeadless: recipe must be an object');
|
|
1331
|
+
}
|
|
1332
|
+
if (typeof recipe.tick !== 'function') {
|
|
1333
|
+
throw new Error('skinHeadless: recipe.tick must be a function');
|
|
1334
|
+
}
|
|
1335
|
+
for (const k in recipe) {
|
|
1336
|
+
if (!Object.prototype.hasOwnProperty.call(recipe, k)) continue;
|
|
1337
|
+
if (typeof recipe[k] === 'function' && KNOWN_HOOKS.indexOf(k) === -1) {
|
|
1338
|
+
throw new Error(_didYouMean('skinHeadless: unknown recipe hook', k, KNOWN_HOOKS));
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// 5. The headless descriptor -- what makes a recipe skinnable. A plain recipe
|
|
1343
|
+
// has no attribute->state mapping, so skinning it is a category error caught
|
|
1344
|
+
// here (fail closed), not a silent no-paint later.
|
|
1345
|
+
const hspec = recipe.headless;
|
|
1346
|
+
if (!hspec || typeof hspec !== 'object' || !Array.isArray(hspec.attrs) ||
|
|
1347
|
+
hspec.attrs.length === 0 || hspec.attrs.some((a) => typeof a !== 'string') ||
|
|
1348
|
+
typeof hspec.read !== 'function') {
|
|
1349
|
+
throw new Error('skinHeadless: recipe must be a headless-skin recipe with recipe.headless = { attrs: string[], read(host, handle, state) } -- a plain recipe cannot be skinned (use mountUIFX or decorateUIFX)');
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// =====================================================================
|
|
1353
|
+
// PHASE 2 -- SIDE EFFECTS (fail-closed unwind, mirrors decorate). The
|
|
1354
|
+
// acquisitions are the overlay canvas, the MutationObserver, the
|
|
1355
|
+
// AbortController, and the shared ticker -- unwound in reverse on a throw.
|
|
1356
|
+
// =====================================================================
|
|
1357
|
+
let canvasAppended = false;
|
|
1358
|
+
let moConnected = false;
|
|
1359
|
+
let acCreated = false;
|
|
1360
|
+
let tickerAcquired = false;
|
|
1361
|
+
let canvas = null;
|
|
1362
|
+
let ac = null;
|
|
1363
|
+
let mo = null;
|
|
1364
|
+
let removeTick = null;
|
|
1365
|
+
|
|
1366
|
+
try {
|
|
1367
|
+
// -- Placement from host's OFFSET box (0004 decision 2): the overlay is a
|
|
1368
|
+
// SIBLING of host, sharing its offsetParent, so it lands over host without
|
|
1369
|
+
// writing any style onto the parent. Read once, refreshed on resize only. --
|
|
1370
|
+
let ow = host.offsetWidth;
|
|
1371
|
+
let oh = host.offsetHeight;
|
|
1372
|
+
let dpr = window.devicePixelRatio || 1;
|
|
1373
|
+
let cw = ow + padding * 2;
|
|
1374
|
+
let ch = oh + padding * 2;
|
|
1375
|
+
|
|
1376
|
+
canvas = document.createElement('canvas');
|
|
1377
|
+
canvas.width = cw * dpr;
|
|
1378
|
+
canvas.height = ch * dpr;
|
|
1379
|
+
Object.assign(canvas.style, {
|
|
1380
|
+
position: 'absolute',
|
|
1381
|
+
left: (host.offsetLeft - padding) + 'px',
|
|
1382
|
+
top: (host.offsetTop - padding) + 'px',
|
|
1383
|
+
width: cw + 'px', height: ch + 'px',
|
|
1384
|
+
pointerEvents: 'none',
|
|
1385
|
+
});
|
|
1386
|
+
const ctx = canvas.getContext('2d');
|
|
1387
|
+
ctx.scale(dpr, dpr);
|
|
1388
|
+
|
|
1389
|
+
host.parentNode.insertBefore(canvas, host.nextSibling);
|
|
1390
|
+
canvasAppended = true;
|
|
1391
|
+
|
|
1392
|
+
const rmq = _reducedMotionQuery();
|
|
1393
|
+
|
|
1394
|
+
// -- State. A superset of the scalar fields a skin reads; read() (below) writes
|
|
1395
|
+
// the primitive's painted state into these preallocated slots. --
|
|
1396
|
+
const state = {
|
|
1397
|
+
hover: false,
|
|
1398
|
+
active: false,
|
|
1399
|
+
focused: (typeof document !== 'undefined' && document.activeElement === host),
|
|
1400
|
+
val: 0, // slider/progress/rating fill 0..1
|
|
1401
|
+
toggled: false, // switch on/off
|
|
1402
|
+
indeterminate: false, // progress loading / mixed
|
|
1403
|
+
disabled: false,
|
|
1404
|
+
complete: false, // progress done
|
|
1405
|
+
error: false,
|
|
1406
|
+
count: 0, // rating item count (read from the primitive)
|
|
1407
|
+
reducedMotion: rmq ? !!rmq.matches : false,
|
|
1408
|
+
budget: 1,
|
|
1409
|
+
w: ow, h: oh, padding, dpr,
|
|
1410
|
+
};
|
|
1411
|
+
const pointer = { x: -999, y: -999, vx: 0, vy: 0 };
|
|
1412
|
+
let rect = null;
|
|
1413
|
+
|
|
1414
|
+
// -- Initialize recipe (validated in phase 1). ctx exists now. --
|
|
1415
|
+
if (recipe.init) recipe.init(ctx, ow, oh, padding);
|
|
1416
|
+
|
|
1417
|
+
// -- Seed state from the primitive's CURRENT painted attributes, before the
|
|
1418
|
+
// first frame (law: hook initial values). read() is the skin's own parse. --
|
|
1419
|
+
hspec.read(host, handle, state);
|
|
1420
|
+
|
|
1421
|
+
// -- Events (via AbortController, exactly what destroy() removes). --
|
|
1422
|
+
ac = new AbortController();
|
|
1423
|
+
acCreated = true;
|
|
1424
|
+
const signal = ac.signal;
|
|
1425
|
+
|
|
1426
|
+
function updatePointer(e) {
|
|
1427
|
+
if (!rect) rect = host.getBoundingClientRect();
|
|
1428
|
+
const nx = e.clientX - rect.left;
|
|
1429
|
+
const ny = e.clientY - rect.top;
|
|
1430
|
+
pointer.vx = nx - pointer.x;
|
|
1431
|
+
pointer.vy = ny - pointer.y;
|
|
1432
|
+
pointer.x = nx;
|
|
1433
|
+
pointer.y = ny;
|
|
1434
|
+
}
|
|
1435
|
+
function refreshRect() { rect = host.getBoundingClientRect(); }
|
|
1436
|
+
// Reposition the overlay from the offset box after a layout change (cold path).
|
|
1437
|
+
// Layout READS are hoisted above the style WRITES (forced-reflow law): a skin
|
|
1438
|
+
// sits over live DOM, the one place this package can force layout.
|
|
1439
|
+
function reposition() {
|
|
1440
|
+
const nw = host.offsetWidth;
|
|
1441
|
+
const nh = host.offsetHeight;
|
|
1442
|
+
const ol = host.offsetLeft;
|
|
1443
|
+
const ot = host.offsetTop;
|
|
1444
|
+
canvas.style.left = (ol - padding) + 'px';
|
|
1445
|
+
canvas.style.top = (ot - padding) + 'px';
|
|
1446
|
+
if (nw !== ow || nh !== oh) {
|
|
1447
|
+
ow = nw; oh = nh;
|
|
1448
|
+
cw = ow + padding * 2;
|
|
1449
|
+
ch = oh + padding * 2;
|
|
1450
|
+
canvas.width = cw * dpr;
|
|
1451
|
+
canvas.height = ch * dpr;
|
|
1452
|
+
canvas.style.width = cw + 'px';
|
|
1453
|
+
canvas.style.height = ch + 'px';
|
|
1454
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1455
|
+
state.w = ow; state.h = oh;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
window.addEventListener('scroll', refreshRect, { passive: true, signal });
|
|
1460
|
+
window.addEventListener('resize', () => { reposition(); refreshRect(); }, { passive: true, signal });
|
|
1461
|
+
|
|
1462
|
+
// Pointer/hover state from the host's own events (a skin may light up on
|
|
1463
|
+
// hover/press). onToggle/onDrag are NOT fired -- the primitive owns value.
|
|
1464
|
+
host.addEventListener('pointermove', updatePointer, { signal });
|
|
1465
|
+
host.addEventListener('pointerenter', (e) => {
|
|
1466
|
+
state.hover = true;
|
|
1467
|
+
refreshRect();
|
|
1468
|
+
updatePointer(e);
|
|
1469
|
+
if (recipe.onHover) recipe.onHover(state, pointer);
|
|
1470
|
+
}, { signal });
|
|
1471
|
+
host.addEventListener('pointerleave', () => {
|
|
1472
|
+
state.hover = false;
|
|
1473
|
+
if (recipe.onLeave) recipe.onLeave(state, pointer);
|
|
1474
|
+
}, { signal });
|
|
1475
|
+
host.addEventListener('pointerdown', (e) => {
|
|
1476
|
+
state.active = true;
|
|
1477
|
+
updatePointer(e);
|
|
1478
|
+
if (recipe.onClick) recipe.onClick(pointer.x, pointer.y, state);
|
|
1479
|
+
}, { signal });
|
|
1480
|
+
host.addEventListener('pointerup', () => { state.active = false; }, { signal });
|
|
1481
|
+
host.addEventListener('focus', () => { state.focused = true; }, { signal });
|
|
1482
|
+
host.addEventListener('blur', () => { state.focused = false; }, { signal });
|
|
1483
|
+
|
|
1484
|
+
// -- THE STATE SOURCE (decisions/0008): one MutationObserver over the painted
|
|
1485
|
+
// attributes the skin declared; read() parses them into `state` at EVENT
|
|
1486
|
+
// time. Idempotent + full re-read, so it is order/timing-independent. --
|
|
1487
|
+
mo = new MutationObserver(() => { hspec.read(host, handle, state); });
|
|
1488
|
+
mo.observe(host, { attributes: true, attributeFilter: hspec.attrs, subtree: true });
|
|
1489
|
+
moConnected = true;
|
|
1490
|
+
|
|
1491
|
+
// -- DPR re-read on display change (cold; absent matchMedia is a silent no-op). --
|
|
1492
|
+
if (typeof window.matchMedia === 'function') {
|
|
1493
|
+
const mq = window.matchMedia('(resolution: ' + dpr + 'dppx)');
|
|
1494
|
+
mq.addEventListener('change', () => {
|
|
1495
|
+
const nd = window.devicePixelRatio || 1;
|
|
1496
|
+
dpr = nd;
|
|
1497
|
+
canvas.width = cw * nd;
|
|
1498
|
+
canvas.height = ch * nd;
|
|
1499
|
+
ctx.setTransform(nd, 0, 0, nd, 0, 0);
|
|
1500
|
+
state.dpr = nd;
|
|
1501
|
+
}, { signal });
|
|
1502
|
+
}
|
|
1503
|
+
if (rmq) {
|
|
1504
|
+
rmq.addEventListener('change', () => { state.reducedMotion = !!rmq.matches; }, { signal });
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// -- Render loop. ONE named frame body; three clock modes; quarantine on throw
|
|
1508
|
+
// (identical to decorate). --
|
|
1509
|
+
let destroyed = false;
|
|
1510
|
+
let quarantined = false;
|
|
1511
|
+
|
|
1512
|
+
function frame(dtMs) {
|
|
1513
|
+
if (destroyed || quarantined) return;
|
|
1514
|
+
const dt = dtMs / 1000;
|
|
1515
|
+
const now = performance.now();
|
|
1516
|
+
|
|
1517
|
+
if (dt > 0) {
|
|
1518
|
+
let inst = _TARGET_DT / dt;
|
|
1519
|
+
if (inst > 1) inst = 1; else if (inst < 0) inst = 0;
|
|
1520
|
+
state.budget += (inst - state.budget) * _BUDGET_SMOOTH;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1524
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
1525
|
+
ctx.save();
|
|
1526
|
+
ctx.translate(padding, padding); // Origin = the host element's top-left
|
|
1527
|
+
try {
|
|
1528
|
+
recipe.tick(ctx, dt, now, state, pointer);
|
|
1529
|
+
} catch (err) {
|
|
1530
|
+
quarantined = true;
|
|
1531
|
+
console.error('skinHeadless: recipe.tick threw; skin quarantined', err);
|
|
1532
|
+
ctx.restore();
|
|
1533
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
ctx.restore();
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
if (driven) {
|
|
1540
|
+
// no ticker acquired; removeTick stays null
|
|
1541
|
+
} else if (callerTicker !== undefined) {
|
|
1542
|
+
removeTick = callerTicker.add(frame);
|
|
1543
|
+
} else {
|
|
1544
|
+
const ticker = acquireTicker();
|
|
1545
|
+
tickerAcquired = true;
|
|
1546
|
+
removeTick = ticker.add(frame);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// -- Public API --
|
|
1550
|
+
return {
|
|
1551
|
+
/** The skinned host element (unchanged; provided for external reads). */
|
|
1552
|
+
el: host,
|
|
1553
|
+
/** The overlay canvas (for external styling). */
|
|
1554
|
+
canvas,
|
|
1555
|
+
/** Current state (read-only reference). */
|
|
1556
|
+
state,
|
|
1557
|
+
/** Drive one frame by hand (U5); callable ONLY in { driven: true }. */
|
|
1558
|
+
tick: driven ? frame : _drivenOnly,
|
|
1559
|
+
/** Hijack-only: a skin reflects the primitive, it does not drive it. */
|
|
1560
|
+
setValue() {
|
|
1561
|
+
throw new Error('skinHeadless: setValue is hijack-only; a skin reflects the primitive, drive the lite-headless handle instead');
|
|
1562
|
+
},
|
|
1563
|
+
setChecked() {
|
|
1564
|
+
throw new Error('skinHeadless: setChecked is hijack-only; a skin reflects the primitive, drive the lite-headless handle instead');
|
|
1565
|
+
},
|
|
1566
|
+
/** Destroy: remove the overlay + observer + every listener the skin added.
|
|
1567
|
+
* Idempotent. The host AND the lite-headless handle are left untouched. */
|
|
1568
|
+
destroy() {
|
|
1569
|
+
if (destroyed) return;
|
|
1570
|
+
destroyed = true;
|
|
1571
|
+
ac.abort();
|
|
1572
|
+
mo.disconnect(); // stop observing painted attrs
|
|
1573
|
+
if (removeTick) removeTick(); // shared OR caller ticker; null when driven
|
|
1574
|
+
if (recipe.destroy) recipe.destroy();
|
|
1575
|
+
if (tickerAcquired) releaseTicker(); // release ONLY the shared ticker we acquired
|
|
1576
|
+
canvas.remove(); // the ONLY DOM node skinHeadless added
|
|
1577
|
+
},
|
|
1578
|
+
};
|
|
1579
|
+
} catch (err) {
|
|
1580
|
+
// A phase-2 step threw (realistically recipe.init or read). Unwind ONLY what
|
|
1581
|
+
// was acquired, reverse order, each flag-guarded. recipe.destroy is NOT
|
|
1582
|
+
// called (init did not complete). Re-throw the ORIGINAL error.
|
|
1583
|
+
if (removeTick) removeTick();
|
|
1584
|
+
if (tickerAcquired) releaseTicker();
|
|
1585
|
+
if (moConnected) mo.disconnect();
|
|
1586
|
+
if (acCreated) ac.abort();
|
|
1587
|
+
if (canvasAppended) canvas.remove();
|
|
1588
|
+
throw err;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1212
1592
|
// =========================================================
|
|
1213
1593
|
// mountUIFXGroup -- The third mount mode (N native elements, one canvas)
|
|
1214
1594
|
// =========================================================
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// UIFXHeadless.d.ts -- types for the ./headless subpath (E1, decisions/0008).
|
|
2
|
+
// Curates the headless-skin surface: skinHeadless (the mount adapter, in the
|
|
3
|
+
// controller) + the skin family and its sibling registry (in the recipes module).
|
|
4
|
+
|
|
5
|
+
export { skinHeadless } from './UIFXController';
|
|
6
|
+
export type {
|
|
7
|
+
SkinOptions,
|
|
8
|
+
SkinInstance,
|
|
9
|
+
HeadlessSkinRecipe,
|
|
10
|
+
HeadlessSkinRecipeFactory,
|
|
11
|
+
HeadlessSkinDescriptor,
|
|
12
|
+
} from './UIFXController';
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
SwitchSkin,
|
|
16
|
+
SliderSkin,
|
|
17
|
+
ProgressSkin,
|
|
18
|
+
RatingSkin,
|
|
19
|
+
HEADLESS_SKINS,
|
|
20
|
+
SKIN_META,
|
|
21
|
+
SKIN_NAMES,
|
|
22
|
+
} from './UIFXRecipes';
|
|
23
|
+
export type { SkinMeta } from './UIFXRecipes';
|
package/UIFXHeadless.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// UIFXHeadless.js -- the ./headless subpath (E1, decisions/0008).
|
|
2
|
+
//
|
|
3
|
+
// Curates the headless-skin surface: skinHeadless (the mount adapter, which lives
|
|
4
|
+
// with the other mounts in UIFXController.js) + the skin family and its sibling
|
|
5
|
+
// registry (in UIFXRecipes.js). Importing one skin tree-shakes the rest
|
|
6
|
+
// (sideEffects:false).
|
|
7
|
+
//
|
|
8
|
+
// @zakkster/lite-headless is a COMPOSE-TARGET, never a dependency: this module (and
|
|
9
|
+
// the whole package) imports it nowhere. skinHeadless couples to a primitive ONLY
|
|
10
|
+
// through the painted-attribute contract (its docs/CSS_CONTRACT.md) that the skin's
|
|
11
|
+
// `headless.read` parses -- so ./headless works against any lite-headless version
|
|
12
|
+
// honouring that contract.
|
|
13
|
+
|
|
14
|
+
export { skinHeadless } from './UIFXController.js';
|
|
15
|
+
export {
|
|
16
|
+
SwitchSkin,
|
|
17
|
+
SliderSkin,
|
|
18
|
+
ProgressSkin,
|
|
19
|
+
RatingSkin,
|
|
20
|
+
HEADLESS_SKINS,
|
|
21
|
+
SKIN_META,
|
|
22
|
+
SKIN_NAMES,
|
|
23
|
+
} from './UIFXRecipes.js';
|
package/UIFXRecipes.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
UIFXRecipe, UIFXInstance, MountOptions,
|
|
3
3
|
UIFXGroupRecipe, GroupOptions, UIFXGroupInstance, DecorateInstance,
|
|
4
|
+
HeadlessSkinRecipe,
|
|
4
5
|
} from './UIFXController';
|
|
5
6
|
|
|
6
7
|
// ===========================================================
|
|
7
|
-
// RECIPE OPTIONS + FACTORIES (all
|
|
8
|
+
// RECIPE OPTIONS + FACTORIES (all 57)
|
|
8
9
|
// ===========================================================
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -272,7 +273,7 @@ export declare function mountRecipe(
|
|
|
272
273
|
): UIFXInstance | UIFXGroupInstance | DecorateInstance;
|
|
273
274
|
|
|
274
275
|
// ===========================================================
|
|
275
|
-
// DEFAULT EXPORT -- combined all-
|
|
276
|
+
// DEFAULT EXPORT -- combined all-57 namespace
|
|
276
277
|
// ===========================================================
|
|
277
278
|
|
|
278
279
|
declare const UIFXAllRecipes: {
|
|
@@ -335,3 +336,30 @@ declare const UIFXAllRecipes: {
|
|
|
335
336
|
SuccessBloom: typeof SuccessBloom;
|
|
336
337
|
};
|
|
337
338
|
export default UIFXAllRecipes;
|
|
339
|
+
|
|
340
|
+
// ===========================================================
|
|
341
|
+
// HEADLESS SKINS (E1, decisions/0008)
|
|
342
|
+
// A sibling registry of RECIPES/RECIPE_META: skins are driven by skinHeadless
|
|
343
|
+
// (a handle + host), never by mountRecipe (a container), so they are kept
|
|
344
|
+
// separate and the 57-recipe count is unchanged.
|
|
345
|
+
// ===========================================================
|
|
346
|
+
|
|
347
|
+
export declare function SwitchSkin(options?: RecipeOptions): HeadlessSkinRecipe;
|
|
348
|
+
export declare function SliderSkin(options?: RecipeOptions): HeadlessSkinRecipe;
|
|
349
|
+
export declare function ProgressSkin(options?: RecipeOptions): HeadlessSkinRecipe;
|
|
350
|
+
export declare function RatingSkin(options?: RecipeOptions): HeadlessSkinRecipe;
|
|
351
|
+
|
|
352
|
+
/** A headless-skin meta row. `primitive` names the lite-headless primitive the
|
|
353
|
+
* skin is designed to paint. */
|
|
354
|
+
export interface SkinMeta {
|
|
355
|
+
id: string;
|
|
356
|
+
name: string;
|
|
357
|
+
primitive: string;
|
|
358
|
+
themeable: boolean;
|
|
359
|
+
motionSafe: boolean;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** id -> skin factory (null-prototype). Driven by skinHeadless, never mountRecipe. */
|
|
363
|
+
export declare const HEADLESS_SKINS: Record<string, (options?: RecipeOptions) => HeadlessSkinRecipe>;
|
|
364
|
+
export declare const SKIN_META: SkinMeta[];
|
|
365
|
+
export declare const SKIN_NAMES: readonly string[];
|
package/UIFXRecipes.js
CHANGED
|
@@ -2571,8 +2571,14 @@ export function ReactionPicker(o = {}) {
|
|
|
2571
2571
|
c.fillStyle=active?colors30[i]:'rgba(255,255,255,.04)';
|
|
2572
2572
|
c.beginPath();c.arc(cx,cy-sizes[i]+10,sizes[i],0,PI2);c.fill();
|
|
2573
2573
|
|
|
2574
|
-
// Emoji face
|
|
2574
|
+
// Emoji face -- font from a const-string LUT. Set an OPAQUE fill
|
|
2575
|
+
// first: the circle's fillStyle above is translucent (4% at rest),
|
|
2576
|
+
// and a colour glyph inherits that alpha, so without this the faces
|
|
2577
|
+
// are invisible until hover. colors[i] keeps a mono-emoji fallback
|
|
2578
|
+
// tinted per reaction; a colour-emoji font ignores the hue and only
|
|
2579
|
+
// takes the full alpha. Precomputed array read -- zero alloc.
|
|
2575
2580
|
c.font=FONTS[Math.round(sizes[i]*1.2)]||FONTS[48];c.textAlign='center';c.textBaseline='middle';
|
|
2581
|
+
c.fillStyle=colors[i];
|
|
2576
2582
|
c.fillText(emojis[i],cx,cy-sizes[i]+10);
|
|
2577
2583
|
}
|
|
2578
2584
|
|
|
@@ -3428,3 +3434,206 @@ export function mountRecipe(container, id, options) {
|
|
|
3428
3434
|
return mountUIFX(container, type, factory, mountOptions);
|
|
3429
3435
|
}
|
|
3430
3436
|
|
|
3437
|
+
// ===========================================================
|
|
3438
|
+
// HEADLESS SKINS (E1, decisions/0008)
|
|
3439
|
+
// A skin is an ordinary recipe PLUS a `headless` descriptor:
|
|
3440
|
+
// { attrs: string[], read(host, handle, state) }
|
|
3441
|
+
// skinHeadless (controller) observes `attrs` on the lite-headless primitive's
|
|
3442
|
+
// painted element and calls read() at EVENT time to parse the painted state into
|
|
3443
|
+
// preallocated state slots. Skins live in their OWN registry (HEADLESS_SKINS /
|
|
3444
|
+
// SKIN_META), NOT RECIPES/RECIPE_META: a skin needs a handle + host, not a
|
|
3445
|
+
// container, so it does not fit mountRecipe. The 57-recipe count is unchanged.
|
|
3446
|
+
// ===========================================================
|
|
3447
|
+
|
|
3448
|
+
// Painted-attribute readers (EVENT time only -- never a per-frame call, so
|
|
3449
|
+
// getAttribute/parseFloat here are off the hot path). A boolean is PRESENT with any
|
|
3450
|
+
// value except the string "false" (handles presence-booleans like data-disabled and
|
|
3451
|
+
// value-booleans like the switch's data-checked="true"; decisions/0008 decision 3).
|
|
3452
|
+
function _skinBool(el, name) {
|
|
3453
|
+
if (!el.hasAttribute(name)) return false;
|
|
3454
|
+
return el.getAttribute(name) !== 'false';
|
|
3455
|
+
}
|
|
3456
|
+
function _ariaTrue(el, name) { return el.getAttribute(name) === 'true'; }
|
|
3457
|
+
function _skinNum(el, name, def) {
|
|
3458
|
+
const v = el.getAttribute(name);
|
|
3459
|
+
if (v == null) return def;
|
|
3460
|
+
const n = parseFloat(v);
|
|
3461
|
+
return n === n ? n : def; // n===n rejects NaN without an isNaN call
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
/** Switch skin -- a sliding track+knob driven by the primitive's data-checked /
|
|
3465
|
+
* aria-checked. Zero per-frame alloc: const colors, globalAlpha, one eased scalar. */
|
|
3466
|
+
export function SwitchSkin(o = {}) {
|
|
3467
|
+
const P = resolveTheme(o, { accent: '#38bdf8', dim: '#3a3a4a', dim2: '#e2e2f0' });
|
|
3468
|
+
let t = 0; // animated 0..1 toward the checked state
|
|
3469
|
+
return {
|
|
3470
|
+
headless: {
|
|
3471
|
+
attrs: ['data-checked', 'aria-checked', 'data-disabled', 'aria-disabled'],
|
|
3472
|
+
read(host, handle, st) {
|
|
3473
|
+
st.toggled = _skinBool(host, 'data-checked') || _ariaTrue(host, 'aria-checked')
|
|
3474
|
+
|| (handle && typeof handle.isChecked === 'function' ? !!handle.isChecked() : false);
|
|
3475
|
+
st.disabled = _skinBool(host, 'data-disabled') || _ariaTrue(host, 'aria-disabled');
|
|
3476
|
+
},
|
|
3477
|
+
},
|
|
3478
|
+
tick(c, dt, now, st) {
|
|
3479
|
+
const w = st.w, h = st.h, r = h / 2;
|
|
3480
|
+
const k = dt * 12; t += ((st.toggled ? 1 : 0) - t) * (k > 1 ? 1 : k);
|
|
3481
|
+
c.globalAlpha = st.disabled ? 0.4 : 1;
|
|
3482
|
+
c.fillStyle = st.toggled ? P.accent : P.dim;
|
|
3483
|
+
roundRect(c, 0, 0, w, h, r); c.fill();
|
|
3484
|
+
const kx = r + t * (w - h);
|
|
3485
|
+
c.fillStyle = P.dim2;
|
|
3486
|
+
c.beginPath(); c.arc(kx, r, r - 3, 0, PI2); c.fill();
|
|
3487
|
+
c.globalAlpha = 1;
|
|
3488
|
+
if (st.focused) fr(c, w, h, r);
|
|
3489
|
+
},
|
|
3490
|
+
};
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
/** Slider skin -- rail + fill + thumb, driven by aria-valuenow/min/max; the thumb
|
|
3494
|
+
* pulses while data-dragging is painted. */
|
|
3495
|
+
export function SliderSkin(o = {}) {
|
|
3496
|
+
const P = resolveTheme(o, { accent: '#fbbf24', dim: '#9999b8', dim2: '#e2e2f0' });
|
|
3497
|
+
let dv = 0; // displayed value, eased toward st.val
|
|
3498
|
+
return {
|
|
3499
|
+
headless: {
|
|
3500
|
+
attrs: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'data-disabled', 'data-dragging'],
|
|
3501
|
+
read(host, handle, st) {
|
|
3502
|
+
const mn = _skinNum(host, 'aria-valuemin', 0);
|
|
3503
|
+
const mx = _skinNum(host, 'aria-valuemax', 100);
|
|
3504
|
+
const nw = _skinNum(host, 'aria-valuenow', mn);
|
|
3505
|
+
const span = mx - mn;
|
|
3506
|
+
let v = span > 0 ? (nw - mn) / span : 0;
|
|
3507
|
+
st.val = v < 0 ? 0 : (v > 1 ? 1 : v);
|
|
3508
|
+
st.active = _skinBool(host, 'data-dragging');
|
|
3509
|
+
st.disabled = _skinBool(host, 'data-disabled');
|
|
3510
|
+
},
|
|
3511
|
+
},
|
|
3512
|
+
tick(c, dt, now, st) {
|
|
3513
|
+
const w = st.w, cy = st.h / 2;
|
|
3514
|
+
const k = dt * 10; dv += (st.val - dv) * (k > 1 ? 1 : k);
|
|
3515
|
+
c.globalAlpha = st.disabled ? 0.2 : 0.4;
|
|
3516
|
+
c.fillStyle = P.dim; roundRect(c, 0, cy - 2, w, 4, 2); c.fill();
|
|
3517
|
+
const fx = dv * w;
|
|
3518
|
+
c.globalAlpha = st.disabled ? 0.4 : 1;
|
|
3519
|
+
c.fillStyle = P.accent; roundRect(c, 0, cy - 2, fx > 0 ? fx : 0, 4, 2); c.fill();
|
|
3520
|
+
const tr = st.active ? 8 : 6;
|
|
3521
|
+
c.fillStyle = P.dim2;
|
|
3522
|
+
c.beginPath(); c.arc(fx, cy, tr, 0, PI2); c.fill();
|
|
3523
|
+
if (st.active) {
|
|
3524
|
+
c.globalAlpha = 0.3; c.fillStyle = P.accent;
|
|
3525
|
+
c.beginPath(); c.arc(fx, cy, tr + 5, 0, PI2); c.fill();
|
|
3526
|
+
}
|
|
3527
|
+
c.globalAlpha = 1;
|
|
3528
|
+
if (st.focused) fr(c, w, st.h, cy);
|
|
3529
|
+
},
|
|
3530
|
+
};
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
/** Progress skin -- a value ring (aria-valuenow/max) with a percent label; an
|
|
3534
|
+
* indeterminate sweep when data-loading, a full accent ring when data-complete. */
|
|
3535
|
+
export function ProgressSkin(o = {}) {
|
|
3536
|
+
const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#a78bfa', dim: '#e2e2f0' });
|
|
3537
|
+
const FONT = pickFont(o, "700 14px 'JetBrains Mono',monospace");
|
|
3538
|
+
let dv = 0; // displayed value
|
|
3539
|
+
let spin = 0; // indeterminate sweep phase
|
|
3540
|
+
return {
|
|
3541
|
+
headless: {
|
|
3542
|
+
attrs: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'data-complete', 'data-loading'],
|
|
3543
|
+
read(host, handle, st) {
|
|
3544
|
+
const mn = _skinNum(host, 'aria-valuemin', 0);
|
|
3545
|
+
const mx = _skinNum(host, 'aria-valuemax', 100);
|
|
3546
|
+
const nw = _skinNum(host, 'aria-valuenow', mn);
|
|
3547
|
+
const span = mx - mn;
|
|
3548
|
+
let v = span > 0 ? (nw - mn) / span : 0;
|
|
3549
|
+
st.val = v < 0 ? 0 : (v > 1 ? 1 : v);
|
|
3550
|
+
st.complete = _skinBool(host, 'data-complete');
|
|
3551
|
+
st.indeterminate = _skinBool(host, 'data-loading');
|
|
3552
|
+
},
|
|
3553
|
+
},
|
|
3554
|
+
tick(c, dt, now, st) {
|
|
3555
|
+
const cx = st.w / 2, cy = st.h / 2, R = Math.min(cx, cy) - 6, lw = 6;
|
|
3556
|
+
c.strokeStyle = P.dim; c.globalAlpha = 0.15; c.lineWidth = lw;
|
|
3557
|
+
c.beginPath(); c.arc(cx, cy, R, 0, PI2); c.stroke();
|
|
3558
|
+
c.globalAlpha = 1;
|
|
3559
|
+
if (st.indeterminate && !st.complete) {
|
|
3560
|
+
spin += dt * 3;
|
|
3561
|
+
c.strokeStyle = P.accent; c.lineWidth = lw;
|
|
3562
|
+
c.beginPath(); c.arc(cx, cy, R, spin, spin + 1.6); c.stroke();
|
|
3563
|
+
} else {
|
|
3564
|
+
const target = st.complete ? 1 : st.val;
|
|
3565
|
+
const k = dt * 6; dv += (target - dv) * (k > 1 ? 1 : k);
|
|
3566
|
+
const a = -Math.PI / 2, ea = a + dv * PI2;
|
|
3567
|
+
c.strokeStyle = st.complete ? P.accent : P.accent2; c.lineWidth = lw;
|
|
3568
|
+
c.beginPath(); c.arc(cx, cy, R, a, ea); c.stroke();
|
|
3569
|
+
c.fillStyle = P.dim; c.font = FONT; c.textAlign = 'center'; c.textBaseline = 'middle';
|
|
3570
|
+
c.fillText(PCT[Math.round(dv * 100)], cx, cy);
|
|
3571
|
+
}
|
|
3572
|
+
if (st.focused) fr(c, st.w, st.h, st.h / 2);
|
|
3573
|
+
},
|
|
3574
|
+
};
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
/** Rating skin -- N bubbles (count from aria-valuemax, bounded) filled to
|
|
3578
|
+
* aria-valuenow/max. Fixed-count loop, no per-frame allocation. */
|
|
3579
|
+
export function RatingSkin(o = {}) {
|
|
3580
|
+
const P = resolveTheme(o, { accent: '#f472b6', dim: '#3a3a4a' });
|
|
3581
|
+
let dv = 0; // animated filled count
|
|
3582
|
+
return {
|
|
3583
|
+
headless: {
|
|
3584
|
+
attrs: ['aria-valuenow', 'aria-valuemax', 'data-disabled'],
|
|
3585
|
+
read(host, handle, st) {
|
|
3586
|
+
const mx = _skinNum(host, 'aria-valuemax', 5);
|
|
3587
|
+
const nw = _skinNum(host, 'aria-valuenow', 0);
|
|
3588
|
+
let n = mx > 0 ? (mx | 0) : 5;
|
|
3589
|
+
st.count = n > 10 ? 10 : n;
|
|
3590
|
+
let v = mx > 0 ? nw / mx : 0;
|
|
3591
|
+
st.val = v < 0 ? 0 : (v > 1 ? 1 : v);
|
|
3592
|
+
st.disabled = _skinBool(host, 'data-disabled');
|
|
3593
|
+
},
|
|
3594
|
+
},
|
|
3595
|
+
tick(c, dt, now, st) {
|
|
3596
|
+
const n = st.count > 0 ? st.count : 5;
|
|
3597
|
+
const k = dt * 12; dv += (st.val * n - dv) * (k > 1 ? 1 : k);
|
|
3598
|
+
const cy = st.h / 2;
|
|
3599
|
+
const gap = st.w / n;
|
|
3600
|
+
const rad = (gap < st.h ? gap : st.h) * 0.32;
|
|
3601
|
+
const base = st.disabled ? 0.4 : 1;
|
|
3602
|
+
for (let i = 0; i < n; i++) {
|
|
3603
|
+
const cx = gap * (i + 0.5);
|
|
3604
|
+
const fillAmt = dv - i;
|
|
3605
|
+
c.globalAlpha = base * 0.35; c.fillStyle = P.dim;
|
|
3606
|
+
c.beginPath(); c.arc(cx, cy, rad, 0, PI2); c.fill();
|
|
3607
|
+
if (fillAmt > 0) {
|
|
3608
|
+
c.globalAlpha = base * (fillAmt > 1 ? 1 : fillAmt); c.fillStyle = P.accent;
|
|
3609
|
+
c.beginPath(); c.arc(cx, cy, rad, 0, PI2); c.fill();
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
c.globalAlpha = 1;
|
|
3613
|
+
if (st.focused) fr(c, st.w, st.h, st.h / 2);
|
|
3614
|
+
},
|
|
3615
|
+
};
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
/**
|
|
3619
|
+
* The headless-skin registry -- a SIBLING of RECIPES, not part of it (decisions/
|
|
3620
|
+
* 0008 decision 4). id -> factory (null-prototype), the meta rows a picker/demo
|
|
3621
|
+
* iterates, and the frozen id list. Each skin is driven by skinHeadless with a
|
|
3622
|
+
* lite-headless handle + host, never by mountRecipe.
|
|
3623
|
+
*/
|
|
3624
|
+
export const HEADLESS_SKINS = Object.assign(Object.create(null), {
|
|
3625
|
+
switchSkin: SwitchSkin,
|
|
3626
|
+
sliderSkin: SliderSkin,
|
|
3627
|
+
progressSkin: ProgressSkin,
|
|
3628
|
+
ratingSkin: RatingSkin,
|
|
3629
|
+
});
|
|
3630
|
+
|
|
3631
|
+
export const SKIN_META = [
|
|
3632
|
+
{ id: 'switchSkin', name: 'Switch Skin', primitive: 'switch', themeable: true, motionSafe: false },
|
|
3633
|
+
{ id: 'sliderSkin', name: 'Slider Skin', primitive: 'slider', themeable: true, motionSafe: false },
|
|
3634
|
+
{ id: 'progressSkin', name: 'Progress Skin', primitive: 'progress', themeable: true, motionSafe: false },
|
|
3635
|
+
{ id: 'ratingSkin', name: 'Rating Skin', primitive: 'rating', themeable: true, motionSafe: false },
|
|
3636
|
+
];
|
|
3637
|
+
|
|
3638
|
+
export const SKIN_NAMES = Object.freeze(Object.keys(HEADLESS_SKINS));
|
|
3639
|
+
|
package/llms.txt
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @zakkster/lite-ui-fx
|
|
2
2
|
> Canvas-hijacked UI components with pluggable recipe system. 57 built-in recipes.
|
|
3
3
|
|
|
4
|
-
VERSION 1.
|
|
4
|
+
VERSION 1.10.0
|
|
5
5
|
|
|
6
6
|
## Install
|
|
7
7
|
npm i @zakkster/lite-ui-fx
|
|
@@ -87,6 +87,34 @@ driven // U5 host clock: boolean; true = no ticker/RAF, the host calls instan
|
|
|
87
87
|
// padding, disabled, seed, colors, theme, text, font, ticker, driven. The hijack-
|
|
88
88
|
// only keys (value/checked/knobMode/announce) throw -- a group uses index, not value.
|
|
89
89
|
|
|
90
|
+
## Mount -- HEADLESS SKIN mode (E1): paint a lite-headless primitive
|
|
91
|
+
// skinHeadless: a FOURTH mount adapter. It places a canvas over a @zakkster/lite-
|
|
92
|
+
// headless primitive and drives a recipe from the primitive's PAINTED state
|
|
93
|
+
// attributes (its docs/CSS_CONTRACT.md) -- lite-ui-fx paints, lite-headless behaves.
|
|
94
|
+
// Arm's-length: couples through the attribute contract, NEVER an import, so
|
|
95
|
+
// lite-headless is never a dependency. Structurally a decoration: host byte-
|
|
96
|
+
// identical, one overlay canvas + one MutationObserver removed on destroy; the
|
|
97
|
+
// lite-headless handle is never destroyed (the caller owns it).
|
|
98
|
+
import { skinHeadless, SwitchSkin, SliderSkin, ProgressSkin, RatingSkin } from '@zakkster/lite-ui-fx/headless';
|
|
99
|
+
import { HEADLESS_SKINS, SKIN_META, SKIN_NAMES } from '@zakkster/lite-ui-fx/headless';
|
|
100
|
+
const skin = skinHeadless(handle, SwitchSkin, { host: switchThumbEl, theme });
|
|
101
|
+
skin.el // the skinned host (unchanged)
|
|
102
|
+
skin.canvas // the overlay (the only DOM node added)
|
|
103
|
+
skin.state // read-only; driven by the observer from painted attrs
|
|
104
|
+
skin.tick(dtMs) // driven mode only
|
|
105
|
+
skin.destroy() // remove overlay + observer + listeners; host + handle untouched
|
|
106
|
+
// A skin is an ordinary recipe PLUS a descriptor: recipe.headless = { attrs:string[],
|
|
107
|
+
// read(host, handle, state) }. skinHeadless observes `attrs` and calls read() at
|
|
108
|
+
// EVENT time (never per frame) to parse painted state into preallocated slots.
|
|
109
|
+
// options: { host(REQUIRED), padding, seed, colors, theme, text, font, ticker, driven };
|
|
110
|
+
// hijack-only keys throw. setValue/setChecked throw (a skin reflects the primitive).
|
|
111
|
+
// E1 skins (SKIN_META, a registry SEPARATE from RECIPES -- the 57-recipe count is
|
|
112
|
+
// unchanged): SwitchSkin (data-checked/aria-checked), SliderSkin (aria-valuenow/
|
|
113
|
+
// min/max + data-dragging), ProgressSkin (aria-valuenow/max + data-complete/
|
|
114
|
+
// data-loading), RatingSkin (aria-valuenow/max over N items). Each themeable, born
|
|
115
|
+
// zero-alloc + t6-gated. A painted attr is truthy when PRESENT with any value but
|
|
116
|
+
// "false" (handles boolean data-disabled and value data-checked="true").
|
|
117
|
+
|
|
90
118
|
## Host clock (U5) -- three mutually-exclusive modes, both mount modes
|
|
91
119
|
// default (neither option): the shared ref-counted ticker -- one RAF for all
|
|
92
120
|
// components, byte-identical to earlier versions.
|
|
@@ -187,9 +215,9 @@ See UIFX-RECIPE-GUIDE.md (included in package).
|
|
|
187
215
|
- Zero-GC in all built-in recipes: const colors + globalAlpha, precomputed
|
|
188
216
|
color/label LUTs, fixed preallocated particle pools, gradients built in init.
|
|
189
217
|
Gated per recipe by the t3-frame-alloc torture tier (default AND themed mount).
|
|
190
|
-
- Themeable: all
|
|
218
|
+
- Themeable: all 57 recipes honour { colors, theme:{light,mid,dark}, text, font },
|
|
191
219
|
resolved once in init (zero per-frame alloc). RECIPE_META.themeable is true for
|
|
192
|
-
all
|
|
220
|
+
all 57. A bare mount is byte-identical to pre-theming. Shipped palettes + APCA
|
|
193
221
|
contrast are authored with @zakkster/lite-hueforge (a dev-only tool, never a
|
|
194
222
|
runtime dependency).
|
|
195
223
|
- U5 host clock: mount { ticker } to ride a caller-supplied lite-ticker (destroy
|
|
@@ -218,3 +246,11 @@ See UIFX-RECIPE-GUIDE.md (included in package).
|
|
|
218
246
|
selects programmatically (fires onSelect once, no focus steal). Additive: the
|
|
219
247
|
single-element API is byte-identical; the four vol.3 fakes (PillTabs/Stepper/
|
|
220
248
|
RadioOrbit/BubbleRating) re-home to real group types, SegmentedSlide is new.
|
|
249
|
+
- E1 headless skins (./headless subpath, skinHeadless): a FOURTH mount adapter that
|
|
250
|
+
paints a @zakkster/lite-headless primitive by OBSERVING its painted state
|
|
251
|
+
attributes (one MutationObserver; parsed at event time, never per frame) --
|
|
252
|
+
lite-ui-fx paints, lite-headless behaves. lite-headless is a compose-target, NEVER
|
|
253
|
+
a dependency (the package imports it nowhere). 4 skins (switch/slider/progress/
|
|
254
|
+
rating) live in a registry (HEADLESS_SKINS/SKIN_META) SEPARATE from the 57 recipes;
|
|
255
|
+
each themeable + zero per-frame alloc (t6-gated). setValue/setChecked throw (a skin
|
|
256
|
+
reflects the primitive); destroy leaves the host + handle untouched. See 0008.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-ui-fx",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Canvas-hijacked UI components with a pluggable recipe system
|
|
3
|
+
"version": "1.10.0",
|
|
4
|
+
"description": "Canvas-hijacked UI components with a pluggable recipe system: 57 built-in recipes across toggles, buttons, sliders, knobs, loaders, checkboxes, counters and ratings, in three mount modes (hijack, decorate, grouped controls) plus a lite-headless skin adapter.",
|
|
5
5
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
"./recipes": {
|
|
17
17
|
"import": "./UIFXRecipes.js",
|
|
18
18
|
"types": "./UIFXRecipes.d.ts"
|
|
19
|
+
},
|
|
20
|
+
"./headless": {
|
|
21
|
+
"import": "./UIFXHeadless.js",
|
|
22
|
+
"types": "./UIFXHeadless.d.ts"
|
|
19
23
|
}
|
|
20
24
|
},
|
|
21
25
|
"files": [
|
|
@@ -23,6 +27,8 @@
|
|
|
23
27
|
"UIFXController.d.ts",
|
|
24
28
|
"UIFXRecipes.js",
|
|
25
29
|
"UIFXRecipes.d.ts",
|
|
30
|
+
"UIFXHeadless.js",
|
|
31
|
+
"UIFXHeadless.d.ts",
|
|
26
32
|
"UIFX-RECIPE-GUIDE.md",
|
|
27
33
|
"llms.txt",
|
|
28
34
|
"CHANGELOG.md",
|