@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 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
 
@@ -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.0';
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';
@@ -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 56)
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-56 namespace
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 (simplified) -- font from a const-string LUT
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.9.0
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 56 recipes honour { colors, theme:{light,mid,dark}, text, font },
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 56. A bare mount is byte-identical to pre-theming. Shipped palettes + APCA
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.9.0",
4
- "description": "Canvas-hijacked UI components with a pluggable recipe system. 50 built-in recipes across toggles, buttons, sliders, knobs, loaders, checkboxes, counters, and ratings.",
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",