bitwrench 2.0.14 → 2.0.16

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.
Files changed (61) hide show
  1. package/README.md +57 -21
  2. package/dist/bitwrench-bccl.cjs.js +3746 -0
  3. package/dist/bitwrench-bccl.cjs.min.js +40 -0
  4. package/dist/bitwrench-bccl.esm.js +3741 -0
  5. package/dist/bitwrench-bccl.esm.min.js +40 -0
  6. package/dist/bitwrench-bccl.umd.js +3752 -0
  7. package/dist/bitwrench-bccl.umd.min.js +40 -0
  8. package/dist/bitwrench-code-edit.cjs.js +99 -49
  9. package/dist/bitwrench-code-edit.cjs.min.js +23 -0
  10. package/dist/bitwrench-code-edit.es5.js +79 -16
  11. package/dist/bitwrench-code-edit.es5.min.js +9 -2
  12. package/dist/bitwrench-code-edit.esm.js +99 -49
  13. package/dist/bitwrench-code-edit.esm.min.js +9 -2
  14. package/dist/bitwrench-code-edit.umd.js +99 -49
  15. package/dist/bitwrench-code-edit.umd.min.js +9 -2
  16. package/dist/bitwrench-lean.cjs.js +4923 -3248
  17. package/dist/bitwrench-lean.cjs.min.js +35 -6
  18. package/dist/bitwrench-lean.es5.js +6325 -4580
  19. package/dist/bitwrench-lean.es5.min.js +32 -3
  20. package/dist/bitwrench-lean.esm.js +4923 -3248
  21. package/dist/bitwrench-lean.esm.min.js +35 -6
  22. package/dist/bitwrench-lean.umd.js +4923 -3248
  23. package/dist/bitwrench-lean.umd.min.js +35 -6
  24. package/dist/bitwrench.cjs.js +5082 -3667
  25. package/dist/bitwrench.cjs.min.js +38 -8
  26. package/dist/bitwrench.css +2289 -6034
  27. package/dist/bitwrench.es5.js +6862 -5346
  28. package/dist/bitwrench.es5.min.js +34 -5
  29. package/dist/bitwrench.esm.js +5082 -3667
  30. package/dist/bitwrench.esm.min.js +38 -8
  31. package/dist/bitwrench.min.css +1 -0
  32. package/dist/bitwrench.umd.js +5082 -3667
  33. package/dist/bitwrench.umd.min.js +38 -8
  34. package/dist/builds.json +184 -74
  35. package/dist/bwserve.cjs.js +646 -0
  36. package/dist/bwserve.esm.js +638 -0
  37. package/dist/sri.json +36 -26
  38. package/package.json +23 -6
  39. package/readme.html +71 -32
  40. package/src/bitwrench-bccl-entry.js +72 -0
  41. package/src/{bitwrench-components-v2.js → bitwrench-bccl.js} +396 -647
  42. package/src/bitwrench-code-edit.js +98 -48
  43. package/src/bitwrench-color-utils.js +24 -18
  44. package/src/bitwrench-components-stub.js +4 -1
  45. package/src/bitwrench-file-ops.js +180 -0
  46. package/src/bitwrench-lean.js +2 -2
  47. package/src/bitwrench-styles.js +1287 -4029
  48. package/src/bitwrench-utils.js +458 -0
  49. package/src/bitwrench.js +2070 -1292
  50. package/src/bwserve/client.js +182 -0
  51. package/src/bwserve/index.js +352 -0
  52. package/src/bwserve/shell.js +103 -0
  53. package/src/cli/index.js +36 -15
  54. package/src/cli/layout-default.js +18 -18
  55. package/src/cli/serve.js +325 -0
  56. package/src/generate-css.js +73 -53
  57. package/src/version.js +3 -3
  58. package/src/bitwrench-component-base.js +0 -736
  59. package/src/bitwrench-components-inline.js +0 -374
  60. package/src/bitwrench-components.js +0 -610
  61. /package/bin/{bitwrench.js → bwcli.js} +0 -0
package/src/bitwrench.js CHANGED
@@ -8,15 +8,23 @@
8
8
  */
9
9
 
10
10
  import { VERSION_INFO } from './version.js';
11
- import { getStructuralStyles, theme, updateTheme,
11
+ import { getStructuralStyles,
12
12
  generateThemedCSS, generateAlternateCSS, derivePalette as _derivePalette,
13
13
  DEFAULT_PALETTE_CONFIG, SPACING_PRESETS, RADIUS_PRESETS, THEME_PRESETS,
14
14
  TYPE_RATIO_PRESETS, ELEVATION_PRESETS, MOTION_PRESETS, generateTypeScale,
15
- resolveLayout, addUnderscoreAliases } from './bitwrench-styles.js';
15
+ resolveLayout } from './bitwrench-styles.js';
16
16
  import { hexToHsl, hslToHex, adjustLightness, mixColor,
17
17
  relativeLuminance, textOnColor, deriveShades,
18
18
  derivePalette, harmonize, deriveAlternateSeed, deriveAlternateConfig,
19
- isLightPalette } from './bitwrench-color-utils.js';
19
+ isLightPalette,
20
+ colorParse, colorRgbToHsl, colorHslToRgb } from './bitwrench-color-utils.js';
21
+ import { bindFileOps } from './bitwrench-file-ops.js';
22
+ import { typeOf as _typeOf, mapScale as _mapScale, clip as _clip,
23
+ choice as _choice, arrayUniq as _arrayUniq, arrayBinA as _arrayBinA,
24
+ arrayBNotInA as _arrayBNotInA, colorInterp as _colorInterp,
25
+ loremIpsum as _loremIpsum, multiArray as _multiArray,
26
+ naturalCompare as _naturalCompare, setIntervalX as _setIntervalX,
27
+ repeatUntil as _repeatUntil } from './bitwrench-utils.js';
20
28
 
21
29
  // Environment-aware module loader for optional Node.js built-ins (fs).
22
30
  // Strategy: try require() first (CJS/UMD), fall back to import() (ESM).
@@ -51,7 +59,7 @@ const bw = {
51
59
  // Fast O(1) lookup for elements by bw_id, id attribute, or bw_uuid.
52
60
  //
53
61
  // Populated by bw.createDOM() when elements have:
54
- // - data-bw-id attribute (user-declared addressable elements)
62
+ // - data-bw_id attribute (user-declared addressable elements)
55
63
  // - id attribute (standard HTML id)
56
64
  // - bw_uuid (internal, for lifecycle-managed elements)
57
65
  //
@@ -209,58 +217,7 @@ bw._getFs = function() {
209
217
  * // baseTypeOnly mode:
210
218
  * bw.typeOf([1,2], true) // => "object"
211
219
  */
212
- bw.typeOf = function(x, baseTypeOnly) {
213
- if (x === null) return "null";
214
-
215
- const basic = typeof x;
216
-
217
- if (basic !== "object") {
218
- return basic; // covers: string, number, boolean, undefined, function, symbol, bigint
219
- }
220
-
221
- if (baseTypeOnly) return basic;
222
-
223
- const stringTag = Object.prototype.toString.call(x);
224
-
225
- const typeMap = {
226
- '[object Array]': 'array',
227
- '[object Date]': 'Date',
228
- '[object RegExp]': 'RegExp',
229
- '[object Error]': 'Error',
230
- '[object Promise]': 'Promise',
231
- '[object Map]': 'Map',
232
- '[object Set]': 'Set',
233
- '[object WeakMap]': 'WeakMap',
234
- '[object WeakSet]': 'WeakSet',
235
- '[object ArrayBuffer]': 'ArrayBuffer',
236
- '[object DataView]': 'DataView',
237
- '[object Int8Array]': 'Int8Array',
238
- '[object Uint8Array]': 'Uint8Array',
239
- '[object Uint8ClampedArray]': 'Uint8ClampedArray',
240
- '[object Int16Array]': 'Int16Array',
241
- '[object Uint16Array]': 'Uint16Array',
242
- '[object Int32Array]': 'Int32Array',
243
- '[object Uint32Array]': 'Uint32Array',
244
- '[object Float32Array]': 'Float32Array',
245
- '[object Float64Array]': 'Float64Array'
246
- };
247
-
248
- if (typeMap[stringTag]) {
249
- return typeMap[stringTag];
250
- }
251
-
252
- // Check for custom bitwrench types
253
- if (x._bw_type) {
254
- return x._bw_type;
255
- }
256
-
257
- // Try constructor name
258
- if (x.constructor && x.constructor.name) {
259
- return x.constructor.name;
260
- }
261
-
262
- return basic;
263
- };
220
+ bw.typeOf = _typeOf;
264
221
 
265
222
  // Alias
266
223
  bw.to = bw.typeOf;
@@ -310,9 +267,9 @@ bw.uuid = function(prefix) {
310
267
  * Accepts a DOM element directly (pass-through) or a string identifier.
311
268
  * String identifiers are tried as: direct map key, getElementById,
312
269
  * querySelector (for CSS selectors starting with . or #), and
313
- * data-bw-id attribute selector.
270
+ * data-bw_id attribute selector.
314
271
  *
315
- * @param {string|Element} id - Element ID, CSS selector, data-bw-id value, or DOM element
272
+ * @param {string|Element} id - Element ID, CSS selector, data-bw_id value, or DOM element
316
273
  * @returns {Element|null} The DOM element, or null if not found
317
274
  * @category Internal
318
275
  */
@@ -341,9 +298,9 @@ bw._el = function(id) {
341
298
  el = document.querySelector(id);
342
299
  }
343
300
 
344
- // 4. Try data-bw-id attribute (for bw.uuid-generated IDs)
301
+ // 4. Try data-bw_id attribute (for bw.uuid-generated IDs)
345
302
  if (!el) {
346
- el = document.querySelector('[data-bw-id="' + id + '"]');
303
+ el = document.querySelector('[data-bw_id="' + id + '"]');
347
304
  }
348
305
 
349
306
  // 5. Cache the result for next time
@@ -358,15 +315,15 @@ bw._el = function(id) {
358
315
  * Register a DOM element in the node cache under one or more keys.
359
316
  *
360
317
  * Called internally by `bw.createDOM()`. Registers elements that have
361
- * id attributes, data-bw-id attributes, or both.
318
+ * id attributes, data-bw_id attributes, or both.
362
319
  *
363
320
  * @param {Element} el - DOM element to register
364
- * @param {string} [bwId] - data-bw-id value to register under
321
+ * @param {string} [bwId] - data-bw_id value to register under
365
322
  * @category Internal
366
323
  */
367
324
  bw._registerNode = function(el, bwId) {
368
325
  if (!el) return;
369
- // Register under data-bw-id
326
+ // Register under data-bw_id
370
327
  if (bwId) {
371
328
  bw._nodeMap[bwId] = el;
372
329
  }
@@ -384,11 +341,11 @@ bw._registerNode = function(el, bwId) {
384
341
  * through bitwrench APIs.
385
342
  *
386
343
  * @param {Element} el - DOM element to deregister
387
- * @param {string} [bwId] - data-bw-id value to remove
344
+ * @param {string} [bwId] - data-bw_id value to remove
388
345
  * @category Internal
389
346
  */
390
347
  bw._deregisterNode = function(el, bwId) {
391
- // Remove data-bw-id entry
348
+ // Remove data-bw_id entry
392
349
  if (bwId) {
393
350
  delete bw._nodeMap[bwId];
394
351
  }
@@ -448,23 +405,6 @@ bw.raw = function(str) {
448
405
  return { __bw_raw: true, v: String(str) };
449
406
  };
450
407
 
451
- /**
452
- * Normalize CSS class names by converting underscores to hyphens for bw-prefixed classes.
453
- *
454
- * Allows users to write either `bw_card` or `bw-card` and get consistent
455
- * hyphenated output. Only converts the `bw_` prefix — other underscores are untouched.
456
- *
457
- * @param {string} classStr - Class string to normalize
458
- * @returns {string} Normalized class string with hyphens
459
- * @category Identifiers
460
- * @example
461
- * bw.normalizeClass('bw_card bw_btn') // => 'bw-card bw-btn'
462
- * bw.normalizeClass('my_class') // => 'my_class' (unchanged)
463
- */
464
- bw.normalizeClass = function(classStr) {
465
- if (typeof classStr !== 'string') return classStr;
466
- return classStr.replace(/\bbw_/g, 'bw-');
467
- };
468
408
 
469
409
  /**
470
410
  * Convert a TACO object (or array of TACOs) to an HTML string.
@@ -493,20 +433,52 @@ bw.normalizeClass = function(classStr) {
493
433
  bw.html = function(taco, options = {}) {
494
434
  // Handle null/undefined
495
435
  if (taco == null) return '';
496
-
436
+
437
+ // Handle ComponentHandle — use its .taco
438
+ if (taco && taco._bwComponent === true) {
439
+ var compOptions = Object.assign({}, options);
440
+ if (!compOptions.state && taco._state) {
441
+ compOptions.state = taco._state;
442
+ }
443
+ return bw.html(taco.taco, compOptions);
444
+ }
445
+
497
446
  // Handle arrays of TACOs
498
447
  if (Array.isArray(taco)) {
499
448
  return taco.map(t => bw.html(t, options)).join('');
500
449
  }
501
-
450
+
502
451
  // Handle bw.raw() marked content
503
452
  if (taco && taco.__bw_raw) {
504
453
  return taco.v;
505
454
  }
506
455
 
456
+ // Handle bw.when() markers
457
+ if (taco && taco._bwWhen && options.state) {
458
+ var whenExpr = taco.expr.replace(/^\$\{|\}$/g, '');
459
+ var whenVal = options.compile
460
+ ? bw._resolveTemplate('${' + whenExpr + '}', options.state, true)
461
+ : bw._evaluatePath(options.state, whenExpr);
462
+ var branch = whenVal ? taco.branches[0] : (taco.branches[1] || null);
463
+ return branch ? bw.html(branch, options) : '';
464
+ }
465
+
466
+ // Handle bw.each() markers
467
+ if (taco && taco._bwEach && options.state) {
468
+ var eachExpr = taco.expr.replace(/^\$\{|\}$/g, '');
469
+ var arr = bw._evaluatePath(options.state, eachExpr);
470
+ if (!Array.isArray(arr)) return '';
471
+ return arr.map(function(item, idx) { return bw.html(taco.factory(item, idx), options); }).join('');
472
+ }
473
+
507
474
  // Handle primitives and non-TACO objects
508
475
  if (typeof taco !== 'object' || !taco.t) {
509
- return options.raw ? String(taco) : bw.escapeHTML(String(taco));
476
+ var str = options.raw ? String(taco) : bw.escapeHTML(String(taco));
477
+ // Resolve template bindings if state provided
478
+ if (options.state && typeof str === 'string' && str.indexOf('${') >= 0) {
479
+ str = bw._resolveTemplate(str, options.state, !!options.compile);
480
+ }
481
+ return str;
510
482
  }
511
483
 
512
484
  const { t: tag, a: attrs = {}, c: content, o: opts = {} } = taco;
@@ -536,12 +508,8 @@ bw.html = function(taco, options = {}) {
536
508
  attrStr += ` style="${bw.escapeHTML(styleStr)}"`;
537
509
  }
538
510
  } else if (key === 'class') {
539
- // Handle class as array or string, normalize bw_ to bw-
540
- const classStr = bw.normalizeClass(
541
- Array.isArray(value)
542
- ? value.filter(Boolean).join(' ')
543
- : String(value)
544
- );
511
+ // Handle class as array or string
512
+ const classStr = Array.isArray(value) ? value.filter(Boolean).join(' ') : String(value);
545
513
  if (classStr) {
546
514
  attrStr += ` class="${bw.escapeHTML(classStr)}"`;
547
515
  }
@@ -549,19 +517,23 @@ bw.html = function(taco, options = {}) {
549
517
  // Boolean attributes
550
518
  attrStr += ` ${key}`;
551
519
  } else {
552
- // Regular attributes
553
- attrStr += ` ${key}="${bw.escapeHTML(String(value))}"`;
520
+ // Regular attributes — resolve ${expr} if state provided
521
+ let resolvedVal = String(value);
522
+ if (options.state && resolvedVal.indexOf('${') >= 0) {
523
+ resolvedVal = bw._resolveTemplate(resolvedVal, options.state, !!options.compile);
524
+ }
525
+ attrStr += ` ${key}="${bw.escapeHTML(resolvedVal)}"`;
554
526
  }
555
527
  }
556
528
 
557
- // Add bw-id as a class if lifecycle hooks present
558
- if ((opts.mounted || opts.unmount) && !attrs.class?.includes('bw-id-')) {
529
+ // Add bw_id as a class if lifecycle hooks present
530
+ if ((opts.mounted || opts.unmount) && !attrs.class?.includes('bw_id_')) {
559
531
  const id = opts.bw_id || bw.uuid();
560
532
  attrStr = attrStr.replace(/class="([^"]*)"/, (_match, classes) => {
561
- return `class="${classes} bw-id-${id}"`.trim();
533
+ return `class="${classes} bw_id_${id}"`.trim();
562
534
  });
563
535
  if (!attrStr.includes('class=')) {
564
- attrStr += ` class="bw-id-${id}"`;
536
+ attrStr += ` class="bw_id_${id}"`;
565
537
  }
566
538
  }
567
539
 
@@ -571,8 +543,12 @@ bw.html = function(taco, options = {}) {
571
543
  }
572
544
 
573
545
  // Process content recursively
574
- const contentStr = content != null ? bw.html(content, options) : '';
575
-
546
+ let contentStr = content != null ? bw.html(content, options) : '';
547
+ // Resolve template bindings in content if state provided
548
+ if (options.state && typeof contentStr === 'string' && contentStr.indexOf('${') >= 0) {
549
+ contentStr = bw._resolveTemplate(contentStr, options.state, !!options.compile);
550
+ }
551
+
576
552
  return `<${tag}${attrStr}>${contentStr}</${tag}>`;
577
553
  };
578
554
 
@@ -592,7 +568,7 @@ bw.html = function(taco, options = {}) {
592
568
  * @example
593
569
  * var el = bw.createDOM({
594
570
  * t: 'button',
595
- * a: { class: 'bw-btn', onclick: () => alert('clicked') },
571
+ * a: { class: 'bw_btn', onclick: () => alert('clicked') },
596
572
  * c: 'Click Me'
597
573
  * });
598
574
  * document.body.appendChild(el);
@@ -614,6 +590,11 @@ bw.createDOM = function(taco, options = {}) {
614
590
  return frag;
615
591
  }
616
592
 
593
+ // Handle ComponentHandle — extract .taco for DOM creation
594
+ if (taco && taco._bwComponent === true) {
595
+ return bw.createDOM(taco.taco, options);
596
+ }
597
+
617
598
  // Handle text nodes
618
599
  if (typeof taco !== 'object' || !taco.t) {
619
600
  return document.createTextNode(String(taco));
@@ -632,12 +613,8 @@ bw.createDOM = function(taco, options = {}) {
632
613
  // Apply styles directly
633
614
  Object.assign(el.style, value);
634
615
  } else if (key === 'class') {
635
- // Handle class as array or string, normalize bw_ to bw-
636
- const classStr = bw.normalizeClass(
637
- Array.isArray(value)
638
- ? value.filter(Boolean).join(' ')
639
- : String(value)
640
- );
616
+ // Handle class as array or string
617
+ const classStr = Array.isArray(value) ? value.filter(Boolean).join(' ') : String(value);
641
618
  if (classStr) {
642
619
  el.className = classStr;
643
620
  }
@@ -658,16 +635,21 @@ bw.createDOM = function(taco, options = {}) {
658
635
  }
659
636
 
660
637
  // Add children, building _bw_refs for fast parent→child access.
661
- // Children with data-bw-id or id attributes get local refs on the parent,
638
+ // Children with data-bw_id or id attributes get local refs on the parent,
662
639
  // so o.render functions can access them without any DOM lookup.
663
640
  if (content != null) {
664
641
  if (Array.isArray(content)) {
665
642
  content.forEach(child => {
666
643
  if (child != null) {
644
+ // Handle ComponentHandle in content arrays (Level 2 children)
645
+ if (child._bwComponent === true) {
646
+ child.mount(el);
647
+ return;
648
+ }
667
649
  var childEl = bw.createDOM(child, options);
668
650
  el.appendChild(childEl);
669
651
  // Build local refs for addressable children
670
- var childBwId = (child && child.a) ? (child.a['data-bw-id'] || child.a.id) : null;
652
+ var childBwId = (child && child.a) ? (child.a['data-bw_id'] || child.a.id) : null;
671
653
  if (childBwId) {
672
654
  if (!el._bw_refs) el._bw_refs = {};
673
655
  el._bw_refs[childBwId] = childEl;
@@ -686,10 +668,13 @@ bw.createDOM = function(taco, options = {}) {
686
668
  } else if (typeof content === 'object' && content.__bw_raw) {
687
669
  // Raw HTML content — inject via innerHTML
688
670
  el.innerHTML = content.v;
671
+ } else if (content._bwComponent === true) {
672
+ // Single ComponentHandle as content
673
+ content.mount(el);
689
674
  } else if (typeof content === 'object' && content.t) {
690
675
  var childEl = bw.createDOM(content, options);
691
676
  el.appendChild(childEl);
692
- var childBwId = content.a ? (content.a['data-bw-id'] || content.a.id) : null;
677
+ var childBwId = content.a ? (content.a['data-bw_id'] || content.a.id) : null;
693
678
  if (childBwId) {
694
679
  if (!el._bw_refs) el._bw_refs = {};
695
680
  el._bw_refs[childBwId] = childEl;
@@ -714,10 +699,10 @@ bw.createDOM = function(taco, options = {}) {
714
699
 
715
700
  // Handle lifecycle hooks and state
716
701
  if (opts.mounted || opts.unmount || opts.render || opts.state) {
717
- const id = attrs['data-bw-id'] || bw.uuid();
718
- el.setAttribute('data-bw-id', id);
702
+ const id = attrs['data-bw_id'] || bw.uuid();
703
+ el.setAttribute('data-bw_id', id);
719
704
 
720
- // Register in node cache under data-bw-id
705
+ // Register in node cache under data-bw_id
721
706
  bw._registerNode(el, id);
722
707
 
723
708
  // Store state
@@ -762,9 +747,9 @@ bw.createDOM = function(taco, options = {}) {
762
747
  opts.unmount(el, el._bw_state || {});
763
748
  });
764
749
  }
765
- } else if (attrs['data-bw-id']) {
766
- // Element has explicit data-bw-id but no lifecycle hooks — still register it
767
- bw._registerNode(el, attrs['data-bw-id']);
750
+ } else if (attrs['data-bw_id']) {
751
+ // Element has explicit data-bw_id but no lifecycle hooks — still register it
752
+ bw._registerNode(el, attrs['data-bw_id']);
768
753
  }
769
754
 
770
755
  return el;
@@ -811,7 +796,7 @@ bw.DOM = function(target, taco, options = {}) {
811
796
  // the target is the mount point, not the content being replaced)
812
797
  const savedState = targetEl._bw_state;
813
798
  const savedRender = targetEl._bw_render;
814
- const savedBwId = targetEl.getAttribute('data-bw-id');
799
+ const savedBwId = targetEl.getAttribute('data-bw_id');
815
800
  const savedSubs = targetEl._bw_subs;
816
801
 
817
802
  // Temporarily remove _bw_subs so cleanup doesn't call them
@@ -824,7 +809,7 @@ bw.DOM = function(target, taco, options = {}) {
824
809
  if (savedState !== undefined) targetEl._bw_state = savedState;
825
810
  if (savedRender) targetEl._bw_render = savedRender;
826
811
  if (savedBwId) {
827
- targetEl.setAttribute('data-bw-id', savedBwId);
812
+ targetEl.setAttribute('data-bw_id', savedBwId);
828
813
  // Re-register mount point in node cache (cleanup deregistered it)
829
814
  bw._registerNode(targetEl, savedBwId);
830
815
  }
@@ -834,15 +819,21 @@ bw.DOM = function(target, taco, options = {}) {
834
819
  targetEl.innerHTML = '';
835
820
 
836
821
  if (taco != null) {
822
+ // Handle ComponentHandle (reactive components from bw.component())
823
+ if (taco._bwComponent === true) {
824
+ taco.mount(targetEl);
825
+ }
837
826
  // Handle component handles (objects with element property)
838
- if (taco.element instanceof Element) {
827
+ else if (taco.element instanceof Element) {
839
828
  targetEl.appendChild(taco.element);
840
829
  }
841
830
  // Handle arrays
842
831
  else if (Array.isArray(taco)) {
843
832
  taco.forEach(t => {
844
833
  if (t != null) {
845
- if (t.element instanceof Element) {
834
+ if (t._bwComponent === true) {
835
+ t.mount(targetEl);
836
+ } else if (t.element instanceof Element) {
846
837
  targetEl.appendChild(t.element);
847
838
  } else {
848
839
  targetEl.appendChild(bw.createDOM(t, options));
@@ -1078,11 +1069,11 @@ bw.renderComponent = function(taco, options = {}) {
1078
1069
  bw.cleanup = function(element) {
1079
1070
  if (!bw._isBrowser || !element) return;
1080
1071
 
1081
- // Find all elements with data-bw-id
1082
- const elements = element.querySelectorAll('[data-bw-id]');
1072
+ // Find all elements with data-bw_id
1073
+ const elements = element.querySelectorAll('[data-bw_id]');
1083
1074
 
1084
1075
  elements.forEach(el => {
1085
- const id = el.getAttribute('data-bw-id');
1076
+ const id = el.getAttribute('data-bw_id');
1086
1077
  const callback = bw._unmountCallbacks.get(id);
1087
1078
 
1088
1079
  if (callback) {
@@ -1106,7 +1097,7 @@ bw.cleanup = function(element) {
1106
1097
  });
1107
1098
 
1108
1099
  // Check element itself
1109
- const id = element.getAttribute('data-bw-id');
1100
+ const id = element.getAttribute('data-bw_id');
1110
1101
  if (id) {
1111
1102
  const callback = bw._unmountCallbacks.get(id);
1112
1103
  if (callback) {
@@ -1125,6 +1116,13 @@ bw.cleanup = function(element) {
1125
1116
  delete element._bw_state;
1126
1117
  delete element._bw_render;
1127
1118
  delete element._bw_refs;
1119
+
1120
+ // Clean up ComponentHandle back-reference
1121
+ if (element._bwComponentHandle) {
1122
+ element._bwComponentHandle.mounted = false;
1123
+ element._bwComponentHandle.element = null;
1124
+ delete element._bwComponentHandle;
1125
+ }
1128
1126
  }
1129
1127
  };
1130
1128
 
@@ -1139,7 +1137,7 @@ bw.cleanup = function(element) {
1139
1137
  * Calls `el._bw_render(el, state)` and emits `bw:statechange` so other
1140
1138
  * components can react without tight coupling.
1141
1139
  *
1142
- * @param {string|Element} target - Element ID, data-bw-id, CSS selector, or DOM element
1140
+ * @param {string|Element} target - Element ID, data-bw_id, CSS selector, or DOM element
1143
1141
  * @returns {Element|null} The element, or null if not found / no render function
1144
1142
  * @category State Management
1145
1143
  * @see bw.patch
@@ -1164,7 +1162,7 @@ bw.update = function(target) {
1164
1162
  * Use `bw.patch()` for lightweight value updates (scores, labels, counters)
1165
1163
  * and `bw.update()` for full structural re-renders.
1166
1164
  *
1167
- * @param {string|Element} id - Element ID, data-bw-id, CSS selector, or DOM element.
1165
+ * @param {string|Element} id - Element ID, data-bw_id, CSS selector, or DOM element.
1168
1166
  * Uses node cache for O(1) lookup; falls back to DOM query on cache miss.
1169
1167
  * @param {string|Object} content - New text content, or TACO object to replace children
1170
1168
  * @param {string} [attr] - If provided, sets this attribute instead of content
@@ -1239,7 +1237,7 @@ bw.patchAll = function(patches) {
1239
1237
  * bubble by default so ancestor elements can listen. Use with `bw.on()` for
1240
1238
  * DOM-scoped communication between components.
1241
1239
  *
1242
- * @param {string|Element} target - Element ID, data-bw-id, CSS selector, or DOM element.
1240
+ * @param {string|Element} target - Element ID, data-bw_id, CSS selector, or DOM element.
1243
1241
  * Uses node cache for O(1) lookup; falls back to DOM query on cache miss.
1244
1242
  * @param {string} eventName - Event name (will be prefixed with 'bw:')
1245
1243
  * @param {*} [detail] - Data to pass with the event
@@ -1266,7 +1264,7 @@ bw.emit = function(target, eventName, detail) {
1266
1264
  * is the first argument so you don't need to destructure `e.detail`.
1267
1265
  * Events bubble, so you can listen on an ancestor element.
1268
1266
  *
1269
- * @param {string|Element} target - Element ID, data-bw-id, CSS selector, or DOM element.
1267
+ * @param {string|Element} target - Element ID, data-bw_id, CSS selector, or DOM element.
1270
1268
  * Uses node cache for O(1) lookup; falls back to DOM query on cache miss.
1271
1269
  * @param {string} eventName - Event name (will be prefixed with 'bw:')
1272
1270
  * @param {Function} handler - Called with (detail, event)
@@ -1364,10 +1362,10 @@ bw.sub = function(topic, handler, el) {
1364
1362
  if (el) {
1365
1363
  if (!el._bw_subs) el._bw_subs = [];
1366
1364
  el._bw_subs.push(unsub);
1367
- // Ensure element has data-bw-id so bw.cleanup() finds it
1368
- if (!el.getAttribute('data-bw-id')) {
1365
+ // Ensure element has data-bw_id so bw.cleanup() finds it
1366
+ if (!el.getAttribute('data-bw_id')) {
1369
1367
  var bwId = 'bw_sub_' + id;
1370
- el.setAttribute('data-bw-id', bwId);
1368
+ el.setAttribute('data-bw_id', bwId);
1371
1369
  }
1372
1370
  }
1373
1371
 
@@ -1396,173 +1394,1875 @@ bw.unsub = function(topic, handler) {
1396
1394
  return removed;
1397
1395
  };
1398
1396
 
1397
+ // ===================================================================================
1398
+ // Function Registry (revived from v1 for string dispatch contexts)
1399
+ // ===================================================================================
1400
+
1401
+ bw._fnRegistry = {};
1402
+ bw._fnIDCounter = 0;
1403
+
1404
+ /**
1405
+ * Register a function in the global function registry.
1406
+ *
1407
+ * Registered functions can be invoked by name in HTML string contexts
1408
+ * (e.g., onclick attributes) via `bw.funcGetById()`. Useful for
1409
+ * serializable event handlers, LLM wire format, and SSR.
1410
+ *
1411
+ * @param {Function} fn - Function to register
1412
+ * @param {string} [name] - Optional name. Auto-generated if omitted.
1413
+ * @returns {string} The registered name (use for dispatch)
1414
+ * @category Function Registry
1415
+ * @see bw.funcGetById
1416
+ * @see bw.funcGetDispatchStr
1417
+ */
1418
+ bw.funcRegister = function(fn, name) {
1419
+ if (typeof fn !== 'function') return '';
1420
+ var fnID = (typeof name === 'string' && name.length > 0) ? name : ('bw_fn_' + bw._fnIDCounter++);
1421
+ bw._fnRegistry[fnID] = fn;
1422
+ return fnID;
1423
+ };
1424
+
1399
1425
  /**
1400
- * Generate CSS from JavaScript objects.
1426
+ * Retrieve a registered function by name.
1401
1427
  *
1402
- * Converts an object of `{ selector: { prop: value } }` rules into a CSS string.
1403
- * CamelCase property names are auto-converted to kebab-case (e.g. `fontSize` → `font-size`).
1404
- * Accepts nested arrays of rule objects.
1428
+ * Returns the function if found, or `errFn` (or a no-op logger) if not.
1405
1429
  *
1406
- * @param {Object|Array|string} rules - CSS rules as JS objects, array of rule objects, or raw CSS string
1407
- * @param {Object} [options] - Generation options
1408
- * @param {boolean} [options.minify=false] - Minify output (no whitespace)
1409
- * @returns {string} CSS string
1410
- * @category CSS & Styling
1411
- * @see bw.injectCSS
1412
- * @example
1413
- * bw.css({
1414
- * '.card': { padding: '1rem', fontSize: '14px', borderRadius: '8px' }
1415
- * })
1416
- * // => '.card {\n padding: 1rem;\n font-size: 14px;\n border-radius: 8px;\n}'
1430
+ * @param {string} name - Registered function name
1431
+ * @param {Function} [errFn] - Fallback if not found
1432
+ * @returns {Function} The registered function or fallback
1433
+ * @category Function Registry
1434
+ * @see bw.funcRegister
1417
1435
  */
1418
- bw.css = function(rules, options = {}) {
1419
- const { minify = false, pretty = !minify } = options;
1436
+ bw.funcGetById = function(name, errFn) {
1437
+ name = String(name);
1438
+ if (name in bw._fnRegistry) return bw._fnRegistry[name];
1439
+ return (typeof errFn === 'function') ? errFn : function() { console.warn('bw.funcGetById: unregistered fn "' + name + '"'); };
1440
+ };
1420
1441
 
1421
- if (typeof rules === 'string') return rules;
1442
+ /**
1443
+ * Generate a dispatch string suitable for inline HTML event attributes.
1444
+ *
1445
+ * @param {string} name - Registered function name
1446
+ * @param {string} [argStr=''] - Arguments string (literal, not variable names)
1447
+ * @returns {string} Dispatch string like `"bw.funcGetById('name')(args)"`
1448
+ * @category Function Registry
1449
+ * @see bw.funcRegister
1450
+ */
1451
+ bw.funcGetDispatchStr = function(name, argStr) {
1452
+ argStr = (argStr != null) ? String(argStr) : '';
1453
+ return "bw.funcGetById('" + name + "')(" + argStr + ")";
1454
+ };
1422
1455
 
1423
- let css = '';
1424
- const indent = pretty ? ' ' : '';
1425
- const newline = pretty ? '\n' : '';
1426
- const space = pretty ? ' ' : '';
1456
+ /**
1457
+ * Remove a function from the registry.
1458
+ *
1459
+ * @param {string} name - Registered function name
1460
+ * @returns {boolean} True if removed, false if not found
1461
+ * @category Function Registry
1462
+ */
1463
+ bw.funcUnregister = function(name) {
1464
+ if (name in bw._fnRegistry) {
1465
+ delete bw._fnRegistry[name];
1466
+ return true;
1467
+ }
1468
+ return false;
1469
+ };
1427
1470
 
1428
- if (Array.isArray(rules)) {
1429
- css = rules.map(rule => bw.css(rule, options)).join(newline);
1430
- } else if (typeof rules === 'object') {
1431
- Object.entries(rules).forEach(([selector, styles]) => {
1432
- if (typeof styles === 'object' && !Array.isArray(styles)) {
1433
- // Handle @media, @keyframes, @supports — recurse into nested block
1434
- if (selector.charAt(0) === '@') {
1435
- const inner = bw.css(styles, options);
1436
- if (inner) {
1437
- css += `${selector}${space}{${newline}${inner}${newline}}${newline}`;
1438
- }
1439
- return;
1440
- }
1441
- const declarations = Object.entries(styles)
1442
- .filter(([, value]) => value != null)
1443
- .map(([prop, value]) => {
1444
- // Convert camelCase to kebab-case
1445
- const kebabProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
1446
- return `${indent}${kebabProp}:${space}${value};`;
1447
- })
1448
- .join(newline);
1471
+ /**
1472
+ * Get a shallow copy of the function registry for inspection.
1473
+ *
1474
+ * @returns {Object} Copy of registry (name → function)
1475
+ * @category Function Registry
1476
+ */
1477
+ bw.funcGetRegistry = function() {
1478
+ var copy = {};
1479
+ for (var k in bw._fnRegistry) {
1480
+ if (Object.prototype.hasOwnProperty.call(bw._fnRegistry, k)) {
1481
+ copy[k] = bw._fnRegistry[k];
1482
+ }
1483
+ }
1484
+ return copy;
1485
+ };
1449
1486
 
1450
- if (declarations) {
1451
- css += `${selector}${space}{${newline}${declarations}${newline}}${newline}`;
1452
- }
1453
- }
1454
- });
1487
+ // ===================================================================================
1488
+ // Template Binding Utilities
1489
+ // ===================================================================================
1490
+
1491
+ /**
1492
+ * Parse binding expressions from a template string.
1493
+ * Returns array of {start, end, expr} for each `${expr}` found.
1494
+ * @private
1495
+ */
1496
+ bw._parseBindings = function(str) {
1497
+ var results = [];
1498
+ var re = /\$\{([^}]+)\}/g;
1499
+ var match;
1500
+ while ((match = re.exec(str)) !== null) {
1501
+ results.push({ start: match.index, end: match.index + match[0].length, expr: match[1].trim() });
1455
1502
  }
1503
+ return results;
1504
+ };
1456
1505
 
1457
- return css.trim();
1506
+ /**
1507
+ * Evaluate a dot-path on a state object. Returns empty string for null/undefined.
1508
+ * @private
1509
+ */
1510
+ bw._evaluatePath = function(state, path) {
1511
+ var parts = path.split('.');
1512
+ var val = state;
1513
+ for (var i = 0; i < parts.length; i++) {
1514
+ if (val == null) return '';
1515
+ val = val[parts[i]];
1516
+ }
1517
+ return (val == null) ? '' : val;
1458
1518
  };
1459
1519
 
1460
1520
  /**
1461
- * Inject CSS into the document head (browser only).
1521
+ * Resolve all `${expr}` bindings in a template string against a state object.
1462
1522
  *
1463
- * Creates or reuses a `<style>` element (identified by `id`). Can accept
1464
- * raw CSS strings or JS rule objects (which are converted via `bw.css()`).
1465
- * By default appends to existing content; set `append: false` to replace.
1523
+ * Tier 1 (default): dot-path lookup only (CSP-safe).
1524
+ * Tier 2 (compile=true): uses new Function for complex expressions.
1466
1525
  *
1467
- * @param {string|Object|Array} css - CSS string, or JS rule objects to convert
1468
- * @param {Object} [options] - Injection options
1469
- * @param {string} [options.id='bw-styles'] - ID for the style element
1470
- * @param {boolean} [options.append=true] - Append to existing CSS (false to replace)
1471
- * @returns {Element} The style element
1472
- * @category CSS & Styling
1473
- * @see bw.css
1474
- * @see bw.loadDefaultStyles
1475
- * @example
1476
- * bw.injectCSS('.my-class { color: red; }');
1477
- * bw.injectCSS({ '.card': { padding: '1rem' } }, { id: 'card-styles' });
1526
+ * @param {string} str - Template string
1527
+ * @param {Object} state - State object
1528
+ * @param {boolean} [compile=false] - Use Tier 2 evaluation
1529
+ * @returns {string} Resolved string
1530
+ * @private
1478
1531
  */
1479
- bw.injectCSS = function(css, options = {}) {
1480
- if (!bw._isBrowser) {
1481
- console.warn('bw.injectCSS requires a DOM environment');
1482
- return null;
1532
+ bw._compiledExprs = {};
1533
+ bw._resolveTemplate = function(str, state, compile) {
1534
+ if (typeof str !== 'string' || str.indexOf('${') < 0) return str;
1535
+ var bindings = bw._parseBindings(str);
1536
+ if (bindings.length === 0) return str;
1537
+
1538
+ var result = '';
1539
+ var lastEnd = 0;
1540
+ for (var i = 0; i < bindings.length; i++) {
1541
+ var b = bindings[i];
1542
+ result += str.slice(lastEnd, b.start);
1543
+ var val;
1544
+ if (compile) {
1545
+ // Tier 2: new Function evaluator (cached)
1546
+ if (!bw._compiledExprs[b.expr]) {
1547
+ try {
1548
+ bw._compiledExprs[b.expr] = new Function('state', 'with(state){return (' + b.expr + ');}');
1549
+ } catch (e) {
1550
+ bw._compiledExprs[b.expr] = function() { return ''; };
1551
+ }
1552
+ }
1553
+ try {
1554
+ val = bw._compiledExprs[b.expr](state);
1555
+ } catch (e) {
1556
+ val = '';
1557
+ }
1558
+ } else {
1559
+ // Tier 1: dot-path only
1560
+ val = bw._evaluatePath(state, b.expr);
1561
+ }
1562
+ result += (val == null) ? '' : String(val);
1563
+ lastEnd = b.end;
1483
1564
  }
1484
-
1485
- const { id = 'bw-styles', append = true } = options;
1486
-
1487
- // Get or create style element
1488
- let styleEl = document.getElementById(id);
1489
-
1490
- if (!styleEl) {
1491
- styleEl = document.createElement('style');
1492
- styleEl.id = id;
1493
- styleEl.type = 'text/css';
1494
- document.head.appendChild(styleEl);
1565
+ result += str.slice(lastEnd);
1566
+ return result;
1567
+ };
1568
+
1569
+ /**
1570
+ * Extract top-level state keys that an expression depends on.
1571
+ * @param {string} expr - Expression string
1572
+ * @param {string[]} stateKeys - Declared state keys
1573
+ * @returns {string[]} Matching dependency keys
1574
+ * @private
1575
+ */
1576
+ bw._extractDeps = function(expr, stateKeys) {
1577
+ var deps = [];
1578
+ for (var i = 0; i < stateKeys.length; i++) {
1579
+ var key = stateKeys[i];
1580
+ // Match word boundary: key must be preceded by start/non-word and followed by non-word/end
1581
+ var re = new RegExp('(?:^|[^\\w$.])' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[^\\w$]|$)');
1582
+ if (re.test(expr) || expr === key || expr.indexOf(key + '.') === 0) {
1583
+ deps.push(key);
1584
+ }
1495
1585
  }
1496
-
1497
- // Convert CSS if needed
1498
- const cssStr = typeof css === 'string' ? css : bw.css(css, options);
1499
-
1500
- // Set or append CSS
1501
- if (append && styleEl.textContent) {
1502
- styleEl.textContent += '\n' + cssStr;
1586
+ return deps;
1587
+ };
1588
+
1589
+ // ===================================================================================
1590
+ // Microtask Batching
1591
+ // ===================================================================================
1592
+
1593
+ bw._dirtyComponents = [];
1594
+ bw._flushScheduled = false;
1595
+
1596
+ /**
1597
+ * Schedule a microtask flush for dirty components.
1598
+ * @private
1599
+ */
1600
+ bw._scheduleFlush = function() {
1601
+ if (bw._flushScheduled) return;
1602
+ bw._flushScheduled = true;
1603
+ if (typeof Promise !== 'undefined') {
1604
+ Promise.resolve().then(bw._doFlush);
1503
1605
  } else {
1504
- styleEl.textContent = cssStr;
1606
+ setTimeout(bw._doFlush, 0);
1505
1607
  }
1506
-
1507
- return styleEl;
1508
1608
  };
1509
1609
 
1510
1610
  /**
1511
- * Merge multiple style objects into one (left-to-right).
1512
- *
1513
- * Like `Object.assign()` for styles, but filters out null/undefined arguments.
1514
- * Compose inline styles or CSS rule objects without mutation.
1515
- *
1516
- * @param {...Object} styles - Style objects to merge (left-to-right)
1517
- * @returns {Object} Merged style object
1518
- * @category CSS & Styling
1519
- * @see bw.u
1520
- * @example
1521
- * var style = bw.s(bw.u.flex, bw.u.gap4, { color: 'red' });
1522
- * // => { display: 'flex', gap: '1rem', color: 'red' }
1611
+ * Flush all dirty components. Deduplicates by _bwId.
1612
+ * @private
1523
1613
  */
1524
- bw.s = function() {
1525
- var result = {};
1526
- for (var i = 0; i < arguments.length; i++) {
1527
- var arg = arguments[i];
1528
- if (arg && typeof arg === 'object') Object.assign(result, arg);
1614
+ bw._doFlush = function() {
1615
+ bw._flushScheduled = false;
1616
+ var queue = bw._dirtyComponents.slice();
1617
+ bw._dirtyComponents = [];
1618
+ // Deduplicate by _bwId
1619
+ var seen = {};
1620
+ for (var i = 0; i < queue.length; i++) {
1621
+ var comp = queue[i];
1622
+ if (!seen[comp._bwId]) {
1623
+ seen[comp._bwId] = true;
1624
+ comp._flush();
1625
+ }
1529
1626
  }
1530
- return result;
1531
1627
  };
1532
1628
 
1533
1629
  /**
1534
- * Pre-built CSS utility objects (like Tailwind utilities, but in JS).
1630
+ * Synchronous flush for testing and imperative code.
1631
+ * Forces immediate re-render of all dirty components.
1535
1632
  *
1536
- * Compose with `bw.s()` to build inline styles without writing raw CSS strings.
1537
- * Includes flex, padding, margin, typography, color, border, and transition utilities.
1633
+ * @category Component
1634
+ */
1635
+ bw.flush = function() {
1636
+ bw._doFlush();
1637
+ };
1638
+
1639
+ // ===================================================================================
1640
+ // ComponentHandle — unified reactive component (Phase 1)
1641
+ // ===================================================================================
1642
+
1643
+ /**
1644
+ * ComponentHandle constructor.
1645
+ * Wraps a TACO definition with reactive state, lifecycle hooks,
1646
+ * template bindings, and named actions.
1538
1647
  *
1539
- * @category CSS & Styling
1540
- * @see bw.s
1541
- * @example
1542
- * { t: 'div', a: { style: bw.s(bw.u.flex, bw.u.gap4, bw.u.p4) },
1543
- * c: 'Flexbox with 1rem gap and padding' }
1648
+ * @param {Object} taco - TACO definition {t, a, c, o}
1649
+ * @constructor
1650
+ * @private
1544
1651
  */
1545
- bw.u = {
1546
- // Display
1547
- flex: { display: 'flex' },
1548
- flexCol: { display: 'flex', flexDirection: 'column' },
1549
- flexRow: { display: 'flex', flexDirection: 'row' },
1550
- flexWrap: { display: 'flex', flexWrap: 'wrap' },
1551
- block: { display: 'block' },
1552
- inline: { display: 'inline' },
1553
- hidden: { display: 'none' },
1652
+ function ComponentHandle(taco) {
1653
+ this._bwComponent = true; // duck-type marker
1654
+ this._bwId = bw.uuid('comp');
1655
+ this.taco = taco;
1656
+ this.element = null;
1657
+ this.mounted = false;
1658
+
1659
+ var o = taco.o || {};
1660
+ // Copy initial state
1661
+ this._state = {};
1662
+ if (o.state) {
1663
+ for (var k in o.state) {
1664
+ if (Object.prototype.hasOwnProperty.call(o.state, k)) {
1665
+ this._state[k] = o.state[k];
1666
+ }
1667
+ }
1668
+ }
1669
+ // Copy actions
1670
+ this._actions = {};
1671
+ if (o.actions) {
1672
+ for (var k2 in o.actions) {
1673
+ if (Object.prototype.hasOwnProperty.call(o.actions, k2)) {
1674
+ this._actions[k2] = o.actions[k2];
1675
+ }
1676
+ }
1677
+ }
1678
+ // Promote o.methods to handle API (MFC/Qt pattern: component owns its methods)
1679
+ this._methods = {};
1680
+ if (o.methods) {
1681
+ var self = this;
1682
+ for (var k3 in o.methods) {
1683
+ if (Object.prototype.hasOwnProperty.call(o.methods, k3)) {
1684
+ this._methods[k3] = o.methods[k3];
1685
+ (function(methodName, methodFn) {
1686
+ self[methodName] = function() {
1687
+ var args = [self].concat(Array.prototype.slice.call(arguments));
1688
+ return methodFn.apply(null, args);
1689
+ };
1690
+ })(k3, o.methods[k3]);
1691
+ }
1692
+ }
1693
+ }
1694
+ // User tag for addressing via bw.message()
1695
+ this._userTag = null;
1696
+ // Lifecycle hooks
1697
+ this._hooks = {
1698
+ willMount: o.willMount || null,
1699
+ mounted: o.mounted || null,
1700
+ willUpdate: o.willUpdate || null,
1701
+ onUpdate: o.onUpdate || null,
1702
+ unmount: o.unmount || null,
1703
+ willDestroy: o.willDestroy || null
1704
+ };
1705
+ // Binding tracking
1706
+ this._bindings = [];
1707
+ this._dirtyKeys = {};
1708
+ this._scheduled = false;
1709
+ this._subs = [];
1710
+ this._eventListeners = [];
1711
+ this._registeredActions = [];
1712
+ this._prevValues = {};
1713
+ this._compile = !!o.compile;
1714
+ this._bw_refs = {};
1715
+ this._refCounter = 0;
1716
+ }
1554
1717
 
1555
- // Flex alignment
1556
- justifyCenter: { justifyContent: 'center' },
1557
- justifyBetween: { justifyContent: 'space-between' },
1558
- justifyEnd: { justifyContent: 'flex-end' },
1559
- alignCenter: { alignItems: 'center' },
1560
- alignStart: { alignItems: 'flex-start' },
1561
- alignEnd: { alignItems: 'flex-end' },
1718
+ // ── State Methods ──
1562
1719
 
1563
- // Gap (0.25rem increments)
1564
- gap1: { gap: '0.25rem' },
1565
- gap2: { gap: '0.5rem' },
1720
+ /**
1721
+ * Get a state value. Dot-path supported: `get('user.name')`
1722
+ */
1723
+ ComponentHandle.prototype.get = function(key) {
1724
+ return bw._evaluatePath(this._state, key);
1725
+ };
1726
+
1727
+ /**
1728
+ * Set a state value. Dot-path supported. Schedules re-render.
1729
+ * @param {string} key - State key (dot-path)
1730
+ * @param {*} value - New value
1731
+ * @param {Object} [opts] - Options. `{sync: true}` for immediate flush.
1732
+ */
1733
+ ComponentHandle.prototype.set = function(key, value, opts) {
1734
+ // Dot-path set
1735
+ var parts = key.split('.');
1736
+ var obj = this._state;
1737
+ for (var i = 0; i < parts.length - 1; i++) {
1738
+ if (obj[parts[i]] == null || typeof obj[parts[i]] !== 'object') {
1739
+ obj[parts[i]] = {};
1740
+ }
1741
+ obj = obj[parts[i]];
1742
+ }
1743
+ obj[parts[parts.length - 1]] = value;
1744
+ // Mark top-level key dirty
1745
+ this._dirtyKeys[parts[0]] = true;
1746
+ if (this.mounted) {
1747
+ if (opts && opts.sync) {
1748
+ this._flush();
1749
+ } else {
1750
+ this._scheduleDirty();
1751
+ }
1752
+ }
1753
+ };
1754
+
1755
+ /**
1756
+ * Get a shallow clone of the full state.
1757
+ */
1758
+ ComponentHandle.prototype.getState = function() {
1759
+ var clone = {};
1760
+ for (var k in this._state) {
1761
+ if (Object.prototype.hasOwnProperty.call(this._state, k)) {
1762
+ clone[k] = this._state[k];
1763
+ }
1764
+ }
1765
+ return clone;
1766
+ };
1767
+
1768
+ /**
1769
+ * Merge multiple state keys. Schedules re-render.
1770
+ * @param {Object} updates - Key-value pairs to merge
1771
+ * @param {Object} [opts] - Options. `{sync: true}` for immediate flush.
1772
+ */
1773
+ ComponentHandle.prototype.setState = function(updates, opts) {
1774
+ for (var k in updates) {
1775
+ if (Object.prototype.hasOwnProperty.call(updates, k)) {
1776
+ this._state[k] = updates[k];
1777
+ this._dirtyKeys[k] = true;
1778
+ }
1779
+ }
1780
+ if (this.mounted) {
1781
+ if (opts && opts.sync) {
1782
+ this._flush();
1783
+ } else {
1784
+ this._scheduleDirty();
1785
+ }
1786
+ }
1787
+ };
1788
+
1789
+ /**
1790
+ * Push a value onto an array in state. Clones the array.
1791
+ */
1792
+ ComponentHandle.prototype.push = function(key, val) {
1793
+ var arr = this.get(key);
1794
+ var newArr = Array.isArray(arr) ? arr.slice() : [];
1795
+ newArr.push(val);
1796
+ this.set(key, newArr);
1797
+ };
1798
+
1799
+ /**
1800
+ * Splice an array in state. Clones the array.
1801
+ */
1802
+ ComponentHandle.prototype.splice = function(key, start, deleteCount) {
1803
+ var arr = this.get(key);
1804
+ var newArr = Array.isArray(arr) ? arr.slice() : [];
1805
+ var args = [start, deleteCount].concat(Array.prototype.slice.call(arguments, 3));
1806
+ Array.prototype.splice.apply(newArr, args);
1807
+ this.set(key, newArr);
1808
+ };
1809
+
1810
+ // ── Scheduling ──
1811
+
1812
+ ComponentHandle.prototype._scheduleDirty = function() {
1813
+ if (!this._scheduled) {
1814
+ this._scheduled = true;
1815
+ bw._dirtyComponents.push(this);
1816
+ bw._scheduleFlush();
1817
+ }
1818
+ };
1819
+
1820
+ // ── Binding Compilation ──
1821
+
1822
+ /**
1823
+ * Walk the TACO tree and extract ${expr} bindings.
1824
+ * Creates binding descriptors with refIds for targeted DOM updates.
1825
+ * @private
1826
+ */
1827
+ ComponentHandle.prototype._compileBindings = function() {
1828
+ this._bindings = [];
1829
+ this._refCounter = 0;
1830
+ var stateKeys = Object.keys(this._state);
1831
+ var self = this;
1832
+
1833
+ function walkTaco(taco, path) {
1834
+ if (taco == null || typeof taco !== 'object' || !taco.t) return taco;
1835
+
1836
+ // Check content for bindings
1837
+ if (typeof taco.c === 'string' && taco.c.indexOf('${') >= 0) {
1838
+ var refId = 'bw_ref_' + self._refCounter++;
1839
+ var parsed = bw._parseBindings(taco.c);
1840
+ var deps = [];
1841
+ for (var j = 0; j < parsed.length; j++) {
1842
+ deps = deps.concat(bw._extractDeps(parsed[j].expr, stateKeys));
1843
+ }
1844
+ self._bindings.push({
1845
+ expr: taco.c,
1846
+ type: 'content',
1847
+ refId: refId,
1848
+ deps: deps,
1849
+ template: taco.c
1850
+ });
1851
+ // Inject data-bw_ref on the TACO for createDOM to pick up
1852
+ if (!taco.a) taco.a = {};
1853
+ taco.a['data-bw_ref'] = refId;
1854
+ }
1855
+
1856
+ // Check attributes for bindings
1857
+ if (taco.a) {
1858
+ for (var attrName in taco.a) {
1859
+ if (!Object.prototype.hasOwnProperty.call(taco.a, attrName)) continue;
1860
+ if (attrName === 'data-bw_ref') continue;
1861
+ var attrVal = taco.a[attrName];
1862
+ if (typeof attrVal === 'string' && attrVal.indexOf('${') >= 0) {
1863
+ var refId2 = 'bw_ref_' + self._refCounter++;
1864
+ var parsed2 = bw._parseBindings(attrVal);
1865
+ var deps2 = [];
1866
+ for (var j2 = 0; j2 < parsed2.length; j2++) {
1867
+ deps2 = deps2.concat(bw._extractDeps(parsed2[j2].expr, stateKeys));
1868
+ }
1869
+ self._bindings.push({
1870
+ expr: attrVal,
1871
+ type: 'attribute',
1872
+ attrName: attrName,
1873
+ refId: refId2,
1874
+ deps: deps2,
1875
+ template: attrVal
1876
+ });
1877
+ if (!taco.a) taco.a = {};
1878
+ taco.a['data-bw_ref'] = taco.a['data-bw_ref'] || refId2;
1879
+ // If multiple attribute bindings on same element, store additional marker
1880
+ if (taco.a['data-bw_ref'] !== refId2) {
1881
+ taco.a['data-bw_ref_' + attrName] = refId2;
1882
+ }
1883
+ }
1884
+ }
1885
+ }
1886
+
1887
+ // Recurse into children
1888
+ if (Array.isArray(taco.c)) {
1889
+ for (var i = 0; i < taco.c.length; i++) {
1890
+ if (taco.c[i] && typeof taco.c[i] === 'object' && taco.c[i].t) {
1891
+ walkTaco(taco.c[i], path.concat(i));
1892
+ }
1893
+ // Handle bw.when/bw.each markers
1894
+ if (taco.c[i] && taco.c[i]._bwWhen) {
1895
+ var whenRefId = 'bw_ref_' + self._refCounter++;
1896
+ var whenDeps = bw._extractDeps(taco.c[i].expr.replace(/^\$\{|\}$/g, ''), stateKeys);
1897
+ self._bindings.push({
1898
+ expr: taco.c[i].expr,
1899
+ type: 'structural',
1900
+ subtype: 'when',
1901
+ refId: whenRefId,
1902
+ deps: whenDeps,
1903
+ branches: taco.c[i].branches,
1904
+ index: i,
1905
+ parentPath: path
1906
+ });
1907
+ taco.c[i]._refId = whenRefId;
1908
+ }
1909
+ if (taco.c[i] && taco.c[i]._bwEach) {
1910
+ var eachRefId = 'bw_ref_' + self._refCounter++;
1911
+ var eachDeps = bw._extractDeps(taco.c[i].expr.replace(/^\$\{|\}$/g, ''), stateKeys);
1912
+ self._bindings.push({
1913
+ expr: taco.c[i].expr,
1914
+ type: 'structural',
1915
+ subtype: 'each',
1916
+ refId: eachRefId,
1917
+ deps: eachDeps,
1918
+ factory: taco.c[i].factory,
1919
+ index: i,
1920
+ parentPath: path
1921
+ });
1922
+ taco.c[i]._refId = eachRefId;
1923
+ }
1924
+ }
1925
+ } else if (taco.c && typeof taco.c === 'object' && taco.c.t) {
1926
+ walkTaco(taco.c, path.concat(0));
1927
+ }
1928
+
1929
+ return taco;
1930
+ }
1931
+
1932
+ walkTaco(this.taco, []);
1933
+ };
1934
+
1935
+ // ── DOM Reference Collection ──
1936
+
1937
+ /**
1938
+ * Build ref map from the live DOM after createDOM.
1939
+ * @private
1940
+ */
1941
+ ComponentHandle.prototype._collectRefs = function() {
1942
+ this._bw_refs = {};
1943
+ if (!this.element) return;
1944
+ var els = this.element.querySelectorAll('[data-bw_ref]');
1945
+ for (var i = 0; i < els.length; i++) {
1946
+ this._bw_refs[els[i].getAttribute('data-bw_ref')] = els[i];
1947
+ }
1948
+ // Also check root element
1949
+ var rootRef = this.element.getAttribute && this.element.getAttribute('data-bw_ref');
1950
+ if (rootRef) {
1951
+ this._bw_refs[rootRef] = this.element;
1952
+ }
1953
+ };
1954
+
1955
+ // ── Lifecycle ──
1956
+
1957
+ /**
1958
+ * Mount the component into a parent DOM element.
1959
+ * Creates DOM, compiles bindings, registers actions, and calls lifecycle hooks.
1960
+ * @param {Element} parentEl - DOM element to mount into
1961
+ */
1962
+ ComponentHandle.prototype.mount = function(parentEl) {
1963
+ // willMount hook
1964
+ if (this._hooks.willMount) this._hooks.willMount(this);
1965
+
1966
+ // Save original TACO for re-renders (structural changes clone from this)
1967
+ if (!this._originalTaco) {
1968
+ this._originalTaco = this.taco;
1969
+ }
1970
+
1971
+ // Deep-clone TACO so binding annotations don't mutate original.
1972
+ // Custom clone to preserve _bwWhen/_bwEach markers and their factory functions.
1973
+ this.taco = this._deepCloneTaco(this._originalTaco);
1974
+
1975
+ // Compile bindings (annotates TACO with data-bw_ref attributes)
1976
+ this._compileBindings();
1977
+
1978
+ // Prepare TACO: resolve initial binding values, evaluate when/each
1979
+ this._prepareTaco(this.taco);
1980
+
1981
+ // Register named actions in function registry
1982
+ var self = this;
1983
+ for (var actionName in this._actions) {
1984
+ if (Object.prototype.hasOwnProperty.call(this._actions, actionName)) {
1985
+ var registeredName = this._bwId + '_' + actionName;
1986
+ (function(aName) {
1987
+ bw.funcRegister(function(evt) {
1988
+ self._actions[aName](self, evt);
1989
+ }, registeredName);
1990
+ })(actionName);
1991
+ this._registeredActions.push(registeredName);
1992
+ }
1993
+ }
1994
+
1995
+ // Wire action names in onclick etc. to dispatch strings
1996
+ this._wireActions(this.taco);
1997
+
1998
+ // Create DOM (strip o before createDOM to prevent double lifecycle)
1999
+ var tacoForDOM = this._tacoForDOM(this.taco);
2000
+ this.element = bw.createDOM(tacoForDOM);
2001
+ this.element._bwComponentHandle = this;
2002
+ this.element.setAttribute('data-bw_comp_id', this._bwId);
2003
+ if (this._userTag) {
2004
+ this.element.classList.add(this._userTag);
2005
+ }
2006
+
2007
+ // Append to parent
2008
+ parentEl.appendChild(this.element);
2009
+
2010
+ // Collect refs from live DOM
2011
+ this._collectRefs();
2012
+
2013
+ // Resolve initial bindings and apply to DOM
2014
+ this._resolveAndApplyAll();
2015
+
2016
+ this.mounted = true;
2017
+
2018
+ // mounted hook (backward compat: fn.length === 2 wraps (el, state))
2019
+ if (this._hooks.mounted) {
2020
+ if (this._hooks.mounted.length === 2) {
2021
+ this._hooks.mounted(this.element, this.getState());
2022
+ } else {
2023
+ this._hooks.mounted(this);
2024
+ }
2025
+ }
2026
+ };
2027
+
2028
+ /**
2029
+ * Prepare TACO for initial render: resolve when/each markers.
2030
+ * @private
2031
+ */
2032
+ ComponentHandle.prototype._prepareTaco = function(taco) {
2033
+ if (!taco || typeof taco !== 'object') return;
2034
+
2035
+ if (Array.isArray(taco.c)) {
2036
+ for (var i = taco.c.length - 1; i >= 0; i--) {
2037
+ var child = taco.c[i];
2038
+ if (child && child._bwWhen) {
2039
+ var exprStr = child.expr.replace(/^\$\{|\}$/g, '');
2040
+ var val;
2041
+ if (this._compile) {
2042
+ try {
2043
+ val = (new Function('state', 'with(state){return (' + exprStr + ');}'))(this._state);
2044
+ } catch(e) { val = false; }
2045
+ } else {
2046
+ val = bw._evaluatePath(this._state, exprStr);
2047
+ }
2048
+ var branch = val ? child.branches[0] : (child.branches[1] || null);
2049
+ if (branch) {
2050
+ // Wrap in a container so we can track it
2051
+ taco.c[i] = { t: 'span', a: { 'data-bw_when': child._refId, style: 'display:contents' }, c: branch };
2052
+ } else {
2053
+ taco.c[i] = { t: 'span', a: { 'data-bw_when': child._refId, style: 'display:contents' }, c: '' };
2054
+ }
2055
+ }
2056
+ if (child && child._bwEach) {
2057
+ var eachExprStr = child.expr.replace(/^\$\{|\}$/g, '');
2058
+ var arr = bw._evaluatePath(this._state, eachExprStr);
2059
+ var items = [];
2060
+ if (Array.isArray(arr)) {
2061
+ for (var j = 0; j < arr.length; j++) {
2062
+ items.push(child.factory(arr[j], j));
2063
+ }
2064
+ }
2065
+ taco.c[i] = { t: 'span', a: { 'data-bw_each': child._refId, style: 'display:contents' }, c: items };
2066
+ }
2067
+ if (taco.c[i] && typeof taco.c[i] === 'object' && taco.c[i].t) {
2068
+ this._prepareTaco(taco.c[i]);
2069
+ }
2070
+ }
2071
+ } else if (taco.c && typeof taco.c === 'object' && taco.c.t) {
2072
+ this._prepareTaco(taco.c);
2073
+ }
2074
+ };
2075
+
2076
+ /**
2077
+ * Wire action name strings (in onclick etc.) to dispatch function calls.
2078
+ * @private
2079
+ */
2080
+ ComponentHandle.prototype._wireActions = function(taco) {
2081
+ if (!taco || typeof taco !== 'object' || !taco.t) return;
2082
+ if (taco.a) {
2083
+ for (var key in taco.a) {
2084
+ if (!Object.prototype.hasOwnProperty.call(taco.a, key)) continue;
2085
+ if (key.startsWith('on') && typeof taco.a[key] === 'string') {
2086
+ var actionName = taco.a[key];
2087
+ if (actionName in this._actions) {
2088
+ var registeredName = this._bwId + '_' + actionName;
2089
+ // Replace string with actual function for createDOM event binding
2090
+ (function(rName) {
2091
+ taco.a[key] = function(evt) {
2092
+ bw.funcGetById(rName)(evt);
2093
+ };
2094
+ })(registeredName);
2095
+ }
2096
+ }
2097
+ }
2098
+ }
2099
+ if (Array.isArray(taco.c)) {
2100
+ for (var i = 0; i < taco.c.length; i++) {
2101
+ this._wireActions(taco.c[i]);
2102
+ }
2103
+ } else if (taco.c && typeof taco.c === 'object' && taco.c.t) {
2104
+ this._wireActions(taco.c);
2105
+ }
2106
+ };
2107
+
2108
+ /**
2109
+ * Deep-clone a TACO tree, preserving _bwWhen/_bwEach markers and their factories.
2110
+ * @private
2111
+ */
2112
+ ComponentHandle.prototype._deepCloneTaco = function(taco) {
2113
+ if (taco == null) return taco;
2114
+ // Preserve _bwWhen / _bwEach markers (contain functions)
2115
+ if (taco._bwWhen) {
2116
+ return { _bwWhen: true, expr: taco.expr, branches: [
2117
+ this._deepCloneTaco(taco.branches[0]),
2118
+ taco.branches[1] ? this._deepCloneTaco(taco.branches[1]) : null
2119
+ ], _refId: taco._refId };
2120
+ }
2121
+ if (taco._bwEach) {
2122
+ return { _bwEach: true, expr: taco.expr, factory: taco.factory, _refId: taco._refId };
2123
+ }
2124
+ if (typeof taco !== 'object' || !taco.t) return taco;
2125
+ var result = { t: taco.t };
2126
+ if (taco.a) {
2127
+ result.a = {};
2128
+ for (var k in taco.a) {
2129
+ if (Object.prototype.hasOwnProperty.call(taco.a, k)) result.a[k] = taco.a[k];
2130
+ }
2131
+ }
2132
+ if (taco.c != null) {
2133
+ if (Array.isArray(taco.c)) {
2134
+ result.c = taco.c.map(function(child) { return this._deepCloneTaco(child); }.bind(this));
2135
+ } else if (typeof taco.c === 'object') {
2136
+ result.c = this._deepCloneTaco(taco.c);
2137
+ } else {
2138
+ result.c = taco.c;
2139
+ }
2140
+ }
2141
+ if (taco.o) result.o = taco.o; // Keep o reference (not deep-cloned; hooks are functions)
2142
+ return result;
2143
+ };
2144
+
2145
+ /**
2146
+ * Create a copy of TACO suitable for createDOM (strips o to prevent double lifecycle).
2147
+ * @private
2148
+ */
2149
+ ComponentHandle.prototype._tacoForDOM = function(taco) {
2150
+ if (!taco || typeof taco !== 'object' || !taco.t) return taco;
2151
+ var result = { t: taco.t };
2152
+ if (taco.a) result.a = taco.a;
2153
+ if (taco.c != null) {
2154
+ if (Array.isArray(taco.c)) {
2155
+ result.c = taco.c.map(function(child) { return this._tacoForDOM(child); }.bind(this));
2156
+ } else if (typeof taco.c === 'object' && taco.c.t) {
2157
+ result.c = this._tacoForDOM(taco.c);
2158
+ } else {
2159
+ result.c = taco.c;
2160
+ }
2161
+ }
2162
+ // Intentionally strip o (no mounted/unmount/state/render on sub-elements)
2163
+ return result;
2164
+ };
2165
+
2166
+ /**
2167
+ * Unmount: remove from DOM, deactivate, preserve state for re-mount.
2168
+ */
2169
+ ComponentHandle.prototype.unmount = function() {
2170
+ if (!this.mounted) return;
2171
+
2172
+ // unmount hook
2173
+ if (this._hooks.unmount) {
2174
+ this._hooks.unmount(this);
2175
+ }
2176
+
2177
+ // Remove DOM event listeners
2178
+ for (var i = 0; i < this._eventListeners.length; i++) {
2179
+ var l = this._eventListeners[i];
2180
+ if (this.element) {
2181
+ this.element.removeEventListener(l.event, l.handler);
2182
+ }
2183
+ }
2184
+ this._eventListeners = [];
2185
+
2186
+ // Unsubscribe pub/sub
2187
+ for (var j = 0; j < this._subs.length; j++) {
2188
+ this._subs[j]();
2189
+ }
2190
+ this._subs = [];
2191
+
2192
+ // Remove from DOM
2193
+ if (this.element && this.element.parentNode) {
2194
+ this.element.parentNode.removeChild(this.element);
2195
+ }
2196
+
2197
+ this.mounted = false;
2198
+ // State preserved — can re-mount
2199
+ };
2200
+
2201
+ /**
2202
+ * Destroy: unmount + clear state + unregister actions.
2203
+ */
2204
+ ComponentHandle.prototype.destroy = function() {
2205
+ // willDestroy hook
2206
+ if (this._hooks.willDestroy) {
2207
+ this._hooks.willDestroy(this);
2208
+ }
2209
+
2210
+ this.unmount();
2211
+
2212
+ // Unregister actions from function registry
2213
+ for (var i = 0; i < this._registeredActions.length; i++) {
2214
+ bw.funcUnregister(this._registeredActions[i]);
2215
+ }
2216
+ this._registeredActions = [];
2217
+
2218
+ // Clear state
2219
+ this._state = {};
2220
+ this._bindings = [];
2221
+ this._bw_refs = {};
2222
+ this._prevValues = {};
2223
+ this._dirtyKeys = {};
2224
+ if (this.element) {
2225
+ delete this.element._bwComponentHandle;
2226
+ this.element = null;
2227
+ }
2228
+ };
2229
+
2230
+ // ── Flush & Binding Resolution ──
2231
+
2232
+ /**
2233
+ * Flush dirty state: resolve changed bindings and apply to DOM.
2234
+ * @private
2235
+ */
2236
+ ComponentHandle.prototype._flush = function() {
2237
+ this._scheduled = false;
2238
+ var changedKeys = Object.keys(this._dirtyKeys);
2239
+ this._dirtyKeys = {};
2240
+ if (changedKeys.length === 0 || !this.mounted) return;
2241
+
2242
+ // willUpdate hook
2243
+ if (this._hooks.willUpdate) {
2244
+ this._hooks.willUpdate(this, changedKeys);
2245
+ }
2246
+
2247
+ // Check if any structural bindings are affected
2248
+ var needsFullRender = false;
2249
+ for (var i = 0; i < this._bindings.length; i++) {
2250
+ var b = this._bindings[i];
2251
+ if (b.type === 'structural') {
2252
+ for (var j = 0; j < b.deps.length; j++) {
2253
+ if (changedKeys.indexOf(b.deps[j]) >= 0) {
2254
+ needsFullRender = true;
2255
+ break;
2256
+ }
2257
+ }
2258
+ if (needsFullRender) break;
2259
+ }
2260
+ }
2261
+
2262
+ if (needsFullRender) {
2263
+ this._render();
2264
+ } else {
2265
+ var patches = this._resolveBindings(changedKeys);
2266
+ this._applyPatches(patches);
2267
+ }
2268
+
2269
+ // onUpdate hook
2270
+ if (this._hooks.onUpdate) {
2271
+ this._hooks.onUpdate(this, changedKeys);
2272
+ }
2273
+ };
2274
+
2275
+ /**
2276
+ * Resolve bindings whose deps intersect with changedKeys.
2277
+ * Returns list of patches to apply.
2278
+ * @private
2279
+ */
2280
+ ComponentHandle.prototype._resolveBindings = function(changedKeys) {
2281
+ var patches = [];
2282
+ for (var i = 0; i < this._bindings.length; i++) {
2283
+ var b = this._bindings[i];
2284
+ if (b.type === 'structural') continue;
2285
+
2286
+ // Check if any dep matches
2287
+ var affected = false;
2288
+ for (var j = 0; j < b.deps.length; j++) {
2289
+ if (changedKeys.indexOf(b.deps[j]) >= 0) {
2290
+ affected = true;
2291
+ break;
2292
+ }
2293
+ }
2294
+ if (!affected) continue;
2295
+
2296
+ // Evaluate
2297
+ var newVal = bw._resolveTemplate(b.template, this._state, this._compile);
2298
+ var prevKey = b.refId + '_' + (b.attrName || 'content');
2299
+ if (this._prevValues[prevKey] !== newVal) {
2300
+ this._prevValues[prevKey] = newVal;
2301
+ patches.push({
2302
+ refId: b.refId,
2303
+ type: b.type,
2304
+ attrName: b.attrName,
2305
+ value: newVal
2306
+ });
2307
+ }
2308
+ }
2309
+ return patches;
2310
+ };
2311
+
2312
+ /**
2313
+ * Apply patches to DOM.
2314
+ * @private
2315
+ */
2316
+ ComponentHandle.prototype._applyPatches = function(patches) {
2317
+ for (var i = 0; i < patches.length; i++) {
2318
+ var p = patches[i];
2319
+ var el = this._bw_refs[p.refId];
2320
+ if (!el) continue;
2321
+ if (p.type === 'content') {
2322
+ el.textContent = p.value;
2323
+ } else if (p.type === 'attribute') {
2324
+ if (p.attrName === 'class') {
2325
+ el.className = p.value;
2326
+ } else {
2327
+ el.setAttribute(p.attrName, p.value);
2328
+ }
2329
+ }
2330
+ }
2331
+ };
2332
+
2333
+ /**
2334
+ * Resolve all bindings and apply (used for initial render).
2335
+ * @private
2336
+ */
2337
+ ComponentHandle.prototype._resolveAndApplyAll = function() {
2338
+ var patches = [];
2339
+ for (var i = 0; i < this._bindings.length; i++) {
2340
+ var b = this._bindings[i];
2341
+ if (b.type === 'structural') continue;
2342
+
2343
+ var newVal = bw._resolveTemplate(b.template, this._state, this._compile);
2344
+ var prevKey = b.refId + '_' + (b.attrName || 'content');
2345
+ this._prevValues[prevKey] = newVal;
2346
+ patches.push({
2347
+ refId: b.refId,
2348
+ type: b.type,
2349
+ attrName: b.attrName,
2350
+ value: newVal
2351
+ });
2352
+ }
2353
+ this._applyPatches(patches);
2354
+ };
2355
+
2356
+ /**
2357
+ * Full re-render for structural changes (when/each branch switches).
2358
+ * @private
2359
+ */
2360
+ ComponentHandle.prototype._render = function() {
2361
+ if (!this.element || !this.element.parentNode) return;
2362
+ var parent = this.element.parentNode;
2363
+ var nextSibling = this.element.nextSibling;
2364
+
2365
+ // Remove old DOM
2366
+ parent.removeChild(this.element);
2367
+
2368
+ // Re-prepare TACO with current state (deep clone preserving functions)
2369
+ this.taco = this._deepCloneTaco(this._originalTaco || this.taco);
2370
+
2371
+ // Re-compile bindings and prepare
2372
+ this._compileBindings();
2373
+ this._prepareTaco(this.taco);
2374
+ this._wireActions(this.taco);
2375
+
2376
+ var tacoForDOM = this._tacoForDOM(this.taco);
2377
+ this.element = bw.createDOM(tacoForDOM);
2378
+ this.element._bwComponentHandle = this;
2379
+ this.element.setAttribute('data-bw_comp_id', this._bwId);
2380
+
2381
+ // Re-insert at same position
2382
+ if (nextSibling) {
2383
+ parent.insertBefore(this.element, nextSibling);
2384
+ } else {
2385
+ parent.appendChild(this.element);
2386
+ }
2387
+
2388
+ // Re-collect refs and apply all bindings
2389
+ this._collectRefs();
2390
+ this._resolveAndApplyAll();
2391
+ };
2392
+
2393
+ // ── Event & Pub/Sub Methods ──
2394
+
2395
+ /**
2396
+ * Add a DOM event listener on the component's root element.
2397
+ * @param {string} event - Event name (e.g., 'click')
2398
+ * @param {Function} handler - Event handler
2399
+ */
2400
+ ComponentHandle.prototype.on = function(event, handler) {
2401
+ if (this.element) {
2402
+ this.element.addEventListener(event, handler);
2403
+ }
2404
+ this._eventListeners.push({ event: event, handler: handler });
2405
+ };
2406
+
2407
+ /**
2408
+ * Remove a DOM event listener.
2409
+ * @param {string} event - Event name
2410
+ * @param {Function} handler - Handler to remove
2411
+ */
2412
+ ComponentHandle.prototype.off = function(event, handler) {
2413
+ if (this.element) {
2414
+ this.element.removeEventListener(event, handler);
2415
+ }
2416
+ this._eventListeners = this._eventListeners.filter(function(l) {
2417
+ return !(l.event === event && l.handler === handler);
2418
+ });
2419
+ };
2420
+
2421
+ /**
2422
+ * Subscribe to a pub/sub topic. Lifecycle-tied: auto-unsubs on destroy.
2423
+ * @param {string} topic - Topic name
2424
+ * @param {Function} handler - Handler function
2425
+ * @returns {Function} Unsubscribe function
2426
+ */
2427
+ ComponentHandle.prototype.sub = function(topic, handler) {
2428
+ var unsub = bw.sub(topic, handler);
2429
+ this._subs.push(unsub);
2430
+ return unsub;
2431
+ };
2432
+
2433
+ /**
2434
+ * Call a named action.
2435
+ * @param {string} name - Action name
2436
+ * @param {...*} args - Arguments passed after comp
2437
+ */
2438
+ ComponentHandle.prototype.action = function(name) {
2439
+ var fn = this._actions[name];
2440
+ if (!fn) {
2441
+ console.warn('ComponentHandle.action: unknown action "' + name + '"');
2442
+ return;
2443
+ }
2444
+ var args = [this].concat(Array.prototype.slice.call(arguments, 1));
2445
+ return fn.apply(null, args);
2446
+ };
2447
+
2448
+ /**
2449
+ * querySelector within the component's DOM.
2450
+ * @param {string} sel - CSS selector
2451
+ * @returns {Element|null}
2452
+ */
2453
+ ComponentHandle.prototype.select = function(sel) {
2454
+ return this.element ? this.element.querySelector(sel) : null;
2455
+ };
2456
+
2457
+ /**
2458
+ * querySelectorAll within the component's DOM.
2459
+ * @param {string} sel - CSS selector
2460
+ * @returns {Element[]}
2461
+ */
2462
+ ComponentHandle.prototype.selectAll = function(sel) {
2463
+ if (!this.element) return [];
2464
+ return Array.prototype.slice.call(this.element.querySelectorAll(sel));
2465
+ };
2466
+
2467
+ /**
2468
+ * Tag this component with a user-defined ID for addressing via bw.message().
2469
+ * The tag is added as a CSS class on the root element (DOM IS the registry).
2470
+ * @param {string} tag - User-defined identifier (e.g. 'dashboard_prod_east')
2471
+ * @returns {ComponentHandle} this (for chaining)
2472
+ */
2473
+ ComponentHandle.prototype.userTag = function(tag) {
2474
+ this._userTag = tag;
2475
+ if (this.element) {
2476
+ this.element.classList.add(tag);
2477
+ }
2478
+ return this;
2479
+ };
2480
+
2481
+ // Expose ComponentHandle on bw (for testing and advanced use)
2482
+ bw._ComponentHandle = ComponentHandle;
2483
+
2484
+ // ===================================================================================
2485
+ // Control Flow Helpers
2486
+ // ===================================================================================
2487
+
2488
+ /**
2489
+ * Conditional rendering helper.
2490
+ * Returns a marker object that ComponentHandle detects during binding compilation.
2491
+ * In static contexts (bw.html with state), evaluates immediately.
2492
+ *
2493
+ * @param {string} expr - Expression string like '${loggedIn}'
2494
+ * @param {Object} tacoTrue - TACO to render when truthy
2495
+ * @param {Object} [tacoFalse] - TACO to render when falsy
2496
+ * @returns {Object} Marker object with _bwWhen flag
2497
+ * @category Component
2498
+ */
2499
+ bw.when = function(expr, tacoTrue, tacoFalse) {
2500
+ return { _bwWhen: true, expr: expr, branches: [tacoTrue, tacoFalse || null] };
2501
+ };
2502
+
2503
+ /**
2504
+ * List rendering helper.
2505
+ * Returns a marker object that ComponentHandle detects during binding compilation.
2506
+ *
2507
+ * @param {string} expr - Expression string like '${items}'
2508
+ * @param {Function} fn - Factory function(item, index) returning TACO
2509
+ * @returns {Object} Marker object with _bwEach flag
2510
+ * @category Component
2511
+ */
2512
+ bw.each = function(expr, fn) {
2513
+ return { _bwEach: true, expr: expr, factory: fn };
2514
+ };
2515
+
2516
+ // ===================================================================================
2517
+ // bw.component() — Factory for ComponentHandle
2518
+ // ===================================================================================
2519
+
2520
+ /**
2521
+ * Create a ComponentHandle from a TACO definition.
2522
+ * The returned handle has .get(), .set(), .mount(), .destroy(), etc.
2523
+ *
2524
+ * @param {Object} taco - TACO definition with {t, a, c, o}
2525
+ * @returns {ComponentHandle} Reactive component handle
2526
+ * @category Component
2527
+ * @see bw.DOM
2528
+ * @example
2529
+ * var counter = bw.component({
2530
+ * t: 'div', c: [{ t: 'h3', c: 'Count: ${count}' }],
2531
+ * o: { state: { count: 0 } }
2532
+ * });
2533
+ * bw.DOM('#app', counter);
2534
+ * counter.set('count', 42); // DOM auto-updates
2535
+ */
2536
+ bw.component = function(taco) {
2537
+ return new ComponentHandle(taco);
2538
+ };
2539
+
2540
+ // ===================================================================================
2541
+ // bw.message() — SendMessage() for the web
2542
+ // ===================================================================================
2543
+
2544
+ /**
2545
+ * Dispatch a message to a component by UUID or user tag.
2546
+ * Finds the component's DOM element, looks up its ComponentHandle,
2547
+ * and calls the named method. This is the bitwrench equivalent of
2548
+ * Win32 SendMessage(hwnd, msg, wParam, lParam).
2549
+ *
2550
+ * @param {string} target - Component UUID (data-bw_comp_id) or user tag (CSS class)
2551
+ * @param {string} action - Method name to call on the component
2552
+ * @param {*} data - Data to pass to the method
2553
+ * @returns {boolean} True if message was dispatched successfully
2554
+ * @category Component
2555
+ * @example
2556
+ * // Tag a component
2557
+ * myDash.userTag('dashboard_prod');
2558
+ * // Dispatch locally
2559
+ * bw.message('dashboard_prod', 'addAlert', { severity: 'warning', text: 'CPU spike' });
2560
+ * // Or from SSE handler:
2561
+ * es.onmessage = function(e) {
2562
+ * var msg = JSON.parse(e.data);
2563
+ * bw.message(msg.target, msg.action, msg.data);
2564
+ * };
2565
+ */
2566
+ bw.message = function(target, action, data) {
2567
+ // Try data-bw_comp_id attribute first, then CSS class (user tag)
2568
+ var el = bw.$('[data-bw_comp_id="' + target + '"]')[0];
2569
+ if (!el) {
2570
+ el = bw.$('.' + target)[0];
2571
+ }
2572
+ if (!el || !el._bwComponentHandle) return false;
2573
+ var comp = el._bwComponentHandle;
2574
+ if (typeof comp[action] !== 'function') {
2575
+ console.warn('bw.message: unknown action "' + action + '" on component ' + target);
2576
+ return false;
2577
+ }
2578
+ comp[action](data);
2579
+ return true;
2580
+ };
2581
+
2582
+ // ===================================================================================
2583
+ // bw.clientApply() / bw.clientConnect() — Server-driven UI protocol
2584
+ // ===================================================================================
2585
+
2586
+ /**
2587
+ * Registry of named functions sent via register messages.
2588
+ * Populated by clientApply({ type: 'register', name, body }).
2589
+ * Invoked by clientApply({ type: 'call', name, args }).
2590
+ * @private
2591
+ */
2592
+ bw._clientFunctions = {};
2593
+
2594
+ /**
2595
+ * Whether exec messages are allowed. Set by clientConnect opts.allowExec.
2596
+ * Default false — exec messages are rejected unless explicitly opted in.
2597
+ * @private
2598
+ */
2599
+ bw._allowExec = false;
2600
+
2601
+ /**
2602
+ * Built-in client functions available via call() without registration.
2603
+ * @private
2604
+ */
2605
+ bw._builtinClientFunctions = {
2606
+ scrollTo: function(selector) {
2607
+ var el = bw._el(selector);
2608
+ if (el) el.scrollTop = el.scrollHeight;
2609
+ },
2610
+ focus: function(selector) {
2611
+ var el = bw._el(selector);
2612
+ if (el && typeof el.focus === 'function') el.focus();
2613
+ },
2614
+ download: function(filename, content, mimeType) {
2615
+ if (typeof document === 'undefined') return;
2616
+ var blob = new Blob([content], { type: mimeType || 'text/plain' });
2617
+ var a = document.createElement('a');
2618
+ a.href = URL.createObjectURL(blob);
2619
+ a.download = filename;
2620
+ a.click();
2621
+ URL.revokeObjectURL(a.href);
2622
+ },
2623
+ clipboard: function(text) {
2624
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
2625
+ navigator.clipboard.writeText(text);
2626
+ }
2627
+ },
2628
+ redirect: function(url) {
2629
+ if (typeof window !== 'undefined') window.location.href = url;
2630
+ },
2631
+ log: function() {
2632
+ console.log.apply(console, arguments);
2633
+ }
2634
+ };
2635
+
2636
+ /**
2637
+ * Parse a bwserve protocol message string, supporting both strict JSON
2638
+ * and r-prefixed relaxed JSON (single-quoted strings, trailing commas).
2639
+ *
2640
+ * The r-prefix format is designed for C/C++ string literals where
2641
+ * double-quote escaping is painful. The parser is a state machine
2642
+ * that walks character by character — not a regex replace.
2643
+ *
2644
+ * Escaping: apostrophes inside single-quoted values must be escaped
2645
+ * with backslash: r{'name':'Barry\'s room'}
2646
+ *
2647
+ * @param {string} str - JSON or r-prefixed relaxed JSON string
2648
+ * @returns {Object} Parsed message object
2649
+ * @throws {SyntaxError} If the string is not valid JSON or relaxed JSON
2650
+ * @category Server
2651
+ */
2652
+ bw.clientParse = function(str) {
2653
+ str = (str || '').trim();
2654
+ if (str.charAt(0) !== 'r') return JSON.parse(str);
2655
+ str = str.slice(1);
2656
+
2657
+ var out = [];
2658
+ var i = 0;
2659
+ var len = str.length;
2660
+
2661
+ while (i < len) {
2662
+ var ch = str[i];
2663
+
2664
+ if (ch === "'") {
2665
+ // Single-quoted string → emit as double-quoted
2666
+ out.push('"');
2667
+ i++;
2668
+ while (i < len) {
2669
+ var c = str[i];
2670
+ if (c === '\\' && i + 1 < len) {
2671
+ var next = str[i + 1];
2672
+ if (next === "'") {
2673
+ out.push("'"); // \' in input → ' in output
2674
+ } else {
2675
+ out.push('\\');
2676
+ out.push(next);
2677
+ }
2678
+ i += 2;
2679
+ } else if (c === '"') {
2680
+ out.push('\\"');
2681
+ i++;
2682
+ } else if (c === "'") {
2683
+ break;
2684
+ } else {
2685
+ out.push(c);
2686
+ i++;
2687
+ }
2688
+ }
2689
+ out.push('"');
2690
+ i++; // skip closing '
2691
+
2692
+ } else if (ch === '"') {
2693
+ // Double-quoted string — pass through verbatim
2694
+ out.push(ch);
2695
+ i++;
2696
+ while (i < len) {
2697
+ var c2 = str[i];
2698
+ if (c2 === '\\' && i + 1 < len) {
2699
+ out.push(c2);
2700
+ out.push(str[i + 1]);
2701
+ i += 2;
2702
+ } else {
2703
+ out.push(c2);
2704
+ i++;
2705
+ if (c2 === '"') break;
2706
+ }
2707
+ }
2708
+
2709
+ } else if (ch === ',') {
2710
+ // Trailing comma check: skip comma if next non-whitespace is } or ]
2711
+ var j = i + 1;
2712
+ while (j < len && (str[j] === ' ' || str[j] === '\t' || str[j] === '\n' || str[j] === '\r')) j++;
2713
+ if (j < len && (str[j] === '}' || str[j] === ']')) {
2714
+ i++; // skip trailing comma
2715
+ } else {
2716
+ out.push(ch);
2717
+ i++;
2718
+ }
2719
+
2720
+ } else {
2721
+ out.push(ch);
2722
+ i++;
2723
+ }
2724
+ }
2725
+
2726
+ return JSON.parse(out.join(''));
2727
+ };
2728
+
2729
+ /**
2730
+ * Apply a bwserve protocol message to the DOM.
2731
+ *
2732
+ * Dispatches one of 9 message types:
2733
+ * replace — bw.DOM(target, node)
2734
+ * append — target.appendChild(bw.createDOM(node))
2735
+ * remove — bw.cleanup(target); target.remove()
2736
+ * patch — bw.patch(target, content, attr)
2737
+ * batch — iterate ops, call clientApply for each
2738
+ * message — bw.message(target, action, data)
2739
+ * register — store a named function for later call()
2740
+ * call — invoke a registered or built-in function
2741
+ * exec — execute arbitrary JS (requires allowExec)
2742
+ *
2743
+ * Target resolution:
2744
+ * Starts with '#' or '.' → CSS selector (querySelector)
2745
+ * Otherwise → getElementById, then bw._el fallback
2746
+ *
2747
+ * @param {Object} msg - Protocol message
2748
+ * @returns {boolean} true if the message was applied successfully
2749
+ * @category Server
2750
+ */
2751
+ bw.clientApply = function(msg) {
2752
+ if (!msg || !msg.type) return false;
2753
+
2754
+ var type = msg.type;
2755
+ var target = msg.target;
2756
+
2757
+ if (type === 'replace') {
2758
+ var el = bw._el(target);
2759
+ if (!el) return false;
2760
+ bw.DOM(el, msg.node);
2761
+ return true;
2762
+
2763
+ } else if (type === 'patch') {
2764
+ var patched = bw.patch(target, msg.content, msg.attr);
2765
+ return patched !== null;
2766
+
2767
+ } else if (type === 'append') {
2768
+ var parent = bw._el(target);
2769
+ if (!parent) return false;
2770
+ var child = bw.createDOM(msg.node);
2771
+ parent.appendChild(child);
2772
+ return true;
2773
+
2774
+ } else if (type === 'remove') {
2775
+ var toRemove = bw._el(target);
2776
+ if (!toRemove) return false;
2777
+ if (typeof bw.cleanup === 'function') bw.cleanup(toRemove);
2778
+ toRemove.remove();
2779
+ return true;
2780
+
2781
+ } else if (type === 'batch') {
2782
+ if (!Array.isArray(msg.ops)) return false;
2783
+ var allOk = true;
2784
+ msg.ops.forEach(function(op) {
2785
+ if (!bw.clientApply(op)) allOk = false;
2786
+ });
2787
+ return allOk;
2788
+
2789
+ } else if (type === 'message') {
2790
+ return bw.message(msg.target, msg.action, msg.data);
2791
+
2792
+ } else if (type === 'register') {
2793
+ if (!msg.name || !msg.body) return false;
2794
+ try {
2795
+ bw._clientFunctions[msg.name] = new Function('return ' + msg.body)();
2796
+ return true;
2797
+ } catch (e) {
2798
+ console.error('[bw] register error:', msg.name, e);
2799
+ return false;
2800
+ }
2801
+
2802
+ } else if (type === 'call') {
2803
+ if (!msg.name) return false;
2804
+ var fn = bw._clientFunctions[msg.name] || bw._builtinClientFunctions[msg.name];
2805
+ if (typeof fn !== 'function') return false;
2806
+ try {
2807
+ var args = Array.isArray(msg.args) ? msg.args : [];
2808
+ fn.apply(null, args);
2809
+ return true;
2810
+ } catch (e) {
2811
+ console.error('[bw] call error:', msg.name, e);
2812
+ return false;
2813
+ }
2814
+
2815
+ } else if (type === 'exec') {
2816
+ if (!bw._allowExec) {
2817
+ console.warn('[bw] exec rejected: allowExec is not enabled');
2818
+ return false;
2819
+ }
2820
+ if (!msg.code) return false;
2821
+ try {
2822
+ new Function(msg.code)();
2823
+ return true;
2824
+ } catch (e) {
2825
+ console.error('[bw] exec error:', e);
2826
+ return false;
2827
+ }
2828
+ }
2829
+
2830
+ return false;
2831
+ };
2832
+
2833
+ /**
2834
+ * Connect to a bwserve SSE endpoint and apply protocol messages automatically.
2835
+ *
2836
+ * Returns a connection object with sendAction(), on(), and close() methods.
2837
+ *
2838
+ * @param {string} url - SSE endpoint URL (e.g., '/__bw/events/client-1')
2839
+ * @param {Object} [opts] - Connection options
2840
+ * @param {string} [opts.transport='sse'] - Transport type: 'sse' (default) or 'poll'
2841
+ * @param {number} [opts.interval=2000] - Poll interval in ms (only for 'poll' transport)
2842
+ * @param {string} [opts.actionUrl] - POST endpoint for actions (default: derived from url)
2843
+ * @param {boolean} [opts.reconnect=true] - Auto-reconnect on disconnect
2844
+ * @param {boolean} [opts.allowExec=false] - Enable exec message type (arbitrary JS execution)
2845
+ * @param {Function} [opts.onStatus] - Status callback: 'connecting'|'connected'|'disconnected'
2846
+ * @param {Function} [opts.onMessage] - Raw message callback (before clientApply)
2847
+ * @returns {Object} Connection object { sendAction, on, close, status }
2848
+ * @category Server
2849
+ */
2850
+ bw.clientConnect = function(url, opts) {
2851
+ opts = opts || {};
2852
+ var transport = opts.transport || 'sse';
2853
+ var actionUrl = opts.actionUrl || url.replace(/\/events\//, '/action/');
2854
+ var reconnect = opts.reconnect !== false;
2855
+ var onStatus = opts.onStatus || function() {};
2856
+ var onMessage = opts.onMessage || null;
2857
+ var handlers = {};
2858
+ // Set the global allowExec flag from connection options
2859
+ bw._allowExec = !!opts.allowExec;
2860
+ var conn = {
2861
+ status: 'connecting',
2862
+ _es: null,
2863
+ _pollTimer: null
2864
+ };
2865
+
2866
+ function setStatus(s) {
2867
+ conn.status = s;
2868
+ onStatus(s);
2869
+ }
2870
+
2871
+ function handleMessage(data) {
2872
+ try {
2873
+ var msg = typeof data === 'string' ? bw.clientParse(data) : data;
2874
+ if (onMessage) onMessage(msg);
2875
+ if (handlers.message) handlers.message(msg);
2876
+ bw.clientApply(msg);
2877
+ } catch (e) {
2878
+ if (handlers.error) handlers.error(e);
2879
+ }
2880
+ }
2881
+
2882
+ if (transport === 'sse' && typeof EventSource !== 'undefined') {
2883
+ setStatus('connecting');
2884
+ var es = new EventSource(url);
2885
+ conn._es = es;
2886
+
2887
+ es.onopen = function() {
2888
+ setStatus('connected');
2889
+ if (handlers.open) handlers.open();
2890
+ };
2891
+
2892
+ es.onmessage = function(e) {
2893
+ handleMessage(e.data);
2894
+ };
2895
+
2896
+ es.onerror = function() {
2897
+ if (conn.status === 'connected') {
2898
+ setStatus('disconnected');
2899
+ }
2900
+ if (handlers.error) handlers.error(new Error('SSE connection error'));
2901
+ if (!reconnect) {
2902
+ es.close();
2903
+ }
2904
+ // EventSource auto-reconnects by default when reconnect=true
2905
+ };
2906
+ } else if (transport === 'poll') {
2907
+ var interval = opts.interval || 2000;
2908
+ setStatus('connected');
2909
+ conn._pollTimer = setInterval(function() {
2910
+ fetch(url).then(function(r) { return r.json(); }).then(function(msgs) {
2911
+ if (Array.isArray(msgs)) {
2912
+ msgs.forEach(handleMessage);
2913
+ } else if (msgs && msgs.type) {
2914
+ handleMessage(msgs);
2915
+ }
2916
+ }).catch(function(e) {
2917
+ if (handlers.error) handlers.error(e);
2918
+ });
2919
+ }, interval);
2920
+ }
2921
+
2922
+ /**
2923
+ * Send an action to the server via POST.
2924
+ * @param {string} action - Action name
2925
+ * @param {Object} [data] - Action payload
2926
+ */
2927
+ conn.sendAction = function(action, data) {
2928
+ var body = JSON.stringify({ type: 'action', action: action, data: data || {} });
2929
+ fetch(actionUrl, {
2930
+ method: 'POST',
2931
+ headers: { 'Content-Type': 'application/json' },
2932
+ body: body
2933
+ }).catch(function(e) {
2934
+ if (handlers.error) handlers.error(e);
2935
+ });
2936
+ };
2937
+
2938
+ /**
2939
+ * Register an event handler.
2940
+ * @param {string} event - 'open'|'message'|'error'|'close'
2941
+ * @param {Function} handler
2942
+ */
2943
+ conn.on = function(event, handler) {
2944
+ handlers[event] = handler;
2945
+ return conn;
2946
+ };
2947
+
2948
+ /**
2949
+ * Close the connection.
2950
+ */
2951
+ conn.close = function() {
2952
+ if (conn._es) {
2953
+ conn._es.close();
2954
+ conn._es = null;
2955
+ }
2956
+ if (conn._pollTimer) {
2957
+ clearInterval(conn._pollTimer);
2958
+ conn._pollTimer = null;
2959
+ }
2960
+ setStatus('disconnected');
2961
+ if (handlers.close) handlers.close();
2962
+ };
2963
+
2964
+ return conn;
2965
+ };
2966
+
2967
+ // ===================================================================================
2968
+ // bw.inspect() — Debug utility
2969
+ // ===================================================================================
2970
+
2971
+ /**
2972
+ * Inspect a component's state, bindings, methods, and metadata.
2973
+ * Works with DOM elements, CSS selectors, or ComponentHandle objects.
2974
+ * Returns the ComponentHandle for console chaining.
2975
+ *
2976
+ * @param {string|Element|ComponentHandle} target - Selector, element, or handle
2977
+ * @returns {ComponentHandle|null} The component handle, or null if not found
2978
+ * @category Component
2979
+ * @example
2980
+ * // In browser console, click element in Elements panel then:
2981
+ * bw.inspect($0);
2982
+ * // Or by selector:
2983
+ * var h = bw.inspect('#my-dashboard');
2984
+ * h.set('count', 99); // chain from returned handle
2985
+ */
2986
+ bw.inspect = function(target) {
2987
+ var el = target;
2988
+ var comp;
2989
+ if (target && target._bwComponent === true) {
2990
+ el = target.element;
2991
+ comp = target;
2992
+ } else {
2993
+ if (typeof target === 'string') {
2994
+ el = bw.$(target)[0];
2995
+ }
2996
+ if (!el) {
2997
+ console.warn('bw.inspect: element not found');
2998
+ return null;
2999
+ }
3000
+ comp = el._bwComponentHandle;
3001
+ }
3002
+ if (!comp) {
3003
+ console.log('bw.inspect: no ComponentHandle on this element');
3004
+ console.log(' Tag:', el.tagName);
3005
+ console.log(' Classes:', el.className);
3006
+ console.log(' _bw_state:', el._bw_state || '(none)');
3007
+ return null;
3008
+ }
3009
+ var deps = comp._bindings.reduce(function(s, b) {
3010
+ return s.concat(b.deps || []);
3011
+ }, []).filter(function(v, i, a) { return a.indexOf(v) === i; });
3012
+ console.group('Component: ' + comp._bwId);
3013
+ console.log('State:', comp._state);
3014
+ console.log('Bindings:', comp._bindings.length, '(deps:', deps, ')');
3015
+ console.log('Methods:', Object.keys(comp._methods));
3016
+ console.log('Actions:', Object.keys(comp._actions));
3017
+ console.log('User tag:', comp._userTag || '(none)');
3018
+ console.log('Mounted:', comp.mounted);
3019
+ console.log('Element:', comp.element);
3020
+ console.groupEnd();
3021
+ return comp;
3022
+ };
3023
+
3024
+ // ===================================================================================
3025
+ // bw.compile() — Pre-compile TACO into optimized factory
3026
+ // ===================================================================================
3027
+
3028
+ /**
3029
+ * Pre-compile a TACO definition into a factory function.
3030
+ * The factory produces ComponentHandles with pre-compiled binding evaluators.
3031
+ *
3032
+ * Phase 1: validates API surface. Template cloning optimization deferred.
3033
+ *
3034
+ * @param {Object} taco - TACO definition
3035
+ * @returns {Function} Factory function(initialState?) → ComponentHandle
3036
+ * @category Component
3037
+ */
3038
+ bw.compile = function(taco) {
3039
+ // Pre-extract all binding expressions
3040
+ var precompiled = [];
3041
+ function walkExpressions(node) {
3042
+ if (!node || typeof node !== 'object') return;
3043
+ if (typeof node.c === 'string' && node.c.indexOf('${') >= 0) {
3044
+ var parsed = bw._parseBindings(node.c);
3045
+ for (var i = 0; i < parsed.length; i++) {
3046
+ try {
3047
+ precompiled.push({
3048
+ expr: parsed[i].expr,
3049
+ fn: new Function('state', 'with(state){return (' + parsed[i].expr + ');}')
3050
+ });
3051
+ } catch(e) {
3052
+ precompiled.push({ expr: parsed[i].expr, fn: function() { return ''; } });
3053
+ }
3054
+ }
3055
+ }
3056
+ if (node.a) {
3057
+ for (var key in node.a) {
3058
+ if (Object.prototype.hasOwnProperty.call(node.a, key)) {
3059
+ var v = node.a[key];
3060
+ if (typeof v === 'string' && v.indexOf('${') >= 0) {
3061
+ var parsed2 = bw._parseBindings(v);
3062
+ for (var j = 0; j < parsed2.length; j++) {
3063
+ try {
3064
+ precompiled.push({
3065
+ expr: parsed2[j].expr,
3066
+ fn: new Function('state', 'with(state){return (' + parsed2[j].expr + ');}')
3067
+ });
3068
+ } catch(e2) {
3069
+ precompiled.push({ expr: parsed2[j].expr, fn: function() { return ''; } });
3070
+ }
3071
+ }
3072
+ }
3073
+ }
3074
+ }
3075
+ }
3076
+ if (Array.isArray(node.c)) {
3077
+ for (var k = 0; k < node.c.length; k++) walkExpressions(node.c[k]);
3078
+ } else if (node.c && typeof node.c === 'object' && node.c.t) {
3079
+ walkExpressions(node.c);
3080
+ }
3081
+ }
3082
+ walkExpressions(taco);
3083
+
3084
+ return function(initialState) {
3085
+ var handle = new ComponentHandle(taco);
3086
+ handle._compile = true;
3087
+ handle._precompiledBindings = precompiled;
3088
+ if (initialState) {
3089
+ for (var k in initialState) {
3090
+ if (Object.prototype.hasOwnProperty.call(initialState, k)) {
3091
+ handle._state[k] = initialState[k];
3092
+ }
3093
+ }
3094
+ }
3095
+ return handle;
3096
+ };
3097
+ };
3098
+
3099
+ /**
3100
+ * Generate CSS from JavaScript objects.
3101
+ *
3102
+ * Converts an object of `{ selector: { prop: value } }` rules into a CSS string.
3103
+ * CamelCase property names are auto-converted to kebab-case (e.g. `fontSize` → `font-size`).
3104
+ * Accepts nested arrays of rule objects.
3105
+ *
3106
+ * @param {Object|Array|string} rules - CSS rules as JS objects, array of rule objects, or raw CSS string
3107
+ * @param {Object} [options] - Generation options
3108
+ * @param {boolean} [options.minify=false] - Minify output (no whitespace)
3109
+ * @returns {string} CSS string
3110
+ * @category CSS & Styling
3111
+ * @see bw.injectCSS
3112
+ * @example
3113
+ * bw.css({
3114
+ * '.card': { padding: '1rem', fontSize: '14px', borderRadius: '8px' }
3115
+ * })
3116
+ * // => '.card {\n padding: 1rem;\n font-size: 14px;\n border-radius: 8px;\n}'
3117
+ */
3118
+ bw.css = function(rules, options = {}) {
3119
+ const { minify = false, pretty = !minify } = options;
3120
+
3121
+ if (typeof rules === 'string') return rules;
3122
+
3123
+ let css = '';
3124
+ const indent = pretty ? ' ' : '';
3125
+ const newline = pretty ? '\n' : '';
3126
+ const space = pretty ? ' ' : '';
3127
+
3128
+ if (Array.isArray(rules)) {
3129
+ css = rules.map(rule => bw.css(rule, options)).join(newline);
3130
+ } else if (typeof rules === 'object') {
3131
+ Object.entries(rules).forEach(([selector, styles]) => {
3132
+ if (typeof styles === 'object' && !Array.isArray(styles)) {
3133
+ // Handle @media, @keyframes, @supports — recurse into nested block
3134
+ if (selector.charAt(0) === '@') {
3135
+ const inner = bw.css(styles, options);
3136
+ if (inner) {
3137
+ css += `${selector}${space}{${newline}${inner}${newline}}${newline}`;
3138
+ }
3139
+ return;
3140
+ }
3141
+ const declarations = Object.entries(styles)
3142
+ .filter(([, value]) => value != null)
3143
+ .map(([prop, value]) => {
3144
+ // Convert camelCase to kebab-case
3145
+ const kebabProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
3146
+ return `${indent}${kebabProp}:${space}${value};`;
3147
+ })
3148
+ .join(newline);
3149
+
3150
+ if (declarations) {
3151
+ css += `${selector}${space}{${newline}${declarations}${newline}}${newline}`;
3152
+ }
3153
+ }
3154
+ });
3155
+ }
3156
+
3157
+ return css.trim();
3158
+ };
3159
+
3160
+ /**
3161
+ * Inject CSS into the document head (browser only).
3162
+ *
3163
+ * Creates or reuses a `<style>` element (identified by `id`). Can accept
3164
+ * raw CSS strings or JS rule objects (which are converted via `bw.css()`).
3165
+ * By default appends to existing content; set `append: false` to replace.
3166
+ *
3167
+ * @param {string|Object|Array} css - CSS string, or JS rule objects to convert
3168
+ * @param {Object} [options] - Injection options
3169
+ * @param {string} [options.id='bw_styles'] - ID for the style element
3170
+ * @param {boolean} [options.append=true] - Append to existing CSS (false to replace)
3171
+ * @returns {Element} The style element
3172
+ * @category CSS & Styling
3173
+ * @see bw.css
3174
+ * @see bw.loadDefaultStyles
3175
+ * @example
3176
+ * bw.injectCSS('.my-class { color: red; }');
3177
+ * bw.injectCSS({ '.card': { padding: '1rem' } }, { id: 'card-styles' });
3178
+ */
3179
+ bw.injectCSS = function(css, options = {}) {
3180
+ if (!bw._isBrowser) {
3181
+ console.warn('bw.injectCSS requires a DOM environment');
3182
+ return null;
3183
+ }
3184
+
3185
+ const { id = 'bw_styles', append = true } = options;
3186
+
3187
+ // Get or create style element
3188
+ let styleEl = document.getElementById(id);
3189
+
3190
+ if (!styleEl) {
3191
+ styleEl = document.createElement('style');
3192
+ styleEl.id = id;
3193
+ styleEl.type = 'text/css';
3194
+ document.head.appendChild(styleEl);
3195
+ }
3196
+
3197
+ // Convert CSS if needed
3198
+ const cssStr = typeof css === 'string' ? css : bw.css(css, options);
3199
+
3200
+ // Set or append CSS
3201
+ if (append && styleEl.textContent) {
3202
+ styleEl.textContent += '\n' + cssStr;
3203
+ } else {
3204
+ styleEl.textContent = cssStr;
3205
+ }
3206
+
3207
+ return styleEl;
3208
+ };
3209
+
3210
+ /**
3211
+ * Merge multiple style objects into one (left-to-right).
3212
+ *
3213
+ * Like `Object.assign()` for styles, but filters out null/undefined arguments.
3214
+ * Compose inline styles or CSS rule objects without mutation.
3215
+ *
3216
+ * @param {...Object} styles - Style objects to merge (left-to-right)
3217
+ * @returns {Object} Merged style object
3218
+ * @category CSS & Styling
3219
+ * @see bw.u
3220
+ * @example
3221
+ * var style = bw.s(bw.u.flex, bw.u.gap4, { color: 'red' });
3222
+ * // => { display: 'flex', gap: '1rem', color: 'red' }
3223
+ */
3224
+ bw.s = function() {
3225
+ var result = {};
3226
+ for (var i = 0; i < arguments.length; i++) {
3227
+ var arg = arguments[i];
3228
+ if (arg && typeof arg === 'object') Object.assign(result, arg);
3229
+ }
3230
+ return result;
3231
+ };
3232
+
3233
+ /**
3234
+ * Pre-built CSS utility objects (like Tailwind utilities, but in JS).
3235
+ *
3236
+ * Compose with `bw.s()` to build inline styles without writing raw CSS strings.
3237
+ * Includes flex, padding, margin, typography, color, border, and transition utilities.
3238
+ *
3239
+ * @category CSS & Styling
3240
+ * @see bw.s
3241
+ * @example
3242
+ * { t: 'div', a: { style: bw.s(bw.u.flex, bw.u.gap4, bw.u.p4) },
3243
+ * c: 'Flexbox with 1rem gap and padding' }
3244
+ */
3245
+ bw.u = {
3246
+ // Display
3247
+ flex: { display: 'flex' },
3248
+ flexCol: { display: 'flex', flexDirection: 'column' },
3249
+ flexRow: { display: 'flex', flexDirection: 'row' },
3250
+ flexWrap: { display: 'flex', flexWrap: 'wrap' },
3251
+ block: { display: 'block' },
3252
+ inline: { display: 'inline' },
3253
+ hidden: { display: 'none' },
3254
+
3255
+ // Flex alignment
3256
+ justifyCenter: { justifyContent: 'center' },
3257
+ justifyBetween: { justifyContent: 'space-between' },
3258
+ justifyEnd: { justifyContent: 'flex-end' },
3259
+ alignCenter: { alignItems: 'center' },
3260
+ alignStart: { alignItems: 'flex-start' },
3261
+ alignEnd: { alignItems: 'flex-end' },
3262
+
3263
+ // Gap (0.25rem increments)
3264
+ gap1: { gap: '0.25rem' },
3265
+ gap2: { gap: '0.5rem' },
1566
3266
  gap3: { gap: '0.75rem' },
1567
3267
  gap4: { gap: '1rem' },
1568
3268
  gap6: { gap: '1.5rem' },
@@ -1682,29 +3382,7 @@ bw.responsive = function(selector, breakpoints) {
1682
3382
  * bw.mapScale(50, 0, 100, 0, 1) // => 0.5
1683
3383
  * bw.mapScale(75, 0, 100, 0, 255) // => 191.25
1684
3384
  */
1685
- bw.mapScale = function(x, in0, in1, out0, out1, options = {}) {
1686
- const { clip = false, expScale = 1 } = options;
1687
-
1688
- // Normalize to 0-1
1689
- let normalized = (x - in0) / (in1 - in0);
1690
-
1691
- // Apply exponential scaling
1692
- if (expScale !== 1) {
1693
- normalized = Math.pow(normalized, expScale);
1694
- }
1695
-
1696
- // Map to output range
1697
- let result = normalized * (out1 - out0) + out0;
1698
-
1699
- // Clip if requested
1700
- if (clip) {
1701
- const min = Math.min(out0, out1);
1702
- const max = Math.max(out0, out1);
1703
- result = Math.max(min, Math.min(max, result));
1704
- }
1705
-
1706
- return result;
1707
- };
3385
+ bw.mapScale = _mapScale;
1708
3386
 
1709
3387
  /**
1710
3388
  * Clamp a value between min and max bounds.
@@ -1720,9 +3398,7 @@ bw.mapScale = function(x, in0, in1, out0, out1, options = {}) {
1720
3398
  * bw.clip(-5, 0, 100) // => 0
1721
3399
  * bw.clip(50, 0, 100) // => 50
1722
3400
  */
1723
- bw.clip = function(value, min, max) {
1724
- return Math.max(min, Math.min(max, value));
1725
- };
3401
+ bw.clip = _clip;
1726
3402
 
1727
3403
  /**
1728
3404
  * DOM selection helper that always returns an array (browser only).
@@ -1791,7 +3467,7 @@ bw.loadDefaultStyles = function(options = {}) {
1791
3467
  // 1. Inject structural CSS (layout, sizing — never changes with theme)
1792
3468
  if (bw._isBrowser) {
1793
3469
  var structuralCSS = bw.css(getStructuralStyles());
1794
- bw.injectCSS(structuralCSS, { id: 'bw-structural', append: false, minify: minify });
3470
+ bw.injectCSS(structuralCSS, { id: 'bw_structural', append: false, minify: minify });
1795
3471
  }
1796
3472
 
1797
3473
  // 2. Inject cosmetic CSS via generateTheme (colors, shadows, radii)
@@ -1800,53 +3476,6 @@ bw.loadDefaultStyles = function(options = {}) {
1800
3476
  return result;
1801
3477
  };
1802
3478
 
1803
- /**
1804
- * Get the current theme configuration as a deep copy.
1805
- *
1806
- * @returns {Object} Theme object with colors, fonts, spacing, etc.
1807
- * @category CSS & Styling
1808
- * @see bw.setTheme
1809
- */
1810
- bw.getTheme = function() {
1811
- if (typeof console !== 'undefined' && console.warn) {
1812
- console.warn('bw.getTheme() is deprecated. Use bw.generateTheme() instead.');
1813
- }
1814
- return JSON.parse(JSON.stringify(theme));
1815
- };
1816
-
1817
- /**
1818
- * Set theme overrides and optionally re-inject CSS custom properties.
1819
- *
1820
- * Merges your overrides into the current theme and updates `--bw-*` CSS
1821
- * custom properties on `<html>` so all components pick up the changes live.
1822
- *
1823
- * @param {Object} overrides - Partial theme object to merge (e.g. { colors: { primary: '#ff0000' } })
1824
- * @param {Object} [options] - Options
1825
- * @param {boolean} [options.inject=true] - Whether to re-inject CSS (browser only)
1826
- * @returns {Object} Updated theme
1827
- * @category CSS & Styling
1828
- * @see bw.getTheme
1829
- * @see bw.loadDefaultStyles
1830
- * @example
1831
- * bw.setTheme({ colors: { primary: '#ff6600' } });
1832
- */
1833
- bw.setTheme = function(overrides, options = {}) {
1834
- if (typeof console !== 'undefined' && console.warn) {
1835
- console.warn('bw.setTheme() is deprecated. Use bw.generateTheme() instead.');
1836
- }
1837
- const { inject = true } = options;
1838
- updateTheme(overrides);
1839
-
1840
- // Update CSS custom properties if colors changed and we're in browser
1841
- if (inject && bw._isBrowser && overrides.colors) {
1842
- const root = document.documentElement;
1843
- for (const [name, value] of Object.entries(overrides.colors)) {
1844
- root.style.setProperty('--bw-' + name, value);
1845
- }
1846
- }
1847
-
1848
- return bw.getTheme();
1849
- };
1850
3479
 
1851
3480
  /**
1852
3481
  * Generate a complete, scoped theme from seed colors.
@@ -1867,6 +3496,8 @@ bw.setTheme = function(overrides, options = {}) {
1867
3496
  * @param {string} [config.info='#0dcaf0'] - Info color hex
1868
3497
  * @param {string} [config.light='#f8f9fa'] - Light color hex
1869
3498
  * @param {string} [config.dark='#212529'] - Dark color hex
3499
+ * @param {string} [config.background] - Page background hex (default: '#ffffff' light, derived dark)
3500
+ * @param {string} [config.surface] - Surface/card background hex (default: '#f8f9fa' light, derived dark)
1870
3501
  * @param {string} [config.spacing='normal'] - 'compact' | 'normal' | 'spacious'
1871
3502
  * @param {string} [config.radius='md'] - 'none' | 'sm' | 'md' | 'lg' | 'pill'
1872
3503
  * @param {number} [config.fontSize=1.0] - Base font size scale factor
@@ -1919,17 +3550,15 @@ bw.generateTheme = function(name, config) {
1919
3550
 
1920
3551
  // Generate primary themed CSS rules
1921
3552
  var themedRules = generateThemedCSS(name, palette, layout);
1922
- var aliasedRules = addUnderscoreAliases(themedRules);
1923
- var cssStr = bw.css(aliasedRules);
3553
+ var cssStr = bw.css(themedRules);
1924
3554
 
1925
3555
  // Derive alternate palette (luminance-inverted)
1926
3556
  var altConfig = deriveAlternateConfig(fullConfig);
1927
3557
  var altPalette = derivePalette(altConfig);
1928
3558
 
1929
- // Generate alternate CSS scoped under .bw-theme-alt
3559
+ // Generate alternate CSS scoped under .bw_theme_alt
1930
3560
  var altRules = generateAlternateCSS(name, altPalette, layout);
1931
- var aliasedAltRules = addUnderscoreAliases(altRules);
1932
- var altCssStr = bw.css(aliasedAltRules);
3561
+ var altCssStr = bw.css(altRules);
1933
3562
 
1934
3563
  // Determine if primary is light-flavored
1935
3564
  var lightPrimary = isLightPalette(fullConfig);
@@ -1937,11 +3566,14 @@ bw.generateTheme = function(name, config) {
1937
3566
  // Inject both CSS sets into DOM if requested
1938
3567
  var shouldInject = config.inject !== false;
1939
3568
  if (shouldInject && bw._isBrowser) {
1940
- var styleId = name ? 'bw-theme-' + name : 'bw-theme-default';
1941
- bw.injectCSS(cssStr, { id: styleId, append: false });
3569
+ var safeName = name ? name.replace(/-/g, '_') : '';
3570
+ var styleId = safeName ? 'bw_theme_' + safeName : 'bw_theme_default';
3571
+ var altStyleId = safeName ? 'bw_theme_' + safeName + '_alt' : 'bw_theme_default_alt';
1942
3572
 
1943
- var altStyleId = name ? 'bw-theme-' + name + '-alt' : 'bw-theme-default-alt';
3573
+ bw.injectCSS(cssStr, { id: styleId, append: false });
1944
3574
  bw.injectCSS(altCssStr, { id: altStyleId, append: false });
3575
+
3576
+ bw._activeThemeStyleIds = [styleId, altStyleId];
1945
3577
  }
1946
3578
 
1947
3579
  // Update bw.u color entries to reflect the palette
@@ -1971,7 +3603,7 @@ bw.generateTheme = function(name, config) {
1971
3603
 
1972
3604
  /**
1973
3605
  * Apply a theme mode. Switches between primary and alternate palettes
1974
- * by adding/removing the `bw-theme-alt` class on `<html>`.
3606
+ * by adding/removing the `bw_theme_alt` class on `<html>`.
1975
3607
  *
1976
3608
  * @param {string} mode - 'primary' | 'alternate' | 'light' | 'dark'
1977
3609
  * @returns {string} Active mode: 'primary' or 'alternate'
@@ -1996,9 +3628,9 @@ bw.applyTheme = function(mode) {
1996
3628
  else wantAlt = false;
1997
3629
 
1998
3630
  if (wantAlt) {
1999
- root.classList.add('bw-theme-alt');
3631
+ root.classList.add('bw_theme_alt');
2000
3632
  } else {
2001
- root.classList.remove('bw-theme-alt');
3633
+ root.classList.remove('bw_theme_alt');
2002
3634
  }
2003
3635
 
2004
3636
  bw._activeThemeMode = wantAlt ? 'alternate' : 'primary';
@@ -2020,6 +3652,29 @@ bw.toggleTheme = function() {
2020
3652
  return bw.applyTheme(current === 'primary' ? 'alternate' : 'primary');
2021
3653
  };
2022
3654
 
3655
+ /**
3656
+ * Remove the currently active theme's injected style elements from the DOM.
3657
+ * Use this before generating a new theme with a different name to prevent
3658
+ * stale CSS accumulation.
3659
+ *
3660
+ * @category CSS & Styling
3661
+ * @see bw.generateTheme
3662
+ * @example
3663
+ * bw.clearTheme(); // remove current theme styles
3664
+ * bw.generateTheme('sunset', conf); // inject fresh theme
3665
+ */
3666
+ bw.clearTheme = function() {
3667
+ if (bw._activeThemeStyleIds && bw._isBrowser) {
3668
+ bw._activeThemeStyleIds.forEach(function(id) {
3669
+ var el = document.getElementById(id);
3670
+ if (el) el.remove();
3671
+ });
3672
+ bw._activeThemeStyleIds = null;
3673
+ }
3674
+ bw._activeTheme = null;
3675
+ bw._activeThemeMode = 'primary';
3676
+ };
3677
+
2023
3678
  // Expose color utility functions on bw namespace
2024
3679
  bw.hexToHsl = hexToHsl;
2025
3680
  bw.hslToHex = hslToHex;
@@ -2036,303 +3691,37 @@ bw.isLightPalette = isLightPalette;
2036
3691
 
2037
3692
  // Expose layout and theme presets
2038
3693
  bw.SPACING_PRESETS = SPACING_PRESETS;
2039
- bw.RADIUS_PRESETS = RADIUS_PRESETS;
2040
- bw.TYPE_RATIO_PRESETS = TYPE_RATIO_PRESETS;
2041
- bw.ELEVATION_PRESETS = ELEVATION_PRESETS;
2042
- bw.MOTION_PRESETS = MOTION_PRESETS;
2043
- bw.generateTypeScale = generateTypeScale;
2044
- bw.DEFAULT_PALETTE_CONFIG = DEFAULT_PALETTE_CONFIG;
2045
- bw.THEME_PRESETS = THEME_PRESETS;
2046
-
2047
- // ===================================================================================
2048
- // Legacy v1 Functions - Useful utilities retained from bitwrench v1
2049
- // ===================================================================================
2050
-
2051
- /**
2052
- * Use a dictionary as a switch statement, with support for function values.
2053
- *
2054
- * Looks up `x` in `choices`. If the value is a function, calls it with `x` as argument.
2055
- * Returns `def` if the key is not found.
2056
- *
2057
- * @param {*} x - Key to look up
2058
- * @param {Object} choices - Dictionary of choices (values can be functions)
2059
- * @param {*} def - Default value if key not found
2060
- * @returns {*} Value or function result
2061
- * @category Array Utilities
2062
- * @example
2063
- * var colors = { red: 1, blue: 2, aqua: function(z) { return z + 'marine'; } };
2064
- * bw.choice('red', colors, '0') // => 1
2065
- * bw.choice('aqua', colors) // => 'aquamarine'
2066
- * bw.choice('pink', colors, 'n/a') // => 'n/a'
2067
- */
2068
- bw.choice = function(x, choices, def) {
2069
- const z = (x in choices) ? choices[x] : def;
2070
- return bw.typeOf(z) === "function" ? z(x) : z;
2071
- };
2072
-
2073
- /**
2074
- * Return unique elements of an array (preserves first occurrence order).
2075
- *
2076
- * @param {Array} x - Input array
2077
- * @returns {Array} Array with unique elements
2078
- * @category Array Utilities
2079
- * @example
2080
- * bw.arrayUniq([1, 2, 2, 3, 1]) // => [1, 2, 3]
2081
- */
2082
- bw.arrayUniq = function(x) {
2083
- if (bw.typeOf(x) !== "array") return [];
2084
- return x.filter((v, i, arr) => arr.indexOf(v) === i);
2085
- };
2086
-
2087
- /**
2088
- * Return the intersection of two arrays (elements present in both).
2089
- *
2090
- * @param {Array} a - First array
2091
- * @param {Array} b - Second array
2092
- * @returns {Array} Unique elements found in both a and b
2093
- * @category Array Utilities
2094
- * @see bw.arrayBNotInA
2095
- * @example
2096
- * bw.arrayBinA([1, 2, 3], [2, 3, 4]) // => [2, 3]
2097
- */
2098
- bw.arrayBinA = function(a, b) {
2099
- if (bw.typeOf(a) !== "array" || bw.typeOf(b) !== "array") return [];
2100
- return bw.arrayUniq(a.filter(n => b.indexOf(n) !== -1));
2101
- };
2102
-
2103
- /**
2104
- * Return elements of b that are not present in a (set difference).
2105
- *
2106
- * @param {Array} a - First array (the "exclude" set)
2107
- * @param {Array} b - Second array (source of results)
2108
- * @returns {Array} Unique elements in b but not in a
2109
- * @category Array Utilities
2110
- * @see bw.arrayBinA
2111
- * @example
2112
- * bw.arrayBNotInA([1, 2, 3], [2, 3, 4, 5]) // => [4, 5]
2113
- */
2114
- bw.arrayBNotInA = function(a, b) {
2115
- if (bw.typeOf(a) !== "array" || bw.typeOf(b) !== "array") return [];
2116
- return bw.arrayUniq(b.filter(n => a.indexOf(n) < 0));
2117
- };
2118
-
2119
- /**
2120
- * Interpolate between an array of colors based on a value in a range.
2121
- *
2122
- * Maps a value from [in0..in1] across a gradient of colors, smoothly blending
2123
- * between adjacent stops. Useful for heatmaps, gauges, and data visualization.
2124
- *
2125
- * @param {number} x - Value to interpolate
2126
- * @param {number} in0 - Input range start
2127
- * @param {number} in1 - Input range end
2128
- * @param {Array} colors - Array of CSS color strings to interpolate between
2129
- * @param {number} [stretch] - Exponential scaling factor (1 = linear)
2130
- * @returns {Array} Interpolated color as [r, g, b, a, "rgb"]
2131
- * @category Color
2132
- * @see bw.colorParse
2133
- * @see bw.mapScale
2134
- * @example
2135
- * bw.colorInterp(50, 0, 100, ['#ff0000', '#00ff00'])
2136
- * // => [128, 128, 0, 255, "rgb"] (yellow midpoint)
2137
- */
2138
- bw.colorInterp = function(x, in0, in1, colors, stretch) {
2139
- let c = Array.isArray(colors) ? colors : ["#000", "#fff"];
2140
- c = c.length === 0 ? ["#000", "#fff"] : c;
2141
- if (c.length === 1) return c[0];
2142
-
2143
- // Convert all colors to RGB format
2144
- c = c.map(col => bw.colorParse(col));
2145
-
2146
- const a = bw.mapScale(x, in0, in1, 0, c.length - 1, { clip: true, expScale: stretch });
2147
- const i = bw.clip(Math.floor(a), 0, c.length - 2);
2148
- const r = a - i;
2149
-
2150
- const interp = (idx) => bw.mapScale(r, 0, 1, c[i][idx], c[i + 1][idx], { clip: true });
2151
- return [interp(0), interp(1), interp(2), interp(3), "rgb"];
2152
- };
2153
-
2154
- /**
2155
- * Convert an HSL color to RGB.
2156
- *
2157
- * Accepts individual h, s, l values or a bitwrench color array [h, s, l, a, "hsl"].
2158
- *
2159
- * @param {number|Array} h - Hue [0..360] or [h,s,l,a,"hsl"] array
2160
- * @param {number} s - Saturation [0..100]
2161
- * @param {number} l - Lightness [0..100]
2162
- * @param {number} [a=255] - Alpha [0..255]
2163
- * @param {boolean} [rnd=true] - Round results to integers
2164
- * @returns {Array} RGB as [r, g, b, a, "rgb"]
2165
- * @category Color
2166
- * @see bw.colorRgbToHsl
2167
- * @example
2168
- * bw.colorHslToRgb(0, 100, 50) // => [255, 0, 0, 255, "rgb"]
2169
- * bw.colorHslToRgb(120, 100, 50) // => [0, 255, 0, 255, "rgb"]
2170
- */
2171
- bw.colorHslToRgb = function(h, s, l, a = 255, rnd = true) {
2172
- if (bw.typeOf(h) === "array") {
2173
- s = h[1]; l = h[2]; a = h[3]; h = h[0];
2174
- }
2175
-
2176
- const hNorm = h / 360;
2177
- const sNorm = s / 100;
2178
- const lNorm = l / 100;
2179
-
2180
- let r, g, b;
2181
-
2182
- if (sNorm === 0) {
2183
- r = g = b = lNorm * 255;
2184
- } else {
2185
- const hue2rgb = (p, q, t) => {
2186
- if (t < 0) t += 1;
2187
- if (t > 1) t -= 1;
2188
- if (t < 1/6) return p + (q - p) * 6 * t;
2189
- if (t < 1/2) return q;
2190
- if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
2191
- return p;
2192
- };
2193
-
2194
- const q = lNorm < 0.5 ? lNorm * (1 + sNorm) : lNorm + sNorm - lNorm * sNorm;
2195
- const p = 2 * lNorm - q;
2196
-
2197
- r = hue2rgb(p, q, hNorm + 1/3) * 255;
2198
- g = hue2rgb(p, q, hNorm) * 255;
2199
- b = hue2rgb(p, q, hNorm - 1/3) * 255;
2200
- }
2201
-
2202
- if (rnd) {
2203
- r = Math.round(r);
2204
- g = Math.round(g);
2205
- b = Math.round(b);
2206
- a = Math.round(a);
2207
- }
2208
-
2209
- return [r, g, b, a, "rgb"];
2210
- };
2211
-
2212
- /**
2213
- * Convert an RGB color to HSL.
2214
- *
2215
- * Accepts individual r, g, b values or a bitwrench color array [r, g, b, a, "rgb"].
2216
- *
2217
- * @param {number|Array} r - Red [0..255] or [r,g,b,a,"rgb"] array
2218
- * @param {number} g - Green [0..255]
2219
- * @param {number} b - Blue [0..255]
2220
- * @param {number} [a=255] - Alpha [0..255]
2221
- * @param {boolean} [rnd=true] - Round results to integers
2222
- * @returns {Array} HSL as [h, s, l, a, "hsl"]
2223
- * @category Color
2224
- * @see bw.colorHslToRgb
2225
- * @example
2226
- * bw.colorRgbToHsl(255, 0, 0) // => [0, 100, 50, 255, "hsl"]
2227
- * bw.colorRgbToHsl(0, 0, 255) // => [240, 100, 50, 255, "hsl"]
2228
- */
2229
- bw.colorRgbToHsl = function(r, g, b, a = 255, rnd = true) {
2230
- if (bw.typeOf(r) === "array") {
2231
- g = r[1]; b = r[2]; a = r[3]; r = r[0];
2232
- }
2233
-
2234
- r /= 255;
2235
- g /= 255;
2236
- b /= 255;
2237
-
2238
- const max = Math.max(r, g, b);
2239
- const min = Math.min(r, g, b);
2240
- let h, s, l = (max + min) / 2;
2241
-
2242
- if (max === min) {
2243
- h = s = 0; // achromatic
2244
- } else {
2245
- const d = max - min;
2246
- s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
2247
-
2248
- switch (max) {
2249
- case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
2250
- case g: h = ((b - r) / d + 2) / 6; break;
2251
- case b: h = ((r - g) / d + 4) / 6; break;
2252
- }
2253
- }
2254
-
2255
- h *= 360;
2256
- s *= 100;
2257
- l *= 100;
2258
-
2259
- if (rnd) {
2260
- h = Math.round(h);
2261
- s = Math.round(s);
2262
- l = Math.round(l);
2263
- a = Math.round(a);
2264
- }
2265
-
2266
- return [h, s, l, a, "hsl"];
2267
- };
3694
+ bw.RADIUS_PRESETS = RADIUS_PRESETS;
3695
+ bw.TYPE_RATIO_PRESETS = TYPE_RATIO_PRESETS;
3696
+ bw.ELEVATION_PRESETS = ELEVATION_PRESETS;
3697
+ bw.MOTION_PRESETS = MOTION_PRESETS;
3698
+ bw.generateTypeScale = generateTypeScale;
3699
+ bw.DEFAULT_PALETTE_CONFIG = DEFAULT_PALETTE_CONFIG;
3700
+ bw.THEME_PRESETS = THEME_PRESETS;
2268
3701
 
2269
- /**
2270
- * Parse a CSS color string into bitwrench's internal array format.
2271
- *
2272
- * Supports hex (#rgb, #rrggbb, #rrggbbaa), rgb(), rgba(), hsl(), and hsla().
2273
- * Also accepts existing bitwrench color arrays (pass-through).
2274
- *
2275
- * @param {string|Array} s - CSS color string (e.g. "#ff0000", "rgb(255,0,0)") or color array
2276
- * @param {number} [defAlpha=255] - Default alpha value
2277
- * @returns {Array} Color as [c0, c1, c2, a, "rgb"|"hsl"]
2278
- * @category Color
2279
- * @see bw.colorInterp
2280
- * @example
2281
- * bw.colorParse('#ff0000') // => [255, 0, 0, 255, "rgb"]
2282
- * bw.colorParse('rgb(0,128,255)') // => [0, 128, 255, 255, "rgb"]
2283
- */
2284
- bw.colorParse = function(s, defAlpha = 255) {
2285
- let r = [0, 0, 0, defAlpha, "rgb"]; // default return
2286
-
2287
- if (bw.typeOf(s) === "array") {
2288
- // Handle bitwrench color array
2289
- const df = [0, 0, 0, 255, "rgb"];
2290
- for (let p = 0; p < s.length && p < df.length; p++) {
2291
- df[p] = s[p];
2292
- }
2293
- return df;
2294
- }
2295
-
2296
- s = String(s).replace(/\s/g, "");
2297
-
2298
- // Handle hex colors
2299
- if (s[0] === "#") {
2300
- const hex = s.slice(1);
2301
- if (hex.length === 3 || hex.length === 4) {
2302
- // #rgb or #rgba
2303
- for (let i = 0; i < hex.length; i++) {
2304
- r[i] = parseInt(hex[i] + hex[i], 16);
2305
- }
2306
- } else if (hex.length === 6 || hex.length === 8) {
2307
- // #rrggbb or #rrggbbaa
2308
- for (let i = 0; i < hex.length; i += 2) {
2309
- r[i / 2] = parseInt(hex.substring(i, i + 2), 16);
2310
- }
2311
- }
2312
- } else {
2313
- // Handle rgb() rgba() hsl() hsla()
2314
- const match = s.match(/^(rgb|hsl)a?\(([^)]+)\)$/i);
2315
- if (match) {
2316
- const type = match[1].toLowerCase();
2317
- const values = match[2].split(",").map(v => parseFloat(v));
2318
-
2319
- if (type === "rgb") {
2320
- r[0] = values[0] || 0;
2321
- r[1] = values[1] || 0;
2322
- r[2] = values[2] || 0;
2323
- r[3] = values[3] !== undefined ? values[3] * 255 : defAlpha;
2324
- r[4] = "rgb";
2325
- } else if (type === "hsl") {
2326
- const rgb = bw.colorHslToRgb(values[0] || 0, values[1] || 0, values[2] || 0,
2327
- values[3] !== undefined ? values[3] * 255 : defAlpha);
2328
- return rgb;
2329
- }
2330
- }
2331
- }
2332
-
2333
- return r;
3702
+ // ===================================================================================
3703
+ // Legacy v1 Functions - Useful utilities retained from bitwrench v1
3704
+ // ===================================================================================
3705
+
3706
+ /** @see bitwrench-utils.js for implementation */
3707
+ bw.choice = _choice;
3708
+ /** @see bitwrench-utils.js for implementation */
3709
+ bw.arrayUniq = _arrayUniq;
3710
+ /** @see bitwrench-utils.js for implementation */
3711
+ bw.arrayBinA = _arrayBinA;
3712
+ /** @see bitwrench-utils.js for implementation */
3713
+ bw.arrayBNotInA = _arrayBNotInA;
3714
+
3715
+ /** @see bitwrench-utils.js for implementation wraps _colorInterp with bw.colorParse */
3716
+ bw.colorInterp = function(x, in0, in1, colors, stretch) {
3717
+ return _colorInterp(x, in0, in1, colors, stretch, colorParse);
2334
3718
  };
2335
3719
 
3720
+ // Color conversion functions — imported from bitwrench-color-utils.js (single source of truth)
3721
+ bw.colorHslToRgb = colorHslToRgb;
3722
+ bw.colorRgbToHsl = colorRgbToHsl;
3723
+ bw.colorParse = colorParse;
3724
+
2336
3725
  /**
2337
3726
  * Set a browser cookie with expiration and options.
2338
3727
  *
@@ -2420,608 +3809,21 @@ bw.getURLParam = function(key, defaultValue) {
2420
3809
  }
2421
3810
  };
2422
3811
 
2423
- /**
2424
- * Create an HTML table string from a 2D data array.
2425
- *
2426
- * Legacy v1 API — returns an HTML string, not a TACO. First row is used
2427
- * as headers by default. For TACO-based tables, use `bw.makeTable()` instead.
2428
- *
2429
- * @param {Array} data - 2D array of table data
2430
- * @param {Object} [opts] - Table options
2431
- * @param {boolean} [opts.useFirstRowAsHeaders=true] - Use first row as headers
2432
- * @param {string} [opts.caption] - Table caption
2433
- * @returns {string} HTML table string
2434
- * @category Legacy (v1)
2435
- * @see bw.makeTable
2436
- */
2437
- bw.htmlTable = function(data, opts = {}) {
2438
- console.warn('bw.htmlTable() is deprecated. Use bw.makeTableFromArray() for TACO output or bw.makeTable() for object-array data.');
2439
- if (bw.typeOf(data) !== "array" || data.length < 1) return "";
2440
-
2441
- const dopts = {
2442
- useFirstRowAsHeaders: true,
2443
- caption: null,
2444
- atr: { class: "table" },
2445
- thead_atr: {},
2446
- th_atr: {},
2447
- tbody_atr: {},
2448
- tr_atr: {},
2449
- td_atr: {}
2450
- };
2451
-
2452
- Object.assign(dopts, opts);
2453
-
2454
- let html = `<table${bw._attrsToStr(dopts.atr)}>`;
2455
-
2456
- if (dopts.caption) {
2457
- html += `<caption>${bw.escapeHTML(dopts.caption)}</caption>`;
2458
- }
2459
-
2460
- let startRow = 0;
2461
-
2462
- // Handle header row
2463
- if (dopts.useFirstRowAsHeaders && data.length > 0) {
2464
- html += `<thead${bw._attrsToStr(dopts.thead_atr)}>`;
2465
- html += `<tr${bw._attrsToStr(dopts.tr_atr)}>`;
2466
-
2467
- data[0].forEach(cell => {
2468
- html += `<th${bw._attrsToStr(dopts.th_atr)}>${bw.escapeHTML(String(cell))}</th>`;
2469
- });
2470
-
2471
- html += "</tr></thead>";
2472
- startRow = 1;
2473
- }
2474
-
2475
- // Body rows
2476
- if (data.length > startRow) {
2477
- html += `<tbody${bw._attrsToStr(dopts.tbody_atr)}>`;
2478
-
2479
- for (let i = startRow; i < data.length; i++) {
2480
- html += `<tr${bw._attrsToStr(dopts.tr_atr)}>`;
2481
-
2482
- data[i].forEach(cell => {
2483
- html += `<td${bw._attrsToStr(dopts.td_atr)}>${bw.escapeHTML(String(cell))}</td>`;
2484
- });
2485
-
2486
- html += "</tr>";
2487
- }
2488
-
2489
- html += "</tbody>";
2490
- }
2491
-
2492
- html += "</table>";
2493
-
2494
- return html;
2495
- };
2496
-
2497
- /**
2498
- * Convert an attributes object to an HTML attribute string
2499
- *
2500
- * Handles boolean attributes (key only), null/undefined/false (skipped),
2501
- * and regular string values (HTML-escaped). Used internally by bw.htmlTable()
2502
- * and bw.htmlTabs().
2503
- *
2504
- * @param {Object} attrs - Attribute key-value pairs
2505
- * @returns {string} HTML attribute string with leading space, or empty string
2506
- * @private
2507
- */
2508
- bw._attrsToStr = function(attrs) {
2509
- if (!attrs || typeof attrs !== "object") return "";
2510
-
2511
- let str = "";
2512
- for (const [key, value] of Object.entries(attrs)) {
2513
- if (value != null && value !== false) {
2514
- if (value === true) {
2515
- str += ` ${key}`;
2516
- } else {
2517
- str += ` ${key}="${bw.escapeHTML(String(value))}"`;
2518
- }
2519
- }
2520
- }
2521
-
2522
- return str;
2523
- };
2524
-
2525
- /**
2526
- * Create an HTML tabs structure from an array of [title, content] pairs.
2527
- *
2528
- * Legacy v1 API — returns an HTML string. For TACO-based tabs,
2529
- * use `bw.makeTabs()` instead.
2530
- *
2531
- * @param {Array} tabData - Array of [title, content] pairs
2532
- * @param {Object} [opts] - Tab options
2533
- * @returns {string} HTML tabs string
2534
- * @category Legacy (v1)
2535
- * @see bw.makeTabs
2536
- */
2537
- bw.htmlTabs = function(tabData, opts = {}) {
2538
- console.warn('bw.htmlTabs() is deprecated. Use bw.makeTabs() instead.');
2539
- if (bw.typeOf(tabData) !== "array" || tabData.length < 1) return "";
2540
-
2541
- const dopts = {
2542
- atr: { class: "bw-tab-container" },
2543
- tab_atr: { class: "bw-tab-item-list" },
2544
- tabc_atr: { class: "bw-tab-content-list" }
2545
- };
2546
-
2547
- Object.assign(dopts, opts);
2548
-
2549
- // Create tab items
2550
- const tabItems = tabData.map((tab, idx) => ({
2551
- t: "li",
2552
- a: {
2553
- class: idx === 0 ? "bw-tab-item bw-tab-active" : "bw-tab-item",
2554
- onclick: "bw.selectTabContent(this)"
2555
- },
2556
- c: tab[0]
2557
- }));
2558
-
2559
- // Create tab content
2560
- const tabContent = tabData.map((tab, idx) => ({
2561
- t: "div",
2562
- a: { class: idx === 0 ? "bw-tab-content bw-show" : "bw-tab-content" },
2563
- c: tab[1]
2564
- }));
2565
-
2566
- return bw.html({
2567
- t: "div",
2568
- a: dopts.atr,
2569
- c: [
2570
- { t: "ul", a: dopts.tab_atr, c: tabItems },
2571
- { t: "div", a: dopts.tabc_atr, c: tabContent }
2572
- ]
2573
- });
2574
- };
2575
-
2576
- /**
2577
- * Tab selection handler — shows the clicked tab's content and hides others.
2578
- *
2579
- * Used internally by `bw.htmlTabs()`. You generally don't call this directly.
2580
- *
2581
- * @param {Element} tabElement - Clicked tab element
2582
- * @category Legacy (v1)
2583
- */
2584
- bw.selectTabContent = function(tabElement) {
2585
- console.warn('bw.selectTabContent() is deprecated. Use bw.makeTabs() instead.');
2586
- if (!bw._isBrowser || !tabElement) return;
2587
-
2588
- const container = tabElement.closest(".bw-tab-container");
2589
- if (!container) return;
2590
-
2591
- // Remove active class from all tabs
2592
- container.querySelectorAll(".bw-tab-item").forEach(tab => {
2593
- tab.classList.remove("bw-tab-active");
2594
- });
2595
-
2596
- // Add active to clicked tab
2597
- tabElement.classList.add("bw-tab-active");
2598
-
2599
- // Get tab index
2600
- const tabIndex = Array.from(tabElement.parentElement.children).indexOf(tabElement);
2601
-
2602
- // Hide all content
2603
- container.querySelectorAll(".bw-tab-content").forEach(content => {
2604
- content.classList.remove("bw-show");
2605
- });
2606
-
2607
- // Show selected content
2608
- const contents = container.querySelectorAll(".bw-tab-content");
2609
- if (contents[tabIndex]) {
2610
- contents[tabIndex].classList.add("bw-show");
2611
- }
2612
- };
2613
-
2614
- /**
2615
- * Generate Lorem Ipsum placeholder text.
2616
- *
2617
- * Useful for prototyping layouts. Generates repeatable text from the standard
2618
- * Lorem Ipsum passage. Omit numChars for a random length between 25-150 characters.
2619
- *
2620
- * @param {number} [numChars] - Number of characters (random 25-150 if not provided)
2621
- * @param {number} [startSpot] - Starting index in Lorem text (random if undefined)
2622
- * @param {boolean} [startWithCapitalLetter=true] - Start with a capital letter
2623
- * @returns {string} Lorem ipsum text
2624
- * @category Text Generation
2625
- * @example
2626
- * bw.loremIpsum(50)
2627
- * // => "Lorem ipsum dolor sit amet, consectetur adipiscin"
2628
- */
2629
- bw.loremIpsum = function(numChars, startSpot, startWithCapitalLetter = true) {
2630
- const lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. ";
2631
-
2632
- // If numChars not provided, generate random length between 25-150
2633
- if (typeof numChars !== "number") {
2634
- numChars = Math.floor(Math.random() * 125) + 25;
2635
- }
2636
-
2637
- // If startSpot is undefined, randomize it
2638
- if (startSpot === undefined) {
2639
- startSpot = Math.floor(Math.random() * lorem.length);
2640
- }
2641
-
2642
- startSpot = startSpot % lorem.length;
2643
-
2644
- // Track how many characters we skip to honor numChars
2645
- let skippedChars = 0;
2646
- // Move startSpot to the next non-whitespace and non-punctuation character
2647
- while (lorem[startSpot] === ' ' || /[.,:;!?]/.test(lorem[startSpot])) {
2648
- startSpot = (startSpot + 1) % lorem.length;
2649
- skippedChars++;
2650
- // Prevent infinite loop in case entire lorem is spaces/punctuation
2651
- if (skippedChars >= lorem.length) {
2652
- startSpot = 0;
2653
- skippedChars = 0;
2654
- break;
2655
- }
2656
- }
2657
-
2658
- let l = lorem.substring(startSpot) + lorem.substring(0, startSpot);
2659
-
2660
- let result = "";
2661
- let remaining = numChars + skippedChars; // Add skipped chars to honor original numChars
2662
-
2663
- while (remaining > 0) {
2664
- result += remaining < l.length ? l.substring(0, remaining) : l;
2665
- remaining -= l.length;
2666
- }
2667
-
2668
- // Trim to exact numChars length
2669
- if (result.length > numChars) {
2670
- result = result.substring(0, numChars);
2671
- }
2672
-
2673
- // Ensure no trailing space
2674
- if (result[result.length - 1] === " ") {
2675
- result = result.substring(0, result.length - 1) + ".";
2676
- }
2677
-
2678
- // Ensure capital letter at start if requested
2679
- if (startWithCapitalLetter) {
2680
- let c = result[0].toUpperCase();
2681
- c = /[A-Z]/.test(c) ? c : "L"; // Use "L" as default if first char isn't a letter
2682
- result = c + result.substring(1);
2683
- }
2684
-
2685
- return result;
2686
- };
2687
-
2688
- /**
2689
- * Create a multidimensional array filled with a value or function result.
2690
- *
2691
- * If value is a function, it's called for each cell (useful for random data).
2692
- *
2693
- * @param {*} value - Value or function to fill array with
2694
- * @param {number|Array} dims - Dimensions (number for 1D, array for multi-D)
2695
- * @returns {Array} Multidimensional array
2696
- * @category Array Utilities
2697
- * @example
2698
- * bw.multiArray(0, [4, 5]) // 4x5 array of 0s
2699
- * bw.multiArray('test', 5) // ['test','test','test','test','test']
2700
- * bw.multiArray(Math.random, [3, 4]) // 3x4 array of random numbers
2701
- */
2702
- bw.multiArray = function(value, dims) {
2703
- const v = () => bw.typeOf(value) === "function" ? value() : value;
2704
- dims = typeof dims === "number" ? [dims] : dims;
2705
-
2706
- const createArray = (dim) => {
2707
- if (dim >= dims.length) return v();
2708
-
2709
- const arr = [];
2710
- for (let i = 0; i < dims[dim]; i++) {
2711
- arr[i] = createArray(dim + 1);
2712
- }
2713
- return arr;
2714
- };
2715
-
2716
- return createArray(0);
2717
- };
2718
-
2719
- /**
2720
- * Natural sort comparison function for use with `Array.sort()`.
2721
- *
2722
- * Sorts strings with embedded numbers in human-expected order
2723
- * (e.g. "file2" before "file10") instead of lexicographic order.
2724
- *
2725
- * @param {*} as - First value
2726
- * @param {*} bs - Second value
2727
- * @returns {number} Sort order (-1, 0, 1)
2728
- * @category Array Utilities
2729
- * @example
2730
- * ['item10', 'item2', 'item1'].sort(bw.naturalCompare)
2731
- * // => ['item1', 'item2', 'item10']
2732
- */
2733
- bw.naturalCompare = function(as, bs) {
2734
- // Handle numbers
2735
- if (isFinite(as) && isFinite(bs)) {
2736
- return Math.sign(as - bs);
2737
- }
2738
-
2739
- const a = String(as).toLowerCase();
2740
- const b = String(bs).toLowerCase();
2741
-
2742
- if (a === b) return as > bs ? 1 : 0;
2743
-
2744
- // If no digits, simple string compare
2745
- if (!/\d/.test(a) || !/\d/.test(b)) {
2746
- return a > b ? 1 : -1;
2747
- }
2748
-
2749
- // Split into chunks of digits/non-digits
2750
- const aParts = a.match(/(\d+|\D+)/g) || [];
2751
- const bParts = b.match(/(\d+|\D+)/g) || [];
2752
-
2753
- const len = Math.min(aParts.length, bParts.length);
2754
-
2755
- for (let i = 0; i < len; i++) {
2756
- const aPart = aParts[i];
2757
- const bPart = bParts[i];
2758
-
2759
- if (aPart !== bPart) {
2760
- // Both numeric
2761
- if (/^\d+$/.test(aPart) && /^\d+$/.test(bPart)) {
2762
- // Handle leading zeros
2763
- let aNum = aPart;
2764
- let bNum = bPart;
2765
-
2766
- if (aPart[0] === "0") aNum = "0." + aPart;
2767
- if (bPart[0] === "0") bNum = "0." + bPart;
2768
-
2769
- return parseFloat(aNum) - parseFloat(bNum);
2770
- }
2771
-
2772
- // String comparison
2773
- return aPart > bPart ? 1 : -1;
2774
- }
2775
- }
2776
-
2777
- // Different lengths
2778
- return aParts.length - bParts.length;
2779
- };
2780
-
2781
- /**
2782
- * Run `setInterval` with a maximum number of repetitions.
2783
- *
2784
- * Like `setInterval` but automatically clears after N calls.
2785
- *
2786
- * @param {Function} callback - Function to call (receives iteration index)
2787
- * @param {number} delay - Delay between calls in ms
2788
- * @param {number} repetitions - Maximum number of times to call
2789
- * @returns {number} Interval ID (can be passed to clearInterval)
2790
- * @category Timing
2791
- * @example
2792
- * bw.setIntervalX(function(i) {
2793
- * console.log('Iteration', i);
2794
- * }, 1000, 5); // Runs 5 times, 1 second apart
2795
- */
2796
- bw.setIntervalX = function(callback, delay, repetitions) {
2797
- let count = 0;
2798
- const intervalID = setInterval(function() {
2799
- callback(count);
2800
-
2801
- if (++count >= repetitions) {
2802
- clearInterval(intervalID);
2803
- }
2804
- }, delay);
2805
-
2806
- return intervalID;
2807
- };
2808
-
2809
- /**
2810
- * Repeat a test function until it returns truthy, or give up after max attempts.
2811
- *
2812
- * Useful for polling (waiting for an element to appear, an API to respond, etc.).
2813
- *
2814
- * @param {Function} testFn - Test function that returns truthy when done
2815
- * @param {Function} successFn - Called with test result when test passes
2816
- * @param {Function} [failFn] - Called on each failed test attempt
2817
- * @param {number} [delay=250] - Delay between attempts in ms
2818
- * @param {number} [maxReps=10] - Maximum number of attempts
2819
- * @param {Function} [lastFn] - Called when done with (success, count)
2820
- * @returns {string|number} "err" if invalid params, otherwise interval ID
2821
- * @category Timing
2822
- * @example
2823
- * bw.repeatUntil(
2824
- * function() { return document.getElementById('myDiv'); },
2825
- * function() { console.log('Element found!'); },
2826
- * null, 100, 30
2827
- * );
2828
- */
2829
- bw.repeatUntil = function(testFn, successFn, failFn, delay = 250, maxReps = 10, lastFn) {
2830
- if (typeof testFn !== "function") return "err";
2831
-
2832
- let count = 0;
2833
-
2834
- const intervalID = setInterval(function() {
2835
- const result = testFn();
2836
- count++;
2837
-
2838
- if (result) {
2839
- clearInterval(intervalID);
2840
- if (successFn) successFn(result);
2841
- if (lastFn) lastFn(true, count);
2842
- } else if (count >= maxReps) {
2843
- clearInterval(intervalID);
2844
- if (failFn) failFn();
2845
- if (lastFn) lastFn(false, count);
2846
- } else {
2847
- if (failFn) failFn();
2848
- }
2849
- }, delay);
2850
-
2851
- return intervalID;
2852
- };
2853
-
2854
- // ===================================================================================
2855
- // File I/O Functions - Works in both Node.js and browser
2856
- // ===================================================================================
2857
-
2858
- /**
2859
- * Save data to a file. Works in both Node.js (fs.writeFile) and browser (download link).
2860
- *
2861
- * @param {string} fname - Filename to save as
2862
- * @param {*} data - Data to save (string or buffer)
2863
- * @category File I/O
2864
- * @see bw.saveClientJSON
2865
- */
2866
- bw.saveClientFile = function(fname, data) {
2867
- if (bw.isNodeJS()) {
2868
- bw._getFs().then(function(fs) {
2869
- if (!fs) { console.error('bw.saveClientFile: fs module not available'); return; }
2870
- fs.writeFile(fname, data, function(err) {
2871
- if (err) {
2872
- console.error("Error saving file:", err);
2873
- }
2874
- });
2875
- });
2876
- } else {
2877
- // Browser environment
2878
- const blob = new Blob([data], { type: "application/octet-stream" });
2879
- const url = window.URL.createObjectURL(blob);
2880
- const a = bw.createDOM({
2881
- t: 'a',
2882
- a: {
2883
- href: url,
2884
- download: fname,
2885
- style: 'display: none'
2886
- }
2887
- });
2888
- document.body.appendChild(a);
2889
- a.click();
2890
- window.URL.revokeObjectURL(url);
2891
- document.body.removeChild(a);
2892
- }
2893
- };
2894
-
2895
- /**
2896
- * Save data as a JSON file with pretty formatting.
2897
- *
2898
- * @param {string} fname - Filename to save as
2899
- * @param {*} data - Data to serialize as JSON
2900
- * @category File I/O
2901
- * @see bw.saveClientFile
2902
- */
2903
- bw.saveClientJSON = function(fname, data) {
2904
- bw.saveClientFile(fname, JSON.stringify(data, null, 2));
2905
- };
2906
-
2907
- /**
2908
- * Load a file by path (Node.js) or URL (browser via XHR).
2909
- *
2910
- * @param {string} fname - File path (Node) or URL (browser)
2911
- * @param {Function} callback - Called with (data, error). data is null on error.
2912
- * @param {Object} [options] - Options
2913
- * @param {string} [options.parser="raw"] - "raw" for string, "JSON" to auto-parse
2914
- * @returns {string} "BW_OK"
2915
- * @category File I/O
2916
- * @see bw.loadClientJSON
2917
- */
2918
- bw.loadClientFile = function(fname, callback, options) {
2919
- var opts = { parser: 'raw' };
2920
- if (options && options.parser) { opts.parser = options.parser; }
2921
- var parse = (opts.parser === 'JSON') ? JSON.parse : function(s) { return s; };
2922
-
2923
- if (bw.isNodeJS()) {
2924
- bw._getFs().then(function(fs) {
2925
- if (!fs) { callback(null, new Error('fs module not available')); return; }
2926
- fs.readFile(fname, 'utf8', function(err, data) {
2927
- if (err) { callback(null, err); }
2928
- else {
2929
- try { callback(parse(data), null); }
2930
- catch (e) { callback(null, e); }
2931
- }
2932
- });
2933
- });
2934
- } else {
2935
- var x = new XMLHttpRequest();
2936
- x.open('GET', fname, true);
2937
- x.onreadystatechange = function() {
2938
- if (x.readyState === 4) {
2939
- if (x.status >= 200 && x.status < 300) {
2940
- try { callback(parse(x.responseText), null); }
2941
- catch (e) { callback(null, e); }
2942
- } else {
2943
- callback(null, new Error('HTTP ' + x.status + ': ' + fname));
2944
- }
2945
- }
2946
- };
2947
- x.send(null);
2948
- }
2949
- return 'BW_OK';
2950
- };
2951
-
2952
- /**
2953
- * Load a JSON file by path (Node.js) or URL (browser). Convenience wrapper
2954
- * around `bw.loadClientFile()` with `parser: "JSON"`.
2955
- *
2956
- * @param {string} fname - File path (Node) or URL (browser)
2957
- * @param {Function} callback - Called with (parsedData, error)
2958
- * @returns {string} "BW_OK"
2959
- * @category File I/O
2960
- * @see bw.loadClientFile
2961
- */
2962
- bw.loadClientJSON = function(fname, callback) {
2963
- return bw.loadClientFile(fname, callback, { parser: 'JSON' });
2964
- };
2965
-
2966
- /**
2967
- * Prompt user to pick a local file via file dialog (browser only).
2968
- *
2969
- * Opens a native file picker and reads the selected file.
2970
- *
2971
- * @param {Function} callback - Called with (data, filename, error)
2972
- * @param {Object} [options] - Options
2973
- * @param {string} [options.accept] - File type filter (e.g. ".json,.txt")
2974
- * @param {string} [options.parser="raw"] - "raw" for string, "JSON" to auto-parse
2975
- * @category File I/O
2976
- * @see bw.loadLocalJSON
2977
- */
2978
- bw.loadLocalFile = function(callback, options) {
2979
- var opts = { parser: 'raw', accept: '' };
2980
- if (options) {
2981
- if (options.parser) { opts.parser = options.parser; }
2982
- if (options.accept) { opts.accept = options.accept; }
2983
- }
2984
- var parse = (opts.parser === 'JSON') ? JSON.parse : function(s) { return s; };
2985
3812
 
2986
- if (bw.isNodeJS()) {
2987
- callback(null, '', new Error('bw.loadLocalFile is browser-only. Use bw.loadClientFile() in Node.'));
2988
- return;
2989
- }
3813
+ /** @see bitwrench-utils.js for implementation */
3814
+ bw.loremIpsum = _loremIpsum;
2990
3815
 
2991
- var input = bw.createDOM({
2992
- t: 'input',
2993
- a: {
2994
- type: 'file',
2995
- accept: opts.accept,
2996
- style: 'display: none'
2997
- }
2998
- });
2999
- input.addEventListener('change', function() {
3000
- var file = input.files[0];
3001
- if (!file) { callback(null, '', new Error('No file selected')); return; }
3002
- var reader = new FileReader();
3003
- reader.onload = function(e) {
3004
- try { callback(parse(e.target.result), file.name, null); }
3005
- catch (err) { callback(null, file.name, err); }
3006
- };
3007
- reader.onerror = function() { callback(null, file.name, reader.error); };
3008
- reader.readAsText(file);
3009
- input.remove();
3010
- });
3011
- document.body.appendChild(input);
3012
- input.click();
3013
- };
3816
+ /** @see bitwrench-utils.js for implementation */
3817
+ bw.multiArray = _multiArray;
3818
+ /** @see bitwrench-utils.js for implementation */
3819
+ bw.naturalCompare = _naturalCompare;
3820
+ /** @see bitwrench-utils.js for implementation */
3821
+ bw.setIntervalX = _setIntervalX;
3822
+ /** @see bitwrench-utils.js for implementation */
3823
+ bw.repeatUntil = _repeatUntil;
3014
3824
 
3015
- /**
3016
- * Prompt user to pick a local JSON file via file dialog (browser only).
3017
- *
3018
- * @param {Function} callback - Called with (parsedData, filename, error)
3019
- * @category File I/O
3020
- * @see bw.loadLocalFile
3021
- */
3022
- bw.loadLocalJSON = function(callback) {
3023
- bw.loadLocalFile(callback, { parser: 'JSON', accept: '.json' });
3024
- };
3825
+ // File I/O — see bitwrench-file-ops.js
3826
+ bindFileOps(bw);
3025
3827
 
3026
3828
  /**
3027
3829
  * Copy text to the system clipboard (browser only).
@@ -3124,10 +3926,10 @@ bw.makeTable = function(config) {
3124
3926
  sortDirection = 'asc'
3125
3927
  } = config;
3126
3928
 
3127
- // Build class list: always include bw-table, add striped/hover, append user className
3128
- let cls = 'bw-table';
3129
- if (striped) cls += ' bw-table-striped';
3130
- if (hover) cls += ' bw-table-hover';
3929
+ // Build class list: always include bw_table, add striped/hover, append user className
3930
+ let cls = 'bw_table';
3931
+ if (striped) cls += ' bw_table_striped';
3932
+ if (hover) cls += ' bw_table_hover';
3131
3933
  if (className) cls += ' ' + className;
3132
3934
  cls = cls.trim();
3133
3935
 
@@ -3341,7 +4143,7 @@ bw.makeBarChart = function(config) {
3341
4143
  } = config;
3342
4144
 
3343
4145
  if (!Array.isArray(data) || data.length === 0) {
3344
- return { t: 'div', a: { class: ('bw-bar-chart-container ' + className).trim() }, c: '' };
4146
+ return { t: 'div', a: { class: ('bw_bar_chart_container ' + className).trim() }, c: '' };
3345
4147
  }
3346
4148
 
3347
4149
  const values = data.map(function(d) { return Number(d[valueKey]) || 0; });
@@ -3354,35 +4156,35 @@ bw.makeBarChart = function(config) {
3354
4156
 
3355
4157
  const children = [];
3356
4158
  if (showValues) {
3357
- children.push({ t: 'div', a: { class: 'bw-bar-value' }, c: formatted });
4159
+ children.push({ t: 'div', a: { class: 'bw_bar_value' }, c: formatted });
3358
4160
  }
3359
4161
  children.push({
3360
4162
  t: 'div',
3361
4163
  a: {
3362
- class: 'bw-bar',
4164
+ class: 'bw_bar',
3363
4165
  style: 'height:' + pct + '%;background:' + color + ';'
3364
4166
  }
3365
4167
  });
3366
4168
  if (showLabels) {
3367
- children.push({ t: 'div', a: { class: 'bw-bar-label' }, c: String(d[labelKey] || '') });
4169
+ children.push({ t: 'div', a: { class: 'bw_bar_label' }, c: String(d[labelKey] || '') });
3368
4170
  }
3369
4171
 
3370
- return { t: 'div', a: { class: 'bw-bar-group' }, c: children };
4172
+ return { t: 'div', a: { class: 'bw_bar_group' }, c: children };
3371
4173
  });
3372
4174
 
3373
4175
  const chartChildren = [];
3374
4176
  if (title) {
3375
- chartChildren.push({ t: 'h3', a: { class: 'bw-bar-chart-title' }, c: title });
4177
+ chartChildren.push({ t: 'h3', a: { class: 'bw_bar_chart_title' }, c: title });
3376
4178
  }
3377
4179
  chartChildren.push({
3378
4180
  t: 'div',
3379
- a: { class: 'bw-bar-chart', style: 'height:' + height + ';' },
4181
+ a: { class: 'bw_bar_chart', style: 'height:' + height + ';' },
3380
4182
  c: bars
3381
4183
  });
3382
4184
 
3383
4185
  return {
3384
4186
  t: 'div',
3385
- a: { class: ('bw-bar-chart-container ' + className).trim() },
4187
+ a: { class: ('bw_bar_chart_container ' + className).trim() },
3386
4188
  c: chartChildren
3387
4189
  };
3388
4190
  };
@@ -3481,7 +4283,7 @@ bw._componentRegistry = new Map();
3481
4283
  * @see bw.DOM
3482
4284
  * @example
3483
4285
  * var handle = bw.render('#app', 'append', {
3484
- * t: 'button', a: { class: 'bw-btn' }, c: 'Click Me',
4286
+ * t: 'button', a: { class: 'bw_btn' }, c: 'Click Me',
3485
4287
  * o: { state: { clicks: 0 } }
3486
4288
  * });
3487
4289
  * handle.setState({ clicks: 1 });
@@ -3519,7 +4321,7 @@ bw.render = function(element, position, taco) {
3519
4321
  }
3520
4322
 
3521
4323
  // Add component ID to element
3522
- domElement.setAttribute('data-bw-id', componentId);
4324
+ domElement.setAttribute('data-bw_id', componentId);
3523
4325
 
3524
4326
  // Insert into DOM based on position
3525
4327
  try {
@@ -3594,7 +4396,7 @@ bw.render = function(element, position, taco) {
3594
4396
 
3595
4397
  // Re-render
3596
4398
  const newElement = bw.createDOM(this._taco);
3597
- newElement.setAttribute('data-bw-id', componentId);
4399
+ newElement.setAttribute('data-bw_id', componentId);
3598
4400
 
3599
4401
  // Replace in DOM
3600
4402
  parent.replaceChild(newElement, this.element);
@@ -3768,7 +4570,7 @@ bw.getAllComponents = function() {
3768
4570
  // =========================================================================
3769
4571
  // Import and register all components
3770
4572
  // =========================================================================
3771
- import * as components from './bitwrench-components-v2.js';
4573
+ import * as components from './bitwrench-bccl.js';
3772
4574
 
3773
4575
  // Register all make functions
3774
4576
  Object.entries(components).forEach(([name, fn]) => {
@@ -3777,50 +4579,26 @@ Object.entries(components).forEach(([name, fn]) => {
3777
4579
  }
3778
4580
  });
3779
4581
 
3780
- // Register component handles
3781
- bw._componentHandles = components.componentHandles || {};
4582
+ // Factory dispatch: bw.make('card', props) → bw.makeCard(props)
4583
+ bw.make = components.make;
3782
4584
 
3783
- // Create functions that return handles
4585
+ // Component registry: bw.BCCL lists all available component types
4586
+ bw.BCCL = components.BCCL;
4587
+
4588
+ // Variant class helper: bw.variantClass('primary') → 'bw_primary'
4589
+ bw.variantClass = components.variantClass;
4590
+
4591
+ // Create functions that return handles (plain renderComponent, no Handle overlay)
3784
4592
  Object.entries(components).forEach(([name, fn]) => {
3785
4593
  if (name.startsWith('make')) {
3786
- const componentType = name.substring(4).toLowerCase(); // Remove 'make' prefix
3787
4594
  const createName = 'create' + name.substring(4); // createCard, createTable, etc.
3788
-
3789
4595
  bw[createName] = function(props) {
3790
4596
  const taco = fn(props);
3791
- const handle = bw.renderComponent(taco);
3792
-
3793
- // Use specialized handle class if available
3794
- const HandleClass = bw._componentHandles[componentType];
3795
- if (HandleClass) {
3796
- const specializedHandle = new HandleClass(handle.element, taco);
3797
- // Copy base handle properties
3798
- Object.setPrototypeOf(specializedHandle, handle);
3799
- return specializedHandle;
3800
- }
3801
-
3802
- return handle;
4597
+ return bw.renderComponent(taco);
3803
4598
  };
3804
4599
  }
3805
4600
  });
3806
4601
 
3807
- // Manual registration for functions defined in this file
3808
- // createTable
3809
- bw.createTable = function(data, options = {}) {
3810
- const taco = bw.makeTable({ data, ...options });
3811
- const handle = bw.renderComponent(taco);
3812
-
3813
- // Use specialized TableHandle
3814
- const TableHandle = bw._componentHandles.table;
3815
- if (TableHandle) {
3816
- const specializedHandle = new TableHandle(handle.element, taco);
3817
- Object.setPrototypeOf(specializedHandle, handle);
3818
- return specializedHandle;
3819
- }
3820
-
3821
- return handle;
3822
- };
3823
-
3824
4602
  // Export for different environments
3825
4603
  export default bw;
3826
4604