@zakkster/lite-ui-fx 1.9.1 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
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
+ CheckboxSkin,
20
+ CheckboxGroupSkin,
21
+ SelectSkin,
22
+ MeterSkin,
23
+ StepsSkin,
24
+ AccordionSkin,
25
+ SkeletonSkin,
26
+ HEADLESS_SKINS,
27
+ SKIN_META,
28
+ SKIN_NAMES,
29
+ } from './UIFXRecipes';
30
+ export type { SkinMeta } from './UIFXRecipes';
@@ -0,0 +1,30 @@
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
+ CheckboxSkin,
21
+ CheckboxGroupSkin,
22
+ SelectSkin,
23
+ MeterSkin,
24
+ StepsSkin,
25
+ AccordionSkin,
26
+ SkeletonSkin,
27
+ HEADLESS_SKINS,
28
+ SKIN_META,
29
+ SKIN_NAMES,
30
+ } 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,37 @@ 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
+ export declare function CheckboxSkin(options?: RecipeOptions): HeadlessSkinRecipe;
352
+ export declare function CheckboxGroupSkin(options?: RecipeOptions): HeadlessSkinRecipe;
353
+ export declare function SelectSkin(options?: RecipeOptions): HeadlessSkinRecipe;
354
+ export declare function MeterSkin(options?: RecipeOptions): HeadlessSkinRecipe;
355
+ export declare function StepsSkin(options?: RecipeOptions): HeadlessSkinRecipe;
356
+ export declare function AccordionSkin(options?: RecipeOptions): HeadlessSkinRecipe;
357
+ export declare function SkeletonSkin(options?: RecipeOptions): HeadlessSkinRecipe;
358
+
359
+ /** A headless-skin meta row. `primitive` names the lite-headless primitive the
360
+ * skin is designed to paint. */
361
+ export interface SkinMeta {
362
+ id: string;
363
+ name: string;
364
+ primitive: string;
365
+ themeable: boolean;
366
+ motionSafe: boolean;
367
+ }
368
+
369
+ /** id -> skin factory (null-prototype). Driven by skinHeadless, never mountRecipe. */
370
+ export declare const HEADLESS_SKINS: Record<string, (options?: RecipeOptions) => HeadlessSkinRecipe>;
371
+ export declare const SKIN_META: SkinMeta[];
372
+ export declare const SKIN_NAMES: readonly string[];
package/UIFXRecipes.js CHANGED
@@ -2548,13 +2548,15 @@ export function DayNightToggle(o = {}) {
2548
2548
 
2549
2549
  /** 13. Reaction Picker -- 5 emoji-style circles that inflate on hover region. */
2550
2550
  export function ReactionPicker(o = {}) {
2551
- let sizes=new Float32Array(5), selected=-1;
2551
+ let sizes=new Float32Array(5).fill(10), selected=-1; // seed at the rest radius so frame 0 draws full discs, not radius-0 nothing
2552
2552
  const emojis=['\u{1F610}','\u{1F642}','\u{1F60A}','\u{1F604}','\u{1F929}'];
2553
2553
  // `colors` is the 5-reaction palette; `theme` seeds it. Normalised to 5.
2554
2554
  const base = o.colors ? o.colors
2555
2555
  : (o.theme ? [o.theme.light,o.theme.mid,o.theme.dark,o.theme.light,o.theme.mid] : ['#9999b8','#fbbf24','#fb923c','#f472b6','#ff6b6b']);
2556
2556
  const colors = base.length>=5 ? base : Array.from({length:5},(_,i)=>base[i%base.length]);
2557
2557
  const colors30=colors.map((col)=>col+'30'); // active fill (was `${color}30`)
2558
+ const REST_FILL='rgba(255,255,255,.10)'; // at-rest disc: was 4% (near-invisible); 10% so the row always reads
2559
+ const RING='rgba(255,255,255,.22)'; // hairline ring, theme-invariant: keeps the control legible even if the emoji glyph never paints
2558
2560
  const REACT_LABELS=['MEH','OK','NICE','GREAT','LOVE']; // was a per-frame array literal
2559
2561
  const FONTS=[]; for(let i=0;i<=48;i++)FONTS[i]=i+'px sans-serif'; // emoji font by rounded size
2560
2562
  return {
@@ -2562,21 +2564,25 @@ export function ReactionPicker(o = {}) {
2562
2564
  const gap=st.w/5;
2563
2565
  const hoverIdx=st.hover?clamp(Math.floor(ptr.x/gap),0,4):-1;
2564
2566
 
2567
+ c.lineWidth=1;c.strokeStyle=RING; // ring state is constant across the 5 discs -- set once per frame
2565
2568
  for(let i=0;i<5;i++){
2566
2569
  const active=i===hoverIdx;
2567
2570
  sizes[i]=lerp(sizes[i],active?16:10,dt*12);
2568
2571
  const cx=gap*i+gap/2,cy=st.h/2;
2569
2572
 
2570
- // Circle
2571
- c.fillStyle=active?colors30[i]:'rgba(255,255,255,.04)';
2572
- c.beginPath();c.arc(cx,cy-sizes[i]+10,sizes[i],0,PI2);c.fill();
2573
+ // Disc + hairline ring. The ring is the robustness net: the disc
2574
+ // fill is faint and the emoji may not paint at all (a colour-emoji
2575
+ // font draws its own glyph; a mono/absent font leaves almost
2576
+ // nothing), but a stroked ring always renders, so the control never
2577
+ // reads blank -- on frame 0 or in an emoji-less environment.
2578
+ c.fillStyle=active?colors30[i]:REST_FILL;
2579
+ c.beginPath();c.arc(cx,cy-sizes[i]+10,sizes[i],0,PI2);c.fill();c.stroke();
2573
2580
 
2574
2581
  // 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.
2582
+ // first: the disc + ring above are translucent, and a colour glyph
2583
+ // inherits that alpha, so without this the faces are dim. colors[i]
2584
+ // keeps a mono-emoji fallback tinted per reaction; a colour-emoji
2585
+ // font ignores the hue and only takes the full alpha. Array read -- zero alloc.
2580
2586
  c.font=FONTS[Math.round(sizes[i]*1.2)]||FONTS[48];c.textAlign='center';c.textBaseline='middle';
2581
2587
  c.fillStyle=colors[i];
2582
2588
  c.fillText(emojis[i],cx,cy-sizes[i]+10);
@@ -3434,3 +3440,465 @@ export function mountRecipe(container, id, options) {
3434
3440
  return mountUIFX(container, type, factory, mountOptions);
3435
3441
  }
3436
3442
 
3443
+ // ===========================================================
3444
+ // HEADLESS SKINS (E1, decisions/0008)
3445
+ // A skin is an ordinary recipe PLUS a `headless` descriptor:
3446
+ // { attrs: string[], read(host, handle, state) }
3447
+ // skinHeadless (controller) observes `attrs` on the lite-headless primitive's
3448
+ // painted element and calls read() at EVENT time to parse the painted state into
3449
+ // preallocated state slots. Skins live in their OWN registry (HEADLESS_SKINS /
3450
+ // SKIN_META), NOT RECIPES/RECIPE_META: a skin needs a handle + host, not a
3451
+ // container, so it does not fit mountRecipe. The 57-recipe count is unchanged.
3452
+ // ===========================================================
3453
+
3454
+ // Painted-attribute readers (EVENT time only -- never a per-frame call, so
3455
+ // getAttribute/parseFloat here are off the hot path). A boolean is PRESENT with any
3456
+ // value except the string "false" (handles presence-booleans like data-disabled and
3457
+ // value-booleans like the switch's data-checked="true"; decisions/0008 decision 3).
3458
+ function _skinBool(el, name) {
3459
+ if (!el.hasAttribute(name)) return false;
3460
+ return el.getAttribute(name) !== 'false';
3461
+ }
3462
+ function _ariaTrue(el, name) { return el.getAttribute(name) === 'true'; }
3463
+ function _skinNum(el, name, def) {
3464
+ const v = el.getAttribute(name);
3465
+ if (v == null) return def;
3466
+ const n = parseFloat(v);
3467
+ return n === n ? n : def; // n===n rejects NaN without an isNaN call
3468
+ }
3469
+
3470
+ /** Switch skin -- a sliding track+knob driven by the primitive's data-checked /
3471
+ * aria-checked. Zero per-frame alloc: const colors, globalAlpha, one eased scalar. */
3472
+ export function SwitchSkin(o = {}) {
3473
+ const P = resolveTheme(o, { accent: '#38bdf8', dim: '#3a3a4a', dim2: '#e2e2f0' });
3474
+ let t = 0; // animated 0..1 toward the checked state
3475
+ return {
3476
+ headless: {
3477
+ attrs: ['data-checked', 'aria-checked', 'data-disabled', 'aria-disabled'],
3478
+ read(host, handle, st) {
3479
+ st.toggled = _skinBool(host, 'data-checked') || _ariaTrue(host, 'aria-checked')
3480
+ || (handle && typeof handle.isChecked === 'function' ? !!handle.isChecked() : false);
3481
+ st.disabled = _skinBool(host, 'data-disabled') || _ariaTrue(host, 'aria-disabled');
3482
+ },
3483
+ },
3484
+ tick(c, dt, now, st) {
3485
+ const w = st.w, h = st.h, r = h / 2;
3486
+ const k = dt * 12; t += ((st.toggled ? 1 : 0) - t) * (k > 1 ? 1 : k);
3487
+ c.globalAlpha = st.disabled ? 0.4 : 1;
3488
+ c.fillStyle = st.toggled ? P.accent : P.dim;
3489
+ roundRect(c, 0, 0, w, h, r); c.fill();
3490
+ const kx = r + t * (w - h);
3491
+ c.fillStyle = P.dim2;
3492
+ c.beginPath(); c.arc(kx, r, r - 3, 0, PI2); c.fill();
3493
+ c.globalAlpha = 1;
3494
+ if (st.focused) fr(c, w, h, r);
3495
+ },
3496
+ };
3497
+ }
3498
+
3499
+ /** Slider skin -- rail + fill + thumb, driven by aria-valuenow/min/max; the thumb
3500
+ * pulses while data-dragging is painted. */
3501
+ export function SliderSkin(o = {}) {
3502
+ const P = resolveTheme(o, { accent: '#fbbf24', dim: '#9999b8', dim2: '#e2e2f0' });
3503
+ let dv = 0; // displayed value, eased toward st.val
3504
+ return {
3505
+ headless: {
3506
+ attrs: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'data-disabled', 'data-dragging'],
3507
+ read(host, handle, st) {
3508
+ const mn = _skinNum(host, 'aria-valuemin', 0);
3509
+ const mx = _skinNum(host, 'aria-valuemax', 100);
3510
+ const nw = _skinNum(host, 'aria-valuenow', mn);
3511
+ const span = mx - mn;
3512
+ let v = span > 0 ? (nw - mn) / span : 0;
3513
+ st.val = v < 0 ? 0 : (v > 1 ? 1 : v);
3514
+ st.active = _skinBool(host, 'data-dragging');
3515
+ st.disabled = _skinBool(host, 'data-disabled');
3516
+ },
3517
+ },
3518
+ tick(c, dt, now, st) {
3519
+ const w = st.w, cy = st.h / 2;
3520
+ const k = dt * 10; dv += (st.val - dv) * (k > 1 ? 1 : k);
3521
+ c.globalAlpha = st.disabled ? 0.2 : 0.4;
3522
+ c.fillStyle = P.dim; roundRect(c, 0, cy - 2, w, 4, 2); c.fill();
3523
+ const fx = dv * w;
3524
+ c.globalAlpha = st.disabled ? 0.4 : 1;
3525
+ c.fillStyle = P.accent; roundRect(c, 0, cy - 2, fx > 0 ? fx : 0, 4, 2); c.fill();
3526
+ const tr = st.active ? 8 : 6;
3527
+ c.fillStyle = P.dim2;
3528
+ c.beginPath(); c.arc(fx, cy, tr, 0, PI2); c.fill();
3529
+ if (st.active) {
3530
+ c.globalAlpha = 0.3; c.fillStyle = P.accent;
3531
+ c.beginPath(); c.arc(fx, cy, tr + 5, 0, PI2); c.fill();
3532
+ }
3533
+ c.globalAlpha = 1;
3534
+ if (st.focused) fr(c, w, st.h, cy);
3535
+ },
3536
+ };
3537
+ }
3538
+
3539
+ /** Progress skin -- a value ring (aria-valuenow/max) with a percent label; an
3540
+ * indeterminate sweep when data-loading, a full accent ring when data-complete. */
3541
+ export function ProgressSkin(o = {}) {
3542
+ const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#a78bfa', dim: '#e2e2f0' });
3543
+ const FONT = pickFont(o, "700 14px 'JetBrains Mono',monospace");
3544
+ let dv = 0; // displayed value
3545
+ let spin = 0; // indeterminate sweep phase
3546
+ return {
3547
+ headless: {
3548
+ attrs: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'data-complete', 'data-loading'],
3549
+ read(host, handle, st) {
3550
+ const mn = _skinNum(host, 'aria-valuemin', 0);
3551
+ const mx = _skinNum(host, 'aria-valuemax', 100);
3552
+ const nw = _skinNum(host, 'aria-valuenow', mn);
3553
+ const span = mx - mn;
3554
+ let v = span > 0 ? (nw - mn) / span : 0;
3555
+ st.val = v < 0 ? 0 : (v > 1 ? 1 : v);
3556
+ st.complete = _skinBool(host, 'data-complete');
3557
+ st.indeterminate = _skinBool(host, 'data-loading');
3558
+ },
3559
+ },
3560
+ tick(c, dt, now, st) {
3561
+ const cx = st.w / 2, cy = st.h / 2, R = Math.min(cx, cy) - 6, lw = 6;
3562
+ c.strokeStyle = P.dim; c.globalAlpha = 0.15; c.lineWidth = lw;
3563
+ c.beginPath(); c.arc(cx, cy, R, 0, PI2); c.stroke();
3564
+ c.globalAlpha = 1;
3565
+ if (st.indeterminate && !st.complete) {
3566
+ spin += dt * 3;
3567
+ c.strokeStyle = P.accent; c.lineWidth = lw;
3568
+ c.beginPath(); c.arc(cx, cy, R, spin, spin + 1.6); c.stroke();
3569
+ } else {
3570
+ const target = st.complete ? 1 : st.val;
3571
+ const k = dt * 6; dv += (target - dv) * (k > 1 ? 1 : k);
3572
+ const a = -Math.PI / 2, ea = a + dv * PI2;
3573
+ c.strokeStyle = st.complete ? P.accent : P.accent2; c.lineWidth = lw;
3574
+ c.beginPath(); c.arc(cx, cy, R, a, ea); c.stroke();
3575
+ c.fillStyle = P.dim; c.font = FONT; c.textAlign = 'center'; c.textBaseline = 'middle';
3576
+ c.fillText(PCT[Math.round(dv * 100)], cx, cy);
3577
+ }
3578
+ if (st.focused) fr(c, st.w, st.h, st.h / 2);
3579
+ },
3580
+ };
3581
+ }
3582
+
3583
+ /** Rating skin -- N bubbles (count from aria-valuemax, bounded) filled to
3584
+ * aria-valuenow/max. Fixed-count loop, no per-frame allocation. */
3585
+ export function RatingSkin(o = {}) {
3586
+ const P = resolveTheme(o, { accent: '#f472b6', dim: '#3a3a4a' });
3587
+ let dv = 0; // animated filled count
3588
+ return {
3589
+ headless: {
3590
+ attrs: ['aria-valuenow', 'aria-valuemax', 'data-disabled'],
3591
+ read(host, handle, st) {
3592
+ const mx = _skinNum(host, 'aria-valuemax', 5);
3593
+ const nw = _skinNum(host, 'aria-valuenow', 0);
3594
+ let n = mx > 0 ? (mx | 0) : 5;
3595
+ st.count = n > 10 ? 10 : n;
3596
+ let v = mx > 0 ? nw / mx : 0;
3597
+ st.val = v < 0 ? 0 : (v > 1 ? 1 : v);
3598
+ st.disabled = _skinBool(host, 'data-disabled');
3599
+ },
3600
+ },
3601
+ tick(c, dt, now, st) {
3602
+ const n = st.count > 0 ? st.count : 5;
3603
+ const k = dt * 12; dv += (st.val * n - dv) * (k > 1 ? 1 : k);
3604
+ const cy = st.h / 2;
3605
+ const gap = st.w / n;
3606
+ const rad = (gap < st.h ? gap : st.h) * 0.32;
3607
+ const base = st.disabled ? 0.4 : 1;
3608
+ for (let i = 0; i < n; i++) {
3609
+ const cx = gap * (i + 0.5);
3610
+ const fillAmt = dv - i;
3611
+ c.globalAlpha = base * 0.35; c.fillStyle = P.dim;
3612
+ c.beginPath(); c.arc(cx, cy, rad, 0, PI2); c.fill();
3613
+ if (fillAmt > 0) {
3614
+ c.globalAlpha = base * (fillAmt > 1 ? 1 : fillAmt); c.fillStyle = P.accent;
3615
+ c.beginPath(); c.arc(cx, cy, rad, 0, PI2); c.fill();
3616
+ }
3617
+ }
3618
+ c.globalAlpha = 1;
3619
+ if (st.focused) fr(c, st.w, st.h, st.h / 2);
3620
+ },
3621
+ };
3622
+ }
3623
+
3624
+ /** Checkbox skin (E1b, decisions/0009) -- a box that draws a tick when checked
3625
+ * and a dash when indeterminate ("mixed"). Tri-state read from aria-checked (the
3626
+ * VALUE form true|false|mixed) or the data-* presence pair -- both map onto the
3627
+ * EXISTING toggled + indeterminate slots (no state-shape change). CheckboxGroupSkin
3628
+ * aliases this: a checkbox-group master paints the identical 3-state contract, so
3629
+ * "mixed" just reads as "some members". Zero per-frame alloc. */
3630
+ export function CheckboxSkin(o = {}) {
3631
+ const P = resolveTheme(o, { accent: '#38bdf8', dim: '#3a3a4a', dim2: '#e2e2f0' });
3632
+ let on = 0; // eased box fill (checked or mixed)
3633
+ let mk = 0; // eased mark draw
3634
+ return {
3635
+ headless: {
3636
+ attrs: ['aria-checked', 'data-checked', 'data-indeterminate', 'data-disabled', 'aria-disabled'],
3637
+ read(host, handle, st) {
3638
+ const ac = host.getAttribute('aria-checked'); // value form: true|false|mixed
3639
+ st.indeterminate = ac === 'mixed' || _skinBool(host, 'data-indeterminate');
3640
+ st.toggled = !st.indeterminate && (ac === 'true' || _skinBool(host, 'data-checked'));
3641
+ st.disabled = _skinBool(host, 'data-disabled') || _ariaTrue(host, 'aria-disabled');
3642
+ },
3643
+ },
3644
+ tick(c, dt, now, st) {
3645
+ const s = st.w < st.h ? st.w : st.h;
3646
+ const bx = (st.w - s) / 2, by = (st.h - s) / 2, rr = s * 0.22;
3647
+ const filled = st.toggled || st.indeterminate;
3648
+ const k = dt * 14;
3649
+ on += ((filled ? 1 : 0) - on) * (k > 1 ? 1 : k);
3650
+ mk += ((filled ? 1 : 0) - mk) * (k > 1 ? 1 : k);
3651
+ const base = st.disabled ? 0.4 : 1;
3652
+ c.globalAlpha = base;
3653
+ c.strokeStyle = P.dim; c.lineWidth = 2;
3654
+ roundRect(c, bx + 1, by + 1, s - 2, s - 2, rr); c.stroke();
3655
+ if (on > 0.01) {
3656
+ c.globalAlpha = base * on;
3657
+ c.fillStyle = P.accent; roundRect(c, bx + 1, by + 1, s - 2, s - 2, rr); c.fill();
3658
+ }
3659
+ if (mk > 0.01) {
3660
+ c.globalAlpha = base * mk;
3661
+ c.strokeStyle = P.dim2; c.lineWidth = s * 0.1; c.lineCap = 'round';
3662
+ c.beginPath();
3663
+ if (st.indeterminate) {
3664
+ c.moveTo(bx + s * 0.28, by + s * 0.5); c.lineTo(bx + s * 0.72, by + s * 0.5);
3665
+ } else {
3666
+ c.moveTo(bx + s * 0.26, by + s * 0.52);
3667
+ c.lineTo(bx + s * 0.44, by + s * 0.70);
3668
+ c.lineTo(bx + s * 0.76, by + s * 0.32);
3669
+ }
3670
+ c.stroke(); c.lineCap = 'butt';
3671
+ }
3672
+ c.globalAlpha = 1;
3673
+ if (st.focused) fr(c, st.w, st.h, rr);
3674
+ },
3675
+ };
3676
+ }
3677
+ export const CheckboxGroupSkin = CheckboxSkin;
3678
+
3679
+ /** Select skin (E1b, decisions/0009) -- the closed/expanded TRIGGER of a
3680
+ * lite-headless select (the item-6 dropdown resolution). A rounded field with a
3681
+ * chevron that flips on aria-expanded and a dot when a value is selected (handle
3682
+ * value() fast path). The portaled listbox is NOT painted -- the single-host
3683
+ * adapter places one overlay over the trigger box; the open-state popup is the
3684
+ * recorded follow-on (decisions/0009). Zero per-frame alloc. */
3685
+ export function SelectSkin(o = {}) {
3686
+ const P = resolveTheme(o, { accent: '#a78bfa', dim: '#3a3a4a', dim2: '#e2e2f0' });
3687
+ let op = 0; // eased open
3688
+ return {
3689
+ headless: {
3690
+ attrs: ['aria-expanded', 'data-disabled', 'aria-disabled'],
3691
+ read(host, handle, st) {
3692
+ st.toggled = _ariaTrue(host, 'aria-expanded');
3693
+ st.disabled = _skinBool(host, 'data-disabled') || _ariaTrue(host, 'aria-disabled');
3694
+ st.complete = !!(handle && typeof handle.value === 'function' && handle.value() != null);
3695
+ },
3696
+ },
3697
+ tick(c, dt, now, st) {
3698
+ const w = st.w, h = st.h, rr = h * 0.28;
3699
+ const k = dt * 12; op += ((st.toggled ? 1 : 0) - op) * (k > 1 ? 1 : k);
3700
+ const base = st.disabled ? 0.4 : 1;
3701
+ c.globalAlpha = base;
3702
+ c.strokeStyle = st.toggled ? P.accent : P.dim; c.lineWidth = 2;
3703
+ roundRect(c, 1, 1, w - 2, h - 2, rr); c.stroke();
3704
+ if (op > 0.01) {
3705
+ c.globalAlpha = base * 0.12 * op;
3706
+ c.fillStyle = P.accent; roundRect(c, 1, 1, w - 2, h - 2, rr); c.fill();
3707
+ c.globalAlpha = base;
3708
+ }
3709
+ if (st.complete) {
3710
+ c.fillStyle = P.accent;
3711
+ c.beginPath(); c.arc(h * 0.5, h * 0.5, h * 0.14, 0, PI2); c.fill();
3712
+ }
3713
+ const cx = w - h * 0.5, cy = h * 0.5, s = h * 0.16, dir = 1 - 2 * op;
3714
+ c.strokeStyle = P.dim2; c.lineWidth = 2; c.lineCap = 'round';
3715
+ c.beginPath();
3716
+ c.moveTo(cx - s, cy - s * 0.5 * dir); c.lineTo(cx, cy + s * 0.5 * dir); c.lineTo(cx + s, cy - s * 0.5 * dir);
3717
+ c.stroke(); c.lineCap = 'butt';
3718
+ c.globalAlpha = 1;
3719
+ if (st.focused) fr(c, w, h, rr);
3720
+ },
3721
+ };
3722
+ }
3723
+
3724
+ /** Meter skin (E1b) -- a horizontal gauge from aria-valuenow/min/max, zone-tinted
3725
+ * from data-zone (optimum|sub-optimum|low). Zero per-frame alloc. */
3726
+ export function MeterSkin(o = {}) {
3727
+ const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#fbbf24', dim: '#9999b8', dim2: '#f87171' });
3728
+ let dv = 0;
3729
+ return {
3730
+ headless: {
3731
+ attrs: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'data-zone', 'data-disabled'],
3732
+ read(host, handle, st) {
3733
+ const mn = _skinNum(host, 'aria-valuemin', 0);
3734
+ const mx = _skinNum(host, 'aria-valuemax', 100);
3735
+ const nw = _skinNum(host, 'aria-valuenow', mn);
3736
+ const span = mx - mn;
3737
+ let v = span > 0 ? (nw - mn) / span : 0;
3738
+ st.val = v < 0 ? 0 : (v > 1 ? 1 : v);
3739
+ const zone = host.getAttribute('data-zone');
3740
+ st.complete = zone === 'optimum';
3741
+ st.error = zone === 'low';
3742
+ st.disabled = _skinBool(host, 'data-disabled');
3743
+ },
3744
+ },
3745
+ tick(c, dt, now, st) {
3746
+ const w = st.w, cy = st.h / 2, bh = st.h < 12 ? st.h : 12;
3747
+ const k = dt * 8; dv += (st.val - dv) * (k > 1 ? 1 : k);
3748
+ const col = st.error ? P.dim2 : (st.complete ? P.accent : P.accent2);
3749
+ c.globalAlpha = st.disabled ? 0.2 : 0.25;
3750
+ c.fillStyle = P.dim; roundRect(c, 0, cy - bh / 2, w, bh, bh / 2); c.fill();
3751
+ c.globalAlpha = st.disabled ? 0.4 : 1;
3752
+ const fx = dv * w;
3753
+ c.fillStyle = col; roundRect(c, 0, cy - bh / 2, fx > 0 ? fx : 0, bh, bh / 2); c.fill();
3754
+ c.globalAlpha = 1;
3755
+ if (st.focused) fr(c, w, st.h, bh / 2);
3756
+ },
3757
+ };
3758
+ }
3759
+
3760
+ /** Steps skin (E1b) -- a rail of N nodes filled up to data-current-index (count
3761
+ * from data-step-count), the current node on the leading edge; data-complete
3762
+ * fills all. count/val slots reused (val carries the raw current index). Fixed
3763
+ * loop bounded to 12, zero per-frame alloc. */
3764
+ export function StepsSkin(o = {}) {
3765
+ const P = resolveTheme(o, { accent: '#a78bfa', dim: '#3a3a4a', dim2: '#e2e2f0' });
3766
+ let dv = 0; // eased current index
3767
+ return {
3768
+ headless: {
3769
+ attrs: ['data-step-count', 'data-current-index', 'data-complete', 'data-disabled'],
3770
+ read(host, handle, st) {
3771
+ let n = _skinNum(host, 'data-step-count', 3) | 0;
3772
+ st.count = n < 2 ? 2 : (n > 12 ? 12 : n);
3773
+ st.val = _skinNum(host, 'data-current-index', 0); // raw current index (0-based)
3774
+ st.complete = _skinBool(host, 'data-complete');
3775
+ st.disabled = _skinBool(host, 'data-disabled');
3776
+ },
3777
+ },
3778
+ tick(c, dt, now, st) {
3779
+ const n = st.count > 1 ? st.count : 3;
3780
+ const cur = st.complete ? n - 1 : (st.val < 0 ? 0 : st.val);
3781
+ const k = dt * 10; dv += (cur - dv) * (k > 1 ? 1 : k);
3782
+ const cy = st.h / 2, gap = st.w / n, rad = (st.h < gap ? st.h : gap) * 0.22;
3783
+ const base = st.disabled ? 0.4 : 1;
3784
+ const x0 = gap * 0.5, xN = gap * (n - 0.5);
3785
+ c.globalAlpha = base * 0.25; c.strokeStyle = P.dim; c.lineWidth = 3;
3786
+ c.beginPath(); c.moveTo(x0, cy); c.lineTo(xN, cy); c.stroke();
3787
+ c.globalAlpha = base; c.strokeStyle = P.accent;
3788
+ const fx = x0 + gap * dv;
3789
+ c.beginPath(); c.moveTo(x0, cy); c.lineTo(fx > xN ? xN : (fx < x0 ? x0 : fx), cy); c.stroke();
3790
+ for (let i = 0; i < n; i++) {
3791
+ const cx = gap * (i + 0.5);
3792
+ c.globalAlpha = base; c.fillStyle = (i <= dv + 0.001) ? P.accent : P.dim;
3793
+ c.beginPath(); c.arc(cx, cy, rad, 0, PI2); c.fill();
3794
+ }
3795
+ c.globalAlpha = 1;
3796
+ if (st.focused) fr(c, st.w, st.h, rad);
3797
+ },
3798
+ };
3799
+ }
3800
+
3801
+ /** Accordion skin (E1b) -- a header chevron that flips on aria-expanded /
3802
+ * data-open plus an underline hint that grows with open. Single trigger host,
3803
+ * zero per-frame alloc. */
3804
+ export function AccordionSkin(o = {}) {
3805
+ const P = resolveTheme(o, { accent: '#38bdf8', dim: '#3a3a4a', dim2: '#e2e2f0' });
3806
+ let op = 0;
3807
+ return {
3808
+ headless: {
3809
+ attrs: ['aria-expanded', 'data-open', 'data-disabled', 'aria-disabled'],
3810
+ read(host, handle, st) {
3811
+ st.toggled = _ariaTrue(host, 'aria-expanded') || _skinBool(host, 'data-open');
3812
+ st.disabled = _skinBool(host, 'data-disabled') || _ariaTrue(host, 'aria-disabled');
3813
+ },
3814
+ },
3815
+ tick(c, dt, now, st) {
3816
+ const w = st.w, h = st.h;
3817
+ const k = dt * 12; op += ((st.toggled ? 1 : 0) - op) * (k > 1 ? 1 : k);
3818
+ const base = st.disabled ? 0.4 : 1;
3819
+ const cx = w - h * 0.5, cy = h * 0.5, s = h * 0.16, dir = 1 - 2 * op;
3820
+ c.globalAlpha = base;
3821
+ c.strokeStyle = st.toggled ? P.accent : P.dim2; c.lineWidth = 2; c.lineCap = 'round';
3822
+ c.beginPath();
3823
+ c.moveTo(cx - s, cy - s * 0.5 * dir); c.lineTo(cx, cy + s * 0.5 * dir); c.lineTo(cx + s, cy - s * 0.5 * dir);
3824
+ c.stroke(); c.lineCap = 'butt';
3825
+ c.globalAlpha = base * 0.5;
3826
+ c.strokeStyle = P.accent; c.lineWidth = 2;
3827
+ const uw = (w - h) * op;
3828
+ c.beginPath(); c.moveTo(2, h - 3); c.lineTo(2 + (uw > 0 ? uw : 0), h - 3); c.stroke();
3829
+ c.globalAlpha = 1;
3830
+ if (st.focused) fr(c, w, h, 4);
3831
+ },
3832
+ };
3833
+ }
3834
+
3835
+ /** Skeleton skin (E1b) -- a shimmer sweep over a rounded placeholder while
3836
+ * data-loading / aria-busy; fades out when ready. Zero per-frame alloc: a moving
3837
+ * highlight band via globalAlpha, never a per-frame gradient. */
3838
+ export function SkeletonSkin(o = {}) {
3839
+ const P = resolveTheme(o, { accent: '#e2e2f0', dim: '#3a3a4a' });
3840
+ let ph = 0; // shimmer phase 0..1
3841
+ let vis = 0; // eased placeholder visibility (1 loading, 0 ready)
3842
+ return {
3843
+ headless: {
3844
+ attrs: ['data-loading', 'aria-busy'],
3845
+ read(host, handle, st) {
3846
+ st.indeterminate = _skinBool(host, 'data-loading') || _ariaTrue(host, 'aria-busy');
3847
+ st.complete = !st.indeterminate;
3848
+ },
3849
+ },
3850
+ tick(c, dt, now, st) {
3851
+ const w = st.w, h = st.h, rr = (h < 10 ? h : 10) * 0.5;
3852
+ const loading = st.indeterminate;
3853
+ const k = dt * 6; vis += ((loading ? 1 : 0) - vis) * (k > 1 ? 1 : k);
3854
+ if (vis < 0.01) { c.globalAlpha = 1; return; }
3855
+ if (loading) { ph += dt * 0.8; if (ph > 1) ph -= 1; }
3856
+ c.globalAlpha = vis * 0.5;
3857
+ c.fillStyle = P.dim; roundRect(c, 0, 0, w, h, rr); c.fill();
3858
+ const bw = w * 0.28, bx = -bw + ph * (w + bw);
3859
+ const bx0 = bx < 0 ? 0 : bx, bx1 = bx + bw > w ? w : bx + bw;
3860
+ if (bx1 > bx0) {
3861
+ c.globalAlpha = vis * 0.18;
3862
+ c.fillStyle = P.accent; roundRect(c, bx0, 0, bx1 - bx0, h, rr); c.fill();
3863
+ }
3864
+ c.globalAlpha = 1;
3865
+ },
3866
+ };
3867
+ }
3868
+
3869
+ /**
3870
+ * The headless-skin registry -- a SIBLING of RECIPES, not part of it (decisions/
3871
+ * 0008 decision 4). id -> factory (null-prototype), the meta rows a picker/demo
3872
+ * iterates, and the frozen id list. Each skin is driven by skinHeadless with a
3873
+ * lite-headless handle + host, never by mountRecipe.
3874
+ */
3875
+ export const HEADLESS_SKINS = Object.assign(Object.create(null), {
3876
+ switchSkin: SwitchSkin,
3877
+ sliderSkin: SliderSkin,
3878
+ progressSkin: ProgressSkin,
3879
+ ratingSkin: RatingSkin,
3880
+ checkboxSkin: CheckboxSkin,
3881
+ checkboxGroupSkin: CheckboxGroupSkin,
3882
+ selectSkin: SelectSkin,
3883
+ meterSkin: MeterSkin,
3884
+ stepsSkin: StepsSkin,
3885
+ accordionSkin: AccordionSkin,
3886
+ skeletonSkin: SkeletonSkin,
3887
+ });
3888
+
3889
+ export const SKIN_META = [
3890
+ { id: 'switchSkin', name: 'Switch Skin', primitive: 'switch', themeable: true, motionSafe: false },
3891
+ { id: 'sliderSkin', name: 'Slider Skin', primitive: 'slider', themeable: true, motionSafe: false },
3892
+ { id: 'progressSkin', name: 'Progress Skin', primitive: 'progress', themeable: true, motionSafe: false },
3893
+ { id: 'ratingSkin', name: 'Rating Skin', primitive: 'rating', themeable: true, motionSafe: false },
3894
+ { id: 'checkboxSkin', name: 'Checkbox Skin', primitive: 'checkbox', themeable: true, motionSafe: false },
3895
+ { id: 'checkboxGroupSkin', name: 'Checkbox Group Skin', primitive: 'checkbox-group', themeable: true, motionSafe: false },
3896
+ { id: 'selectSkin', name: 'Select Skin', primitive: 'select', themeable: true, motionSafe: false },
3897
+ { id: 'meterSkin', name: 'Meter Skin', primitive: 'meter', themeable: true, motionSafe: false },
3898
+ { id: 'stepsSkin', name: 'Steps Skin', primitive: 'steps', themeable: true, motionSafe: false },
3899
+ { id: 'accordionSkin', name: 'Accordion Skin', primitive: 'accordion', themeable: true, motionSafe: false },
3900
+ { id: 'skeletonSkin', name: 'Skeleton Skin', primitive: 'skeleton', themeable: true, motionSafe: false },
3901
+ ];
3902
+
3903
+ export const SKIN_NAMES = Object.freeze(Object.keys(HEADLESS_SKINS));
3904
+