@zakkster/lite-ui-fx 1.9.1 → 1.11.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 +90 -0
- package/README.md +32 -2
- package/UIFXController.d.ts +81 -0
- package/UIFXController.js +381 -1
- package/UIFXHeadless.d.ts +30 -0
- package/UIFXHeadless.js +30 -0
- package/UIFXRecipes.d.ts +37 -2
- package/UIFXRecipes.js +477 -9
- package/llms.txt +51 -3
- package/package.json +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,96 @@ 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.11.0] -- 2026-09-08
|
|
9
|
+
|
|
10
|
+
Enrichment session E1b (decisions/0009): seven bespoke headless skins continuing the
|
|
11
|
+
E1 adapter -- the select trigger (resolving the dropdown decision toward a V4 skin),
|
|
12
|
+
a tri-state checkbox + checkbox-group master, and meter/steps/accordion/skeleton.
|
|
13
|
+
Single-host, no new dependency; the 57-recipe registry is byte-identical.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Seven headless skins on the `./headless` subpath, registered in the sibling
|
|
18
|
+
`HEADLESS_SKINS` / `SKIN_META` / `SKIN_NAMES` (4 -> 11 skins; RECIPES / RECIPE_META
|
|
19
|
+
and the 57-recipe count unchanged). Each is single-host, themeable, and allocates
|
|
20
|
+
zero bytes per frame, gated by the t6 torture tier (now gating 11 skins):
|
|
21
|
+
- `CheckboxSkin` -- tri-state from `aria-checked` (`true`|`false`|`mixed`) or the
|
|
22
|
+
`data-checked` / `data-indeterminate` presence pair, onto the existing `toggled`
|
|
23
|
+
/ `indeterminate` state slots.
|
|
24
|
+
- `CheckboxGroupSkin` -- the same body over a checkbox-group master (identical
|
|
25
|
+
3-state contract; "mixed" reads as partial members).
|
|
26
|
+
- `SelectSkin` -- the select TRIGGER: `aria-expanded` drives an open/close chevron,
|
|
27
|
+
the handle `value()` fast path drives a selected-value dot. The portaled listbox
|
|
28
|
+
open-state is a recorded follow-on (decisions/0009).
|
|
29
|
+
- `MeterSkin` -- `aria-valuenow`/`min`/`max` fill, zone-tinted from `data-zone`.
|
|
30
|
+
- `StepsSkin` -- a node rail from `data-step-count` / `data-current-index` /
|
|
31
|
+
`data-complete`.
|
|
32
|
+
- `AccordionSkin` -- a header chevron + underline from `aria-expanded` / `data-open`.
|
|
33
|
+
- `SkeletonSkin` -- a zero-allocation `globalAlpha` shimmer from `data-loading` /
|
|
34
|
+
`aria-busy`.
|
|
35
|
+
`@zakkster/lite-headless` is imported nowhere and is not a dependency; no dependency
|
|
36
|
+
was added.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- `llms.txt` and `README.md`: the `./headless` skin catalog lists all 11 skins, and
|
|
41
|
+
the sibling primitive count is corrected ("59" -> 62, the `@zakkster/lite-headless`
|
|
42
|
+
1.9.1 catalog). Documentation only; no code path changed.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
none
|
|
47
|
+
|
|
48
|
+
### Removed
|
|
49
|
+
|
|
50
|
+
none
|
|
51
|
+
|
|
52
|
+
## [1.10.0] -- 2026-09-08
|
|
53
|
+
|
|
54
|
+
Enrichment session E1: skinHeadless, a fourth mount adapter that paints a
|
|
55
|
+
`@zakkster/lite-headless` primitive by observing the state attributes it paints.
|
|
56
|
+
Additive -- the three existing mount modes and the 57-recipe registry are
|
|
57
|
+
byte-identical.
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- `skinHeadless(handle, recipeFactory, options)` -- a fourth mount adapter, exported
|
|
62
|
+
from `.` and from the new `./headless` subpath. It places an overlay canvas over
|
|
63
|
+
the element a `@zakkster/lite-headless` primitive paints on and drives a recipe from
|
|
64
|
+
that primitive's painted state attributes via one `MutationObserver`, parsed at
|
|
65
|
+
event time (never per frame). It couples through the painted-attribute contract
|
|
66
|
+
only: `@zakkster/lite-headless` is imported nowhere and is not a dependency.
|
|
67
|
+
Structurally a decoration -- no native element, host byte-identical, one overlay
|
|
68
|
+
canvas + one observer removed on `destroy()`, the primitive handle never destroyed.
|
|
69
|
+
`setValue`/`setChecked` throw. See decisions/0008.
|
|
70
|
+
- The `./headless` subpath (`UIFXHeadless.js` + `UIFXHeadless.d.ts`) and four headless
|
|
71
|
+
skins: `SwitchSkin` (data-checked/aria-checked), `SliderSkin` (aria-valuenow/min/max
|
|
72
|
+
+ data-dragging), `ProgressSkin` (aria-valuenow/max + data-complete/data-loading),
|
|
73
|
+
`RatingSkin` (aria-valuenow/max). They live in a registry (`HEADLESS_SKINS`,
|
|
74
|
+
`SKIN_META`, `SKIN_NAMES`) separate from `RECIPES`/`RECIPE_META`, so the 57-recipe
|
|
75
|
+
count is unchanged. Each resolves theme in init and allocates zero bytes per frame,
|
|
76
|
+
gated by a new torture tier (t6); the existing alloc baseline is unchanged.
|
|
77
|
+
|
|
78
|
+
### Changed
|
|
79
|
+
|
|
80
|
+
- `package.json` `description`: corrected the recipe count (was "50", now 57) and
|
|
81
|
+
named the three mount modes plus the headless-skin adapter.
|
|
82
|
+
- `llms.txt` and `UIFXRecipes.d.ts`: corrected a stale recipe count ("56" -> 57 in the
|
|
83
|
+
themeable and default-namespace notes) left over from the U7 addition of the 57th
|
|
84
|
+
recipe. Shipped-doc accuracy only; no code path changed.
|
|
85
|
+
|
|
86
|
+
### Fixed
|
|
87
|
+
|
|
88
|
+
- ReactionPicker: paints a hairline ring around each disc and seeds the discs at
|
|
89
|
+
rest radius, so the row is visible on the first frame and where colour-emoji
|
|
90
|
+
glyphs do not render. Previously only the 4%-alpha discs and the emoji drew, so
|
|
91
|
+
an un-ticked or emoji-less environment showed an empty card. No added per-frame
|
|
92
|
+
allocation; the torture alloc baseline is unchanged.
|
|
93
|
+
|
|
94
|
+
### Removed
|
|
95
|
+
|
|
96
|
+
none
|
|
97
|
+
|
|
8
98
|
## [1.9.1] -- 2026-09-07
|
|
9
99
|
|
|
10
100
|
Patch: a rendering fix for the ReactionPicker recipe.
|
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 11 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`). E1b skins: `CheckboxSkin` (tri-state `aria-checked` `true`|`false`|`mixed`), `CheckboxGroupSkin` (the same body over a checkbox-group master), `SelectSkin` (the select **trigger** -- `aria-expanded` + `handle.value()`; the portaled listbox open-state is a follow-on), `MeterSkin` (`aria-valuenow`/`min`/`max` + `data-zone`), `StepsSkin` (`data-step-count`/`data-current-index`), `AccordionSkin` (`aria-expanded`/`data-open`), `SkeletonSkin` (`data-loading`/`aria-busy`). See [0008](decisions/0008-headless-skins.md) + [0009](decisions/0009-headless-skins-select.md).
|
|
235
|
+
|
|
208
236
|
### The recipe registry
|
|
209
237
|
|
|
210
238
|
```js
|
|
@@ -348,6 +376,8 @@ 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).
|
|
380
|
+
- **[0009](decisions/0009-headless-skins-select.md) -- Headless skins II: the select + tri-state pack.** Adds seven bespoke skins (checkbox tri-state + checkbox-group master, select trigger, meter, steps, accordion, skeleton), all single-host and mapping onto the existing state slots (no state-shape change). `SelectSkin` resolves the `<select>`/dropdown decision toward a V4 skin of the trigger; the portaled listbox open-state is a recorded follow-on. No new dependency (additive, 1.11.0).
|
|
351
381
|
|
|
352
382
|
---
|
|
353
383
|
|
|
@@ -370,7 +400,7 @@ The suite covers the a11y state machine (one Space press = one `onToggle`, state
|
|
|
370
400
|
- **Not a component framework.** It paints controls; it does not do layout, routing, or state management. Bring your own.
|
|
371
401
|
- **Not a worker-mode renderer.** A 200x48 UI canvas does not amortise a worker hop; the shared main-thread ticker is the right tool. (`@zakkster/lite-ambient-fx` is the worker-mode fullscreen backdrop.)
|
|
372
402
|
- **Not a chart or data-viz library.** These are interactive *controls*, not plots. Charts are `@zakkster/lite-charts`.
|
|
373
|
-
- **Not an ARIA behaviour engine.** It renders. The one keyboard behaviour it writes is the tablist roving-tabindex for a `TABS` group (radio/rating/stepper selection is the browser's own); it does not own focus traps, dismiss stacks, or listbox/combobox/menu patterns. `@zakkster/lite-headless` owns behaviour, permanently -- and its
|
|
403
|
+
- **Not an ARIA behaviour engine.** It renders. The one keyboard behaviour it writes is the tablist roving-tabindex for a `TABS` group (radio/rating/stepper selection is the browser's own); it does not own focus traps, dismiss stacks, or listbox/combobox/menu patterns. `@zakkster/lite-headless` owns behaviour, permanently -- and its 62 primitives are a headless-skin target.
|
|
374
404
|
|
|
375
405
|
---
|
|
376
406
|
|
|
@@ -383,7 +413,7 @@ Part of the **@zakkster** zero-GC stack:
|
|
|
383
413
|
- [`lite-random`](https://www.npmjs.com/package/@zakkster/lite-random) -- seeded Mulberry32 RNG (deterministic particle recipes)
|
|
384
414
|
- [`lite-scratch-fx`](https://www.npmjs.com/package/@zakkster/lite-scratch-fx) -- canvas scratch-reveal recipes; shares the `{ light, mid, dark }` theme shape
|
|
385
415
|
- [`lite-ambient-fx`](https://www.npmjs.com/package/@zakkster/lite-ambient-fx) -- fullscreen ambient backdrops (the worker-mode sibling)
|
|
386
|
-
- [`lite-headless`](https://www.npmjs.com/package/@zakkster/lite-headless) --
|
|
416
|
+
- [`lite-headless`](https://www.npmjs.com/package/@zakkster/lite-headless) -- 62 ARIA-correct primitives; a headless-skin target (`skinHeadless`)
|
|
387
417
|
- **`lite-ui-fx`** -- this package
|
|
388
418
|
|
|
389
419
|
---
|
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.11.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
|
// =========================================================
|