@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 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 59 primitives are a decorate-mode skin target.
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) -- 59 ARIA-correct primitives; a decorate-mode skin target
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
  ---
@@ -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.9.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
  // =========================================================