@zakkster/lite-ui-fx 1.8.0 → 1.9.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/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.8.0';
25
+ export const VERSION = '1.9.0';
26
26
 
27
27
  // ---------------------------------------------------------
28
28
  // SHARED TICKER (ref-counted, one RAF for all UI components)
@@ -192,6 +192,52 @@ export const UIType = Object.freeze({
192
192
  const _KNOWN_TYPES = new Set(Object.values(UIType));
193
193
 
194
194
 
195
+ // ---------------------------------------------------------
196
+ // GROUP TYPES (U7) -- N native elements, one canvas, one recipe
197
+ // ---------------------------------------------------------
198
+
199
+ // A grouped control is N native elements sharing ONE canvas and ONE recipe
200
+ // (decisions/0007). Unlike a UIType (one element) a GroupType routes to
201
+ // mountUIFXGroup, which lays out `count` items in a horizontal strip and gives
202
+ // the recipe a SoA view of them: state.index (selected), state.count, and the
203
+ // itemX/itemY/itemW/itemH Float32Array lanes. onSelect(index, state) is the
204
+ // ninth, group-only recipe hook (mountUIFX/decorateUIFX reject it -- fail closed).
205
+ /** @enum {string} */
206
+ export const GroupType = Object.freeze({
207
+ RADIO: 'radio', // fieldset + N <input type=radio>; native roving selection
208
+ TABS: 'tabs', // role=tablist + N role=tab buttons; roving tabindex + arrows
209
+ STEPPER: 'stepper', // one <input type=number> spinbutton; native Up/Down
210
+ RATING: 'rating', // radiogroup of N radios; native roving selection
211
+ });
212
+
213
+ // Valid group types, derived once from GroupType so the group mount guard and
214
+ // the recipe registry stay one source of truth (0003 pattern). Cold.
215
+ const _KNOWN_GROUP_TYPES = new Set(Object.values(GroupType));
216
+
217
+ // A group recipe accepts the eight existing hooks PLUS onSelect. onSelect is
218
+ // group-only: it is absent from KNOWN_HOOKS, so mountUIFX/decorateUIFX reject a
219
+ // recipe carrying it (fail closed), and it is present here so a group recipe may.
220
+ const KNOWN_GROUP_HOOKS = ['init', 'tick', 'onHover', 'onLeave', 'onClick', 'onToggle', 'onDrag', 'onSelect', 'destroy'];
221
+
222
+ // Options valid on a group mount. `items` (the per-item labels) is required; the
223
+ // initial selection is `index` (an integer, not the hijack float `value`). The
224
+ // hijack-only keys (value/checked/knobMode/announce) are absent -> did-you-mean.
225
+ const GROUP_OPTIONS = ['items', 'index', 'label', 'width', 'height', 'padding', 'disabled', 'seed', 'colors', 'theme', 'text', 'font', 'ticker', 'driven'];
226
+
227
+ // Per-group-type default item box [w, h]. The strip is `count` items wide for the
228
+ // multi-element types; STEPPER is a single spinbutton, so its pair is the whole
229
+ // control's default box (its `count` pips are drawn inside that width).
230
+ const _GROUP_ITEM_DEFAULT = {
231
+ radio: [56, 56],
232
+ tabs: [84, 38],
233
+ rating: [40, 44],
234
+ stepper: [132, 46], // total box (single element), not per item
235
+ };
236
+
237
+ // Monotonic id for unique radio `name` grouping across concurrent mounts. Cold.
238
+ let _groupUid = 0;
239
+
240
+
195
241
  // =========================================================
196
242
  // UIFXController -- The Canvas Hijacker
197
243
  // =========================================================
@@ -1163,4 +1209,482 @@ export function decorateUIFX(el, recipeFactory, options = {}) {
1163
1209
  }
1164
1210
  }
1165
1211
 
1212
+ // =========================================================
1213
+ // mountUIFXGroup -- The third mount mode (N native elements, one canvas)
1214
+ // =========================================================
1215
+
1216
+ /**
1217
+ * Mount a grouped control: N native elements (radios in a fieldset, tabs in a
1218
+ * tablist, a spinbutton, a rating radiogroup) sharing ONE canvas and one recipe
1219
+ * (decisions/0007). The native elements own selection + keyboard + a11y; the
1220
+ * canvas paints the group by reading state.index/state.count and the per-item
1221
+ * geometry lanes. Additive to mountUIFX/decorateUIFX -- neither is touched.
1222
+ *
1223
+ * @param {HTMLElement} container Parent to mount into.
1224
+ * @param {string} groupType One of GroupType (radio|tabs|stepper|rating).
1225
+ * @param {Function} recipeFactory (options) => Recipe (may add onSelect).
1226
+ * @param {Object} options { items:string[] (>=2, required), index=0, ... }
1227
+ * @returns {{ els:HTMLElement[], canvas, wrapper, state, index, setIndex, tick, destroy }}
1228
+ */
1229
+ export function mountUIFXGroup(container, groupType, recipeFactory, options = {}) {
1230
+ // =====================================================================
1231
+ // PHASE 1 -- VALIDATION ONLY (fail closed; mirrors mountUIFX). No DOM,
1232
+ // no ticker, no recipe.init until every check below has passed.
1233
+ // =====================================================================
1234
+
1235
+ if (!container || typeof container.appendChild !== 'function') {
1236
+ throw new Error('mountUIFXGroup: container must be a DOM element');
1237
+ }
1238
+ if (!_KNOWN_GROUP_TYPES.has(groupType)) {
1239
+ throw new Error('mountUIFXGroup: groupType must be one of GroupType.RADIO, TABS, STEPPER, RATING');
1240
+ }
1241
+ for (const k in options) {
1242
+ if (!Object.prototype.hasOwnProperty.call(options, k)) continue;
1243
+ if (GROUP_OPTIONS.indexOf(k) === -1) {
1244
+ throw new Error(_didYouMean('mountUIFXGroup: unknown option', k, GROUP_OPTIONS));
1245
+ }
1246
+ }
1247
+
1248
+ // items: the per-item labels. Required, an array of >=2 strings (a group of
1249
+ // one is not a group). Its length IS the item/step count. Fail closed.
1250
+ const items = options.items;
1251
+ if (!Array.isArray(items) || items.length < 2 || items.some((s) => typeof s !== 'string')) {
1252
+ throw new Error('mountUIFXGroup: option "items" must be an array of >=2 label strings');
1253
+ }
1254
+ const count = items.length;
1255
+
1256
+ // index: the initial selection, an integer in [0, count-1] (default 0). This
1257
+ // is the group's value -- distinct from the hijack float "value" (fail closed).
1258
+ let initialIndex = options.index === undefined ? 0 : options.index;
1259
+ if (typeof initialIndex !== 'number' || !Number.isInteger(initialIndex) ||
1260
+ initialIndex < 0 || initialIndex >= count) {
1261
+ throw new Error('mountUIFXGroup: option "index" must be an integer in [0, items.length-1]');
1262
+ }
1263
+
1264
+ const disabled = options.disabled === undefined ? false : !!options.disabled;
1265
+ const width = options.width;
1266
+ const height = options.height;
1267
+ const padding = options.padding === undefined ? 40 : options.padding;
1268
+ const label = options.label === undefined ? '' : options.label;
1269
+
1270
+ // Theming options (0002), same validators as the other two mounts. Cold.
1271
+ const _theme = options.theme;
1272
+ if (_theme !== undefined) {
1273
+ if (_theme === null || typeof _theme !== 'object' ||
1274
+ typeof _theme.light !== 'string' || typeof _theme.mid !== 'string' ||
1275
+ typeof _theme.dark !== 'string' || Object.keys(_theme).length !== 3) {
1276
+ throw new Error('mountUIFXGroup: option "theme" must be { light, mid, dark } of color strings');
1277
+ }
1278
+ }
1279
+ const _colors = options.colors;
1280
+ if (_colors !== undefined &&
1281
+ (!Array.isArray(_colors) || _colors.some((c) => typeof c !== 'string'))) {
1282
+ throw new Error('mountUIFXGroup: option "colors" must be an array of color strings');
1283
+ }
1284
+ if (options.text !== undefined && typeof options.text !== 'string') {
1285
+ throw new Error('mountUIFXGroup: option "text" must be a string');
1286
+ }
1287
+ if (options.font !== undefined && typeof options.font !== 'string') {
1288
+ throw new Error('mountUIFXGroup: option "font" must be a string');
1289
+ }
1290
+ if (options.seed !== undefined &&
1291
+ (typeof options.seed !== 'number' || !Number.isFinite(options.seed))) {
1292
+ throw new Error('mountUIFXGroup: option "seed" must be a finite number');
1293
+ }
1294
+
1295
+ // Host clock (U5, 0005) -- identical three modes as the other two mounts.
1296
+ const callerTicker = options.ticker;
1297
+ if (options.driven !== undefined && typeof options.driven !== 'boolean') {
1298
+ throw new Error('mountUIFXGroup: option "driven" must be a boolean');
1299
+ }
1300
+ const driven = options.driven === true;
1301
+ if (callerTicker !== undefined) {
1302
+ if (driven) {
1303
+ throw new Error('mountUIFXGroup: options "ticker" and "driven" are mutually exclusive');
1304
+ }
1305
+ if (!callerTicker || typeof callerTicker.add !== 'function') {
1306
+ throw new Error('mountUIFXGroup: option "ticker" must be a ticker with an .add(fn) method');
1307
+ }
1308
+ }
1309
+
1310
+ if (typeof recipeFactory !== 'function') {
1311
+ throw new Error('mountUIFXGroup: recipeFactory must be a function');
1312
+ }
1313
+ const recipe = recipeFactory(options);
1314
+ if (!recipe || typeof recipe !== 'object') {
1315
+ throw new Error('mountUIFXGroup: recipe must be an object');
1316
+ }
1317
+ if (typeof recipe.tick !== 'function') {
1318
+ throw new Error('mountUIFXGroup: recipe.tick must be a function');
1319
+ }
1320
+ for (const k in recipe) {
1321
+ if (!Object.prototype.hasOwnProperty.call(recipe, k)) continue;
1322
+ if (typeof recipe[k] === 'function' && KNOWN_GROUP_HOOKS.indexOf(k) === -1) {
1323
+ throw new Error(_didYouMean('mountUIFXGroup: unknown recipe hook', k, KNOWN_GROUP_HOOKS));
1324
+ }
1325
+ }
1326
+
1327
+ // =====================================================================
1328
+ // PHASE 2 -- SIDE EFFECTS (fail-closed unwind, mirrors mountUIFX).
1329
+ // =====================================================================
1330
+ let wrapperAppended = false;
1331
+ let acCreated = false;
1332
+ let tickerAcquired = false;
1333
+ let wrapper = null;
1334
+ let ac = null;
1335
+ let removeTick = null;
1336
+ let _moveTab = null; // TABS roving mover, shared with setIndex (not on state)
1337
+
1338
+ try {
1339
+ // -- Geometry. A horizontal strip of `count` item slots. Multi-element types
1340
+ // (radio/tabs/rating) size the strip = count * itemW; STEPPER is one
1341
+ // spinbutton whose default box holds `count` pips. width/height override
1342
+ // the total. All geometry is ARITHMETIC (no getBoundingClientRect): it
1343
+ // works headless and forces ZERO reflow (better than the U-11 one-read). --
1344
+ const def = _GROUP_ITEM_DEFAULT[groupType];
1345
+ const _single = groupType === GroupType.STEPPER;
1346
+ const w = width || (_single ? def[0] : def[0] * count);
1347
+ const h = height || def[1];
1348
+ let dpr = window.devicePixelRatio || 1;
1349
+
1350
+ // Per-item geometry lanes, preallocated once (the recipe reads them by index;
1351
+ // zero per-frame allocation). Uniform slots: itemW = w/count across the strip.
1352
+ const iw = w / count;
1353
+ const itemX = new Float32Array(count);
1354
+ const itemY = new Float32Array(count);
1355
+ const itemW = new Float32Array(count);
1356
+ const itemH = new Float32Array(count);
1357
+ for (let i = 0; i < count; i++) {
1358
+ itemX[i] = i * iw; itemY[i] = 0; itemW[i] = iw; itemH[i] = h;
1359
+ }
1360
+
1361
+ // -- Build the native group. `root` holds the interactive elements; `els` is
1362
+ // the array of them (radios/buttons, or the single number input). --
1363
+ const uid = _groupUid++;
1364
+ let root;
1365
+ const els = [];
1366
+ if (groupType === GroupType.RADIO || groupType === GroupType.RATING) {
1367
+ root = document.createElement('fieldset');
1368
+ root.setAttribute('role', 'radiogroup');
1369
+ if (label) root.setAttribute('aria-label', label);
1370
+ Object.assign(root.style, {
1371
+ position: 'relative', display: 'inline-block',
1372
+ width: `${w}px`, height: `${h}px`,
1373
+ margin: '0', padding: '0', border: 'none', minWidth: '0',
1374
+ });
1375
+ const name = 'uifx-group-' + uid;
1376
+ for (let i = 0; i < count; i++) {
1377
+ const r = document.createElement('input');
1378
+ r.type = 'radio';
1379
+ r.name = name;
1380
+ r.setAttribute('aria-label', items[i]);
1381
+ if (i === initialIndex) r.checked = true;
1382
+ if (disabled) r.disabled = true;
1383
+ Object.assign(r.style, {
1384
+ position: 'absolute', top: '0', left: `${itemX[i]}px`,
1385
+ width: `${itemW[i]}px`, height: `${h}px`,
1386
+ opacity: '0', margin: '0', cursor: 'pointer', zIndex: '2',
1387
+ });
1388
+ root.appendChild(r);
1389
+ els.push(r);
1390
+ }
1391
+ } else if (groupType === GroupType.TABS) {
1392
+ root = document.createElement('div');
1393
+ root.setAttribute('role', 'tablist');
1394
+ if (label) root.setAttribute('aria-label', label);
1395
+ Object.assign(root.style, {
1396
+ position: 'relative', display: 'inline-block',
1397
+ width: `${w}px`, height: `${h}px`,
1398
+ });
1399
+ for (let i = 0; i < count; i++) {
1400
+ const b = document.createElement('button');
1401
+ b.type = 'button';
1402
+ b.setAttribute('role', 'tab');
1403
+ b.textContent = items[i]; // accessible name
1404
+ b.setAttribute('aria-selected', i === initialIndex ? 'true' : 'false');
1405
+ b.tabIndex = i === initialIndex ? 0 : -1; // roving tabindex
1406
+ if (disabled) b.disabled = true;
1407
+ Object.assign(b.style, {
1408
+ position: 'absolute', top: '0', left: `${itemX[i]}px`,
1409
+ width: `${itemW[i]}px`, height: `${h}px`,
1410
+ opacity: '0', margin: '0', padding: '0', border: 'none',
1411
+ background: 'transparent', cursor: 'pointer', zIndex: '2',
1412
+ WebkitAppearance: 'none', appearance: 'none',
1413
+ });
1414
+ root.appendChild(b);
1415
+ els.push(b);
1416
+ }
1417
+ } else { // STEPPER -- one spinbutton across the whole box; count pips drawn inside
1418
+ root = document.createElement('div');
1419
+ Object.assign(root.style, {
1420
+ position: 'relative', display: 'inline-block',
1421
+ width: `${w}px`, height: `${h}px`,
1422
+ });
1423
+ const inp = document.createElement('input');
1424
+ inp.type = 'number';
1425
+ inp.min = '0';
1426
+ inp.max = String(count - 1);
1427
+ inp.step = '1';
1428
+ inp.value = String(initialIndex);
1429
+ if (label) inp.setAttribute('aria-label', label);
1430
+ if (disabled) inp.disabled = true;
1431
+ Object.assign(inp.style, {
1432
+ position: 'absolute', top: '0', left: '0',
1433
+ width: `${w}px`, height: `${h}px`,
1434
+ opacity: '0', margin: '0', padding: '0', border: 'none',
1435
+ background: 'transparent', cursor: 'pointer', zIndex: '2',
1436
+ WebkitAppearance: 'none', appearance: 'none',
1437
+ });
1438
+ root.appendChild(inp);
1439
+ els.push(inp);
1440
+ }
1441
+
1442
+ // -- Canvas overlay (DPR-aware), sized to the strip + padding. --
1443
+ const canvas = document.createElement('canvas');
1444
+ const cw = w + padding * 2;
1445
+ const ch = h + padding * 2;
1446
+ canvas.width = cw * dpr;
1447
+ canvas.height = ch * dpr;
1448
+ Object.assign(canvas.style, {
1449
+ position: 'absolute', top: '0', left: '0',
1450
+ width: `${cw}px`, height: `${ch}px`,
1451
+ transform: `translate(-${padding}px, -${padding}px)`,
1452
+ pointerEvents: 'none', zIndex: '1',
1453
+ });
1454
+ const ctx = canvas.getContext('2d');
1455
+ ctx.scale(dpr, dpr);
1456
+
1457
+ wrapper = document.createElement('div');
1458
+ Object.assign(wrapper.style, {
1459
+ position: 'relative', display: 'inline-block',
1460
+ width: `${w}px`, height: `${h}px`,
1461
+ });
1462
+ wrapper.appendChild(root);
1463
+ wrapper.appendChild(canvas);
1464
+ container.appendChild(wrapper);
1465
+ wrapperAppended = true;
1466
+
1467
+ const rmq = _reducedMotionQuery();
1468
+
1469
+ // -- State: a SUPERSET of the scalar per-frame state (every field present, so
1470
+ // a single-element recipe never breaks) PLUS the group fields. The single-
1471
+ // value fields (val/toggled/indeterminate) are neutral here; a group uses
1472
+ // index/count. Geometry lanes are references (zero per-frame alloc). --
1473
+ const state = {
1474
+ hover: false,
1475
+ active: false,
1476
+ focused: false,
1477
+ toggled: false, // scalar-superset neutral (a group has no single toggle)
1478
+ indeterminate: false, // scalar-superset neutral
1479
+ disabled,
1480
+ val: 0, // scalar-superset neutral (a group uses index/count)
1481
+ reducedMotion: rmq ? !!rmq.matches : false,
1482
+ budget: 1,
1483
+ w, h, padding, dpr,
1484
+ // group fields:
1485
+ index: initialIndex, // selected item (0..count-1)
1486
+ count, // number of items/steps
1487
+ hoverIndex: -1, // item under the pointer, -1 when none
1488
+ labels: items, // the item label strings (reference; cold-set)
1489
+ itemX, itemY, itemW, itemH, // per-item geometry lanes (Float32Array)
1490
+ };
1491
+ const pointer = { x: -999, y: -999, vx: 0, vy: 0 };
1492
+
1493
+ if (recipe.init) recipe.init(ctx, w, h, padding);
1494
+
1495
+ // -- Events (all via AbortController). --
1496
+ ac = new AbortController();
1497
+ acCreated = true;
1498
+ const signal = ac.signal;
1499
+
1500
+ // Selection: set state.index, fire onSelect exactly once when asked. A
1501
+ // programmatic native write emits no native event, so setIndex's explicit
1502
+ // fire is the only one (no double fire) -- same discipline as setValue (U4a).
1503
+ function select(i, fireHook) {
1504
+ state.index = i;
1505
+ if (fireHook && recipe.onSelect) recipe.onSelect(i, state);
1506
+ }
1507
+
1508
+ // Per-element hover -> hoverIndex, and group hover. Cold pointer handlers on
1509
+ // the native elements (canvas is pointerEvents:none). Zero layout reads.
1510
+ // Iterate els.length, NOT count: STEPPER is one native element for N steps.
1511
+ for (let i = 0; i < els.length; i++) {
1512
+ const idx = i;
1513
+ els[i].addEventListener('pointerenter', () => {
1514
+ state.hover = true; state.hoverIndex = idx;
1515
+ if (recipe.onHover) recipe.onHover(state, pointer);
1516
+ }, { signal });
1517
+ els[i].addEventListener('pointerleave', () => {
1518
+ state.hoverIndex = -1;
1519
+ if (recipe.onLeave) recipe.onLeave(state, pointer);
1520
+ }, { signal });
1521
+ }
1522
+ // Group focus tracking (focusin/out bubble; any element focused == focused).
1523
+ root.addEventListener('focusin', () => { state.focused = true; }, { signal });
1524
+ root.addEventListener('focusout', () => { state.focused = false; state.hover = false; }, { signal });
1525
+
1526
+ // Per-type selection wiring.
1527
+ if (groupType === GroupType.RADIO || groupType === GroupType.RATING) {
1528
+ // Native radios: arrow keys move focus AND check the newly-focused radio,
1529
+ // firing 'change' on it (one change per move). Click checks + fires change.
1530
+ for (let i = 0; i < count; i++) {
1531
+ const idx = i;
1532
+ els[i].addEventListener('change', () => {
1533
+ if (els[idx].checked) select(idx, true);
1534
+ }, { signal });
1535
+ }
1536
+ } else if (groupType === GroupType.TABS) {
1537
+ // Hand-written APG roving tabindex: only the selected tab is tabbable;
1538
+ // Left/Right (+ Up/Down) and Home/End move selection + focus.
1539
+ _moveTab = function moveTab(i, focusIt) {
1540
+ for (let j = 0; j < count; j++) {
1541
+ els[j].tabIndex = j === i ? 0 : -1;
1542
+ els[j].setAttribute('aria-selected', j === i ? 'true' : 'false');
1543
+ }
1544
+ if (focusIt && els[i].focus) els[i].focus();
1545
+ select(i, true);
1546
+ };
1547
+ for (let i = 0; i < count; i++) {
1548
+ const idx = i;
1549
+ els[i].addEventListener('click', () => _moveTab(idx, true), { signal });
1550
+ }
1551
+ root.addEventListener('keydown', (e) => {
1552
+ let ni = state.index;
1553
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') ni = (state.index + 1) % count;
1554
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') ni = (state.index - 1 + count) % count;
1555
+ else if (e.key === 'Home') ni = 0;
1556
+ else if (e.key === 'End') ni = count - 1;
1557
+ else return;
1558
+ e.preventDefault();
1559
+ _moveTab(ni, true);
1560
+ }, { signal });
1561
+ } else { // STEPPER
1562
+ // Native spinbutton: ArrowUp/Down + typing fire 'input'. Read + clamp to
1563
+ // [0,count-1]; 'change' (blur) would double-fire, so listen 'input' only.
1564
+ els[0].addEventListener('input', () => {
1565
+ let v = parseInt(els[0].value, 10);
1566
+ if (!Number.isFinite(v)) return; // mid-edit empty field: ignore
1567
+ if (v < 0) v = 0; else if (v > count - 1) v = count - 1;
1568
+ if (String(v) !== els[0].value) els[0].value = String(v); // reflect the clamp
1569
+ select(v, true);
1570
+ }, { signal });
1571
+ }
1572
+
1573
+ // -- DPR re-read on display change (cold; absent matchMedia is a silent
1574
+ // no-op -- fail closed). --
1575
+ if (typeof window.matchMedia === 'function') {
1576
+ const mq = window.matchMedia('(resolution: ' + dpr + 'dppx)');
1577
+ mq.addEventListener('change', () => {
1578
+ const nd = window.devicePixelRatio || 1;
1579
+ dpr = nd;
1580
+ canvas.width = cw * nd;
1581
+ canvas.height = ch * nd;
1582
+ ctx.setTransform(nd, 0, 0, nd, 0, 0);
1583
+ state.dpr = nd;
1584
+ }, { signal });
1585
+ }
1586
+ if (rmq) {
1587
+ rmq.addEventListener('change', () => { state.reducedMotion = !!rmq.matches; }, { signal });
1588
+ }
1589
+
1590
+ // -- Render loop. ONE named frame body; three clock modes invoke it with no
1591
+ // wrapper; same budget update + quarantine-on-throw as the other mounts. --
1592
+ let destroyed = false;
1593
+ let quarantined = false;
1594
+
1595
+ function frame(dtMs) {
1596
+ if (destroyed || quarantined) return;
1597
+ const dt = dtMs / 1000;
1598
+ const now = performance.now();
1599
+ if (dt > 0) {
1600
+ let inst = _TARGET_DT / dt;
1601
+ if (inst > 1) inst = 1; else if (inst < 0) inst = 0;
1602
+ state.budget += (inst - state.budget) * _BUDGET_SMOOTH;
1603
+ }
1604
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1605
+ ctx.clearRect(0, 0, cw, ch);
1606
+ ctx.save();
1607
+ ctx.translate(padding, padding); // Origin = the strip's top-left
1608
+ try {
1609
+ recipe.tick(ctx, dt, now, state, pointer);
1610
+ } catch (err) {
1611
+ quarantined = true;
1612
+ console.error('mountUIFXGroup: recipe.tick threw for groupType "' + groupType + '"; group quarantined', err);
1613
+ ctx.restore();
1614
+ ctx.clearRect(0, 0, cw, ch);
1615
+ return;
1616
+ }
1617
+ ctx.restore();
1618
+ }
1619
+
1620
+ if (driven) {
1621
+ // no ticker acquired; removeTick stays null
1622
+ } else if (callerTicker !== undefined) {
1623
+ removeTick = callerTicker.add(frame);
1624
+ } else {
1625
+ const ticker = acquireTicker();
1626
+ tickerAcquired = true;
1627
+ removeTick = ticker.add(frame);
1628
+ }
1629
+
1630
+ // -- Public API --
1631
+ return {
1632
+ /** The native interactive elements (radios / tabs, or the single spinbutton). */
1633
+ els,
1634
+ /** The overlay canvas. */
1635
+ canvas,
1636
+ /** The wrapper div. */
1637
+ wrapper,
1638
+ /** Current state (read-only reference; state.index is the live selection). */
1639
+ state,
1640
+ /** The selected index right now (convenience over state.index). */
1641
+ get index() { return state.index; },
1642
+
1643
+ /** Drive one frame by hand (U5). Callable ONLY in { driven: true } mode. */
1644
+ tick: driven ? frame : _drivenOnly,
1645
+
1646
+ /**
1647
+ * Programmatically select item i in [0, count-1]: updates the native
1648
+ * element(s), state.index, and fires onSelect exactly once (a programmatic
1649
+ * native write emits no native event, so no double fire). Does NOT steal
1650
+ * focus. Fail closed on a bad index.
1651
+ */
1652
+ setIndex(i) {
1653
+ if (destroyed) return;
1654
+ if (typeof i !== 'number' || !Number.isInteger(i) || i < 0 || i >= count) {
1655
+ throw new Error('setIndex: i must be an integer in [0, count-1]');
1656
+ }
1657
+ if (groupType === GroupType.RADIO || groupType === GroupType.RATING) {
1658
+ els[i].checked = true; // no native 'change' from a programmatic set
1659
+ } else if (groupType === GroupType.TABS) {
1660
+ _moveTab(i, false); // roving update without focus; fires onSelect
1661
+ return;
1662
+ } else { // STEPPER
1663
+ els[0].value = String(i); // no native 'input' from a programmatic set
1664
+ }
1665
+ select(i, true);
1666
+ },
1667
+
1668
+ /** Destroy everything. Idempotent. */
1669
+ destroy() {
1670
+ if (destroyed) return;
1671
+ destroyed = true;
1672
+ ac.abort();
1673
+ if (removeTick) removeTick();
1674
+ if (recipe.destroy) recipe.destroy();
1675
+ if (tickerAcquired) releaseTicker();
1676
+ wrapper.remove();
1677
+ },
1678
+ };
1679
+ } catch (err) {
1680
+ // A phase-2 step threw (realistically recipe.init). Unwind ONLY what was
1681
+ // acquired, reverse order, each flag-guarded. recipe.destroy is NOT called.
1682
+ if (removeTick) removeTick();
1683
+ if (tickerAcquired) releaseTicker();
1684
+ if (acCreated) ac.abort();
1685
+ if (wrapperAppended) wrapper.remove();
1686
+ throw err;
1687
+ }
1688
+ }
1689
+
1166
1690
  export default mountUIFX;
package/UIFXRecipes.d.ts CHANGED
@@ -1,4 +1,7 @@
1
- import type { UIFXRecipe, UIFXInstance, MountOptions } from './UIFXController';
1
+ import type {
2
+ UIFXRecipe, UIFXInstance, MountOptions,
3
+ UIFXGroupRecipe, GroupOptions, UIFXGroupInstance, DecorateInstance,
4
+ } from './UIFXController';
2
5
 
3
6
  // ===========================================================
4
7
  // RECIPE OPTIONS + FACTORIES (all 56)
@@ -81,7 +84,8 @@ export declare function FlameCounter(options?: RecipeOptions): UIFXRecipe;
81
84
  export declare function GlitchCounter(options?: RecipeOptions): UIFXRecipe;
82
85
 
83
86
  // -- Vol.2: Rating --
84
- export declare function BubbleRating(options?: RecipeOptions): UIFXRecipe;
87
+ /** U7 GROUP (rating): mount via mountUIFXGroup(GroupType.RATING, ...). */
88
+ export declare function BubbleRating(options?: RecipeOptions): UIFXGroupRecipe;
85
89
 
86
90
  // -- Vol.3: Knobs --
87
91
  export declare function VolumeKnob(options?: RecipeOptions): UIFXRecipe;
@@ -95,9 +99,13 @@ export declare function SignalMeter(options?: RecipeOptions): UIFXRecipe;
95
99
  export declare function LiquidFill(options?: RecipeOptions): UIFXRecipe;
96
100
 
97
101
  // -- Vol.3: Controls --
98
- export declare function PillTabs(options?: RecipeOptions): UIFXRecipe;
99
- export declare function Stepper(options?: RecipeOptions): UIFXRecipe;
100
- export declare function RadioOrbit(options?: RecipeOptions): UIFXRecipe;
102
+ // -- U7 GROUP recipes (mounted via mountUIFXGroup): N native elements + one
103
+ // canvas. PillTabs/SegmentedSlide -> GroupType.TABS, Stepper -> STEPPER,
104
+ // RadioOrbit -> RADIO. See decisions/0007. --
105
+ export declare function PillTabs(options?: RecipeOptions): UIFXGroupRecipe;
106
+ export declare function SegmentedSlide(options?: RecipeOptions): UIFXGroupRecipe;
107
+ export declare function Stepper(options?: RecipeOptions): UIFXGroupRecipe;
108
+ export declare function RadioOrbit(options?: RecipeOptions): UIFXGroupRecipe;
101
109
 
102
110
  // -- Vol.3: Indicators --
103
111
  export declare function PasswordStrength(options?: RecipeOptions): UIFXRecipe;
@@ -203,14 +211,24 @@ export declare const UIFXRecipes5: {
203
211
  SuccessBloom: typeof SuccessBloom;
204
212
  };
205
213
 
214
+ /** U7 additions -- grouped controls (mounted via mountUIFXGroup). PillTabs/Stepper/
215
+ * RadioOrbit/BubbleRating re-home from vol.3 single-element fakes; SegmentedSlide
216
+ * is new. See decisions/0007. */
217
+ export declare const UIFXRecipes6: {
218
+ SegmentedSlide: typeof SegmentedSlide;
219
+ };
220
+
206
221
  // ===========================================================
207
222
  // RECIPE REGISTRY
208
223
  // ===========================================================
209
224
 
210
- // 'decorate' is not a UIType (it creates no native element); it is the registry
211
- // routing tag for a recipe mounted AROUND a live element via decorateUIFX. See
212
- // decisions/0004.
213
- export type RecipeType = 'toggle' | 'button' | 'slider' | 'checkbox' | 'progress' | 'knob' | 'decorate';
225
+ // Beyond the UITypes there are two non-UIType routing tags: 'decorate' (U4b,
226
+ // mounted AROUND a live element via decorateUIFX -- 0004) and the four GroupTypes
227
+ // (U7, mounted as N native elements + one canvas via mountUIFXGroup -- 0007).
228
+ export type RecipeType =
229
+ | 'toggle' | 'button' | 'slider' | 'checkbox' | 'progress' | 'knob'
230
+ | 'decorate'
231
+ | 'radio' | 'tabs' | 'stepper' | 'rating';
214
232
 
215
233
  export type RecipeFactory = (options?: Record<string, unknown>) => UIFXRecipe;
216
234
 
@@ -243,14 +261,15 @@ export declare function registerRecipe(
243
261
  * Resolve a recipe id to its factory + declared type and mount it. A hijack
244
262
  * recipe mounts via mountUIFX (the native element is created inside `container`);
245
263
  * a recipe whose meta.type is 'decorate' mounts via decorateUIFX, treating
246
- * `container` as the LIVE element to decorate (a canvas is placed AROUND it).
247
- * Fail closed: unknown id or a conflicting options.type throws.
264
+ * `container` as the LIVE element to decorate; a GROUP type (radio/tabs/stepper/
265
+ * rating) mounts via mountUIFXGroup with `container` as the parent and `items` in
266
+ * options. Fail closed: unknown id or a conflicting options.type throws.
248
267
  */
249
268
  export declare function mountRecipe(
250
269
  container: HTMLElement,
251
270
  id: string,
252
- options?: MountOptions & { type?: RecipeType },
253
- ): UIFXInstance;
271
+ options?: (MountOptions | GroupOptions) & { type?: RecipeType },
272
+ ): UIFXInstance | UIFXGroupInstance | DecorateInstance;
254
273
 
255
274
  // ===========================================================
256
275
  // DEFAULT EXPORT -- combined all-56 namespace
@@ -296,6 +315,7 @@ declare const UIFXAllRecipes: {
296
315
  SignalMeter: typeof SignalMeter;
297
316
  LiquidFill: typeof LiquidFill;
298
317
  PillTabs: typeof PillTabs;
318
+ SegmentedSlide: typeof SegmentedSlide;
299
319
  Stepper: typeof Stepper;
300
320
  RadioOrbit: typeof RadioOrbit;
301
321
  PasswordStrength: typeof PasswordStrength;