@zakkster/lite-ui-fx 1.11.0 → 1.12.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,50 @@ 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.12.0] -- 2026-09-08
9
+
10
+ Enrichment session E2 (decisions/0010): eight bespoke decorations on the U4b decorate
11
+ mode -- text-fx, card, and pointer micro-interactions painted AROUND a live element.
12
+ The first session since U4b to grow the MAIN recipe registry; no new dependency.
13
+
14
+ ### Added
15
+
16
+ - Eight `decorateUIFX` recipes on the `./recipes` subpath (RECIPE_META type
17
+ `decorate`), growing `RECIPE_NAMES` 57 -> 65 across three new families. Each is
18
+ themeable, `motionSafe` (a reduced-motion calm path), and allocates zero bytes per
19
+ frame, gated by the t3 torture tier (now gating 65 recipes, default AND themed):
20
+ - Text, painted OVER the host's own live glyphs: `TextShimmer` (a swept light band),
21
+ `SpotlightText` (a pointer-follow reading highlight), `UnderlineDraw` (a
22
+ focus-triggered underline wipe, distinct from TypewriterField).
23
+ - Card: `BorderBeam` (a comet around the perimeter), `TiltShine` (a pointer-driven
24
+ sheen), `CardSpotlight` (a pointer-follow radial glow).
25
+ - Pointer: `MagneticPull` (an accent ring eased toward the pointer; the host never
26
+ moves), `PointerRipple` (expanding rings from each press, a fixed Float32Array pool).
27
+ A recipe receives only `(ctx, dt, now, state, pointer)` -- never a DOM handle -- so a
28
+ decoration cannot read layout and is structurally reflow-safe. Shimmer/glow/beam use
29
+ const colours + `globalAlpha` with no gradient built in tick; no dependency was added
30
+ (`package.json` deps stay `lite-lerp` / `lite-random` / `lite-ticker`).
31
+ - `test/decorations-e2.test.mjs`: registry shape, additive-diff + exact-restore over
32
+ `<input>` / `<div>` / `<button>` hosts, and reduced-motion calm-path divergence. The
33
+ t3 decorate churn now sweeps the pointer and fires the click hook so the pointer
34
+ decorations' full draw paths are inside the alloc window.
35
+
36
+ ### Changed
37
+
38
+ - `llms.txt` and `README.md`: the recipe catalog + counts move 57 -> 65 (new families
39
+ Text, Card, Pointer), the `motionSafe` roster 6 -> 14, and the catalog size figure is
40
+ re-measured (~29 KB min+gzip). `demo/index.html` auto-generates the eight scenes from
41
+ RECIPE_META (the card/pointer families decorate a visible card host). Documentation
42
+ and demo only; no shipped code path changed.
43
+
44
+ ### Fixed
45
+
46
+ none
47
+
48
+ ### Removed
49
+
50
+ none
51
+
8
52
  ## [1.11.0] -- 2026-09-08
9
53
 
10
54
  Enrichment session E1b (decisions/0009): seven bespoke headless skins continuing the
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-ui-fx
2
2
 
3
- > Canvas microinteractions on real native controls. A DPR-aware canvas is hijacked over a hidden native element -- decorated around a live one -- or shared across a group of them -- and painted by a pluggable, zero-GC **recipe**. The native element owns focus, keyboard, and pointer events; the canvas owns the visuals. 57 built-in recipes behind a tree-shakeable registry, one option convention for theming, one clock you can hand it, and reduced-motion built in.
3
+ > Canvas microinteractions on real native controls. A DPR-aware canvas is hijacked over a hidden native element -- decorated around a live one -- or shared across a group of them -- and painted by a pluggable, zero-GC **recipe**. The native element owns focus, keyboard, and pointer events; the canvas owns the visuals. 65 built-in recipes behind a tree-shakeable registry, one option convention for theming, one clock you can hand it, and reduced-motion built in.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@zakkster/lite-ui-fx.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-ui-fx)
6
6
  ![Zero-GC](https://img.shields.io/badge/Zero--GC-Recipes-00C853?style=for-the-badge&logo=leaf&logoColor=white)
@@ -81,9 +81,9 @@ The alternative is a hand-rolled canvas threshold loop (no a11y, allocates freel
81
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.
82
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
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.
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).
84
+ - **65 built-in recipes** on the `./recipes` subpath, versioned, typed, and tree-shakeable. With `sideEffects: false`, importing one recipe drops the other 64. 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), Text (3), Card (3), Pointer (2).
85
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.
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.
86
+ - **One option convention for theming** -- `{ colors, theme: { light, mid, dark }, text, font }` honoured by all 65 recipes, resolved once in `init` so a themed mount stays zero-GC and a bare mount is byte-identical to pre-theming.
87
87
  - **Host integration** -- ride a caller-supplied `lite-ticker` (`{ ticker }`), drive frames by hand (`{ driven: true }` + `instance.tick(dtMs)`), or take the shared ref-counted ticker by default. Plus `state.reducedMotion` (matchMedia-watched) and `state.budget` (0..1 frame budget).
88
88
  - **Full TypeScript declarations** for both entry points, and a written recipe guide ([`UIFX-RECIPE-GUIDE.md`](UIFX-RECIPE-GUIDE.md)) shipped in the package.
89
89
 
@@ -213,7 +213,7 @@ The fourth mount adapter, on the `./headless` subpath: **skin a `@zakkster/lite-
213
213
 
214
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
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.
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 65 recipes.
217
217
 
218
218
  ```js
219
219
  import { skinHeadless, SwitchSkin } from '@zakkster/lite-ui-fx/headless';
@@ -273,7 +273,7 @@ The `state` object passed to `tick(ctx, dt, now, state)` every frame:
273
273
  | `index` / `count` / `hoverIndex` | `number` | group mode only: selection, item count, hovered item (-1 none) |
274
274
  | `labels` / `itemX` / `itemY` / `itemW` / `itemH` | `string[]` / `Float32Array` | group mode only: item labels + per-item geometry lanes (read by index) |
275
275
 
276
- `RECIPE_META` rows: `{ id, name, type, family, themeable, motionSafe }`. `themeable` is true for all 57; `motionSafe` is true for exactly the recipes that ship a calm reduced-motion path (6 today: SwarmToggle plus the five decorate recipes) and honestly false for the rest.
276
+ `RECIPE_META` rows: `{ id, name, type, family, themeable, motionSafe }`. `themeable` is true for all 65; `motionSafe` is true for exactly the recipes that ship a calm reduced-motion path (14 today: SwarmToggle plus the five U4b decorate recipes and the eight E2 decorations) and honestly false for the rest.
277
277
 
278
278
  ---
279
279
 
@@ -353,13 +353,13 @@ Everything a recipe needs is resolved in `init` (cold): the palette and any ramp
353
353
  | Pointer move / drag | **0** | arithmetic only; the bounding rect is cached on pointer-enter, not read per move |
354
354
  | `init` / theme resolve | once, cold | palette, ramps, gradients, pools -- then read-only in the loop |
355
355
 
356
- The `t3-frame-alloc` torture tier asserts, per recipe, **zero distinct `fillStyle` string allocations per frame at steady state** and **zero gradient constructions after `init`** -- in both a default and a themed mount, across all 57 recipes (grouped controls included, driven through their selection). Two positive controls -- one allocating a color string per frame, one per group item per frame -- must FAIL the gate, or it would be decorative. The full harness (`@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`) proves **0 retained bytes, 0 major GCs, and ~0.88 B/op** across the whole mount / interact / destroy loop under `--expose-gc`:
356
+ The `t3-frame-alloc` torture tier asserts, per recipe, **zero distinct `fillStyle` string allocations per frame at steady state** and **zero gradient constructions after `init`** -- in both a default and a themed mount, across all 65 recipes (grouped controls included, driven through their selection). Two positive controls -- one allocating a color string per frame, one per group item per frame -- must FAIL the gate, or it would be decorative. The full harness (`@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`) proves **0 retained bytes, 0 major GCs, and ~0.88 B/op** across the whole mount / interact / destroy loop under `--expose-gc`:
357
357
 
358
358
  ```
359
359
  GATE leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | alloc=0.8759765625 B/op
360
360
  ```
361
361
 
362
- For size: the controller alone is **~7.1 KB min+gzip** (its three deps external); the full catalog of 57 recipes is **~25 KB min+gzip**, and it tree-shakes -- import one recipe and the bundler drops the other 56.
362
+ For size: the controller alone is **~7.1 KB min+gzip** (its three deps external); the full catalog of 65 recipes is **~29 KB min+gzip**, and it tree-shakes -- import one recipe and the bundler drops the other 64.
363
363
 
364
364
  </details>
365
365
 
@@ -369,21 +369,22 @@ For size: the controller alone is **~7.1 KB min+gzip** (its three deps external)
369
369
 
370
370
  Each is an ADR under [`decisions/`](decisions/):
371
371
 
372
- - **[0001](decisions/0001-recipes-position.md) -- Recipes ship inside the package.** No more copy-paste-from-a-ZIP: 57 recipes are versioned, typed, and tree-shakeable behind the `./recipes` subpath, exactly the shape the sibling fx packages use.
373
- - **[0002](decisions/0002-recipe-options.md) -- One recipe option convention.** `{ colors, theme, text, font }` across all 57, resolved cold in `init`; defaults reproduce today's literals byte-for-byte; `text` closes the WCAG label-in-name gap.
372
+ - **[0001](decisions/0001-recipes-position.md) -- Recipes ship inside the package.** No more copy-paste-from-a-ZIP: 65 recipes are versioned, typed, and tree-shakeable behind the `./recipes` subpath, exactly the shape the sibling fx packages use.
373
+ - **[0002](decisions/0002-recipe-options.md) -- One recipe option convention.** `{ colors, theme, text, font }` across all 65, resolved cold in `init`; defaults reproduce today's literals byte-for-byte; `text` closes the WCAG label-in-name gap.
374
374
  - **[0003](decisions/0003-element-types.md) -- Real native element types.** CHECKBOX (tri-state), PROGRESS (`setValue`-driven, opt-in `aria-live`), KNOB (native arrows + pointer map) wrap the *correct* native element, not a faked toggle.
375
375
  - **[0004](decisions/0004-decorate-mode.md) -- Decorate mode.** A second mount mode for a canvas around a live element -- the honest home for a decoration over a real input, host byte-identical.
376
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.
377
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).
378
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).
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 recipe registry (additive, 1.10.0).
380
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).
381
+ - **[0010](decisions/0010-text-pointer-card-decorations.md) -- Text-fx + pointer + card decorations.** Eight decorate-mode recipes (text shimmer/spotlight/underline, card border-beam/tilt-shine/spotlight, pointer magnetic-pull/ripple) grow the main recipe registry to 65. Each is zero per-frame alloc (const colours + `globalAlpha`, no gradient), `motionSafe` with a real calm path, and structurally reflow-safe -- a recipe receives `ctx` + `state` + `pointer`, never a DOM handle, so it cannot read layout. No new dependency: particle trail/glow and a host-owned scramble defer to later phases (additive, 1.12.0).
381
382
 
382
383
  ---
383
384
 
384
385
  ## Testing
385
386
 
386
- **237 deterministic node:test cases across 27 suites, all pass**, plus a torture gate that proves 0 B/op steady state and leak-freedom.
387
+ **299 deterministic node:test cases across 38 suites, all pass**, plus a torture gate that proves 0 B/op steady state and leak-freedom.
387
388
 
388
389
  ```bash
389
390
  npm test # node:test: contract, boundary, registry, theming, reduced-motion, docs
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.11.0';
25
+ export const VERSION = '1.12.0';
26
26
 
27
27
  // ---------------------------------------------------------
28
28
  // SHARED TICKER (ref-counted, one RAF for all UI components)
package/UIFXRecipes.d.ts CHANGED
@@ -134,6 +134,15 @@ export declare function PullRefresh(options?: RecipeOptions): UIFXRecipe;
134
134
  export declare function FocusHalo(options?: RecipeOptions): UIFXRecipe;
135
135
  export declare function ErrorShake(options?: RecipeOptions): UIFXRecipe;
136
136
  export declare function SuccessBloom(options?: RecipeOptions): UIFXRecipe;
137
+ // E2 decorations (text-fx + card + pointer; decisions/0010)
138
+ export declare function TextShimmer(options?: RecipeOptions): UIFXRecipe;
139
+ export declare function SpotlightText(options?: RecipeOptions): UIFXRecipe;
140
+ export declare function UnderlineDraw(options?: RecipeOptions): UIFXRecipe;
141
+ export declare function BorderBeam(options?: RecipeOptions): UIFXRecipe;
142
+ export declare function TiltShine(options?: RecipeOptions): UIFXRecipe;
143
+ export declare function CardSpotlight(options?: RecipeOptions): UIFXRecipe;
144
+ export declare function MagneticPull(options?: RecipeOptions & { maxPull?: number }): UIFXRecipe;
145
+ export declare function PointerRipple(options?: RecipeOptions): UIFXRecipe;
137
146
 
138
147
  // ===========================================================
139
148
  // BARREL OBJECTS (back-compat)
@@ -334,6 +343,14 @@ declare const UIFXAllRecipes: {
334
343
  FocusHalo: typeof FocusHalo;
335
344
  ErrorShake: typeof ErrorShake;
336
345
  SuccessBloom: typeof SuccessBloom;
346
+ TextShimmer: typeof TextShimmer;
347
+ SpotlightText: typeof SpotlightText;
348
+ UnderlineDraw: typeof UnderlineDraw;
349
+ BorderBeam: typeof BorderBeam;
350
+ TiltShine: typeof TiltShine;
351
+ CardSpotlight: typeof CardSpotlight;
352
+ MagneticPull: typeof MagneticPull;
353
+ PointerRipple: typeof PointerRipple;
337
354
  };
338
355
  export default UIFXAllRecipes;
339
356
 
@@ -341,7 +358,7 @@ export default UIFXAllRecipes;
341
358
  // HEADLESS SKINS (E1, decisions/0008)
342
359
  // A sibling registry of RECIPES/RECIPE_META: skins are driven by skinHeadless
343
360
  // (a handle + host), never by mountRecipe (a container), so they are kept
344
- // separate and the 57-recipe count is unchanged.
361
+ // separate and the recipe count is unchanged by skins.
345
362
  // ===========================================================
346
363
 
347
364
  export declare function SwitchSkin(options?: RecipeOptions): HeadlessSkinRecipe;
package/UIFXRecipes.js CHANGED
@@ -3049,6 +3049,256 @@ export function SuccessBloom(o = {}) {
3049
3049
  }
3050
3050
 
3051
3051
 
3052
+ // ===========================================================
3053
+ // E2 DECORATIONS -- text-fx, card, and pointer (decorate mode)
3054
+ // ===========================================================
3055
+ // Eight decorateUIFX recipes (RECIPE_META type 'decorate'): a canvas wrapped
3056
+ // AROUND a live element, never modifying it. A recipe receives only (ctx, dt,
3057
+ // now, state, pointer) -- never a DOM handle -- so it cannot read layout and is
3058
+ // structurally reflow-safe (the decorate adapter owns every DOM read). Each is
3059
+ // zero per-frame alloc (const colours + globalAlpha; NO gradient or string built
3060
+ // in tick) and ships a reduced-motion calm path (motionSafe). The text-fx paint
3061
+ // OVER the host's own live glyphs -- they never re-render the string. See
3062
+ // decisions/0010-text-pointer-card-decorations.md.
3063
+
3064
+ /** Text Shimmer (E2 DECORATE) -- a soft light band sweeps across the host's text
3065
+ * left->right on a loop, painting OVER the live glyphs (never rendering them).
3066
+ * Reduced motion (U5): a faint static sheen, no sweep. */
3067
+ export function TextShimmer(o = {}) {
3068
+ const P = resolveTheme(o, { accent: '#e2e8f0' });
3069
+ let phase = 0;
3070
+ return {
3071
+ tick(c, dt, now, st) {
3072
+ if (st.reducedMotion) {
3073
+ c.fillStyle = P.accent; c.globalAlpha = 0.05;
3074
+ c.fillRect(0, 0, st.w, st.h); c.globalAlpha = 1; return;
3075
+ }
3076
+ const BW = st.w * 0.26, slant = st.h * 0.4;
3077
+ phase = (phase + dt * 0.55) % 1;
3078
+ const cx = -BW + phase * (st.w + BW * 2);
3079
+ c.fillStyle = P.accent;
3080
+ for (let i = 0; i < 3; i++) {
3081
+ const w = BW * (1 - i * 0.3);
3082
+ c.globalAlpha = 0.09 - i * 0.025;
3083
+ c.beginPath();
3084
+ c.moveTo(cx - w + slant, 0); c.lineTo(cx + w + slant, 0);
3085
+ c.lineTo(cx + w - slant, st.h); c.lineTo(cx - w - slant, st.h);
3086
+ c.closePath(); c.fill();
3087
+ }
3088
+ c.globalAlpha = 1;
3089
+ },
3090
+ };
3091
+ }
3092
+
3093
+ /** Spotlight Text (E2 DECORATE) -- a soft highlight tracks the pointer's x across
3094
+ * the text line while hovering, a reading light over the live glyphs. Reduced
3095
+ * motion (U5): a centred static highlight on hover, no follow. */
3096
+ export function SpotlightText(o = {}) {
3097
+ const P = resolveTheme(o, { accent: '#fde68a' });
3098
+ let on = 0, sx = -1;
3099
+ return {
3100
+ tick(c, dt, now, st, ptr) {
3101
+ const target = st.hover ? 1 : 0;
3102
+ on = st.reducedMotion ? target : lerp(on, target, dt * 8);
3103
+ if (on < 0.01) return;
3104
+ const tx = st.reducedMotion ? st.w / 2 : clamp(ptr.x, 0, st.w);
3105
+ sx = (st.reducedMotion || sx < 0) ? tx : lerp(sx, tx, dt * 12);
3106
+ const rx = st.w * 0.22, ry = st.h * 0.55, cy = st.h / 2;
3107
+ c.fillStyle = P.accent;
3108
+ for (let i = 3; i >= 1; i--) {
3109
+ c.globalAlpha = on * 0.05 * i;
3110
+ c.beginPath(); c.ellipse(sx, cy, rx * (i / 3), ry * (i / 3), 0, 0, PI2); c.fill();
3111
+ }
3112
+ c.globalAlpha = 1;
3113
+ },
3114
+ };
3115
+ }
3116
+
3117
+ /** Underline Draw (E2 DECORATE) -- a gradient-free underline wipes in left->right
3118
+ * when the host gains focus and retracts on blur, with a soft leading dot.
3119
+ * Distinct from TypewriterField (which grows with typed length). Reduced motion
3120
+ * (U5): the underline snaps to full-width on focus, no wipe, no dot. */
3121
+ export function UnderlineDraw(o = {}) {
3122
+ const P = resolveTheme(o, { accent: '#38bdf8' });
3123
+ let draw = 0;
3124
+ return {
3125
+ tick(c, dt, now, st) {
3126
+ const target = st.focused ? 1 : 0;
3127
+ draw = st.reducedMotion ? target : lerp(draw, target, dt * 9);
3128
+ const y = st.h - 3, x0 = 2, full = st.w - 4;
3129
+ c.strokeStyle = P.accent; c.lineWidth = 2;
3130
+ c.globalAlpha = 0.15; c.beginPath(); c.moveTo(x0, y); c.lineTo(x0 + full, y); c.stroke();
3131
+ if (draw > 0.01) {
3132
+ const x1 = x0 + full * draw;
3133
+ c.globalAlpha = 1; c.beginPath(); c.moveTo(x0, y); c.lineTo(x1, y); c.stroke();
3134
+ if (!st.reducedMotion && draw < 0.995) {
3135
+ c.fillStyle = P.accent;
3136
+ c.beginPath(); c.arc(x1, y, 2.5, 0, PI2); c.fill();
3137
+ }
3138
+ }
3139
+ c.globalAlpha = 1;
3140
+ },
3141
+ };
3142
+ }
3143
+
3144
+ /** Border Beam (E2 DECORATE) -- a bright comet travels around the host's border
3145
+ * over a faint full outline (Magic UI style). Reduced motion (U5): the static
3146
+ * faint outline only, no travelling beam. */
3147
+ export function BorderBeam(o = {}) {
3148
+ const P = resolveTheme(o, { accent: '#a78bfa' });
3149
+ const R = 10;
3150
+ let t = 0;
3151
+ return {
3152
+ tick(c, dt, now, st) {
3153
+ c.strokeStyle = P.accent; c.globalAlpha = 0.14; c.lineWidth = 1.5;
3154
+ rr(c, 0, 0, st.w, st.h, R); c.stroke();
3155
+ c.globalAlpha = 1;
3156
+ if (st.reducedMotion) return;
3157
+ t = (t + dt * 0.4) % 1;
3158
+ const per = 2 * (st.w + st.h), head = t * per;
3159
+ c.fillStyle = P.accent;
3160
+ for (let i = 0; i < 6; i++) {
3161
+ let d = head - i * (per * 0.012); if (d < 0) d += per;
3162
+ let x, y;
3163
+ if (d < st.w) { x = d; y = 0; }
3164
+ else if (d < st.w + st.h) { x = st.w; y = d - st.w; }
3165
+ else if (d < 2 * st.w + st.h) { x = st.w - (d - st.w - st.h); y = st.h; }
3166
+ else { x = 0; y = st.h - (d - 2 * st.w - st.h); }
3167
+ c.globalAlpha = 0.6 * (1 - i / 6);
3168
+ c.beginPath(); c.arc(x, y, 2.2, 0, PI2); c.fill();
3169
+ }
3170
+ c.globalAlpha = 1;
3171
+ },
3172
+ };
3173
+ }
3174
+
3175
+ /** Tilt Shine (E2 DECORATE) -- a diagonal sheen sweeps across the card toward the
3176
+ * pointer while hovering, with the border catching a little light. Clipped to
3177
+ * the card. Reduced motion (U5): a static centred sheen on hover, no follow. */
3178
+ export function TiltShine(o = {}) {
3179
+ const P = resolveTheme(o, { accent: '#ffffff' });
3180
+ const R = 10;
3181
+ let on = 0, sx = 0.5;
3182
+ return {
3183
+ tick(c, dt, now, st, ptr) {
3184
+ const target = st.hover ? 1 : 0;
3185
+ on = st.reducedMotion ? target : lerp(on, target, dt * 8);
3186
+ if (on < 0.01) return;
3187
+ const nx = st.reducedMotion ? 0.5 : clamp(ptr.x / st.w, 0, 1);
3188
+ sx = st.reducedMotion ? nx : lerp(sx, nx, dt * 10);
3189
+ c.save();
3190
+ rr(c, 0, 0, st.w, st.h, R); c.clip();
3191
+ const bx = sx * st.w, bw = st.w * 0.5, slant = st.h * 0.6;
3192
+ c.fillStyle = P.accent;
3193
+ for (let i = 0; i < 3; i++) {
3194
+ const w = bw * (1 - i * 0.3);
3195
+ c.globalAlpha = on * (0.10 - i * 0.03);
3196
+ c.beginPath();
3197
+ c.moveTo(bx - w + slant, 0); c.lineTo(bx + w + slant, 0);
3198
+ c.lineTo(bx + w - slant, st.h); c.lineTo(bx - w - slant, st.h);
3199
+ c.closePath(); c.fill();
3200
+ }
3201
+ c.restore();
3202
+ c.globalAlpha = on * 0.4; c.strokeStyle = P.accent; c.lineWidth = 1.5;
3203
+ rr(c, 0, 0, st.w, st.h, R); c.stroke();
3204
+ c.globalAlpha = 1;
3205
+ },
3206
+ };
3207
+ }
3208
+
3209
+ /** Card Spotlight (E2 DECORATE) -- a soft radial glow follows the pointer over the
3210
+ * card surface with the border brightening (Aceternity style). Clipped to the
3211
+ * card. Reduced motion (U5): a static centred glow on hover, no follow. */
3212
+ export function CardSpotlight(o = {}) {
3213
+ const P = resolveTheme(o, { accent: '#38bdf8' });
3214
+ const R = 10;
3215
+ let on = 0, gx = 0, gy = 0, seeded = 0;
3216
+ return {
3217
+ tick(c, dt, now, st, ptr) {
3218
+ const target = st.hover ? 1 : 0;
3219
+ on = st.reducedMotion ? target : lerp(on, target, dt * 7);
3220
+ const tx = st.reducedMotion ? st.w / 2 : clamp(ptr.x, 0, st.w);
3221
+ const ty = st.reducedMotion ? st.h / 2 : clamp(ptr.y, 0, st.h);
3222
+ if (!seeded) { gx = tx; gy = ty; seeded = 1; }
3223
+ else { gx = st.reducedMotion ? tx : lerp(gx, tx, dt * 12); gy = st.reducedMotion ? ty : lerp(gy, ty, dt * 12); }
3224
+ if (on < 0.01) return;
3225
+ c.save();
3226
+ rr(c, 0, 0, st.w, st.h, R); c.clip();
3227
+ const rad = Math.min(st.w, st.h) * 0.9;
3228
+ c.fillStyle = P.accent;
3229
+ for (let i = 5; i >= 1; i--) {
3230
+ c.globalAlpha = on * 0.03 * (6 - i);
3231
+ c.beginPath(); c.arc(gx, gy, rad * (i / 5), 0, PI2); c.fill();
3232
+ }
3233
+ c.restore();
3234
+ c.globalAlpha = on * 0.35; c.strokeStyle = P.accent; c.lineWidth = 1.5;
3235
+ rr(c, 0, 0, st.w, st.h, R); c.stroke();
3236
+ c.globalAlpha = 1;
3237
+ },
3238
+ };
3239
+ }
3240
+
3241
+ /** Magnetic Pull (E2 DECORATE) -- an accent ring is drawn pulled toward the
3242
+ * pointer within a radius while hovering; the host itself never moves (a
3243
+ * decoration reflects, it does not drive). Reduced motion (U5): a centred static
3244
+ * ring on hover, no pull. */
3245
+ export function MagneticPull(o = {}) {
3246
+ const { maxPull = 14 } = o;
3247
+ const P = resolveTheme(o, { accent: '#f472b6' });
3248
+ const R = 10;
3249
+ let bx = 0, by = 0, on = 0;
3250
+ return {
3251
+ tick(c, dt, now, st, ptr) {
3252
+ const target = st.hover ? 1 : 0;
3253
+ on = st.reducedMotion ? target : lerp(on, target, dt * 8);
3254
+ const tx = (st.hover && !st.reducedMotion) ? clamp(ptr.x - st.w / 2, -maxPull, maxPull) : 0;
3255
+ const ty = (st.hover && !st.reducedMotion) ? clamp(ptr.y - st.h / 2, -maxPull, maxPull) : 0;
3256
+ bx = st.reducedMotion ? 0 : lerp(bx, tx, dt * 10);
3257
+ by = st.reducedMotion ? 0 : lerp(by, ty, dt * 10);
3258
+ if (on < 0.01) return;
3259
+ c.save(); c.translate(bx, by);
3260
+ c.strokeStyle = P.accent; c.lineWidth = 1.5; c.globalAlpha = on * 0.9;
3261
+ rr(c, 1, 1, st.w - 2, st.h - 2, R); c.stroke();
3262
+ c.globalAlpha = on * 0.18;
3263
+ rr(c, -3, -3, st.w + 6, st.h + 6, R + 3); c.stroke();
3264
+ c.restore();
3265
+ c.globalAlpha = 1;
3266
+ },
3267
+ };
3268
+ }
3269
+
3270
+ /** Pointer Ripple (E2 DECORATE) -- concentric rings expand from each pointer press
3271
+ * on the host, fading as they grow (Material style). Zero-alloc: a fixed
3272
+ * Float32Array ring pool preallocated in the factory. Reduced motion (U5): a
3273
+ * brief static ring at the press, no expansion. */
3274
+ export function PointerRipple(o = {}) {
3275
+ const P = resolveTheme(o, { accent: '#22d3ee' });
3276
+ const N = 6;
3277
+ const rx = new Float32Array(N), ry = new Float32Array(N), ra = new Float32Array(N);
3278
+ let head = 0;
3279
+ function spawn(x, y) {
3280
+ rx[head] = x; ry[head] = y; ra[head] = 1;
3281
+ head = (head + 1) % N;
3282
+ }
3283
+ return {
3284
+ onClick(x, y) { spawn(x, y); },
3285
+ tick(c, dt, now, st) {
3286
+ c.strokeStyle = P.accent; c.lineWidth = 2;
3287
+ const maxR = Math.max(st.w, st.h) * 0.8;
3288
+ for (let i = 0; i < N; i++) {
3289
+ if (ra[i] <= 0) continue;
3290
+ ra[i] -= dt * (st.reducedMotion ? 3 : 1.4);
3291
+ if (ra[i] <= 0) { ra[i] = 0; continue; }
3292
+ const grow = st.reducedMotion ? 0.2 : (1 - ra[i]);
3293
+ c.globalAlpha = ra[i] * 0.7;
3294
+ c.beginPath(); c.arc(rx[i], ry[i], 4 + grow * maxR, 0, PI2); c.stroke();
3295
+ }
3296
+ c.globalAlpha = 1;
3297
+ },
3298
+ };
3299
+ }
3300
+
3301
+
3052
3302
  // U4a additions -- new native element types (CHECKBOX, PROGRESS). Kept out of the
3053
3303
  // Vol.1-3 historical snapshots above so those stay accurate; all recipes remain
3054
3304
  // reachable via RECIPES / RECIPE_META and their named exports regardless.
@@ -3205,6 +3455,14 @@ export const RECIPES = Object.assign(Object.create(null), {
3205
3455
  focusHalo: FocusHalo,
3206
3456
  errorShake: ErrorShake,
3207
3457
  successBloom: SuccessBloom,
3458
+ textShimmer: TextShimmer,
3459
+ spotlightText: SpotlightText,
3460
+ underlineDraw: UnderlineDraw,
3461
+ borderBeam: BorderBeam,
3462
+ tiltShine: TiltShine,
3463
+ cardSpotlight: CardSpotlight,
3464
+ magneticPull: MagneticPull,
3465
+ pointerRipple: PointerRipple,
3208
3466
  });
3209
3467
 
3210
3468
  /**
@@ -3277,6 +3535,14 @@ export const RECIPE_META = [
3277
3535
  { id: 'focusHalo', name: 'Focus Halo', type: 'decorate', family: 'Form', themeable: true, motionSafe: true },
3278
3536
  { id: 'errorShake', name: 'Error Shake', type: 'decorate', family: 'Form', themeable: true, motionSafe: true },
3279
3537
  { id: 'successBloom', name: 'Success Bloom', type: 'decorate', family: 'Form', themeable: true, motionSafe: true },
3538
+ { id: 'textShimmer', name: 'Text Shimmer', type: 'decorate', family: 'Text', themeable: true, motionSafe: true },
3539
+ { id: 'spotlightText', name: 'Spotlight Text', type: 'decorate', family: 'Text', themeable: true, motionSafe: true },
3540
+ { id: 'underlineDraw', name: 'Underline Draw', type: 'decorate', family: 'Text', themeable: true, motionSafe: true },
3541
+ { id: 'borderBeam', name: 'Border Beam', type: 'decorate', family: 'Card', themeable: true, motionSafe: true },
3542
+ { id: 'tiltShine', name: 'Tilt Shine', type: 'decorate', family: 'Card', themeable: true, motionSafe: true },
3543
+ { id: 'cardSpotlight', name: 'Card Spotlight', type: 'decorate', family: 'Card', themeable: true, motionSafe: true },
3544
+ { id: 'magneticPull', name: 'Magnetic Pull', type: 'decorate', family: 'Pointer', themeable: true, motionSafe: true },
3545
+ { id: 'pointerRipple', name: 'Pointer Ripple', type: 'decorate', family: 'Pointer', themeable: true, motionSafe: true },
3280
3546
  ];
3281
3547
 
3282
3548
  /** Names of every built-in recipe (the keys of RECIPES at load time). */
package/llms.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # @zakkster/lite-ui-fx
2
- > Canvas-hijacked UI components with pluggable recipe system. 57 built-in recipes.
2
+ > Canvas-hijacked UI components with pluggable recipe system. 65 built-in recipes.
3
3
 
4
- VERSION 1.11.0
4
+ VERSION 1.12.0
5
5
 
6
6
  ## Install
7
7
  npm i @zakkster/lite-ui-fx
@@ -14,11 +14,12 @@ Recipe = { tick(), init?(), onHover?(), onClick?(), onToggle?(), onDrag?(), dest
14
14
  ## Import -- Controller
15
15
  import { mountUIFX, decorateUIFX, mountUIFXGroup, UIType, GroupType } from '@zakkster/lite-ui-fx';
16
16
 
17
- ## Import -- Recipes (one ./recipes subpath, 57 total, tree-shakeable)
17
+ ## Import -- Recipes (one ./recipes subpath, 65 total, tree-shakeable)
18
18
  import { SwarmToggle, MagneticButton, SparkSlider } from '@zakkster/lite-ui-fx/recipes';
19
19
  import { PendulumToggle, HeartbeatButton, AuroraSlider } from '@zakkster/lite-ui-fx/recipes';
20
20
  import { VolumeKnob, WaterLevel, TimerCountdown } from '@zakkster/lite-ui-fx/recipes';
21
21
  import { PillTabs, SegmentedSlide, RadioOrbit, BubbleRating } from '@zakkster/lite-ui-fx/recipes'; // U7 group recipes
22
+ import { TextShimmer, SpotlightText, BorderBeam, CardSpotlight, MagneticPull, PointerRipple } from '@zakkster/lite-ui-fx/recipes'; // E2 decorations
22
23
 
23
24
  ## Import -- Recipe registry (data-driven pickers)
24
25
  import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from '@zakkster/lite-ui-fx/recipes';
@@ -109,7 +110,7 @@ skin.destroy() // remove overlay + observer + listeners; host + handle untouch
109
110
  // EVENT time (never per frame) to parse painted state into preallocated slots.
110
111
  // options: { host(REQUIRED), padding, seed, colors, theme, text, font, ticker, driven };
111
112
  // hijack-only keys throw. setValue/setChecked throw (a skin reflects the primitive).
112
- // Skins (SKIN_META, a registry SEPARATE from RECIPES -- the 57-recipe count is
113
+ // Skins (SKIN_META, a registry SEPARATE from RECIPES -- the recipe count is
113
114
  // unchanged). E1: SwitchSkin (data-checked/aria-checked), SliderSkin (aria-
114
115
  // valuenow/min/max + data-dragging), ProgressSkin (aria-valuenow/max + data-
115
116
  // complete/data-loading), RatingSkin (aria-valuenow/max over N). E1b (0009):
@@ -165,7 +166,7 @@ GroupType.RATING -> radiogroup of N radios (rating semantics); native roving
165
166
  // itemX/itemY/itemW/itemH (Float32Array per-item geometry lanes, read by index).
166
167
  // The scalar value fields (val/toggled/indeterminate) are neutral for a group.
167
168
 
168
- ## 57 Built-in Recipes
169
+ ## 65 Built-in Recipes
169
170
 
170
171
  ### Vol. 1 -- 10 recipes
171
172
  Toggles: SwarmToggle, LiquidToggle, NeonPulseToggle
@@ -208,6 +209,21 @@ Rating (GroupType.RATING): BubbleRating
208
209
  // fakes to real group types, so their native arrow-key selection is now correct;
209
210
  // SegmentedSlide is new. Selection is state.index; onSelect(index, state) fires once.
210
211
 
212
+ ### E2 -- text-fx + pointer + card decorations (decorateUIFX: a canvas AROUND a live element)
213
+ Text (paint OVER live text): TextShimmer, SpotlightText, UnderlineDraw
214
+ Card: BorderBeam, TiltShine, CardSpotlight
215
+ Pointer: MagneticPull, PointerRipple
216
+ // All type 'decorate' (RECIPE_META), motionSafe (a reduced-motion calm path), zero
217
+ // per-frame alloc. They paint OVER a live element and never modify it; a recipe
218
+ // receives only (ctx, dt, now, state, pointer) -- never a DOM handle -- so it cannot
219
+ // read layout and is structurally reflow-safe (the decorate adapter owns every DOM
220
+ // read). No lite-particles/lite-noise: shimmer/glow/beam are const colours +
221
+ // globalAlpha, no gradient built in tick. Text-fx cover <input> AND <textarea>;
222
+ // card/pointer decorate any live element. DEFERRED (decisions/0010): pointer trail +
223
+ // proximity-glow (need lite-particles -> the 1.13.0 phase); ScrambleIn (needs
224
+ // host-owned text -> a future hijack-mode text tranche); Dock magnify (a multi-
225
+ // element row -> a future group/dock vehicle). See 0010.
226
+
211
227
  ## Writing Custom Recipes
212
228
  See UIFX-RECIPE-GUIDE.md (included in package).
213
229
 
@@ -223,9 +239,9 @@ See UIFX-RECIPE-GUIDE.md (included in package).
223
239
  - Zero-GC in all built-in recipes: const colors + globalAlpha, precomputed
224
240
  color/label LUTs, fixed preallocated particle pools, gradients built in init.
225
241
  Gated per recipe by the t3-frame-alloc torture tier (default AND themed mount).
226
- - Themeable: all 57 recipes honour { colors, theme:{light,mid,dark}, text, font },
242
+ - Themeable: all 65 recipes honour { colors, theme:{light,mid,dark}, text, font },
227
243
  resolved once in init (zero per-frame alloc). RECIPE_META.themeable is true for
228
- all 57. A bare mount is byte-identical to pre-theming. Shipped palettes + APCA
244
+ all 65. A bare mount is byte-identical to pre-theming. Shipped palettes + APCA
229
245
  contrast are authored with @zakkster/lite-hueforge (a dev-only tool, never a
230
246
  runtime dependency).
231
247
  - U5 host clock: mount { ticker } to ride a caller-supplied lite-ticker (destroy
@@ -234,7 +250,8 @@ See UIFX-RECIPE-GUIDE.md (included in package).
234
250
  - U5 reduced motion: state.reducedMotion (matchMedia, watched) makes calm-path
235
251
  recipes render statically -- ErrorShake stops shaking, SwarmToggle/SuccessBloom/
236
252
  FocusHalo drop their motion. RECIPE_META.motionSafe is true for EXACTLY the
237
- recipes that ship a calm path (SwarmToggle + the 5 decorate recipes today; the
253
+ recipes that ship a calm path (SwarmToggle + the 5 U4b decorate recipes + the 8 E2
254
+ decorations = 14 today; the
238
255
  rest honestly false until each lands one). mountRecipe warns (not throws) mounting
239
256
  a motionSafe:false recipe under active reduce. state.budget (0..1) lets budget-aware
240
257
  recipes shed work before frames drop.
@@ -259,7 +276,7 @@ See UIFX-RECIPE-GUIDE.md (included in package).
259
276
  attributes (one MutationObserver; parsed at event time, never per frame) --
260
277
  lite-ui-fx paints, lite-headless behaves. lite-headless is a compose-target, NEVER
261
278
  a dependency (the package imports it nowhere). 11 skins live in a registry
262
- (HEADLESS_SKINS/SKIN_META) SEPARATE from the 57 recipes: E1 switch/slider/progress/
279
+ (HEADLESS_SKINS/SKIN_META) SEPARATE from the 65 recipes: E1 switch/slider/progress/
263
280
  rating; E1b (decisions/0009) checkbox (tri-state) + checkbox-group master, select
264
281
  (the trigger -- resolving the dropdown/item-6 decision toward a V4 skin; the
265
282
  portaled listbox open-state is a follow-on), meter, steps, accordion, skeleton --
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-ui-fx",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
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",