bitwrench 2.0.13 → 2.0.15

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 (46) hide show
  1. package/README.md +4 -4
  2. package/dist/bitwrench-code-edit.cjs.js +46 -46
  3. package/dist/bitwrench-code-edit.cjs.min.js +16 -0
  4. package/dist/bitwrench-code-edit.es5.js +8 -8
  5. package/dist/bitwrench-code-edit.es5.min.js +2 -2
  6. package/dist/bitwrench-code-edit.esm.js +46 -46
  7. package/dist/bitwrench-code-edit.esm.min.js +2 -2
  8. package/dist/bitwrench-code-edit.umd.js +46 -46
  9. package/dist/bitwrench-code-edit.umd.min.js +2 -2
  10. package/dist/bitwrench-lean.cjs.js +5011 -3419
  11. package/dist/bitwrench-lean.cjs.min.js +35 -6
  12. package/dist/bitwrench-lean.es5.js +6218 -4272
  13. package/dist/bitwrench-lean.es5.min.js +32 -3
  14. package/dist/bitwrench-lean.esm.js +5011 -3419
  15. package/dist/bitwrench-lean.esm.min.js +35 -6
  16. package/dist/bitwrench-lean.umd.js +5011 -3419
  17. package/dist/bitwrench-lean.umd.min.js +35 -6
  18. package/dist/bitwrench.cjs.js +6966 -4662
  19. package/dist/bitwrench.cjs.min.js +38 -8
  20. package/dist/bitwrench.css +2453 -4784
  21. package/dist/bitwrench.es5.js +9592 -6813
  22. package/dist/bitwrench.es5.min.js +34 -5
  23. package/dist/bitwrench.esm.js +6966 -4662
  24. package/dist/bitwrench.esm.min.js +38 -8
  25. package/dist/bitwrench.min.css +1 -0
  26. package/dist/bitwrench.umd.js +6966 -4662
  27. package/dist/bitwrench.umd.min.js +38 -8
  28. package/dist/builds.json +89 -67
  29. package/dist/sri.json +28 -26
  30. package/package.json +7 -5
  31. package/readme.html +14 -14
  32. package/src/{bitwrench-components-v2.js → bitwrench-bccl.js} +1311 -600
  33. package/src/bitwrench-code-edit.js +45 -45
  34. package/src/bitwrench-color-utils.js +154 -27
  35. package/src/bitwrench-components-stub.js +4 -1
  36. package/src/bitwrench-file-ops.js +180 -0
  37. package/src/bitwrench-lean.js +2 -2
  38. package/src/bitwrench-styles.js +1468 -3494
  39. package/src/bitwrench-utils.js +458 -0
  40. package/src/bitwrench.js +1795 -1349
  41. package/src/cli/layout-default.js +18 -18
  42. package/src/generate-css.js +73 -53
  43. package/src/version.js +3 -3
  44. package/src/bitwrench-component-base.js +0 -736
  45. package/src/bitwrench-components-inline.js +0 -374
  46. package/src/bitwrench-components.js +0 -610
package/src/bitwrench.js CHANGED
@@ -8,13 +8,23 @@
8
8
  */
9
9
 
10
10
  import { VERSION_INFO } from './version.js';
11
- import { getStructuralStyles, theme, updateTheme, generateDarkModeCSS,
12
- generateThemedCSS, derivePalette as _derivePalette,
11
+ import { getStructuralStyles,
12
+ generateThemedCSS, generateAlternateCSS, derivePalette as _derivePalette,
13
13
  DEFAULT_PALETTE_CONFIG, SPACING_PRESETS, RADIUS_PRESETS, THEME_PRESETS,
14
- resolveLayout, addUnderscoreAliases } from './bitwrench-styles.js';
14
+ TYPE_RATIO_PRESETS, ELEVATION_PRESETS, MOTION_PRESETS, generateTypeScale,
15
+ resolveLayout } from './bitwrench-styles.js';
15
16
  import { hexToHsl, hslToHex, adjustLightness, mixColor,
16
17
  relativeLuminance, textOnColor, deriveShades,
17
- derivePalette } from './bitwrench-color-utils.js';
18
+ derivePalette, harmonize, deriveAlternateSeed, deriveAlternateConfig,
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';
18
28
 
19
29
  // Environment-aware module loader for optional Node.js built-ins (fs).
20
30
  // Strategy: try require() first (CJS/UMD), fall back to import() (ESM).
@@ -49,7 +59,7 @@ const bw = {
49
59
  // Fast O(1) lookup for elements by bw_id, id attribute, or bw_uuid.
50
60
  //
51
61
  // Populated by bw.createDOM() when elements have:
52
- // - data-bw-id attribute (user-declared addressable elements)
62
+ // - data-bw_id attribute (user-declared addressable elements)
53
63
  // - id attribute (standard HTML id)
54
64
  // - bw_uuid (internal, for lifecycle-managed elements)
55
65
  //
@@ -207,58 +217,7 @@ bw._getFs = function() {
207
217
  * // baseTypeOnly mode:
208
218
  * bw.typeOf([1,2], true) // => "object"
209
219
  */
210
- bw.typeOf = function(x, baseTypeOnly) {
211
- if (x === null) return "null";
212
-
213
- const basic = typeof x;
214
-
215
- if (basic !== "object") {
216
- return basic; // covers: string, number, boolean, undefined, function, symbol, bigint
217
- }
218
-
219
- if (baseTypeOnly) return basic;
220
-
221
- const stringTag = Object.prototype.toString.call(x);
222
-
223
- const typeMap = {
224
- '[object Array]': 'array',
225
- '[object Date]': 'Date',
226
- '[object RegExp]': 'RegExp',
227
- '[object Error]': 'Error',
228
- '[object Promise]': 'Promise',
229
- '[object Map]': 'Map',
230
- '[object Set]': 'Set',
231
- '[object WeakMap]': 'WeakMap',
232
- '[object WeakSet]': 'WeakSet',
233
- '[object ArrayBuffer]': 'ArrayBuffer',
234
- '[object DataView]': 'DataView',
235
- '[object Int8Array]': 'Int8Array',
236
- '[object Uint8Array]': 'Uint8Array',
237
- '[object Uint8ClampedArray]': 'Uint8ClampedArray',
238
- '[object Int16Array]': 'Int16Array',
239
- '[object Uint16Array]': 'Uint16Array',
240
- '[object Int32Array]': 'Int32Array',
241
- '[object Uint32Array]': 'Uint32Array',
242
- '[object Float32Array]': 'Float32Array',
243
- '[object Float64Array]': 'Float64Array'
244
- };
245
-
246
- if (typeMap[stringTag]) {
247
- return typeMap[stringTag];
248
- }
249
-
250
- // Check for custom bitwrench types
251
- if (x._bw_type) {
252
- return x._bw_type;
253
- }
254
-
255
- // Try constructor name
256
- if (x.constructor && x.constructor.name) {
257
- return x.constructor.name;
258
- }
259
-
260
- return basic;
261
- };
220
+ bw.typeOf = _typeOf;
262
221
 
263
222
  // Alias
264
223
  bw.to = bw.typeOf;
@@ -308,9 +267,9 @@ bw.uuid = function(prefix) {
308
267
  * Accepts a DOM element directly (pass-through) or a string identifier.
309
268
  * String identifiers are tried as: direct map key, getElementById,
310
269
  * querySelector (for CSS selectors starting with . or #), and
311
- * data-bw-id attribute selector.
270
+ * data-bw_id attribute selector.
312
271
  *
313
- * @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
314
273
  * @returns {Element|null} The DOM element, or null if not found
315
274
  * @category Internal
316
275
  */
@@ -339,9 +298,9 @@ bw._el = function(id) {
339
298
  el = document.querySelector(id);
340
299
  }
341
300
 
342
- // 4. Try data-bw-id attribute (for bw.uuid-generated IDs)
301
+ // 4. Try data-bw_id attribute (for bw.uuid-generated IDs)
343
302
  if (!el) {
344
- el = document.querySelector('[data-bw-id="' + id + '"]');
303
+ el = document.querySelector('[data-bw_id="' + id + '"]');
345
304
  }
346
305
 
347
306
  // 5. Cache the result for next time
@@ -356,15 +315,15 @@ bw._el = function(id) {
356
315
  * Register a DOM element in the node cache under one or more keys.
357
316
  *
358
317
  * Called internally by `bw.createDOM()`. Registers elements that have
359
- * id attributes, data-bw-id attributes, or both.
318
+ * id attributes, data-bw_id attributes, or both.
360
319
  *
361
320
  * @param {Element} el - DOM element to register
362
- * @param {string} [bwId] - data-bw-id value to register under
321
+ * @param {string} [bwId] - data-bw_id value to register under
363
322
  * @category Internal
364
323
  */
365
324
  bw._registerNode = function(el, bwId) {
366
325
  if (!el) return;
367
- // Register under data-bw-id
326
+ // Register under data-bw_id
368
327
  if (bwId) {
369
328
  bw._nodeMap[bwId] = el;
370
329
  }
@@ -382,11 +341,11 @@ bw._registerNode = function(el, bwId) {
382
341
  * through bitwrench APIs.
383
342
  *
384
343
  * @param {Element} el - DOM element to deregister
385
- * @param {string} [bwId] - data-bw-id value to remove
344
+ * @param {string} [bwId] - data-bw_id value to remove
386
345
  * @category Internal
387
346
  */
388
347
  bw._deregisterNode = function(el, bwId) {
389
- // Remove data-bw-id entry
348
+ // Remove data-bw_id entry
390
349
  if (bwId) {
391
350
  delete bw._nodeMap[bwId];
392
351
  }
@@ -446,23 +405,6 @@ bw.raw = function(str) {
446
405
  return { __bw_raw: true, v: String(str) };
447
406
  };
448
407
 
449
- /**
450
- * Normalize CSS class names by converting underscores to hyphens for bw-prefixed classes.
451
- *
452
- * Allows users to write either `bw_card` or `bw-card` and get consistent
453
- * hyphenated output. Only converts the `bw_` prefix — other underscores are untouched.
454
- *
455
- * @param {string} classStr - Class string to normalize
456
- * @returns {string} Normalized class string with hyphens
457
- * @category Identifiers
458
- * @example
459
- * bw.normalizeClass('bw_card bw_btn') // => 'bw-card bw-btn'
460
- * bw.normalizeClass('my_class') // => 'my_class' (unchanged)
461
- */
462
- bw.normalizeClass = function(classStr) {
463
- if (typeof classStr !== 'string') return classStr;
464
- return classStr.replace(/\bbw_/g, 'bw-');
465
- };
466
408
 
467
409
  /**
468
410
  * Convert a TACO object (or array of TACOs) to an HTML string.
@@ -491,20 +433,52 @@ bw.normalizeClass = function(classStr) {
491
433
  bw.html = function(taco, options = {}) {
492
434
  // Handle null/undefined
493
435
  if (taco == null) return '';
494
-
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
+
495
446
  // Handle arrays of TACOs
496
447
  if (Array.isArray(taco)) {
497
448
  return taco.map(t => bw.html(t, options)).join('');
498
449
  }
499
-
450
+
500
451
  // Handle bw.raw() marked content
501
452
  if (taco && taco.__bw_raw) {
502
453
  return taco.v;
503
454
  }
504
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
+
505
474
  // Handle primitives and non-TACO objects
506
475
  if (typeof taco !== 'object' || !taco.t) {
507
- 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;
508
482
  }
509
483
 
510
484
  const { t: tag, a: attrs = {}, c: content, o: opts = {} } = taco;
@@ -534,12 +508,8 @@ bw.html = function(taco, options = {}) {
534
508
  attrStr += ` style="${bw.escapeHTML(styleStr)}"`;
535
509
  }
536
510
  } else if (key === 'class') {
537
- // Handle class as array or string, normalize bw_ to bw-
538
- const classStr = bw.normalizeClass(
539
- Array.isArray(value)
540
- ? value.filter(Boolean).join(' ')
541
- : String(value)
542
- );
511
+ // Handle class as array or string
512
+ const classStr = Array.isArray(value) ? value.filter(Boolean).join(' ') : String(value);
543
513
  if (classStr) {
544
514
  attrStr += ` class="${bw.escapeHTML(classStr)}"`;
545
515
  }
@@ -547,19 +517,23 @@ bw.html = function(taco, options = {}) {
547
517
  // Boolean attributes
548
518
  attrStr += ` ${key}`;
549
519
  } else {
550
- // Regular attributes
551
- 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)}"`;
552
526
  }
553
527
  }
554
528
 
555
- // Add bw-id as a class if lifecycle hooks present
556
- 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_')) {
557
531
  const id = opts.bw_id || bw.uuid();
558
532
  attrStr = attrStr.replace(/class="([^"]*)"/, (_match, classes) => {
559
- return `class="${classes} bw-id-${id}"`.trim();
533
+ return `class="${classes} bw_id_${id}"`.trim();
560
534
  });
561
535
  if (!attrStr.includes('class=')) {
562
- attrStr += ` class="bw-id-${id}"`;
536
+ attrStr += ` class="bw_id_${id}"`;
563
537
  }
564
538
  }
565
539
 
@@ -569,8 +543,12 @@ bw.html = function(taco, options = {}) {
569
543
  }
570
544
 
571
545
  // Process content recursively
572
- const contentStr = content != null ? bw.html(content, options) : '';
573
-
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
+
574
552
  return `<${tag}${attrStr}>${contentStr}</${tag}>`;
575
553
  };
576
554
 
@@ -590,7 +568,7 @@ bw.html = function(taco, options = {}) {
590
568
  * @example
591
569
  * var el = bw.createDOM({
592
570
  * t: 'button',
593
- * a: { class: 'bw-btn', onclick: () => alert('clicked') },
571
+ * a: { class: 'bw_btn', onclick: () => alert('clicked') },
594
572
  * c: 'Click Me'
595
573
  * });
596
574
  * document.body.appendChild(el);
@@ -612,6 +590,11 @@ bw.createDOM = function(taco, options = {}) {
612
590
  return frag;
613
591
  }
614
592
 
593
+ // Handle ComponentHandle — extract .taco for DOM creation
594
+ if (taco && taco._bwComponent === true) {
595
+ return bw.createDOM(taco.taco, options);
596
+ }
597
+
615
598
  // Handle text nodes
616
599
  if (typeof taco !== 'object' || !taco.t) {
617
600
  return document.createTextNode(String(taco));
@@ -630,12 +613,8 @@ bw.createDOM = function(taco, options = {}) {
630
613
  // Apply styles directly
631
614
  Object.assign(el.style, value);
632
615
  } else if (key === 'class') {
633
- // Handle class as array or string, normalize bw_ to bw-
634
- const classStr = bw.normalizeClass(
635
- Array.isArray(value)
636
- ? value.filter(Boolean).join(' ')
637
- : String(value)
638
- );
616
+ // Handle class as array or string
617
+ const classStr = Array.isArray(value) ? value.filter(Boolean).join(' ') : String(value);
639
618
  if (classStr) {
640
619
  el.className = classStr;
641
620
  }
@@ -656,16 +635,21 @@ bw.createDOM = function(taco, options = {}) {
656
635
  }
657
636
 
658
637
  // Add children, building _bw_refs for fast parent→child access.
659
- // 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,
660
639
  // so o.render functions can access them without any DOM lookup.
661
640
  if (content != null) {
662
641
  if (Array.isArray(content)) {
663
642
  content.forEach(child => {
664
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
+ }
665
649
  var childEl = bw.createDOM(child, options);
666
650
  el.appendChild(childEl);
667
651
  // Build local refs for addressable children
668
- 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;
669
653
  if (childBwId) {
670
654
  if (!el._bw_refs) el._bw_refs = {};
671
655
  el._bw_refs[childBwId] = childEl;
@@ -684,10 +668,13 @@ bw.createDOM = function(taco, options = {}) {
684
668
  } else if (typeof content === 'object' && content.__bw_raw) {
685
669
  // Raw HTML content — inject via innerHTML
686
670
  el.innerHTML = content.v;
671
+ } else if (content._bwComponent === true) {
672
+ // Single ComponentHandle as content
673
+ content.mount(el);
687
674
  } else if (typeof content === 'object' && content.t) {
688
675
  var childEl = bw.createDOM(content, options);
689
676
  el.appendChild(childEl);
690
- 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;
691
678
  if (childBwId) {
692
679
  if (!el._bw_refs) el._bw_refs = {};
693
680
  el._bw_refs[childBwId] = childEl;
@@ -712,10 +699,10 @@ bw.createDOM = function(taco, options = {}) {
712
699
 
713
700
  // Handle lifecycle hooks and state
714
701
  if (opts.mounted || opts.unmount || opts.render || opts.state) {
715
- const id = attrs['data-bw-id'] || bw.uuid();
716
- el.setAttribute('data-bw-id', id);
702
+ const id = attrs['data-bw_id'] || bw.uuid();
703
+ el.setAttribute('data-bw_id', id);
717
704
 
718
- // Register in node cache under data-bw-id
705
+ // Register in node cache under data-bw_id
719
706
  bw._registerNode(el, id);
720
707
 
721
708
  // Store state
@@ -760,9 +747,9 @@ bw.createDOM = function(taco, options = {}) {
760
747
  opts.unmount(el, el._bw_state || {});
761
748
  });
762
749
  }
763
- } else if (attrs['data-bw-id']) {
764
- // Element has explicit data-bw-id but no lifecycle hooks — still register it
765
- 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']);
766
753
  }
767
754
 
768
755
  return el;
@@ -809,7 +796,7 @@ bw.DOM = function(target, taco, options = {}) {
809
796
  // the target is the mount point, not the content being replaced)
810
797
  const savedState = targetEl._bw_state;
811
798
  const savedRender = targetEl._bw_render;
812
- const savedBwId = targetEl.getAttribute('data-bw-id');
799
+ const savedBwId = targetEl.getAttribute('data-bw_id');
813
800
  const savedSubs = targetEl._bw_subs;
814
801
 
815
802
  // Temporarily remove _bw_subs so cleanup doesn't call them
@@ -822,7 +809,7 @@ bw.DOM = function(target, taco, options = {}) {
822
809
  if (savedState !== undefined) targetEl._bw_state = savedState;
823
810
  if (savedRender) targetEl._bw_render = savedRender;
824
811
  if (savedBwId) {
825
- targetEl.setAttribute('data-bw-id', savedBwId);
812
+ targetEl.setAttribute('data-bw_id', savedBwId);
826
813
  // Re-register mount point in node cache (cleanup deregistered it)
827
814
  bw._registerNode(targetEl, savedBwId);
828
815
  }
@@ -832,15 +819,21 @@ bw.DOM = function(target, taco, options = {}) {
832
819
  targetEl.innerHTML = '';
833
820
 
834
821
  if (taco != null) {
822
+ // Handle ComponentHandle (reactive components from bw.component())
823
+ if (taco._bwComponent === true) {
824
+ taco.mount(targetEl);
825
+ }
835
826
  // Handle component handles (objects with element property)
836
- if (taco.element instanceof Element) {
827
+ else if (taco.element instanceof Element) {
837
828
  targetEl.appendChild(taco.element);
838
829
  }
839
830
  // Handle arrays
840
831
  else if (Array.isArray(taco)) {
841
832
  taco.forEach(t => {
842
833
  if (t != null) {
843
- if (t.element instanceof Element) {
834
+ if (t._bwComponent === true) {
835
+ t.mount(targetEl);
836
+ } else if (t.element instanceof Element) {
844
837
  targetEl.appendChild(t.element);
845
838
  } else {
846
839
  targetEl.appendChild(bw.createDOM(t, options));
@@ -1076,11 +1069,11 @@ bw.renderComponent = function(taco, options = {}) {
1076
1069
  bw.cleanup = function(element) {
1077
1070
  if (!bw._isBrowser || !element) return;
1078
1071
 
1079
- // Find all elements with data-bw-id
1080
- const elements = element.querySelectorAll('[data-bw-id]');
1072
+ // Find all elements with data-bw_id
1073
+ const elements = element.querySelectorAll('[data-bw_id]');
1081
1074
 
1082
1075
  elements.forEach(el => {
1083
- const id = el.getAttribute('data-bw-id');
1076
+ const id = el.getAttribute('data-bw_id');
1084
1077
  const callback = bw._unmountCallbacks.get(id);
1085
1078
 
1086
1079
  if (callback) {
@@ -1104,7 +1097,7 @@ bw.cleanup = function(element) {
1104
1097
  });
1105
1098
 
1106
1099
  // Check element itself
1107
- const id = element.getAttribute('data-bw-id');
1100
+ const id = element.getAttribute('data-bw_id');
1108
1101
  if (id) {
1109
1102
  const callback = bw._unmountCallbacks.get(id);
1110
1103
  if (callback) {
@@ -1123,6 +1116,13 @@ bw.cleanup = function(element) {
1123
1116
  delete element._bw_state;
1124
1117
  delete element._bw_render;
1125
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
+ }
1126
1126
  }
1127
1127
  };
1128
1128
 
@@ -1137,7 +1137,7 @@ bw.cleanup = function(element) {
1137
1137
  * Calls `el._bw_render(el, state)` and emits `bw:statechange` so other
1138
1138
  * components can react without tight coupling.
1139
1139
  *
1140
- * @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
1141
1141
  * @returns {Element|null} The element, or null if not found / no render function
1142
1142
  * @category State Management
1143
1143
  * @see bw.patch
@@ -1162,7 +1162,7 @@ bw.update = function(target) {
1162
1162
  * Use `bw.patch()` for lightweight value updates (scores, labels, counters)
1163
1163
  * and `bw.update()` for full structural re-renders.
1164
1164
  *
1165
- * @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.
1166
1166
  * Uses node cache for O(1) lookup; falls back to DOM query on cache miss.
1167
1167
  * @param {string|Object} content - New text content, or TACO object to replace children
1168
1168
  * @param {string} [attr] - If provided, sets this attribute instead of content
@@ -1237,7 +1237,7 @@ bw.patchAll = function(patches) {
1237
1237
  * bubble by default so ancestor elements can listen. Use with `bw.on()` for
1238
1238
  * DOM-scoped communication between components.
1239
1239
  *
1240
- * @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.
1241
1241
  * Uses node cache for O(1) lookup; falls back to DOM query on cache miss.
1242
1242
  * @param {string} eventName - Event name (will be prefixed with 'bw:')
1243
1243
  * @param {*} [detail] - Data to pass with the event
@@ -1264,7 +1264,7 @@ bw.emit = function(target, eventName, detail) {
1264
1264
  * is the first argument so you don't need to destructure `e.detail`.
1265
1265
  * Events bubble, so you can listen on an ancestor element.
1266
1266
  *
1267
- * @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.
1268
1268
  * Uses node cache for O(1) lookup; falls back to DOM query on cache miss.
1269
1269
  * @param {string} eventName - Event name (will be prefixed with 'bw:')
1270
1270
  * @param {Function} handler - Called with (detail, event)
@@ -1362,10 +1362,10 @@ bw.sub = function(topic, handler, el) {
1362
1362
  if (el) {
1363
1363
  if (!el._bw_subs) el._bw_subs = [];
1364
1364
  el._bw_subs.push(unsub);
1365
- // Ensure element has data-bw-id so bw.cleanup() finds it
1366
- 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')) {
1367
1367
  var bwId = 'bw_sub_' + id;
1368
- el.setAttribute('data-bw-id', bwId);
1368
+ el.setAttribute('data-bw_id', bwId);
1369
1369
  }
1370
1370
  }
1371
1371
 
@@ -1394,173 +1394,1490 @@ bw.unsub = function(topic, handler) {
1394
1394
  return removed;
1395
1395
  };
1396
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
+
1397
1425
  /**
1398
- * Generate CSS from JavaScript objects.
1426
+ * Retrieve a registered function by name.
1399
1427
  *
1400
- * Converts an object of `{ selector: { prop: value } }` rules into a CSS string.
1401
- * CamelCase property names are auto-converted to kebab-case (e.g. `fontSize` → `font-size`).
1402
- * Accepts nested arrays of rule objects.
1428
+ * Returns the function if found, or `errFn` (or a no-op logger) if not.
1403
1429
  *
1404
- * @param {Object|Array|string} rules - CSS rules as JS objects, array of rule objects, or raw CSS string
1405
- * @param {Object} [options] - Generation options
1406
- * @param {boolean} [options.minify=false] - Minify output (no whitespace)
1407
- * @returns {string} CSS string
1408
- * @category CSS & Styling
1409
- * @see bw.injectCSS
1410
- * @example
1411
- * bw.css({
1412
- * '.card': { padding: '1rem', fontSize: '14px', borderRadius: '8px' }
1413
- * })
1414
- * // => '.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
1415
1435
  */
1416
- bw.css = function(rules, options = {}) {
1417
- 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
+ };
1418
1441
 
1419
- 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
+ };
1420
1455
 
1421
- let css = '';
1422
- const indent = pretty ? ' ' : '';
1423
- const newline = pretty ? '\n' : '';
1424
- 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
+ };
1425
1470
 
1426
- if (Array.isArray(rules)) {
1427
- css = rules.map(rule => bw.css(rule, options)).join(newline);
1428
- } else if (typeof rules === 'object') {
1429
- Object.entries(rules).forEach(([selector, styles]) => {
1430
- if (typeof styles === 'object' && !Array.isArray(styles)) {
1431
- // Handle @media, @keyframes, @supports — recurse into nested block
1432
- if (selector.charAt(0) === '@') {
1433
- const inner = bw.css(styles, options);
1434
- if (inner) {
1435
- css += `${selector}${space}{${newline}${inner}${newline}}${newline}`;
1436
- }
1437
- return;
1438
- }
1439
- const declarations = Object.entries(styles)
1440
- .filter(([, value]) => value != null)
1441
- .map(([prop, value]) => {
1442
- // Convert camelCase to kebab-case
1443
- const kebabProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
1444
- return `${indent}${kebabProp}:${space}${value};`;
1445
- })
1446
- .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
+ };
1447
1486
 
1448
- if (declarations) {
1449
- css += `${selector}${space}{${newline}${declarations}${newline}}${newline}`;
1450
- }
1451
- }
1452
- });
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() });
1453
1502
  }
1503
+ return results;
1504
+ };
1454
1505
 
1455
- 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;
1456
1518
  };
1457
1519
 
1458
1520
  /**
1459
- * Inject CSS into the document head (browser only).
1521
+ * Resolve all `${expr}` bindings in a template string against a state object.
1460
1522
  *
1461
- * Creates or reuses a `<style>` element (identified by `id`). Can accept
1462
- * raw CSS strings or JS rule objects (which are converted via `bw.css()`).
1463
- * 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.
1464
1525
  *
1465
- * @param {string|Object|Array} css - CSS string, or JS rule objects to convert
1466
- * @param {Object} [options] - Injection options
1467
- * @param {string} [options.id='bw-styles'] - ID for the style element
1468
- * @param {boolean} [options.append=true] - Append to existing CSS (false to replace)
1469
- * @returns {Element} The style element
1470
- * @category CSS & Styling
1471
- * @see bw.css
1472
- * @see bw.loadDefaultStyles
1473
- * @example
1474
- * bw.injectCSS('.my-class { color: red; }');
1475
- * 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
1476
1531
  */
1477
- bw.injectCSS = function(css, options = {}) {
1478
- if (!bw._isBrowser) {
1479
- console.warn('bw.injectCSS requires a DOM environment');
1480
- 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;
1481
1564
  }
1482
-
1483
- const { id = 'bw-styles', append = true } = options;
1484
-
1485
- // Get or create style element
1486
- let styleEl = document.getElementById(id);
1487
-
1488
- if (!styleEl) {
1489
- styleEl = document.createElement('style');
1490
- styleEl.id = id;
1491
- styleEl.type = 'text/css';
1492
- 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
+ }
1493
1585
  }
1494
-
1495
- // Convert CSS if needed
1496
- const cssStr = typeof css === 'string' ? css : bw.css(css, options);
1497
-
1498
- // Set or append CSS
1499
- if (append && styleEl.textContent) {
1500
- 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);
1501
1605
  } else {
1502
- styleEl.textContent = cssStr;
1606
+ setTimeout(bw._doFlush, 0);
1503
1607
  }
1504
-
1505
- return styleEl;
1506
1608
  };
1507
1609
 
1508
1610
  /**
1509
- * Merge multiple style objects into one (left-to-right).
1510
- *
1511
- * Like `Object.assign()` for styles, but filters out null/undefined arguments.
1512
- * Compose inline styles or CSS rule objects without mutation.
1513
- *
1514
- * @param {...Object} styles - Style objects to merge (left-to-right)
1515
- * @returns {Object} Merged style object
1516
- * @category CSS & Styling
1517
- * @see bw.u
1518
- * @example
1519
- * var style = bw.s(bw.u.flex, bw.u.gap4, { color: 'red' });
1520
- * // => { display: 'flex', gap: '1rem', color: 'red' }
1611
+ * Flush all dirty components. Deduplicates by _bwId.
1612
+ * @private
1521
1613
  */
1522
- bw.s = function() {
1523
- var result = {};
1524
- for (var i = 0; i < arguments.length; i++) {
1525
- var arg = arguments[i];
1526
- 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
+ }
1527
1626
  }
1528
- return result;
1529
1627
  };
1530
1628
 
1531
1629
  /**
1532
- * 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.
1533
1632
  *
1534
- * Compose with `bw.s()` to build inline styles without writing raw CSS strings.
1535
- * 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.
1536
1647
  *
1537
- * @category CSS & Styling
1538
- * @see bw.s
1539
- * @example
1540
- * { t: 'div', a: { style: bw.s(bw.u.flex, bw.u.gap4, bw.u.p4) },
1541
- * c: 'Flexbox with 1rem gap and padding' }
1648
+ * @param {Object} taco - TACO definition {t, a, c, o}
1649
+ * @constructor
1650
+ * @private
1542
1651
  */
1543
- bw.u = {
1544
- // Display
1545
- flex: { display: 'flex' },
1546
- flexCol: { display: 'flex', flexDirection: 'column' },
1547
- flexRow: { display: 'flex', flexDirection: 'row' },
1548
- flexWrap: { display: 'flex', flexWrap: 'wrap' },
1549
- block: { display: 'block' },
1550
- inline: { display: 'inline' },
1551
- 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
+ }
1552
1717
 
1553
- // Flex alignment
1554
- justifyCenter: { justifyContent: 'center' },
1555
- justifyBetween: { justifyContent: 'space-between' },
1556
- justifyEnd: { justifyContent: 'flex-end' },
1557
- alignCenter: { alignItems: 'center' },
1558
- alignStart: { alignItems: 'flex-start' },
1559
- alignEnd: { alignItems: 'flex-end' },
1718
+ // ── State Methods ──
1560
1719
 
1561
- // Gap (0.25rem increments)
1562
- gap1: { gap: '0.25rem' },
1563
- 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.inspect() — Debug utility
2584
+ // ===================================================================================
2585
+
2586
+ /**
2587
+ * Inspect a component's state, bindings, methods, and metadata.
2588
+ * Works with DOM elements, CSS selectors, or ComponentHandle objects.
2589
+ * Returns the ComponentHandle for console chaining.
2590
+ *
2591
+ * @param {string|Element|ComponentHandle} target - Selector, element, or handle
2592
+ * @returns {ComponentHandle|null} The component handle, or null if not found
2593
+ * @category Component
2594
+ * @example
2595
+ * // In browser console, click element in Elements panel then:
2596
+ * bw.inspect($0);
2597
+ * // Or by selector:
2598
+ * var h = bw.inspect('#my-dashboard');
2599
+ * h.set('count', 99); // chain from returned handle
2600
+ */
2601
+ bw.inspect = function(target) {
2602
+ var el = target;
2603
+ var comp;
2604
+ if (target && target._bwComponent === true) {
2605
+ el = target.element;
2606
+ comp = target;
2607
+ } else {
2608
+ if (typeof target === 'string') {
2609
+ el = bw.$(target)[0];
2610
+ }
2611
+ if (!el) {
2612
+ console.warn('bw.inspect: element not found');
2613
+ return null;
2614
+ }
2615
+ comp = el._bwComponentHandle;
2616
+ }
2617
+ if (!comp) {
2618
+ console.log('bw.inspect: no ComponentHandle on this element');
2619
+ console.log(' Tag:', el.tagName);
2620
+ console.log(' Classes:', el.className);
2621
+ console.log(' _bw_state:', el._bw_state || '(none)');
2622
+ return null;
2623
+ }
2624
+ var deps = comp._bindings.reduce(function(s, b) {
2625
+ return s.concat(b.deps || []);
2626
+ }, []).filter(function(v, i, a) { return a.indexOf(v) === i; });
2627
+ console.group('Component: ' + comp._bwId);
2628
+ console.log('State:', comp._state);
2629
+ console.log('Bindings:', comp._bindings.length, '(deps:', deps, ')');
2630
+ console.log('Methods:', Object.keys(comp._methods));
2631
+ console.log('Actions:', Object.keys(comp._actions));
2632
+ console.log('User tag:', comp._userTag || '(none)');
2633
+ console.log('Mounted:', comp.mounted);
2634
+ console.log('Element:', comp.element);
2635
+ console.groupEnd();
2636
+ return comp;
2637
+ };
2638
+
2639
+ // ===================================================================================
2640
+ // bw.compile() — Pre-compile TACO into optimized factory
2641
+ // ===================================================================================
2642
+
2643
+ /**
2644
+ * Pre-compile a TACO definition into a factory function.
2645
+ * The factory produces ComponentHandles with pre-compiled binding evaluators.
2646
+ *
2647
+ * Phase 1: validates API surface. Template cloning optimization deferred.
2648
+ *
2649
+ * @param {Object} taco - TACO definition
2650
+ * @returns {Function} Factory function(initialState?) → ComponentHandle
2651
+ * @category Component
2652
+ */
2653
+ bw.compile = function(taco) {
2654
+ // Pre-extract all binding expressions
2655
+ var precompiled = [];
2656
+ function walkExpressions(node) {
2657
+ if (!node || typeof node !== 'object') return;
2658
+ if (typeof node.c === 'string' && node.c.indexOf('${') >= 0) {
2659
+ var parsed = bw._parseBindings(node.c);
2660
+ for (var i = 0; i < parsed.length; i++) {
2661
+ try {
2662
+ precompiled.push({
2663
+ expr: parsed[i].expr,
2664
+ fn: new Function('state', 'with(state){return (' + parsed[i].expr + ');}')
2665
+ });
2666
+ } catch(e) {
2667
+ precompiled.push({ expr: parsed[i].expr, fn: function() { return ''; } });
2668
+ }
2669
+ }
2670
+ }
2671
+ if (node.a) {
2672
+ for (var key in node.a) {
2673
+ if (Object.prototype.hasOwnProperty.call(node.a, key)) {
2674
+ var v = node.a[key];
2675
+ if (typeof v === 'string' && v.indexOf('${') >= 0) {
2676
+ var parsed2 = bw._parseBindings(v);
2677
+ for (var j = 0; j < parsed2.length; j++) {
2678
+ try {
2679
+ precompiled.push({
2680
+ expr: parsed2[j].expr,
2681
+ fn: new Function('state', 'with(state){return (' + parsed2[j].expr + ');}')
2682
+ });
2683
+ } catch(e2) {
2684
+ precompiled.push({ expr: parsed2[j].expr, fn: function() { return ''; } });
2685
+ }
2686
+ }
2687
+ }
2688
+ }
2689
+ }
2690
+ }
2691
+ if (Array.isArray(node.c)) {
2692
+ for (var k = 0; k < node.c.length; k++) walkExpressions(node.c[k]);
2693
+ } else if (node.c && typeof node.c === 'object' && node.c.t) {
2694
+ walkExpressions(node.c);
2695
+ }
2696
+ }
2697
+ walkExpressions(taco);
2698
+
2699
+ return function(initialState) {
2700
+ var handle = new ComponentHandle(taco);
2701
+ handle._compile = true;
2702
+ handle._precompiledBindings = precompiled;
2703
+ if (initialState) {
2704
+ for (var k in initialState) {
2705
+ if (Object.prototype.hasOwnProperty.call(initialState, k)) {
2706
+ handle._state[k] = initialState[k];
2707
+ }
2708
+ }
2709
+ }
2710
+ return handle;
2711
+ };
2712
+ };
2713
+
2714
+ /**
2715
+ * Generate CSS from JavaScript objects.
2716
+ *
2717
+ * Converts an object of `{ selector: { prop: value } }` rules into a CSS string.
2718
+ * CamelCase property names are auto-converted to kebab-case (e.g. `fontSize` → `font-size`).
2719
+ * Accepts nested arrays of rule objects.
2720
+ *
2721
+ * @param {Object|Array|string} rules - CSS rules as JS objects, array of rule objects, or raw CSS string
2722
+ * @param {Object} [options] - Generation options
2723
+ * @param {boolean} [options.minify=false] - Minify output (no whitespace)
2724
+ * @returns {string} CSS string
2725
+ * @category CSS & Styling
2726
+ * @see bw.injectCSS
2727
+ * @example
2728
+ * bw.css({
2729
+ * '.card': { padding: '1rem', fontSize: '14px', borderRadius: '8px' }
2730
+ * })
2731
+ * // => '.card {\n padding: 1rem;\n font-size: 14px;\n border-radius: 8px;\n}'
2732
+ */
2733
+ bw.css = function(rules, options = {}) {
2734
+ const { minify = false, pretty = !minify } = options;
2735
+
2736
+ if (typeof rules === 'string') return rules;
2737
+
2738
+ let css = '';
2739
+ const indent = pretty ? ' ' : '';
2740
+ const newline = pretty ? '\n' : '';
2741
+ const space = pretty ? ' ' : '';
2742
+
2743
+ if (Array.isArray(rules)) {
2744
+ css = rules.map(rule => bw.css(rule, options)).join(newline);
2745
+ } else if (typeof rules === 'object') {
2746
+ Object.entries(rules).forEach(([selector, styles]) => {
2747
+ if (typeof styles === 'object' && !Array.isArray(styles)) {
2748
+ // Handle @media, @keyframes, @supports — recurse into nested block
2749
+ if (selector.charAt(0) === '@') {
2750
+ const inner = bw.css(styles, options);
2751
+ if (inner) {
2752
+ css += `${selector}${space}{${newline}${inner}${newline}}${newline}`;
2753
+ }
2754
+ return;
2755
+ }
2756
+ const declarations = Object.entries(styles)
2757
+ .filter(([, value]) => value != null)
2758
+ .map(([prop, value]) => {
2759
+ // Convert camelCase to kebab-case
2760
+ const kebabProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
2761
+ return `${indent}${kebabProp}:${space}${value};`;
2762
+ })
2763
+ .join(newline);
2764
+
2765
+ if (declarations) {
2766
+ css += `${selector}${space}{${newline}${declarations}${newline}}${newline}`;
2767
+ }
2768
+ }
2769
+ });
2770
+ }
2771
+
2772
+ return css.trim();
2773
+ };
2774
+
2775
+ /**
2776
+ * Inject CSS into the document head (browser only).
2777
+ *
2778
+ * Creates or reuses a `<style>` element (identified by `id`). Can accept
2779
+ * raw CSS strings or JS rule objects (which are converted via `bw.css()`).
2780
+ * By default appends to existing content; set `append: false` to replace.
2781
+ *
2782
+ * @param {string|Object|Array} css - CSS string, or JS rule objects to convert
2783
+ * @param {Object} [options] - Injection options
2784
+ * @param {string} [options.id='bw_styles'] - ID for the style element
2785
+ * @param {boolean} [options.append=true] - Append to existing CSS (false to replace)
2786
+ * @returns {Element} The style element
2787
+ * @category CSS & Styling
2788
+ * @see bw.css
2789
+ * @see bw.loadDefaultStyles
2790
+ * @example
2791
+ * bw.injectCSS('.my-class { color: red; }');
2792
+ * bw.injectCSS({ '.card': { padding: '1rem' } }, { id: 'card-styles' });
2793
+ */
2794
+ bw.injectCSS = function(css, options = {}) {
2795
+ if (!bw._isBrowser) {
2796
+ console.warn('bw.injectCSS requires a DOM environment');
2797
+ return null;
2798
+ }
2799
+
2800
+ const { id = 'bw_styles', append = true } = options;
2801
+
2802
+ // Get or create style element
2803
+ let styleEl = document.getElementById(id);
2804
+
2805
+ if (!styleEl) {
2806
+ styleEl = document.createElement('style');
2807
+ styleEl.id = id;
2808
+ styleEl.type = 'text/css';
2809
+ document.head.appendChild(styleEl);
2810
+ }
2811
+
2812
+ // Convert CSS if needed
2813
+ const cssStr = typeof css === 'string' ? css : bw.css(css, options);
2814
+
2815
+ // Set or append CSS
2816
+ if (append && styleEl.textContent) {
2817
+ styleEl.textContent += '\n' + cssStr;
2818
+ } else {
2819
+ styleEl.textContent = cssStr;
2820
+ }
2821
+
2822
+ return styleEl;
2823
+ };
2824
+
2825
+ /**
2826
+ * Merge multiple style objects into one (left-to-right).
2827
+ *
2828
+ * Like `Object.assign()` for styles, but filters out null/undefined arguments.
2829
+ * Compose inline styles or CSS rule objects without mutation.
2830
+ *
2831
+ * @param {...Object} styles - Style objects to merge (left-to-right)
2832
+ * @returns {Object} Merged style object
2833
+ * @category CSS & Styling
2834
+ * @see bw.u
2835
+ * @example
2836
+ * var style = bw.s(bw.u.flex, bw.u.gap4, { color: 'red' });
2837
+ * // => { display: 'flex', gap: '1rem', color: 'red' }
2838
+ */
2839
+ bw.s = function() {
2840
+ var result = {};
2841
+ for (var i = 0; i < arguments.length; i++) {
2842
+ var arg = arguments[i];
2843
+ if (arg && typeof arg === 'object') Object.assign(result, arg);
2844
+ }
2845
+ return result;
2846
+ };
2847
+
2848
+ /**
2849
+ * Pre-built CSS utility objects (like Tailwind utilities, but in JS).
2850
+ *
2851
+ * Compose with `bw.s()` to build inline styles without writing raw CSS strings.
2852
+ * Includes flex, padding, margin, typography, color, border, and transition utilities.
2853
+ *
2854
+ * @category CSS & Styling
2855
+ * @see bw.s
2856
+ * @example
2857
+ * { t: 'div', a: { style: bw.s(bw.u.flex, bw.u.gap4, bw.u.p4) },
2858
+ * c: 'Flexbox with 1rem gap and padding' }
2859
+ */
2860
+ bw.u = {
2861
+ // Display
2862
+ flex: { display: 'flex' },
2863
+ flexCol: { display: 'flex', flexDirection: 'column' },
2864
+ flexRow: { display: 'flex', flexDirection: 'row' },
2865
+ flexWrap: { display: 'flex', flexWrap: 'wrap' },
2866
+ block: { display: 'block' },
2867
+ inline: { display: 'inline' },
2868
+ hidden: { display: 'none' },
2869
+
2870
+ // Flex alignment
2871
+ justifyCenter: { justifyContent: 'center' },
2872
+ justifyBetween: { justifyContent: 'space-between' },
2873
+ justifyEnd: { justifyContent: 'flex-end' },
2874
+ alignCenter: { alignItems: 'center' },
2875
+ alignStart: { alignItems: 'flex-start' },
2876
+ alignEnd: { alignItems: 'flex-end' },
2877
+
2878
+ // Gap (0.25rem increments)
2879
+ gap1: { gap: '0.25rem' },
2880
+ gap2: { gap: '0.5rem' },
1564
2881
  gap3: { gap: '0.75rem' },
1565
2882
  gap4: { gap: '1rem' },
1566
2883
  gap6: { gap: '1.5rem' },
@@ -1624,8 +2941,10 @@ bw.u = {
1624
2941
  /**
1625
2942
  * Generate responsive CSS with media query breakpoints.
1626
2943
  *
1627
- * Produces a CSS string with `@media` rules for sm (640px), md (768px),
1628
- * lg (1024px), and xl (1280px) breakpoints. Pass the result to `bw.injectCSS()`.
2944
+ * Produces a CSS string with `@media (min-width)` rules for standard
2945
+ * breakpoints. These match the grid system and theme.breakpoints:
2946
+ * sm: 576px, md: 768px, lg: 992px, xl: 1200px
2947
+ * Pass the result to `bw.injectCSS()`.
1629
2948
  *
1630
2949
  * @param {string} selector - CSS selector
1631
2950
  * @param {Object} breakpoints - Object with keys: base, sm, md, lg, xl
@@ -1642,7 +2961,7 @@ bw.u = {
1642
2961
  * bw.injectCSS(css);
1643
2962
  */
1644
2963
  bw.responsive = function(selector, breakpoints) {
1645
- var sizes = { sm: '640px', md: '768px', lg: '1024px', xl: '1280px' };
2964
+ var sizes = { sm: '576px', md: '768px', lg: '992px', xl: '1200px' };
1646
2965
  var parts = [];
1647
2966
  Object.keys(breakpoints).forEach(function(key) {
1648
2967
  var rules = {};
@@ -1678,29 +2997,7 @@ bw.responsive = function(selector, breakpoints) {
1678
2997
  * bw.mapScale(50, 0, 100, 0, 1) // => 0.5
1679
2998
  * bw.mapScale(75, 0, 100, 0, 255) // => 191.25
1680
2999
  */
1681
- bw.mapScale = function(x, in0, in1, out0, out1, options = {}) {
1682
- const { clip = false, expScale = 1 } = options;
1683
-
1684
- // Normalize to 0-1
1685
- let normalized = (x - in0) / (in1 - in0);
1686
-
1687
- // Apply exponential scaling
1688
- if (expScale !== 1) {
1689
- normalized = Math.pow(normalized, expScale);
1690
- }
1691
-
1692
- // Map to output range
1693
- let result = normalized * (out1 - out0) + out0;
1694
-
1695
- // Clip if requested
1696
- if (clip) {
1697
- const min = Math.min(out0, out1);
1698
- const max = Math.max(out0, out1);
1699
- result = Math.max(min, Math.min(max, result));
1700
- }
1701
-
1702
- return result;
1703
- };
3000
+ bw.mapScale = _mapScale;
1704
3001
 
1705
3002
  /**
1706
3003
  * Clamp a value between min and max bounds.
@@ -1716,9 +3013,7 @@ bw.mapScale = function(x, in0, in1, out0, out1, options = {}) {
1716
3013
  * bw.clip(-5, 0, 100) // => 0
1717
3014
  * bw.clip(50, 0, 100) // => 50
1718
3015
  */
1719
- bw.clip = function(value, min, max) {
1720
- return Math.max(min, Math.min(max, value));
1721
- };
3016
+ bw.clip = _clip;
1722
3017
 
1723
3018
  /**
1724
3019
  * DOM selection helper that always returns an array (browser only).
@@ -1776,7 +3071,8 @@ if (bw._isBrowser) {
1776
3071
  * @returns {Element|null} Style element if in browser, null in Node.js
1777
3072
  * @category CSS & Styling
1778
3073
  * @see bw.setTheme
1779
- * @see bw.toggleDarkMode
3074
+ * @see bw.applyTheme
3075
+ * @see bw.toggleTheme
1780
3076
  * @example
1781
3077
  * bw.loadDefaultStyles(); // inject all default CSS
1782
3078
  */
@@ -1786,7 +3082,7 @@ bw.loadDefaultStyles = function(options = {}) {
1786
3082
  // 1. Inject structural CSS (layout, sizing — never changes with theme)
1787
3083
  if (bw._isBrowser) {
1788
3084
  var structuralCSS = bw.css(getStructuralStyles());
1789
- bw.injectCSS(structuralCSS, { id: 'bw-structural', append: false, minify: minify });
3085
+ bw.injectCSS(structuralCSS, { id: 'bw_structural', append: false, minify: minify });
1790
3086
  }
1791
3087
 
1792
3088
  // 2. Inject cosmetic CSS via generateTheme (colors, shadows, radii)
@@ -1795,100 +3091,6 @@ bw.loadDefaultStyles = function(options = {}) {
1795
3091
  return result;
1796
3092
  };
1797
3093
 
1798
- /**
1799
- * Get the current theme configuration as a deep copy.
1800
- *
1801
- * @returns {Object} Theme object with colors, fonts, spacing, etc.
1802
- * @category CSS & Styling
1803
- * @see bw.setTheme
1804
- */
1805
- bw.getTheme = function() {
1806
- if (typeof console !== 'undefined' && console.warn) {
1807
- console.warn('bw.getTheme() is deprecated. Use bw.generateTheme() instead.');
1808
- }
1809
- return JSON.parse(JSON.stringify(theme));
1810
- };
1811
-
1812
- /**
1813
- * Set theme overrides and optionally re-inject CSS custom properties.
1814
- *
1815
- * Merges your overrides into the current theme and updates `--bw-*` CSS
1816
- * custom properties on `<html>` so all components pick up the changes live.
1817
- *
1818
- * @param {Object} overrides - Partial theme object to merge (e.g. { colors: { primary: '#ff0000' } })
1819
- * @param {Object} [options] - Options
1820
- * @param {boolean} [options.inject=true] - Whether to re-inject CSS (browser only)
1821
- * @returns {Object} Updated theme
1822
- * @category CSS & Styling
1823
- * @see bw.getTheme
1824
- * @see bw.loadDefaultStyles
1825
- * @example
1826
- * bw.setTheme({ colors: { primary: '#ff6600' } });
1827
- */
1828
- bw.setTheme = function(overrides, options = {}) {
1829
- if (typeof console !== 'undefined' && console.warn) {
1830
- console.warn('bw.setTheme() is deprecated. Use bw.generateTheme() instead.');
1831
- }
1832
- const { inject = true } = options;
1833
- updateTheme(overrides);
1834
-
1835
- // Update CSS custom properties if colors changed and we're in browser
1836
- if (inject && bw._isBrowser && overrides.colors) {
1837
- const root = document.documentElement;
1838
- for (const [name, value] of Object.entries(overrides.colors)) {
1839
- root.style.setProperty('--bw-' + name, value);
1840
- }
1841
- }
1842
-
1843
- return bw.getTheme();
1844
- };
1845
-
1846
- /**
1847
- * Toggle dark mode on/off.
1848
- *
1849
- * Adds/removes the `bw-dark` class on `<html>` and injects dark mode CSS
1850
- * overrides. Pass `true`/`false` to force a mode, or omit to toggle.
1851
- *
1852
- * @param {boolean} [force] - Force dark (true) or light (false). Omit to toggle.
1853
- * @returns {boolean} Whether dark mode is now active
1854
- * @category CSS & Styling
1855
- * @see bw.setTheme
1856
- * @example
1857
- * bw.toggleDarkMode(); // toggle
1858
- * bw.toggleDarkMode(true); // force dark
1859
- * bw.toggleDarkMode(false); // force light
1860
- */
1861
- bw.toggleDarkMode = function(force) {
1862
- const isDark = force !== undefined ? force : !theme.darkMode;
1863
- theme.darkMode = isDark;
1864
-
1865
- if (bw._isBrowser) {
1866
- const root = document.documentElement;
1867
- if (isDark) {
1868
- root.classList.add('bw-dark');
1869
- // Generate palette-aware dark mode CSS, or fall back to default
1870
- var palette = bw._activePalette || derivePalette(DEFAULT_PALETTE_CONFIG);
1871
- var darkRules = generateDarkModeCSS(palette);
1872
- var darkCSS = bw.css(darkRules);
1873
-
1874
- // Remove existing dark styles to allow regeneration
1875
- var existing = document.getElementById('bw-dark-styles');
1876
- if (existing) existing.remove();
1877
-
1878
- var styleEl = document.createElement('style');
1879
- styleEl.id = 'bw-dark-styles';
1880
- styleEl.textContent = darkCSS;
1881
- document.head.appendChild(styleEl);
1882
- } else {
1883
- root.classList.remove('bw-dark');
1884
- // Remove dark mode styles when switching to light
1885
- var darkEl = document.getElementById('bw-dark-styles');
1886
- if (darkEl) darkEl.remove();
1887
- }
1888
- }
1889
-
1890
- return isDark;
1891
- };
1892
3094
 
1893
3095
  /**
1894
3096
  * Generate a complete, scoped theme from seed colors.
@@ -1909,16 +3111,24 @@ bw.toggleDarkMode = function(force) {
1909
3111
  * @param {string} [config.info='#0dcaf0'] - Info color hex
1910
3112
  * @param {string} [config.light='#f8f9fa'] - Light color hex
1911
3113
  * @param {string} [config.dark='#212529'] - Dark color hex
3114
+ * @param {string} [config.background] - Page background hex (default: '#ffffff' light, derived dark)
3115
+ * @param {string} [config.surface] - Surface/card background hex (default: '#f8f9fa' light, derived dark)
1912
3116
  * @param {string} [config.spacing='normal'] - 'compact' | 'normal' | 'spacious'
1913
3117
  * @param {string} [config.radius='md'] - 'none' | 'sm' | 'md' | 'lg' | 'pill'
1914
3118
  * @param {number} [config.fontSize=1.0] - Base font size scale factor
3119
+ * @param {string|number} [config.typeRatio='normal'] - 'tight' | 'normal' | 'relaxed' | 'dramatic' or a number
3120
+ * @param {string} [config.elevation='md'] - 'flat' | 'sm' | 'md' | 'lg'
3121
+ * @param {string} [config.motion='standard'] - 'reduced' | 'standard' | 'expressive'
3122
+ * @param {number} [config.harmonize=0.20] - 0-1, semantic color hue shift toward primary
1915
3123
  * @param {boolean} [config.inject=true] - Inject into DOM (browser only)
1916
- * @returns {Object} { css, palette, name }
3124
+ * @returns {Object} { css, palette, name, isLightPrimary, alternate: { css, palette } }
1917
3125
  * @category CSS & Styling
3126
+ * @see bw.applyTheme
3127
+ * @see bw.toggleTheme
1918
3128
  * @see bw.loadDefaultStyles
1919
3129
  * @example
1920
- * // Generate and inject an ocean theme
1921
- * bw.generateTheme('ocean', {
3130
+ * // Generate and inject an ocean theme (primary + alternate)
3131
+ * var theme = bw.generateTheme('ocean', {
1922
3132
  * primary: '#0077b6',
1923
3133
  * secondary: '#90e0ef',
1924
3134
  * tertiary: '#00b4d8'
@@ -1927,14 +3137,16 @@ bw.toggleDarkMode = function(force) {
1927
3137
  * // Apply to a container
1928
3138
  * document.getElementById('app').classList.add('ocean');
1929
3139
  *
3140
+ * // Toggle to alternate palette
3141
+ * bw.toggleTheme();
3142
+ *
1930
3143
  * // Generate CSS for static export (Node.js)
1931
3144
  * var result = bw.generateTheme('sunset', {
1932
3145
  * primary: '#e76f51',
1933
3146
  * secondary: '#264653',
1934
- * tertiary: '#e9c46a',
1935
3147
  * inject: false
1936
3148
  * });
1937
- * fs.writeFileSync('sunset.css', result.css);
3149
+ * fs.writeFileSync('sunset.css', result.css + result.alternate.css);
1938
3150
  */
1939
3151
  bw.generateTheme = function(name, config) {
1940
3152
  if (!config || !config.primary || !config.secondary) {
@@ -1945,29 +3157,38 @@ bw.generateTheme = function(name, config) {
1945
3157
  var fullConfig = Object.assign({}, DEFAULT_PALETTE_CONFIG, config);
1946
3158
  if (!config.tertiary) fullConfig.tertiary = fullConfig.primary;
1947
3159
 
1948
- // Derive palette
3160
+ // Derive primary palette
1949
3161
  var palette = derivePalette(fullConfig);
1950
3162
 
1951
- // Store active palette for dark mode
1952
- bw._activePalette = palette;
1953
-
1954
3163
  // Resolve layout
1955
3164
  var layout = resolveLayout(fullConfig);
1956
3165
 
1957
- // Generate themed CSS rules
3166
+ // Generate primary themed CSS rules
1958
3167
  var themedRules = generateThemedCSS(name, palette, layout);
3168
+ var cssStr = bw.css(themedRules);
3169
+
3170
+ // Derive alternate palette (luminance-inverted)
3171
+ var altConfig = deriveAlternateConfig(fullConfig);
3172
+ var altPalette = derivePalette(altConfig);
1959
3173
 
1960
- // Add underscore aliases
1961
- var aliasedRules = addUnderscoreAliases(themedRules);
3174
+ // Generate alternate CSS scoped under .bw_theme_alt
3175
+ var altRules = generateAlternateCSS(name, altPalette, layout);
3176
+ var altCssStr = bw.css(altRules);
1962
3177
 
1963
- // Convert to CSS string
1964
- var cssStr = bw.css(aliasedRules);
3178
+ // Determine if primary is light-flavored
3179
+ var lightPrimary = isLightPalette(fullConfig);
1965
3180
 
1966
- // Inject into DOM if requested and in browser
3181
+ // Inject both CSS sets into DOM if requested
1967
3182
  var shouldInject = config.inject !== false;
1968
3183
  if (shouldInject && bw._isBrowser) {
1969
- var styleId = name ? 'bw-theme-' + name : 'bw-theme-default';
3184
+ var safeName = name ? name.replace(/-/g, '_') : '';
3185
+ var styleId = safeName ? 'bw_theme_' + safeName : 'bw_theme_default';
3186
+ var altStyleId = safeName ? 'bw_theme_' + safeName + '_alt' : 'bw_theme_default_alt';
3187
+
1970
3188
  bw.injectCSS(cssStr, { id: styleId, append: false });
3189
+ bw.injectCSS(altCssStr, { id: altStyleId, append: false });
3190
+
3191
+ bw._activeThemeStyleIds = [styleId, altStyleId];
1971
3192
  }
1972
3193
 
1973
3194
  // Update bw.u color entries to reflect the palette
@@ -1978,314 +3199,144 @@ bw.generateTheme = function(name, config) {
1978
3199
  bw.u.textWhite = { color: '#ffffff' };
1979
3200
  }
1980
3201
 
1981
- return { css: cssStr, palette: palette, name: name };
1982
- };
1983
-
1984
- // Expose color utility functions on bw namespace
1985
- bw.hexToHsl = hexToHsl;
1986
- bw.hslToHex = hslToHex;
1987
- bw.adjustLightness = adjustLightness;
1988
- bw.mixColor = mixColor;
1989
- bw.relativeLuminance = relativeLuminance;
1990
- bw.textOnColor = textOnColor;
1991
- bw.deriveShades = deriveShades;
1992
- bw.derivePalette = derivePalette;
1993
-
1994
- // Expose layout and theme presets
1995
- bw.SPACING_PRESETS = SPACING_PRESETS;
1996
- bw.RADIUS_PRESETS = RADIUS_PRESETS;
1997
- bw.DEFAULT_PALETTE_CONFIG = DEFAULT_PALETTE_CONFIG;
1998
- bw.THEME_PRESETS = THEME_PRESETS;
1999
-
2000
- // ===================================================================================
2001
- // Legacy v1 Functions - Useful utilities retained from bitwrench v1
2002
- // ===================================================================================
2003
-
2004
- /**
2005
- * Use a dictionary as a switch statement, with support for function values.
2006
- *
2007
- * Looks up `x` in `choices`. If the value is a function, calls it with `x` as argument.
2008
- * Returns `def` if the key is not found.
2009
- *
2010
- * @param {*} x - Key to look up
2011
- * @param {Object} choices - Dictionary of choices (values can be functions)
2012
- * @param {*} def - Default value if key not found
2013
- * @returns {*} Value or function result
2014
- * @category Array Utilities
2015
- * @example
2016
- * var colors = { red: 1, blue: 2, aqua: function(z) { return z + 'marine'; } };
2017
- * bw.choice('red', colors, '0') // => 1
2018
- * bw.choice('aqua', colors) // => 'aquamarine'
2019
- * bw.choice('pink', colors, 'n/a') // => 'n/a'
2020
- */
2021
- bw.choice = function(x, choices, def) {
2022
- const z = (x in choices) ? choices[x] : def;
2023
- return bw.typeOf(z) === "function" ? z(x) : z;
2024
- };
2025
-
2026
- /**
2027
- * Return unique elements of an array (preserves first occurrence order).
2028
- *
2029
- * @param {Array} x - Input array
2030
- * @returns {Array} Array with unique elements
2031
- * @category Array Utilities
2032
- * @example
2033
- * bw.arrayUniq([1, 2, 2, 3, 1]) // => [1, 2, 3]
2034
- */
2035
- bw.arrayUniq = function(x) {
2036
- if (bw.typeOf(x) !== "array") return [];
2037
- return x.filter((v, i, arr) => arr.indexOf(v) === i);
2038
- };
2039
-
2040
- /**
2041
- * Return the intersection of two arrays (elements present in both).
2042
- *
2043
- * @param {Array} a - First array
2044
- * @param {Array} b - Second array
2045
- * @returns {Array} Unique elements found in both a and b
2046
- * @category Array Utilities
2047
- * @see bw.arrayBNotInA
2048
- * @example
2049
- * bw.arrayBinA([1, 2, 3], [2, 3, 4]) // => [2, 3]
2050
- */
2051
- bw.arrayBinA = function(a, b) {
2052
- if (bw.typeOf(a) !== "array" || bw.typeOf(b) !== "array") return [];
2053
- return bw.arrayUniq(a.filter(n => b.indexOf(n) !== -1));
2054
- };
2055
-
2056
- /**
2057
- * Return elements of b that are not present in a (set difference).
2058
- *
2059
- * @param {Array} a - First array (the "exclude" set)
2060
- * @param {Array} b - Second array (source of results)
2061
- * @returns {Array} Unique elements in b but not in a
2062
- * @category Array Utilities
2063
- * @see bw.arrayBinA
2064
- * @example
2065
- * bw.arrayBNotInA([1, 2, 3], [2, 3, 4, 5]) // => [4, 5]
2066
- */
2067
- bw.arrayBNotInA = function(a, b) {
2068
- if (bw.typeOf(a) !== "array" || bw.typeOf(b) !== "array") return [];
2069
- return bw.arrayUniq(b.filter(n => a.indexOf(n) < 0));
2070
- };
3202
+ // Store active theme state
3203
+ var result = {
3204
+ css: cssStr,
3205
+ palette: palette,
3206
+ name: name,
3207
+ isLightPrimary: lightPrimary,
3208
+ alternate: {
3209
+ css: altCssStr,
3210
+ palette: altPalette
3211
+ }
3212
+ };
3213
+ bw._activeTheme = result;
3214
+ bw._activeThemeMode = 'primary';
2071
3215
 
2072
- /**
2073
- * Interpolate between an array of colors based on a value in a range.
2074
- *
2075
- * Maps a value from [in0..in1] across a gradient of colors, smoothly blending
2076
- * between adjacent stops. Useful for heatmaps, gauges, and data visualization.
2077
- *
2078
- * @param {number} x - Value to interpolate
2079
- * @param {number} in0 - Input range start
2080
- * @param {number} in1 - Input range end
2081
- * @param {Array} colors - Array of CSS color strings to interpolate between
2082
- * @param {number} [stretch] - Exponential scaling factor (1 = linear)
2083
- * @returns {Array} Interpolated color as [r, g, b, a, "rgb"]
2084
- * @category Color
2085
- * @see bw.colorParse
2086
- * @see bw.mapScale
2087
- * @example
2088
- * bw.colorInterp(50, 0, 100, ['#ff0000', '#00ff00'])
2089
- * // => [128, 128, 0, 255, "rgb"] (yellow midpoint)
2090
- */
2091
- bw.colorInterp = function(x, in0, in1, colors, stretch) {
2092
- let c = Array.isArray(colors) ? colors : ["#000", "#fff"];
2093
- c = c.length === 0 ? ["#000", "#fff"] : c;
2094
- if (c.length === 1) return c[0];
2095
-
2096
- // Convert all colors to RGB format
2097
- c = c.map(col => bw.colorParse(col));
2098
-
2099
- const a = bw.mapScale(x, in0, in1, 0, c.length - 1, { clip: true, expScale: stretch });
2100
- const i = bw.clip(Math.floor(a), 0, c.length - 2);
2101
- const r = a - i;
2102
-
2103
- const interp = (idx) => bw.mapScale(r, 0, 1, c[i][idx], c[i + 1][idx], { clip: true });
2104
- return [interp(0), interp(1), interp(2), interp(3), "rgb"];
3216
+ return result;
2105
3217
  };
2106
3218
 
2107
3219
  /**
2108
- * Convert an HSL color to RGB.
2109
- *
2110
- * Accepts individual h, s, l values or a bitwrench color array [h, s, l, a, "hsl"].
3220
+ * Apply a theme mode. Switches between primary and alternate palettes
3221
+ * by adding/removing the `bw_theme_alt` class on `<html>`.
2111
3222
  *
2112
- * @param {number|Array} h - Hue [0..360] or [h,s,l,a,"hsl"] array
2113
- * @param {number} s - Saturation [0..100]
2114
- * @param {number} l - Lightness [0..100]
2115
- * @param {number} [a=255] - Alpha [0..255]
2116
- * @param {boolean} [rnd=true] - Round results to integers
2117
- * @returns {Array} RGB as [r, g, b, a, "rgb"]
2118
- * @category Color
2119
- * @see bw.colorRgbToHsl
3223
+ * @param {string} mode - 'primary' | 'alternate' | 'light' | 'dark'
3224
+ * @returns {string} Active mode: 'primary' or 'alternate'
3225
+ * @category CSS & Styling
3226
+ * @see bw.generateTheme
3227
+ * @see bw.toggleTheme
2120
3228
  * @example
2121
- * bw.colorHslToRgb(0, 100, 50) // => [255, 0, 0, 255, "rgb"]
2122
- * bw.colorHslToRgb(120, 100, 50) // => [0, 255, 0, 255, "rgb"]
3229
+ * bw.applyTheme('alternate'); // switch to alternate palette
3230
+ * bw.applyTheme('dark'); // switch to whichever palette is darker
3231
+ * bw.applyTheme('primary'); // switch back to primary palette
2123
3232
  */
2124
- bw.colorHslToRgb = function(h, s, l, a = 255, rnd = true) {
2125
- if (bw.typeOf(h) === "array") {
2126
- s = h[1]; l = h[2]; a = h[3]; h = h[0];
2127
- }
2128
-
2129
- const hNorm = h / 360;
2130
- const sNorm = s / 100;
2131
- const lNorm = l / 100;
2132
-
2133
- let r, g, b;
2134
-
2135
- if (sNorm === 0) {
2136
- r = g = b = lNorm * 255;
3233
+ bw.applyTheme = function(mode) {
3234
+ if (!bw._isBrowser) return mode || 'primary';
3235
+ var root = document.documentElement;
3236
+ var isLight = bw._activeTheme ? bw._activeTheme.isLightPrimary : true;
3237
+
3238
+ var wantAlt;
3239
+ if (mode === 'primary') wantAlt = false;
3240
+ else if (mode === 'alternate') wantAlt = true;
3241
+ else if (mode === 'light') wantAlt = !isLight;
3242
+ else if (mode === 'dark') wantAlt = isLight;
3243
+ else wantAlt = false;
3244
+
3245
+ if (wantAlt) {
3246
+ root.classList.add('bw_theme_alt');
2137
3247
  } else {
2138
- const hue2rgb = (p, q, t) => {
2139
- if (t < 0) t += 1;
2140
- if (t > 1) t -= 1;
2141
- if (t < 1/6) return p + (q - p) * 6 * t;
2142
- if (t < 1/2) return q;
2143
- if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
2144
- return p;
2145
- };
2146
-
2147
- const q = lNorm < 0.5 ? lNorm * (1 + sNorm) : lNorm + sNorm - lNorm * sNorm;
2148
- const p = 2 * lNorm - q;
2149
-
2150
- r = hue2rgb(p, q, hNorm + 1/3) * 255;
2151
- g = hue2rgb(p, q, hNorm) * 255;
2152
- b = hue2rgb(p, q, hNorm - 1/3) * 255;
3248
+ root.classList.remove('bw_theme_alt');
2153
3249
  }
2154
-
2155
- if (rnd) {
2156
- r = Math.round(r);
2157
- g = Math.round(g);
2158
- b = Math.round(b);
2159
- a = Math.round(a);
2160
- }
2161
-
2162
- return [r, g, b, a, "rgb"];
2163
- };
2164
3250
 
2165
- /**
2166
- * Convert an RGB color to HSL.
2167
- *
2168
- * Accepts individual r, g, b values or a bitwrench color array [r, g, b, a, "rgb"].
2169
- *
2170
- * @param {number|Array} r - Red [0..255] or [r,g,b,a,"rgb"] array
2171
- * @param {number} g - Green [0..255]
2172
- * @param {number} b - Blue [0..255]
2173
- * @param {number} [a=255] - Alpha [0..255]
2174
- * @param {boolean} [rnd=true] - Round results to integers
2175
- * @returns {Array} HSL as [h, s, l, a, "hsl"]
2176
- * @category Color
2177
- * @see bw.colorHslToRgb
2178
- * @example
2179
- * bw.colorRgbToHsl(255, 0, 0) // => [0, 100, 50, 255, "hsl"]
2180
- * bw.colorRgbToHsl(0, 0, 255) // => [240, 100, 50, 255, "hsl"]
2181
- */
2182
- bw.colorRgbToHsl = function(r, g, b, a = 255, rnd = true) {
2183
- if (bw.typeOf(r) === "array") {
2184
- g = r[1]; b = r[2]; a = r[3]; r = r[0];
2185
- }
2186
-
2187
- r /= 255;
2188
- g /= 255;
2189
- b /= 255;
2190
-
2191
- const max = Math.max(r, g, b);
2192
- const min = Math.min(r, g, b);
2193
- let h, s, l = (max + min) / 2;
2194
-
2195
- if (max === min) {
2196
- h = s = 0; // achromatic
2197
- } else {
2198
- const d = max - min;
2199
- s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
2200
-
2201
- switch (max) {
2202
- case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
2203
- case g: h = ((b - r) / d + 2) / 6; break;
2204
- case b: h = ((r - g) / d + 4) / 6; break;
2205
- }
2206
- }
2207
-
2208
- h *= 360;
2209
- s *= 100;
2210
- l *= 100;
2211
-
2212
- if (rnd) {
2213
- h = Math.round(h);
2214
- s = Math.round(s);
2215
- l = Math.round(l);
2216
- a = Math.round(a);
2217
- }
2218
-
2219
- return [h, s, l, a, "hsl"];
3251
+ bw._activeThemeMode = wantAlt ? 'alternate' : 'primary';
3252
+ return bw._activeThemeMode;
2220
3253
  };
2221
3254
 
2222
3255
  /**
2223
- * Parse a CSS color string into bitwrench's internal array format.
2224
- *
2225
- * Supports hex (#rgb, #rrggbb, #rrggbbaa), rgb(), rgba(), hsl(), and hsla().
2226
- * Also accepts existing bitwrench color arrays (pass-through).
3256
+ * Toggle between primary and alternate theme palettes.
2227
3257
  *
2228
- * @param {string|Array} s - CSS color string (e.g. "#ff0000", "rgb(255,0,0)") or color array
2229
- * @param {number} [defAlpha=255] - Default alpha value
2230
- * @returns {Array} Color as [c0, c1, c2, a, "rgb"|"hsl"]
2231
- * @category Color
2232
- * @see bw.colorInterp
3258
+ * @returns {string} Active mode after toggle: 'primary' or 'alternate'
3259
+ * @category CSS & Styling
3260
+ * @see bw.applyTheme
3261
+ * @see bw.generateTheme
2233
3262
  * @example
2234
- * bw.colorParse('#ff0000') // => [255, 0, 0, 255, "rgb"]
2235
- * bw.colorParse('rgb(0,128,255)') // => [0, 128, 255, 255, "rgb"]
3263
+ * bw.toggleTheme(); // flip between primary and alternate
2236
3264
  */
2237
- bw.colorParse = function(s, defAlpha = 255) {
2238
- let r = [0, 0, 0, defAlpha, "rgb"]; // default return
2239
-
2240
- if (bw.typeOf(s) === "array") {
2241
- // Handle bitwrench color array
2242
- const df = [0, 0, 0, 255, "rgb"];
2243
- for (let p = 0; p < s.length && p < df.length; p++) {
2244
- df[p] = s[p];
2245
- }
2246
- return df;
2247
- }
2248
-
2249
- s = String(s).replace(/\s/g, "");
2250
-
2251
- // Handle hex colors
2252
- if (s[0] === "#") {
2253
- const hex = s.slice(1);
2254
- if (hex.length === 3 || hex.length === 4) {
2255
- // #rgb or #rgba
2256
- for (let i = 0; i < hex.length; i++) {
2257
- r[i] = parseInt(hex[i] + hex[i], 16);
2258
- }
2259
- } else if (hex.length === 6 || hex.length === 8) {
2260
- // #rrggbb or #rrggbbaa
2261
- for (let i = 0; i < hex.length; i += 2) {
2262
- r[i / 2] = parseInt(hex.substring(i, i + 2), 16);
2263
- }
2264
- }
2265
- } else {
2266
- // Handle rgb() rgba() hsl() hsla()
2267
- const match = s.match(/^(rgb|hsl)a?\(([^)]+)\)$/i);
2268
- if (match) {
2269
- const type = match[1].toLowerCase();
2270
- const values = match[2].split(",").map(v => parseFloat(v));
2271
-
2272
- if (type === "rgb") {
2273
- r[0] = values[0] || 0;
2274
- r[1] = values[1] || 0;
2275
- r[2] = values[2] || 0;
2276
- r[3] = values[3] !== undefined ? values[3] * 255 : defAlpha;
2277
- r[4] = "rgb";
2278
- } else if (type === "hsl") {
2279
- const rgb = bw.colorHslToRgb(values[0] || 0, values[1] || 0, values[2] || 0,
2280
- values[3] !== undefined ? values[3] * 255 : defAlpha);
2281
- return rgb;
2282
- }
2283
- }
3265
+ bw.toggleTheme = function() {
3266
+ var current = bw._activeThemeMode || 'primary';
3267
+ return bw.applyTheme(current === 'primary' ? 'alternate' : 'primary');
3268
+ };
3269
+
3270
+ /**
3271
+ * Remove the currently active theme's injected style elements from the DOM.
3272
+ * Use this before generating a new theme with a different name to prevent
3273
+ * stale CSS accumulation.
3274
+ *
3275
+ * @category CSS & Styling
3276
+ * @see bw.generateTheme
3277
+ * @example
3278
+ * bw.clearTheme(); // remove current theme styles
3279
+ * bw.generateTheme('sunset', conf); // inject fresh theme
3280
+ */
3281
+ bw.clearTheme = function() {
3282
+ if (bw._activeThemeStyleIds && bw._isBrowser) {
3283
+ bw._activeThemeStyleIds.forEach(function(id) {
3284
+ var el = document.getElementById(id);
3285
+ if (el) el.remove();
3286
+ });
3287
+ bw._activeThemeStyleIds = null;
2284
3288
  }
2285
-
2286
- return r;
3289
+ bw._activeTheme = null;
3290
+ bw._activeThemeMode = 'primary';
3291
+ };
3292
+
3293
+ // Expose color utility functions on bw namespace
3294
+ bw.hexToHsl = hexToHsl;
3295
+ bw.hslToHex = hslToHex;
3296
+ bw.adjustLightness = adjustLightness;
3297
+ bw.mixColor = mixColor;
3298
+ bw.relativeLuminance = relativeLuminance;
3299
+ bw.textOnColor = textOnColor;
3300
+ bw.deriveShades = deriveShades;
3301
+ bw.derivePalette = derivePalette;
3302
+ bw.harmonize = harmonize;
3303
+ bw.deriveAlternateSeed = deriveAlternateSeed;
3304
+ bw.deriveAlternateConfig = deriveAlternateConfig;
3305
+ bw.isLightPalette = isLightPalette;
3306
+
3307
+ // Expose layout and theme presets
3308
+ bw.SPACING_PRESETS = SPACING_PRESETS;
3309
+ bw.RADIUS_PRESETS = RADIUS_PRESETS;
3310
+ bw.TYPE_RATIO_PRESETS = TYPE_RATIO_PRESETS;
3311
+ bw.ELEVATION_PRESETS = ELEVATION_PRESETS;
3312
+ bw.MOTION_PRESETS = MOTION_PRESETS;
3313
+ bw.generateTypeScale = generateTypeScale;
3314
+ bw.DEFAULT_PALETTE_CONFIG = DEFAULT_PALETTE_CONFIG;
3315
+ bw.THEME_PRESETS = THEME_PRESETS;
3316
+
3317
+ // ===================================================================================
3318
+ // Legacy v1 Functions - Useful utilities retained from bitwrench v1
3319
+ // ===================================================================================
3320
+
3321
+ /** @see bitwrench-utils.js for implementation */
3322
+ bw.choice = _choice;
3323
+ /** @see bitwrench-utils.js for implementation */
3324
+ bw.arrayUniq = _arrayUniq;
3325
+ /** @see bitwrench-utils.js for implementation */
3326
+ bw.arrayBinA = _arrayBinA;
3327
+ /** @see bitwrench-utils.js for implementation */
3328
+ bw.arrayBNotInA = _arrayBNotInA;
3329
+
3330
+ /** @see bitwrench-utils.js for implementation — wraps _colorInterp with bw.colorParse */
3331
+ bw.colorInterp = function(x, in0, in1, colors, stretch) {
3332
+ return _colorInterp(x, in0, in1, colors, stretch, colorParse);
2287
3333
  };
2288
3334
 
3335
+ // Color conversion functions — imported from bitwrench-color-utils.js (single source of truth)
3336
+ bw.colorHslToRgb = colorHslToRgb;
3337
+ bw.colorRgbToHsl = colorRgbToHsl;
3338
+ bw.colorParse = colorParse;
3339
+
2289
3340
  /**
2290
3341
  * Set a browser cookie with expiration and options.
2291
3342
  *
@@ -2373,608 +3424,21 @@ bw.getURLParam = function(key, defaultValue) {
2373
3424
  }
2374
3425
  };
2375
3426
 
2376
- /**
2377
- * Create an HTML table string from a 2D data array.
2378
- *
2379
- * Legacy v1 API — returns an HTML string, not a TACO. First row is used
2380
- * as headers by default. For TACO-based tables, use `bw.makeTable()` instead.
2381
- *
2382
- * @param {Array} data - 2D array of table data
2383
- * @param {Object} [opts] - Table options
2384
- * @param {boolean} [opts.useFirstRowAsHeaders=true] - Use first row as headers
2385
- * @param {string} [opts.caption] - Table caption
2386
- * @returns {string} HTML table string
2387
- * @category Legacy (v1)
2388
- * @see bw.makeTable
2389
- */
2390
- bw.htmlTable = function(data, opts = {}) {
2391
- console.warn('bw.htmlTable() is deprecated. Use bw.makeTableFromArray() for TACO output or bw.makeTable() for object-array data.');
2392
- if (bw.typeOf(data) !== "array" || data.length < 1) return "";
2393
-
2394
- const dopts = {
2395
- useFirstRowAsHeaders: true,
2396
- caption: null,
2397
- atr: { class: "table" },
2398
- thead_atr: {},
2399
- th_atr: {},
2400
- tbody_atr: {},
2401
- tr_atr: {},
2402
- td_atr: {}
2403
- };
2404
-
2405
- Object.assign(dopts, opts);
2406
-
2407
- let html = `<table${bw._attrsToStr(dopts.atr)}>`;
2408
-
2409
- if (dopts.caption) {
2410
- html += `<caption>${bw.escapeHTML(dopts.caption)}</caption>`;
2411
- }
2412
-
2413
- let startRow = 0;
2414
-
2415
- // Handle header row
2416
- if (dopts.useFirstRowAsHeaders && data.length > 0) {
2417
- html += `<thead${bw._attrsToStr(dopts.thead_atr)}>`;
2418
- html += `<tr${bw._attrsToStr(dopts.tr_atr)}>`;
2419
-
2420
- data[0].forEach(cell => {
2421
- html += `<th${bw._attrsToStr(dopts.th_atr)}>${bw.escapeHTML(String(cell))}</th>`;
2422
- });
2423
-
2424
- html += "</tr></thead>";
2425
- startRow = 1;
2426
- }
2427
-
2428
- // Body rows
2429
- if (data.length > startRow) {
2430
- html += `<tbody${bw._attrsToStr(dopts.tbody_atr)}>`;
2431
-
2432
- for (let i = startRow; i < data.length; i++) {
2433
- html += `<tr${bw._attrsToStr(dopts.tr_atr)}>`;
2434
-
2435
- data[i].forEach(cell => {
2436
- html += `<td${bw._attrsToStr(dopts.td_atr)}>${bw.escapeHTML(String(cell))}</td>`;
2437
- });
2438
-
2439
- html += "</tr>";
2440
- }
2441
-
2442
- html += "</tbody>";
2443
- }
2444
-
2445
- html += "</table>";
2446
-
2447
- return html;
2448
- };
2449
-
2450
- /**
2451
- * Convert an attributes object to an HTML attribute string
2452
- *
2453
- * Handles boolean attributes (key only), null/undefined/false (skipped),
2454
- * and regular string values (HTML-escaped). Used internally by bw.htmlTable()
2455
- * and bw.htmlTabs().
2456
- *
2457
- * @param {Object} attrs - Attribute key-value pairs
2458
- * @returns {string} HTML attribute string with leading space, or empty string
2459
- * @private
2460
- */
2461
- bw._attrsToStr = function(attrs) {
2462
- if (!attrs || typeof attrs !== "object") return "";
2463
-
2464
- let str = "";
2465
- for (const [key, value] of Object.entries(attrs)) {
2466
- if (value != null && value !== false) {
2467
- if (value === true) {
2468
- str += ` ${key}`;
2469
- } else {
2470
- str += ` ${key}="${bw.escapeHTML(String(value))}"`;
2471
- }
2472
- }
2473
- }
2474
-
2475
- return str;
2476
- };
2477
-
2478
- /**
2479
- * Create an HTML tabs structure from an array of [title, content] pairs.
2480
- *
2481
- * Legacy v1 API — returns an HTML string. For TACO-based tabs,
2482
- * use `bw.makeTabs()` instead.
2483
- *
2484
- * @param {Array} tabData - Array of [title, content] pairs
2485
- * @param {Object} [opts] - Tab options
2486
- * @returns {string} HTML tabs string
2487
- * @category Legacy (v1)
2488
- * @see bw.makeTabs
2489
- */
2490
- bw.htmlTabs = function(tabData, opts = {}) {
2491
- console.warn('bw.htmlTabs() is deprecated. Use bw.makeTabs() instead.');
2492
- if (bw.typeOf(tabData) !== "array" || tabData.length < 1) return "";
2493
-
2494
- const dopts = {
2495
- atr: { class: "bw-tab-container" },
2496
- tab_atr: { class: "bw-tab-item-list" },
2497
- tabc_atr: { class: "bw-tab-content-list" }
2498
- };
2499
-
2500
- Object.assign(dopts, opts);
2501
-
2502
- // Create tab items
2503
- const tabItems = tabData.map((tab, idx) => ({
2504
- t: "li",
2505
- a: {
2506
- class: idx === 0 ? "bw-tab-item bw-tab-active" : "bw-tab-item",
2507
- onclick: "bw.selectTabContent(this)"
2508
- },
2509
- c: tab[0]
2510
- }));
2511
-
2512
- // Create tab content
2513
- const tabContent = tabData.map((tab, idx) => ({
2514
- t: "div",
2515
- a: { class: idx === 0 ? "bw-tab-content bw-show" : "bw-tab-content" },
2516
- c: tab[1]
2517
- }));
2518
-
2519
- return bw.html({
2520
- t: "div",
2521
- a: dopts.atr,
2522
- c: [
2523
- { t: "ul", a: dopts.tab_atr, c: tabItems },
2524
- { t: "div", a: dopts.tabc_atr, c: tabContent }
2525
- ]
2526
- });
2527
- };
2528
-
2529
- /**
2530
- * Tab selection handler — shows the clicked tab's content and hides others.
2531
- *
2532
- * Used internally by `bw.htmlTabs()`. You generally don't call this directly.
2533
- *
2534
- * @param {Element} tabElement - Clicked tab element
2535
- * @category Legacy (v1)
2536
- */
2537
- bw.selectTabContent = function(tabElement) {
2538
- console.warn('bw.selectTabContent() is deprecated. Use bw.makeTabs() instead.');
2539
- if (!bw._isBrowser || !tabElement) return;
2540
-
2541
- const container = tabElement.closest(".bw-tab-container");
2542
- if (!container) return;
2543
-
2544
- // Remove active class from all tabs
2545
- container.querySelectorAll(".bw-tab-item").forEach(tab => {
2546
- tab.classList.remove("bw-tab-active");
2547
- });
2548
-
2549
- // Add active to clicked tab
2550
- tabElement.classList.add("bw-tab-active");
2551
-
2552
- // Get tab index
2553
- const tabIndex = Array.from(tabElement.parentElement.children).indexOf(tabElement);
2554
-
2555
- // Hide all content
2556
- container.querySelectorAll(".bw-tab-content").forEach(content => {
2557
- content.classList.remove("bw-show");
2558
- });
2559
-
2560
- // Show selected content
2561
- const contents = container.querySelectorAll(".bw-tab-content");
2562
- if (contents[tabIndex]) {
2563
- contents[tabIndex].classList.add("bw-show");
2564
- }
2565
- };
2566
-
2567
- /**
2568
- * Generate Lorem Ipsum placeholder text.
2569
- *
2570
- * Useful for prototyping layouts. Generates repeatable text from the standard
2571
- * Lorem Ipsum passage. Omit numChars for a random length between 25-150 characters.
2572
- *
2573
- * @param {number} [numChars] - Number of characters (random 25-150 if not provided)
2574
- * @param {number} [startSpot] - Starting index in Lorem text (random if undefined)
2575
- * @param {boolean} [startWithCapitalLetter=true] - Start with a capital letter
2576
- * @returns {string} Lorem ipsum text
2577
- * @category Text Generation
2578
- * @example
2579
- * bw.loremIpsum(50)
2580
- * // => "Lorem ipsum dolor sit amet, consectetur adipiscin"
2581
- */
2582
- bw.loremIpsum = function(numChars, startSpot, startWithCapitalLetter = true) {
2583
- 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. ";
2584
-
2585
- // If numChars not provided, generate random length between 25-150
2586
- if (typeof numChars !== "number") {
2587
- numChars = Math.floor(Math.random() * 125) + 25;
2588
- }
2589
-
2590
- // If startSpot is undefined, randomize it
2591
- if (startSpot === undefined) {
2592
- startSpot = Math.floor(Math.random() * lorem.length);
2593
- }
2594
-
2595
- startSpot = startSpot % lorem.length;
2596
-
2597
- // Track how many characters we skip to honor numChars
2598
- let skippedChars = 0;
2599
- // Move startSpot to the next non-whitespace and non-punctuation character
2600
- while (lorem[startSpot] === ' ' || /[.,:;!?]/.test(lorem[startSpot])) {
2601
- startSpot = (startSpot + 1) % lorem.length;
2602
- skippedChars++;
2603
- // Prevent infinite loop in case entire lorem is spaces/punctuation
2604
- if (skippedChars >= lorem.length) {
2605
- startSpot = 0;
2606
- skippedChars = 0;
2607
- break;
2608
- }
2609
- }
2610
-
2611
- let l = lorem.substring(startSpot) + lorem.substring(0, startSpot);
2612
-
2613
- let result = "";
2614
- let remaining = numChars + skippedChars; // Add skipped chars to honor original numChars
2615
-
2616
- while (remaining > 0) {
2617
- result += remaining < l.length ? l.substring(0, remaining) : l;
2618
- remaining -= l.length;
2619
- }
2620
-
2621
- // Trim to exact numChars length
2622
- if (result.length > numChars) {
2623
- result = result.substring(0, numChars);
2624
- }
2625
-
2626
- // Ensure no trailing space
2627
- if (result[result.length - 1] === " ") {
2628
- result = result.substring(0, result.length - 1) + ".";
2629
- }
2630
-
2631
- // Ensure capital letter at start if requested
2632
- if (startWithCapitalLetter) {
2633
- let c = result[0].toUpperCase();
2634
- c = /[A-Z]/.test(c) ? c : "L"; // Use "L" as default if first char isn't a letter
2635
- result = c + result.substring(1);
2636
- }
2637
-
2638
- return result;
2639
- };
2640
-
2641
- /**
2642
- * Create a multidimensional array filled with a value or function result.
2643
- *
2644
- * If value is a function, it's called for each cell (useful for random data).
2645
- *
2646
- * @param {*} value - Value or function to fill array with
2647
- * @param {number|Array} dims - Dimensions (number for 1D, array for multi-D)
2648
- * @returns {Array} Multidimensional array
2649
- * @category Array Utilities
2650
- * @example
2651
- * bw.multiArray(0, [4, 5]) // 4x5 array of 0s
2652
- * bw.multiArray('test', 5) // ['test','test','test','test','test']
2653
- * bw.multiArray(Math.random, [3, 4]) // 3x4 array of random numbers
2654
- */
2655
- bw.multiArray = function(value, dims) {
2656
- const v = () => bw.typeOf(value) === "function" ? value() : value;
2657
- dims = typeof dims === "number" ? [dims] : dims;
2658
-
2659
- const createArray = (dim) => {
2660
- if (dim >= dims.length) return v();
2661
-
2662
- const arr = [];
2663
- for (let i = 0; i < dims[dim]; i++) {
2664
- arr[i] = createArray(dim + 1);
2665
- }
2666
- return arr;
2667
- };
2668
-
2669
- return createArray(0);
2670
- };
2671
-
2672
- /**
2673
- * Natural sort comparison function for use with `Array.sort()`.
2674
- *
2675
- * Sorts strings with embedded numbers in human-expected order
2676
- * (e.g. "file2" before "file10") instead of lexicographic order.
2677
- *
2678
- * @param {*} as - First value
2679
- * @param {*} bs - Second value
2680
- * @returns {number} Sort order (-1, 0, 1)
2681
- * @category Array Utilities
2682
- * @example
2683
- * ['item10', 'item2', 'item1'].sort(bw.naturalCompare)
2684
- * // => ['item1', 'item2', 'item10']
2685
- */
2686
- bw.naturalCompare = function(as, bs) {
2687
- // Handle numbers
2688
- if (isFinite(as) && isFinite(bs)) {
2689
- return Math.sign(as - bs);
2690
- }
2691
-
2692
- const a = String(as).toLowerCase();
2693
- const b = String(bs).toLowerCase();
2694
-
2695
- if (a === b) return as > bs ? 1 : 0;
2696
-
2697
- // If no digits, simple string compare
2698
- if (!/\d/.test(a) || !/\d/.test(b)) {
2699
- return a > b ? 1 : -1;
2700
- }
2701
-
2702
- // Split into chunks of digits/non-digits
2703
- const aParts = a.match(/(\d+|\D+)/g) || [];
2704
- const bParts = b.match(/(\d+|\D+)/g) || [];
2705
-
2706
- const len = Math.min(aParts.length, bParts.length);
2707
-
2708
- for (let i = 0; i < len; i++) {
2709
- const aPart = aParts[i];
2710
- const bPart = bParts[i];
2711
-
2712
- if (aPart !== bPart) {
2713
- // Both numeric
2714
- if (/^\d+$/.test(aPart) && /^\d+$/.test(bPart)) {
2715
- // Handle leading zeros
2716
- let aNum = aPart;
2717
- let bNum = bPart;
2718
-
2719
- if (aPart[0] === "0") aNum = "0." + aPart;
2720
- if (bPart[0] === "0") bNum = "0." + bPart;
2721
-
2722
- return parseFloat(aNum) - parseFloat(bNum);
2723
- }
2724
-
2725
- // String comparison
2726
- return aPart > bPart ? 1 : -1;
2727
- }
2728
- }
2729
-
2730
- // Different lengths
2731
- return aParts.length - bParts.length;
2732
- };
2733
-
2734
- /**
2735
- * Run `setInterval` with a maximum number of repetitions.
2736
- *
2737
- * Like `setInterval` but automatically clears after N calls.
2738
- *
2739
- * @param {Function} callback - Function to call (receives iteration index)
2740
- * @param {number} delay - Delay between calls in ms
2741
- * @param {number} repetitions - Maximum number of times to call
2742
- * @returns {number} Interval ID (can be passed to clearInterval)
2743
- * @category Timing
2744
- * @example
2745
- * bw.setIntervalX(function(i) {
2746
- * console.log('Iteration', i);
2747
- * }, 1000, 5); // Runs 5 times, 1 second apart
2748
- */
2749
- bw.setIntervalX = function(callback, delay, repetitions) {
2750
- let count = 0;
2751
- const intervalID = setInterval(function() {
2752
- callback(count);
2753
-
2754
- if (++count >= repetitions) {
2755
- clearInterval(intervalID);
2756
- }
2757
- }, delay);
2758
-
2759
- return intervalID;
2760
- };
2761
-
2762
- /**
2763
- * Repeat a test function until it returns truthy, or give up after max attempts.
2764
- *
2765
- * Useful for polling (waiting for an element to appear, an API to respond, etc.).
2766
- *
2767
- * @param {Function} testFn - Test function that returns truthy when done
2768
- * @param {Function} successFn - Called with test result when test passes
2769
- * @param {Function} [failFn] - Called on each failed test attempt
2770
- * @param {number} [delay=250] - Delay between attempts in ms
2771
- * @param {number} [maxReps=10] - Maximum number of attempts
2772
- * @param {Function} [lastFn] - Called when done with (success, count)
2773
- * @returns {string|number} "err" if invalid params, otherwise interval ID
2774
- * @category Timing
2775
- * @example
2776
- * bw.repeatUntil(
2777
- * function() { return document.getElementById('myDiv'); },
2778
- * function() { console.log('Element found!'); },
2779
- * null, 100, 30
2780
- * );
2781
- */
2782
- bw.repeatUntil = function(testFn, successFn, failFn, delay = 250, maxReps = 10, lastFn) {
2783
- if (typeof testFn !== "function") return "err";
2784
-
2785
- let count = 0;
2786
-
2787
- const intervalID = setInterval(function() {
2788
- const result = testFn();
2789
- count++;
2790
-
2791
- if (result) {
2792
- clearInterval(intervalID);
2793
- if (successFn) successFn(result);
2794
- if (lastFn) lastFn(true, count);
2795
- } else if (count >= maxReps) {
2796
- clearInterval(intervalID);
2797
- if (failFn) failFn();
2798
- if (lastFn) lastFn(false, count);
2799
- } else {
2800
- if (failFn) failFn();
2801
- }
2802
- }, delay);
2803
-
2804
- return intervalID;
2805
- };
2806
-
2807
- // ===================================================================================
2808
- // File I/O Functions - Works in both Node.js and browser
2809
- // ===================================================================================
2810
-
2811
- /**
2812
- * Save data to a file. Works in both Node.js (fs.writeFile) and browser (download link).
2813
- *
2814
- * @param {string} fname - Filename to save as
2815
- * @param {*} data - Data to save (string or buffer)
2816
- * @category File I/O
2817
- * @see bw.saveClientJSON
2818
- */
2819
- bw.saveClientFile = function(fname, data) {
2820
- if (bw.isNodeJS()) {
2821
- bw._getFs().then(function(fs) {
2822
- if (!fs) { console.error('bw.saveClientFile: fs module not available'); return; }
2823
- fs.writeFile(fname, data, function(err) {
2824
- if (err) {
2825
- console.error("Error saving file:", err);
2826
- }
2827
- });
2828
- });
2829
- } else {
2830
- // Browser environment
2831
- const blob = new Blob([data], { type: "application/octet-stream" });
2832
- const url = window.URL.createObjectURL(blob);
2833
- const a = bw.createDOM({
2834
- t: 'a',
2835
- a: {
2836
- href: url,
2837
- download: fname,
2838
- style: 'display: none'
2839
- }
2840
- });
2841
- document.body.appendChild(a);
2842
- a.click();
2843
- window.URL.revokeObjectURL(url);
2844
- document.body.removeChild(a);
2845
- }
2846
- };
2847
-
2848
- /**
2849
- * Save data as a JSON file with pretty formatting.
2850
- *
2851
- * @param {string} fname - Filename to save as
2852
- * @param {*} data - Data to serialize as JSON
2853
- * @category File I/O
2854
- * @see bw.saveClientFile
2855
- */
2856
- bw.saveClientJSON = function(fname, data) {
2857
- bw.saveClientFile(fname, JSON.stringify(data, null, 2));
2858
- };
2859
-
2860
- /**
2861
- * Load a file by path (Node.js) or URL (browser via XHR).
2862
- *
2863
- * @param {string} fname - File path (Node) or URL (browser)
2864
- * @param {Function} callback - Called with (data, error). data is null on error.
2865
- * @param {Object} [options] - Options
2866
- * @param {string} [options.parser="raw"] - "raw" for string, "JSON" to auto-parse
2867
- * @returns {string} "BW_OK"
2868
- * @category File I/O
2869
- * @see bw.loadClientJSON
2870
- */
2871
- bw.loadClientFile = function(fname, callback, options) {
2872
- var opts = { parser: 'raw' };
2873
- if (options && options.parser) { opts.parser = options.parser; }
2874
- var parse = (opts.parser === 'JSON') ? JSON.parse : function(s) { return s; };
2875
-
2876
- if (bw.isNodeJS()) {
2877
- bw._getFs().then(function(fs) {
2878
- if (!fs) { callback(null, new Error('fs module not available')); return; }
2879
- fs.readFile(fname, 'utf8', function(err, data) {
2880
- if (err) { callback(null, err); }
2881
- else {
2882
- try { callback(parse(data), null); }
2883
- catch (e) { callback(null, e); }
2884
- }
2885
- });
2886
- });
2887
- } else {
2888
- var x = new XMLHttpRequest();
2889
- x.open('GET', fname, true);
2890
- x.onreadystatechange = function() {
2891
- if (x.readyState === 4) {
2892
- if (x.status >= 200 && x.status < 300) {
2893
- try { callback(parse(x.responseText), null); }
2894
- catch (e) { callback(null, e); }
2895
- } else {
2896
- callback(null, new Error('HTTP ' + x.status + ': ' + fname));
2897
- }
2898
- }
2899
- };
2900
- x.send(null);
2901
- }
2902
- return 'BW_OK';
2903
- };
2904
-
2905
- /**
2906
- * Load a JSON file by path (Node.js) or URL (browser). Convenience wrapper
2907
- * around `bw.loadClientFile()` with `parser: "JSON"`.
2908
- *
2909
- * @param {string} fname - File path (Node) or URL (browser)
2910
- * @param {Function} callback - Called with (parsedData, error)
2911
- * @returns {string} "BW_OK"
2912
- * @category File I/O
2913
- * @see bw.loadClientFile
2914
- */
2915
- bw.loadClientJSON = function(fname, callback) {
2916
- return bw.loadClientFile(fname, callback, { parser: 'JSON' });
2917
- };
2918
-
2919
- /**
2920
- * Prompt user to pick a local file via file dialog (browser only).
2921
- *
2922
- * Opens a native file picker and reads the selected file.
2923
- *
2924
- * @param {Function} callback - Called with (data, filename, error)
2925
- * @param {Object} [options] - Options
2926
- * @param {string} [options.accept] - File type filter (e.g. ".json,.txt")
2927
- * @param {string} [options.parser="raw"] - "raw" for string, "JSON" to auto-parse
2928
- * @category File I/O
2929
- * @see bw.loadLocalJSON
2930
- */
2931
- bw.loadLocalFile = function(callback, options) {
2932
- var opts = { parser: 'raw', accept: '' };
2933
- if (options) {
2934
- if (options.parser) { opts.parser = options.parser; }
2935
- if (options.accept) { opts.accept = options.accept; }
2936
- }
2937
- var parse = (opts.parser === 'JSON') ? JSON.parse : function(s) { return s; };
2938
3427
 
2939
- if (bw.isNodeJS()) {
2940
- callback(null, '', new Error('bw.loadLocalFile is browser-only. Use bw.loadClientFile() in Node.'));
2941
- return;
2942
- }
3428
+ /** @see bitwrench-utils.js for implementation */
3429
+ bw.loremIpsum = _loremIpsum;
2943
3430
 
2944
- var input = bw.createDOM({
2945
- t: 'input',
2946
- a: {
2947
- type: 'file',
2948
- accept: opts.accept,
2949
- style: 'display: none'
2950
- }
2951
- });
2952
- input.addEventListener('change', function() {
2953
- var file = input.files[0];
2954
- if (!file) { callback(null, '', new Error('No file selected')); return; }
2955
- var reader = new FileReader();
2956
- reader.onload = function(e) {
2957
- try { callback(parse(e.target.result), file.name, null); }
2958
- catch (err) { callback(null, file.name, err); }
2959
- };
2960
- reader.onerror = function() { callback(null, file.name, reader.error); };
2961
- reader.readAsText(file);
2962
- input.remove();
2963
- });
2964
- document.body.appendChild(input);
2965
- input.click();
2966
- };
3431
+ /** @see bitwrench-utils.js for implementation */
3432
+ bw.multiArray = _multiArray;
3433
+ /** @see bitwrench-utils.js for implementation */
3434
+ bw.naturalCompare = _naturalCompare;
3435
+ /** @see bitwrench-utils.js for implementation */
3436
+ bw.setIntervalX = _setIntervalX;
3437
+ /** @see bitwrench-utils.js for implementation */
3438
+ bw.repeatUntil = _repeatUntil;
2967
3439
 
2968
- /**
2969
- * Prompt user to pick a local JSON file via file dialog (browser only).
2970
- *
2971
- * @param {Function} callback - Called with (parsedData, filename, error)
2972
- * @category File I/O
2973
- * @see bw.loadLocalFile
2974
- */
2975
- bw.loadLocalJSON = function(callback) {
2976
- bw.loadLocalFile(callback, { parser: 'JSON', accept: '.json' });
2977
- };
3440
+ // File I/O — see bitwrench-file-ops.js
3441
+ bindFileOps(bw);
2978
3442
 
2979
3443
  /**
2980
3444
  * Copy text to the system clipboard (browser only).
@@ -3035,9 +3499,13 @@ bw.copyToClipboard = function(text) {
3035
3499
  /**
3036
3500
  * Create a sortable TACO table from an array of row objects.
3037
3501
  *
3502
+ * Returns a bare `<table>` TACO — no wrapper, title, or responsive scroll.
3503
+ * Use this when you need full control over table placement, or when embedding
3504
+ * the table inside your own layout. For a ready-to-use table with title,
3505
+ * responsive wrapper, and defaults (striped + hover), use `bw.makeDataTable()`.
3506
+ *
3038
3507
  * Auto-detects columns from data keys if not specified. Supports click-to-sort
3039
- * headers with ascending/descending indicators. Returns a TACO object —
3040
- * render with `bw.DOM()` or `bw.html()`.
3508
+ * headers with ascending/descending indicators.
3041
3509
  *
3042
3510
  * @param {Object} config - Table configuration
3043
3511
  * @param {Array<Object>} config.data - Array of row objects to display
@@ -3073,10 +3541,10 @@ bw.makeTable = function(config) {
3073
3541
  sortDirection = 'asc'
3074
3542
  } = config;
3075
3543
 
3076
- // Build class list: always include bw-table, add striped/hover, append user className
3077
- let cls = 'bw-table';
3078
- if (striped) cls += ' bw-table-striped';
3079
- if (hover) cls += ' bw-table-hover';
3544
+ // Build class list: always include bw_table, add striped/hover, append user className
3545
+ let cls = 'bw_table';
3546
+ if (striped) cls += ' bw_table_striped';
3547
+ if (hover) cls += ' bw_table_hover';
3080
3548
  if (className) cls += ' ' + className;
3081
3549
  cls = cls.trim();
3082
3550
 
@@ -3290,7 +3758,7 @@ bw.makeBarChart = function(config) {
3290
3758
  } = config;
3291
3759
 
3292
3760
  if (!Array.isArray(data) || data.length === 0) {
3293
- return { t: 'div', a: { class: ('bw-bar-chart-container ' + className).trim() }, c: '' };
3761
+ return { t: 'div', a: { class: ('bw_bar_chart_container ' + className).trim() }, c: '' };
3294
3762
  }
3295
3763
 
3296
3764
  const values = data.map(function(d) { return Number(d[valueKey]) || 0; });
@@ -3303,44 +3771,46 @@ bw.makeBarChart = function(config) {
3303
3771
 
3304
3772
  const children = [];
3305
3773
  if (showValues) {
3306
- children.push({ t: 'div', a: { class: 'bw-bar-value' }, c: formatted });
3774
+ children.push({ t: 'div', a: { class: 'bw_bar_value' }, c: formatted });
3307
3775
  }
3308
3776
  children.push({
3309
3777
  t: 'div',
3310
3778
  a: {
3311
- class: 'bw-bar',
3779
+ class: 'bw_bar',
3312
3780
  style: 'height:' + pct + '%;background:' + color + ';'
3313
3781
  }
3314
3782
  });
3315
3783
  if (showLabels) {
3316
- children.push({ t: 'div', a: { class: 'bw-bar-label' }, c: String(d[labelKey] || '') });
3784
+ children.push({ t: 'div', a: { class: 'bw_bar_label' }, c: String(d[labelKey] || '') });
3317
3785
  }
3318
3786
 
3319
- return { t: 'div', a: { class: 'bw-bar-group' }, c: children };
3787
+ return { t: 'div', a: { class: 'bw_bar_group' }, c: children };
3320
3788
  });
3321
3789
 
3322
3790
  const chartChildren = [];
3323
3791
  if (title) {
3324
- chartChildren.push({ t: 'h3', a: { class: 'bw-bar-chart-title' }, c: title });
3792
+ chartChildren.push({ t: 'h3', a: { class: 'bw_bar_chart_title' }, c: title });
3325
3793
  }
3326
3794
  chartChildren.push({
3327
3795
  t: 'div',
3328
- a: { class: 'bw-bar-chart', style: 'height:' + height + ';' },
3796
+ a: { class: 'bw_bar_chart', style: 'height:' + height + ';' },
3329
3797
  c: bars
3330
3798
  });
3331
3799
 
3332
3800
  return {
3333
3801
  t: 'div',
3334
- a: { class: ('bw-bar-chart-container ' + className).trim() },
3802
+ a: { class: ('bw_bar_chart_container ' + className).trim() },
3335
3803
  c: chartChildren
3336
3804
  };
3337
3805
  };
3338
3806
 
3339
3807
  /**
3340
- * Create a responsive data table with title and optional wrapper
3808
+ * Create a ready-to-use data table with title and responsive wrapper.
3341
3809
  *
3342
- * Wraps bw.makeTable() output in a responsive container div.
3343
- * Adds an optional title heading above the table.
3810
+ * Convenience wrapper around `bw.makeTable()` that adds a title heading,
3811
+ * responsive horizontal scroll container, and defaults to striped + hover.
3812
+ * Use this for the common case; use `bw.makeTable()` when you need a bare
3813
+ * table element with no wrapper.
3344
3814
  *
3345
3815
  * @param {Object} config - Table configuration
3346
3816
  * @param {string} [config.title] - Table title heading
@@ -3428,7 +3898,7 @@ bw._componentRegistry = new Map();
3428
3898
  * @see bw.DOM
3429
3899
  * @example
3430
3900
  * var handle = bw.render('#app', 'append', {
3431
- * t: 'button', a: { class: 'bw-btn' }, c: 'Click Me',
3901
+ * t: 'button', a: { class: 'bw_btn' }, c: 'Click Me',
3432
3902
  * o: { state: { clicks: 0 } }
3433
3903
  * });
3434
3904
  * handle.setState({ clicks: 1 });
@@ -3466,7 +3936,7 @@ bw.render = function(element, position, taco) {
3466
3936
  }
3467
3937
 
3468
3938
  // Add component ID to element
3469
- domElement.setAttribute('data-bw-id', componentId);
3939
+ domElement.setAttribute('data-bw_id', componentId);
3470
3940
 
3471
3941
  // Insert into DOM based on position
3472
3942
  try {
@@ -3541,7 +4011,7 @@ bw.render = function(element, position, taco) {
3541
4011
 
3542
4012
  // Re-render
3543
4013
  const newElement = bw.createDOM(this._taco);
3544
- newElement.setAttribute('data-bw-id', componentId);
4014
+ newElement.setAttribute('data-bw_id', componentId);
3545
4015
 
3546
4016
  // Replace in DOM
3547
4017
  parent.replaceChild(newElement, this.element);
@@ -3715,7 +4185,7 @@ bw.getAllComponents = function() {
3715
4185
  // =========================================================================
3716
4186
  // Import and register all components
3717
4187
  // =========================================================================
3718
- import * as components from './bitwrench-components-v2.js';
4188
+ import * as components from './bitwrench-bccl.js';
3719
4189
 
3720
4190
  // Register all make functions
3721
4191
  Object.entries(components).forEach(([name, fn]) => {
@@ -3724,50 +4194,26 @@ Object.entries(components).forEach(([name, fn]) => {
3724
4194
  }
3725
4195
  });
3726
4196
 
3727
- // Register component handles
3728
- bw._componentHandles = components.componentHandles || {};
4197
+ // Factory dispatch: bw.make('card', props) → bw.makeCard(props)
4198
+ bw.make = components.make;
3729
4199
 
3730
- // Create functions that return handles
4200
+ // Component registry: bw.BCCL lists all available component types
4201
+ bw.BCCL = components.BCCL;
4202
+
4203
+ // Variant class helper: bw.variantClass('primary') → 'bw_primary'
4204
+ bw.variantClass = components.variantClass;
4205
+
4206
+ // Create functions that return handles (plain renderComponent, no Handle overlay)
3731
4207
  Object.entries(components).forEach(([name, fn]) => {
3732
4208
  if (name.startsWith('make')) {
3733
- const componentType = name.substring(4).toLowerCase(); // Remove 'make' prefix
3734
4209
  const createName = 'create' + name.substring(4); // createCard, createTable, etc.
3735
-
3736
4210
  bw[createName] = function(props) {
3737
4211
  const taco = fn(props);
3738
- const handle = bw.renderComponent(taco);
3739
-
3740
- // Use specialized handle class if available
3741
- const HandleClass = bw._componentHandles[componentType];
3742
- if (HandleClass) {
3743
- const specializedHandle = new HandleClass(handle.element, taco);
3744
- // Copy base handle properties
3745
- Object.setPrototypeOf(specializedHandle, handle);
3746
- return specializedHandle;
3747
- }
3748
-
3749
- return handle;
4212
+ return bw.renderComponent(taco);
3750
4213
  };
3751
4214
  }
3752
4215
  });
3753
4216
 
3754
- // Manual registration for functions defined in this file
3755
- // createTable
3756
- bw.createTable = function(data, options = {}) {
3757
- const taco = bw.makeTable({ data, ...options });
3758
- const handle = bw.renderComponent(taco);
3759
-
3760
- // Use specialized TableHandle
3761
- const TableHandle = bw._componentHandles.table;
3762
- if (TableHandle) {
3763
- const specializedHandle = new TableHandle(handle.element, taco);
3764
- Object.setPrototypeOf(specializedHandle, handle);
3765
- return specializedHandle;
3766
- }
3767
-
3768
- return handle;
3769
- };
3770
-
3771
4217
  // Export for different environments
3772
4218
  export default bw;
3773
4219