bitwrench 2.0.32 → 2.1.1

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 (126) hide show
  1. package/README.md +211 -125
  2. package/dist/bitwrench-bccl.cjs.js +349 -188
  3. package/dist/bitwrench-bccl.cjs.min.js +2 -39
  4. package/dist/bitwrench-bccl.cjs.min.js.gz +0 -0
  5. package/dist/bitwrench-bccl.esm.js +349 -188
  6. package/dist/bitwrench-bccl.esm.min.js +2 -39
  7. package/dist/bitwrench-bccl.esm.min.js.gz +0 -0
  8. package/dist/bitwrench-bccl.umd.js +349 -188
  9. package/dist/bitwrench-bccl.umd.min.js +2 -39
  10. package/dist/bitwrench-bccl.umd.min.js.gz +0 -0
  11. package/dist/bitwrench-code-edit.cjs.js +17 -6
  12. package/dist/bitwrench-code-edit.cjs.min.js +2 -20
  13. package/dist/bitwrench-code-edit.es5.js +8 -3
  14. package/dist/bitwrench-code-edit.es5.min.js +2 -19
  15. package/dist/bitwrench-code-edit.esm.js +17 -6
  16. package/dist/bitwrench-code-edit.esm.min.js +2 -19
  17. package/dist/bitwrench-code-edit.umd.js +17 -6
  18. package/dist/bitwrench-code-edit.umd.min.js +2 -19
  19. package/dist/bitwrench-code-edit.umd.min.js.gz +0 -0
  20. package/dist/bitwrench-debug.js +1 -1
  21. package/dist/bitwrench-debug.min.js +1 -1
  22. package/dist/bitwrench-lean.cjs.js +2492 -1628
  23. package/dist/bitwrench-lean.cjs.min.js +2 -80
  24. package/dist/bitwrench-lean.cjs.min.js.gz +0 -0
  25. package/dist/bitwrench-lean.es5.js +2740 -1838
  26. package/dist/bitwrench-lean.es5.min.js +2 -49
  27. package/dist/bitwrench-lean.es5.min.js.gz +0 -0
  28. package/dist/bitwrench-lean.esm.js +2492 -1628
  29. package/dist/bitwrench-lean.esm.min.js +2 -80
  30. package/dist/bitwrench-lean.esm.min.js.gz +0 -0
  31. package/dist/bitwrench-lean.umd.js +2492 -1628
  32. package/dist/bitwrench-lean.umd.min.js +2 -80
  33. package/dist/bitwrench-lean.umd.min.js.gz +0 -0
  34. package/dist/bitwrench-util-color.cjs.js +251 -0
  35. package/dist/bitwrench-util-color.cjs.min.js +3 -0
  36. package/dist/bitwrench-util-color.es5.js +256 -0
  37. package/dist/bitwrench-util-color.es5.min.js +3 -0
  38. package/dist/bitwrench-util-color.esm.js +241 -0
  39. package/dist/bitwrench-util-color.esm.min.js +3 -0
  40. package/dist/bitwrench-util-color.umd.js +257 -0
  41. package/dist/bitwrench-util-color.umd.min.js +3 -0
  42. package/dist/bitwrench-util-css.cjs.js +2 -1
  43. package/dist/bitwrench-util-css.cjs.min.js +2 -21
  44. package/dist/bitwrench-util-css.es5.js +2 -1
  45. package/dist/bitwrench-util-css.es5.min.js +2 -20
  46. package/dist/bitwrench-util-css.esm.js +2 -1
  47. package/dist/bitwrench-util-css.esm.min.js +1 -19
  48. package/dist/bitwrench-util-css.umd.js +2 -1
  49. package/dist/bitwrench-util-css.umd.min.js +2 -20
  50. package/dist/bitwrench-util-css.umd.min.js.gz +0 -0
  51. package/dist/bitwrench.cjs.js +2826 -1801
  52. package/dist/bitwrench.cjs.min.js +2 -99
  53. package/dist/bitwrench.cjs.min.js.gz +0 -0
  54. package/dist/bitwrench.css +403 -479
  55. package/dist/bitwrench.d.ts +70 -73
  56. package/dist/bitwrench.es5.js +3106 -2020
  57. package/dist/bitwrench.es5.min.js +2 -67
  58. package/dist/bitwrench.es5.min.js.gz +0 -0
  59. package/dist/bitwrench.esm.js +2826 -1801
  60. package/dist/bitwrench.esm.min.js +2 -99
  61. package/dist/bitwrench.esm.min.js.gz +0 -0
  62. package/dist/bitwrench.min.css +1 -1
  63. package/dist/bitwrench.umd.js +2826 -1801
  64. package/dist/bitwrench.umd.min.js +2 -99
  65. package/dist/bitwrench.umd.min.js.gz +0 -0
  66. package/dist/builds.json +222 -134
  67. package/dist/bwserve.cjs.js +289 -282
  68. package/dist/bwserve.d.ts +157 -0
  69. package/dist/bwserve.esm.js +290 -283
  70. package/dist/sri.json +54 -46
  71. package/docs/README.md +6 -3
  72. package/docs/app-patterns.md +7 -6
  73. package/docs/bitwrench-for-wasm.md +53 -54
  74. package/docs/bitwrench-mcp.md +2 -2
  75. package/docs/bitwrench-northstar-principles.md +406 -0
  76. package/docs/bitwrench-taco-schema-discussion.md +2 -2
  77. package/docs/bitwrench_api.md +191 -106
  78. package/docs/bitwrench_typescript_usage.md +5 -5
  79. package/docs/bw-attach.md +29 -75
  80. package/docs/bwserve.md +200 -168
  81. package/docs/cli.md +36 -12
  82. package/docs/component-cheatsheet.md +2 -2
  83. package/docs/component-library.md +4 -4
  84. package/docs/component-lifecycle.md +234 -0
  85. package/docs/drift-lint.md +268 -0
  86. package/docs/framework-translation-table.md +4 -4
  87. package/docs/llm-bitwrench-guide.md +60 -50
  88. package/docs/routing.md +11 -13
  89. package/docs/state-management.md +110 -109
  90. package/docs/taco-format.md +13 -14
  91. package/docs/theming.md +13 -3
  92. package/docs/thinking-in-bitwrench.md +858 -983
  93. package/docs/tutorial-bwserve.md +37 -36
  94. package/docs/tutorial-embedded.md +10 -21
  95. package/docs/tutorial-website.md +2 -2
  96. package/package.json +38 -7
  97. package/readme.html +262 -161
  98. package/src/bitwrench-bccl-entry.js +2 -2
  99. package/src/bitwrench-bccl.js +346 -185
  100. package/src/bitwrench-code-edit.js +16 -5
  101. package/src/bitwrench-color-utils.js +117 -181
  102. package/src/bitwrench-file-ops.js +2 -2
  103. package/src/bitwrench-lean.js +4 -3
  104. package/src/bitwrench-router.js +5 -2
  105. package/src/bitwrench-styles.js +420 -504
  106. package/src/bitwrench-util-color.js +240 -0
  107. package/src/bitwrench-util-css.js +1 -0
  108. package/src/bitwrench-utils.js +4 -0
  109. package/src/bitwrench.d.ts +70 -73
  110. package/src/bitwrench.h +5 -0
  111. package/src/bitwrench.js +1939 -933
  112. package/src/bwserve/attach.js +0 -1
  113. package/src/bwserve/bwclient.js +172 -32
  114. package/src/bwserve/bwshell.js +0 -4
  115. package/src/bwserve/client.js +59 -220
  116. package/src/bwserve/index.js +59 -26
  117. package/src/bwserve.d.ts +157 -0
  118. package/src/bwserve.h +5 -0
  119. package/src/cli/attach.js +12 -75
  120. package/src/cli/convert.js +2 -2
  121. package/src/cli/serve.js +37 -35
  122. package/src/generate-css.js +1 -1
  123. package/src/mcp/knowledge.js +4 -4
  124. package/src/mcp/live.js +21 -13
  125. package/src/mcp/tools.js +0 -1
  126. package/src/version.js +3 -7
package/src/bitwrench.js CHANGED
@@ -9,19 +9,20 @@
9
9
 
10
10
  import { VERSION_INFO } from './version.js';
11
11
  import { getStructuralStyles, getResetStyles,
12
- generateThemedCSS, derivePalette as _derivePalette,
12
+ generateThemedCSS,
13
13
  DEFAULT_PALETTE_CONFIG, SPACING_PRESETS, RADIUS_PRESETS, THEME_PRESETS,
14
14
  TYPE_RATIO_PRESETS, ELEVATION_PRESETS, MOTION_PRESETS, generateTypeScale,
15
15
  resolveLayout, scopeRulesUnder } from './bitwrench-styles.js';
16
16
  import { hexToHsl, hslToHex, adjustLightness, mixColor,
17
17
  relativeLuminance, textOnColor, deriveShades,
18
18
  derivePalette, harmonize, deriveAlternateSeed, deriveAlternateConfig,
19
- isLightPalette,
20
- colorParse, colorRgbToHsl, colorHslToRgb } from './bitwrench-color-utils.js';
19
+ isLightPalette } from './bitwrench-color-utils.js';
20
+ import { colorParse as _colorParse, colorRgbToHsl as _colorRgbToHsl,
21
+ colorHslToRgb as _colorHslToRgb, colorInterp as _colorInterp } from './bitwrench-color-utils.js';
21
22
  import { bindFileOps } from './bitwrench-file-ops.js';
22
23
  import { typeOf as _typeOf, mapScale as _mapScale, clip as _clip,
23
24
  choice as _choice, arrayUniq as _arrayUniq, arrayBinA as _arrayBinA,
24
- arrayBNotInA as _arrayBNotInA, colorInterp as _colorInterp,
25
+ arrayBNotInA as _arrayBNotInA,
25
26
  loremIpsum as _loremIpsum, multiArray as _multiArray,
26
27
  naturalCompare as _naturalCompare, setIntervalX as _setIntervalX,
27
28
  repeatUntil as _repeatUntil } from './bitwrench-utils.js';
@@ -51,23 +52,24 @@ const bw = {
51
52
 
52
53
  // Internal state
53
54
  _idCounter: 0,
54
- _unmountCallbacks: new Map(),
55
55
  _topics: {}, // topic → [{handler, id}] (plain object for IE11 compat)
56
56
  _subIdCounter: 0, // monotonic ID for subscriptions
57
+ _detached: {}, // uuid → true for detach-exempt elements
58
+ _mounted: {}, // uuid → true for elements that have fired mounted()
57
59
 
58
60
  // ── Node reference cache ──────────────────────────────────────────────
59
61
  // Fast O(1) lookup for elements by id attribute or bw_uuid_* class.
60
62
  //
61
- // Populated by bw.createDOM() when elements have:
63
+ // Populated by bw.create() when elements have:
62
64
  // - id attribute (standard HTML id)
63
65
  // - bw_uuid_* class (lifecycle-managed or explicitly addressed elements)
64
66
  //
65
- // Cleaned up by bw.cleanup() when elements are destroyed via bitwrench APIs.
67
+ // Cleaned up by bw.unmount() when elements are destroyed via bitwrench APIs.
66
68
  // On cache miss, falls back to querySelector/getElementById — never fails,
67
69
  // just slower. Stale entries (refs to detached nodes) are removed on miss
68
70
  // via parentNode === null check (IE11-safe, unlike el.isConnected).
69
71
  //
70
- // Elements created via bw.createDOM() also get el._bw_refs — a local map of
72
+ // Elements created via bw.create() also get el._bw_refs — a local map of
71
73
  // child id/UUID -> DOM node ref for fast parent->child access in o.render.
72
74
  // This is the bitwrench equivalent of React's compiled template "holes".
73
75
  //
@@ -136,7 +138,7 @@ Object.defineProperty(bw, '_isBrowser', {
136
138
  // because it can't prove they're side-effect-free. We can, so we alias
137
139
  // them here. Each alias saves bytes in the minified output, and the short
138
140
  // names also reduce visual noise in the hot paths (binding pipeline,
139
- // createDOM, etc.).
141
+ // create, etc.).
140
142
  //
141
143
  // Alias Target Sites
142
144
  // ───────── ────────────────────────────────────── ─────
@@ -204,6 +206,7 @@ bw._getFs = function() {
204
206
  if (!bw.isNodeJS()) { bw._fsCache = null; return Promise.resolve(null); }
205
207
 
206
208
  // Strategy 1: synchronous require (CJS / UMD in Node.js)
209
+ /* c8 ignore next 7 -- require() is not defined in ESM test environment */
207
210
  if (typeof require === 'function') {
208
211
  try {
209
212
  bw._fsCache = require('fs');
@@ -216,17 +219,20 @@ bw._getFs = function() {
216
219
  try {
217
220
  var _importDynamic = new Function('m', 'return import(m)');
218
221
  return _importDynamic('fs').then(function(mod) {
222
+ /* c8 ignore next -- mod.default always exists in Node.js ESM */
219
223
  bw._fsCache = mod.default || mod;
220
224
  return bw._fsCache;
221
225
  }).catch(function() {
222
226
  bw._fsCache = null;
223
227
  return null;
224
228
  });
229
+ /* c8 ignore start -- Function() constructor never fails in test environments */
225
230
  } catch(e) {
226
231
  // Function() construction failed (shouldn't happen, but safety net)
227
232
  bw._fsCache = null;
228
233
  return Promise.resolve(null);
229
234
  }
235
+ /* c8 ignore stop */
230
236
  };
231
237
 
232
238
  /**
@@ -327,7 +333,7 @@ bw.uuid = function(prefix) {
327
333
  * applies the second argument to the element and returns the element:
328
334
  * - string/number: sets `el.textContent`
329
335
  * - function: calls `apply(el)`, returns el
330
- * - TACO object: clears children, mounts TACO via `bw.createDOM()`
336
+ * - TACO object: clears children, mounts TACO via `bw.create()`
331
337
  * - array: clears children, appends each item (string -> text node, TACO -> element)
332
338
  *
333
339
  * @param {string|Element} target - Element ref, ID, CSS selector, or bw_uuid_* class
@@ -355,25 +361,33 @@ bw.el = function(target, apply) {
355
361
  // 1. Check cache
356
362
  var cached = bw._nodeMap[target];
357
363
  if (cached) {
358
- if (cached.parentNode !== null) {
364
+ // Detach-exempt elements survive the staleness check
365
+ var cachedUuid = bw.getUUID(cached);
366
+ if (cached.parentNode !== null || (cachedUuid && bw._detached[cachedUuid])) {
359
367
  el = cached;
368
+ // Clear detach exemption on reconnect
369
+ if (cachedUuid && bw._detached[cachedUuid] && cached.parentNode !== null) {
370
+ delete bw._detached[cachedUuid];
371
+ }
360
372
  } else {
361
373
  delete bw._nodeMap[target];
362
374
  }
363
375
  }
364
376
  if (!el) {
365
- // 2. getElementById
366
- el = document.getElementById(target);
367
- // 3. querySelector for CSS selectors
368
- if (!el && (target.charAt(0) === '#' || target.charAt(0) === '.')) {
369
- el = document.querySelector(target);
370
- }
371
- // 4. bw_uuid_* class lookup
372
- if (!el && target.indexOf('bw_uuid_') === 0) {
373
- el = document.querySelector('.' + target);
377
+ // UUID strings are registry-only — never querySelector resurrection (§3.2)
378
+ if (target.indexOf('bw_uuid_') === 0) {
379
+ // Not in registry null (no querySelector fallback)
380
+ el = null;
381
+ } else {
382
+ // 2. getElementById
383
+ el = document.getElementById(target);
384
+ // 3. querySelector for CSS selectors
385
+ if (!el && (target.charAt(0) === '#' || target.charAt(0) === '.')) {
386
+ el = document.querySelector(target);
387
+ }
388
+ // 4. Cache result
389
+ if (el) bw._nodeMap[target] = el;
374
390
  }
375
- // 5. Cache result
376
- if (el) bw._nodeMap[target] = el;
377
391
  }
378
392
  }
379
393
 
@@ -396,7 +410,7 @@ function _applyTo(el, apply) {
396
410
  apply.forEach(function(item) {
397
411
  if (item != null) {
398
412
  if (_is(item, 'object') && item.t) {
399
- el.appendChild(bw.createDOM(item));
413
+ el.appendChild(bw.create(item));
400
414
  } else {
401
415
  el.appendChild(document.createTextNode(String(item)));
402
416
  }
@@ -404,20 +418,17 @@ function _applyTo(el, apply) {
404
418
  });
405
419
  } else if (_is(apply, 'object') && apply !== null && apply.t) {
406
420
  el.innerHTML = '';
407
- el.appendChild(bw.createDOM(apply));
421
+ el.appendChild(bw.create(apply));
408
422
  } else {
409
423
  el.textContent = String(apply);
410
424
  }
411
425
  }
412
426
 
413
- // Internal alias — kept for one release cycle (v2.0.26).
414
- // Will be removed in v2.0.27. Use bw.el() instead.
415
- bw._el = bw.el;
416
427
 
417
428
  /**
418
429
  * Register a DOM element in the node cache under one or more keys.
419
430
  *
420
- * Called internally by `bw.createDOM()`. Registers elements that have
431
+ * Called internally by `bw.create()`. Registers elements that have
421
432
  * id attributes, UUID classes, or both.
422
433
  *
423
434
  * @param {Element} el - DOM element to register
@@ -440,7 +451,7 @@ bw._registerNode = function(el, uuid) {
440
451
  /**
441
452
  * Remove a DOM element from the node cache.
442
453
  *
443
- * Called internally by `bw.cleanup()` when elements are destroyed
454
+ * Called internally by `bw.unmount()` when elements are destroyed
444
455
  * through bitwrench APIs.
445
456
  *
446
457
  * @param {Element} el - DOM element to deregister
@@ -465,7 +476,7 @@ bw._deregisterNode = function(el, uuid) {
465
476
 
466
477
  /**
467
478
  * Marker class for elements with lifecycle hooks (mounted/unmount/render/state).
468
- * Used by cleanup() to find lifecycle-managed elements via querySelectorAll('.bw_lc').
479
+ * Used by unmount() to find lifecycle-managed elements via querySelectorAll('.bw_lc').
469
480
  * @private
470
481
  */
471
482
  var _BW_LC = 'bw_lc';
@@ -581,13 +592,13 @@ bw.escapeHTML = function(str) {
581
592
  };
582
593
 
583
594
  /**
584
- * Mark a string as raw HTML so it will not be escaped by bw.html() or bw.createDOM().
595
+ * Mark a string as raw HTML so it will not be escaped by bw.html() or bw.create().
585
596
  *
586
597
  * By default, bitwrench escapes all text content to prevent XSS. Use bw.raw()
587
598
  * when you need to embed pre-sanitized HTML, entities, or inline markup.
588
599
  *
589
600
  * @param {string} str - HTML string to mark as raw
590
- * @returns {Object} Marked object recognized by bw.html() and bw.createDOM()
601
+ * @returns {Object} Marked object recognized by bw.html() and bw.create()
591
602
  * @category DOM Generation
592
603
  * @see bw.escapeHTML
593
604
  * @see bw.html
@@ -614,7 +625,7 @@ bw.raw = function(str) {
614
625
  * @returns {Object} Plain TACO object {t, a?, c?, o?}
615
626
  * @category Utilities
616
627
  * @see bw.html
617
- * @see bw.createDOM
628
+ * @see bw.create
618
629
  * @see bw.DOM
619
630
  * @example
620
631
  * bw.h('div')
@@ -650,7 +661,7 @@ bw.h = function(tag, attrs, content, options) {
650
661
  * @param {boolean} [options.raw=false] - If true, skip HTML escaping on content
651
662
  * @returns {string} HTML string
652
663
  * @category DOM Generation
653
- * @see bw.createDOM
664
+ * @see bw.create
654
665
  * @see bw.DOM
655
666
  * @example
656
667
  * bw.html({ t: 'h1', c: 'Hello' })
@@ -694,24 +705,59 @@ bw.html = function(taco, options = {}) {
694
705
 
695
706
  // Build attributes string
696
707
  let attrStr = '';
697
-
708
+ var fnMarkers = []; // bw_fn_* markers to add to class
709
+ var fnsRegistry = options.fns || null;
710
+
698
711
  for (const [key, value] of Object.entries(attrs)) {
699
712
  // Skip null, undefined, false
700
713
  if (value == null || value === false) continue;
701
-
702
- // Serialize event handlers via funcRegister
714
+
715
+ // Serialize event handlers
703
716
  if (key.startsWith('on')) {
704
717
  if (_is(value, 'function')) {
705
- var fnId = bw.funcRegister(value);
706
- attrStr += ' ' + key + '="' + bw.funcGetDispatchStr(fnId, 'event') + '"';
718
+ if (fnsRegistry) {
719
+ // Check for unserializable functions (bound, native)
720
+ var fnStr = '';
721
+ try { fnStr = value.toString(); } catch(e) {}
722
+ if (fnStr.indexOf('[native code]') !== -1) {
723
+ if (!options._fnUnserializableWarned) {
724
+ bw.pub('bw:diag', { code: 'fn_unserializable', msg: 'bound/native function cannot be serialized' });
725
+ options._fnUnserializableWarned = true;
726
+ }
727
+ continue;
728
+ }
729
+ // Register function in per-render registry
730
+ var eventName = key.substring(2);
731
+ var fnId = null;
732
+ // Dedupe by reference + event type
733
+ var fnKeys = _keys(fnsRegistry);
734
+ for (var fi = 0; fi < fnKeys.length; fi++) {
735
+ if (fnsRegistry[fnKeys[fi]].fn === value && fnsRegistry[fnKeys[fi]].event === eventName) {
736
+ fnId = fnKeys[fi]; break;
737
+ }
738
+ }
739
+ if (!fnId) {
740
+ fnId = 'bw_fn_' + (options._fnCounter || 0);
741
+ options._fnCounter = (options._fnCounter || 0) + 1;
742
+ fnsRegistry[fnId] = { fn: value, event: eventName };
743
+ }
744
+ fnMarkers.push(fnId);
745
+ // No inline on* attribute emitted
746
+ } else {
747
+ // No {fns} registry → skip function attrs, warn once per render
748
+ if (!options._fnSkipWarned) {
749
+ bw.pub('bw:diag', { code: 'fn_skipped', msg: 'function attrs skipped without {fns} option' });
750
+ options._fnSkipWarned = true;
751
+ }
752
+ }
753
+ continue;
707
754
  } else if (_is(value, 'string')) {
708
755
  attrStr += ' ' + key + '="' + bw.escapeHTML(value) + '"';
709
756
  }
710
757
  continue;
711
758
  }
712
-
759
+
713
760
  if (key === 'style' && _is(value, 'object')) {
714
- // Convert style object to string
715
761
  const styleStr = Object.entries(value)
716
762
  .filter(([, v]) => v != null)
717
763
  .map(([k, v]) => `${k}:${v}`)
@@ -720,16 +766,11 @@ bw.html = function(taco, options = {}) {
720
766
  attrStr += ` style="${bw.escapeHTML(styleStr)}"`;
721
767
  }
722
768
  } else if (key === 'class') {
723
- // Handle class as array or string
724
- const classStr = _isA(value) ? value.filter(Boolean).join(' ') : String(value);
725
- if (classStr) {
726
- attrStr += ` class="${bw.escapeHTML(classStr)}"`;
727
- }
769
+ // Handled below with identity stamps
770
+ continue;
728
771
  } else if (value === true) {
729
- // Boolean attributes
730
772
  attrStr += ` ${key}`;
731
773
  } else {
732
- // Regular attributes — resolve ${expr} if state provided
733
774
  let resolvedVal = String(value);
734
775
  if (options.state && resolvedVal.indexOf('${') >= 0) {
735
776
  resolvedVal = bw._resolveTemplate(resolvedVal, options.state, !!options.compile);
@@ -738,15 +779,29 @@ bw.html = function(taco, options = {}) {
738
779
  }
739
780
  }
740
781
 
741
- // Add bw_uuid + bw_lc classes if lifecycle hooks present
742
- if ((opts.mounted || opts.unmount) && !_UUID_RE.test(attrs.class || '')) {
743
- const uuid = bw.uuid('uuid');
744
- attrStr = attrStr.replace(/class="([^"]*)"/, (_match, classes) => {
745
- return `class="${classes} ${uuid} ${_BW_LC}"`.trim();
746
- });
747
- if (!attrStr.includes('class=')) {
748
- attrStr += ` class="${uuid} ${_BW_LC}"`;
749
- }
782
+ // Build class attribute: user classes + identity stamps + fn markers
783
+ var classTokens = [];
784
+ var userClass = attrs.class || '';
785
+ if (_isA(userClass)) userClass = userClass.filter(Boolean).join(' ');
786
+ if (userClass) classTokens.push(String(userClass));
787
+
788
+ // Add identity stamps for o.* elements
789
+ var hasOpts = opts && (opts.type || opts.state || opts.mounted || opts.unmount || opts.handle || opts.render || opts.slots);
790
+ if (hasOpts && !_UUID_RE.test(userClass)) {
791
+ var uuid = bw.uuid('uuid');
792
+ classTokens.push(uuid);
793
+ classTokens.push(_BW_LC);
794
+ classTokens.push('bw_is_component');
795
+ if (opts.type) classTokens.push('bw_is_component_' + opts.type);
796
+ }
797
+
798
+ // Add fn marker classes
799
+ for (var fmi = 0; fmi < fnMarkers.length; fmi++) {
800
+ classTokens.push(fnMarkers[fmi]);
801
+ }
802
+
803
+ if (classTokens.length > 0) {
804
+ attrStr += ' class="' + bw.escapeHTML(classTokens.join(' ')) + '"';
750
805
  }
751
806
 
752
807
  // Build HTML
@@ -804,27 +859,30 @@ bw.htmlPage = function(opts) {
804
859
  var favicon = opts.favicon || '';
805
860
  var lang = opts.lang || 'en';
806
861
 
807
- // Snapshot funcRegistry counter before rendering
808
- var fnCounterBefore = bw._fnIDCounter;
862
+ var useHandlers = opts.handlers !== false;
863
+
864
+ // Per-render function registry
865
+ var fns = useHandlers ? {} : null;
809
866
 
810
867
  // Render body content
811
868
  var bodyHTML;
812
869
  if (_is(body, 'string')) {
813
870
  bodyHTML = body;
814
871
  } else {
815
- var htmlOpts = {};
872
+ var htmlOpts = { _fnCounter: 0 };
816
873
  if (state) htmlOpts.state = state;
874
+ if (fns) htmlOpts.fns = fns;
817
875
  bodyHTML = bw.html(body, htmlOpts);
818
876
  }
819
877
 
820
- // Collect functions registered during this render
821
- var fnCounterAfter = bw._fnIDCounter;
878
+ // Build registry entries from per-render fns
822
879
  var registryEntries = '';
823
- for (var i = fnCounterBefore; i < fnCounterAfter; i++) {
824
- var fnKey = 'bw_fn_' + i;
825
- if (bw._fnRegistry[fnKey]) {
826
- registryEntries += 'bw._fnRegistry[\'' + fnKey + '\']=' +
827
- bw._fnRegistry[fnKey].toString() + ';\n';
880
+ if (fns) {
881
+ var fnKeys = _keys(fns);
882
+ for (var i = 0; i < fnKeys.length; i++) {
883
+ var fnEntry = fns[fnKeys[i]];
884
+ registryEntries += 'r[\'' + fnKeys[i] + '\']={fn:' +
885
+ fnEntry.fn.toString() + ',event:\'' + fnEntry.event + '\'};\n';
828
886
  }
829
887
  }
830
888
 
@@ -833,6 +891,7 @@ bw.htmlPage = function(opts) {
833
891
  if (runtime === 'inline') {
834
892
  // Read UMD bundle synchronously if in Node.js
835
893
  var umdSource = null;
894
+ /* c8 ignore start -- htmlPage inline runtime: require('fs')/require('path')/__filename only available in CJS builds, not ESM test runner */
836
895
  if (bw._isNode) {
837
896
  try {
838
897
  var fs = (typeof require === 'function') ? require('fs') : null;
@@ -853,6 +912,8 @@ bw.htmlPage = function(opts) {
853
912
  }
854
913
  } catch(e) { /* fall through */ }
855
914
  }
915
+ /* c8 ignore stop */
916
+ /* c8 ignore next 4 -- umdSource path depends on CJS fs.readFileSync */
856
917
  if (umdSource) {
857
918
  runtimeHead = '<script>' + umdSource + '</script>';
858
919
  } else {
@@ -899,17 +960,31 @@ bw.htmlPage = function(opts) {
899
960
  // Combine all CSS
900
961
  var allCSS = (themeCSS ? themeCSS + '\n' : '') + css;
901
962
 
902
- // Body-end script: registry entries + optional loadStyles
963
+ // CSP nonce
964
+ var nonce = (bw.config && bw.config.cspNonce) ? bw.config.cspNonce : null;
965
+ var nonceAttr = nonce ? ' nonce="' + bw.escapeHTML(nonce) + '"' : '';
966
+
967
+ // Body-end script: binder for registered functions
903
968
  var bodyEndScript = '';
904
969
  var bodyEndParts = [];
905
970
  if (registryEntries) {
971
+ // Emit binder: iterate registered functions, find elements by bw_fn_* class,
972
+ // bind event listeners. Wrap in try/catch with bw_act guidance.
973
+ bodyEndParts.push('(function(){var r={};');
906
974
  bodyEndParts.push(registryEntries);
975
+ bodyEndParts.push('for(var k in r){if(r.hasOwnProperty(k)){');
976
+ bodyEndParts.push('var els=document.querySelectorAll("."+k);');
977
+ bodyEndParts.push('for(var i=0;i<els.length;i++){');
978
+ bodyEndParts.push('(function(el,entry){el.addEventListener(entry.event,function(event){');
979
+ bodyEndParts.push('try{entry.fn.call(el,event);}catch(e){');
980
+ bodyEndParts.push('console.error("bw_act handler error — if this is a ReferenceError from a closure, use bw_act_* class tokens instead:",e);}');
981
+ bodyEndParts.push('});})(els[i],r[k]);}}}})();');
907
982
  }
908
983
  if (runtime === 'inline' || runtime === 'cdn') {
909
984
  bodyEndParts.push('if(typeof bw!=="undefined"){bw.loadStyles();}');
910
985
  }
911
986
  if (bodyEndParts.length > 0) {
912
- bodyEndScript = '<script>\n' + bodyEndParts.join('\n') + '\n</script>';
987
+ bodyEndScript = '<script' + nonceAttr + '>\n' + bodyEndParts.join('\n') + '\n</script>';
913
988
  }
914
989
 
915
990
  // Assemble document
@@ -922,9 +997,13 @@ bw.htmlPage = function(opts) {
922
997
  ];
923
998
  parts.push('<title>' + safeTitle + '</title>');
924
999
  if (faviconTag) parts.push(faviconTag);
925
- if (runtimeHead) parts.push(runtimeHead);
1000
+ if (runtimeHead) {
1001
+ // Add nonce to runtime script tags
1002
+ if (nonce) runtimeHead = runtimeHead.replace(/<script(?![^>]*\bnonce\b)/g, '<script' + nonceAttr);
1003
+ parts.push(runtimeHead);
1004
+ }
926
1005
  if (headHTML) parts.push(headHTML);
927
- if (allCSS) parts.push('<style>' + allCSS + '</style>');
1006
+ if (allCSS) parts.push('<style' + nonceAttr + '>' + allCSS + '</style>');
928
1007
  parts.push('</head>');
929
1008
  parts.push('<body>');
930
1009
  parts.push(bodyHTML);
@@ -936,31 +1015,32 @@ bw.htmlPage = function(opts) {
936
1015
  };
937
1016
 
938
1017
  /**
939
- * Create a live DOM element from a TACO object (browser only).
1018
+ * Create a hydrated, detached DOM element from a TACO object (browser only).
940
1019
  *
941
- * Unlike `bw.html()` which returns a string, this creates real DOM elements
942
- * with event handlers, lifecycle hooks (mounted/unmount), and state. Used
943
- * internally by `bw.DOM()`. Throws in Node.js — use `bw.html()` instead.
1020
+ * v2.1 Phase verb: the element is fully wired (state, handles, slots, events,
1021
+ * unmount closure) but NOT registered and mounted() is NOT fired. Registration
1022
+ * happens in mountTree(); mounted fires there too.
944
1023
  *
945
1024
  * @param {Object} taco - TACO object with {t, a, c, o}
946
1025
  * @param {Object} [options] - Creation options
947
- * @returns {Element|Text} DOM element or text node
1026
+ * @returns {Element|Text|DocumentFragment} DOM element, text node, or fragment
948
1027
  * @category DOM Generation
949
- * @see bw.html
950
- * @see bw.DOM
951
- * @example
952
- * var el = bw.createDOM({
953
- * t: 'button',
954
- * a: { class: 'bw_btn', onclick: () => alert('clicked') },
955
- * c: 'Click Me'
956
- * });
957
- * document.body.appendChild(el);
1028
+ * @see bw.mount
1029
+ * @see bw.mountTree
958
1030
  */
959
- bw.createDOM = function(taco, options = {}) {
1031
+ bw.create = function(taco, options) {
960
1032
  if (!bw._isBrowser) {
961
- throw new Error('bw.createDOM requires a DOM environment (document/window). Use bw.html() instead.');
1033
+ throw new Error('bw.create requires a DOM environment (document/window). Use bw.html() instead.');
962
1034
  }
963
-
1035
+ return _createNode(taco, options || {});
1036
+ };
1037
+
1038
+ /**
1039
+ * Internal: recursively build DOM from TACO. Separated so spy on bw.create
1040
+ * sees only the top-level call, not the recursive child builds.
1041
+ * @private
1042
+ */
1043
+ function _createNode(taco, options) {
964
1044
  // Handle null/undefined
965
1045
  if (taco == null) return document.createTextNode('');
966
1046
 
@@ -973,53 +1053,49 @@ bw.createDOM = function(taco, options = {}) {
973
1053
  return frag;
974
1054
  }
975
1055
 
976
- // Handle text nodes
1056
+ // Handle text nodes (primitives)
977
1057
  if (!_is(taco, 'object') || !taco.t) {
978
1058
  return document.createTextNode(String(taco));
979
1059
  }
980
1060
 
981
- const { t: tag, a: attrs = {}, c: content, o: opts = {} } = taco;
1061
+ // Read attrs from taco without mutating the original
1062
+ var tag = taco.t;
1063
+ var attrs = taco.a || {};
1064
+ var content = taco.c;
1065
+ var opts = taco.o || {};
982
1066
 
983
1067
  // SVG namespace: detect SVG context and thread through children.
984
- // {t:'svg'} starts SVG context; foreignObject children revert to HTML.
985
1068
  var svgCtx = options._svgCtx || (tag === 'svg');
986
1069
  var el = svgCtx ? document.createElementNS(_SVG_NS, tag) : document.createElement(tag);
987
-
988
- // Set attributes
989
- for (const [key, value] of Object.entries(attrs)) {
1070
+
1071
+ // Set attributes — never mutate taco.a, read from it
1072
+ var attrKeys = _keys(attrs);
1073
+ for (var ai = 0; ai < attrKeys.length; ai++) {
1074
+ var key = attrKeys[ai];
1075
+ var value = attrs[key];
990
1076
  if (value == null || value === false) continue;
991
-
1077
+
992
1078
  if (key === 'style' && _is(value, 'object')) {
993
- // Apply styles directly
994
1079
  Object.assign(el.style, value);
995
1080
  } else if (key === 'class') {
996
- // Handle class as array or string
997
- // SVG elements use SVGAnimatedString for className, so use setAttribute
998
- const classStr = _isA(value) ? value.filter(Boolean).join(' ') : String(value);
1081
+ var classStr = _isA(value) ? value.filter(Boolean).join(' ') : String(value);
999
1082
  if (classStr) {
1000
1083
  if (svgCtx) el.setAttribute('class', classStr);
1001
1084
  else el.className = classStr;
1002
1085
  }
1003
- } else if (key.startsWith('on') && _is(value, 'function')) {
1004
- // Event handlers
1005
- const eventName = key.slice(2).toLowerCase();
1086
+ } else if (key.indexOf('on') === 0 && key.length > 2 && _is(value, 'function')) {
1087
+ var eventName = key.slice(2).toLowerCase();
1006
1088
  el.addEventListener(eventName, value);
1007
1089
  } else if (key === 'value' && tag === 'input') {
1008
- // Special handling for input value
1009
1090
  el.value = value;
1010
1091
  } else if (value === true) {
1011
- // Boolean attributes
1012
1092
  el.setAttribute(key, '');
1013
1093
  } else {
1014
- // Regular attributes
1015
1094
  el.setAttribute(key, String(value));
1016
1095
  }
1017
1096
  }
1018
-
1097
+
1019
1098
  // Add children, building _bw_refs for fast parent→child access.
1020
- // Children with id attributes or bw_uuid_* classes get local refs on the parent,
1021
- // so o.render functions can access them without any DOM lookup.
1022
- // SVG: foreignObject children revert to HTML namespace; otherwise inherit.
1023
1099
  var childOpts = options;
1024
1100
  var childSvgCtx = svgCtx && tag !== 'foreignObject';
1025
1101
  if (childSvgCtx !== (options._svgCtx || false)) {
@@ -1027,17 +1103,16 @@ bw.createDOM = function(taco, options = {}) {
1027
1103
  }
1028
1104
  if (content != null) {
1029
1105
  if (_isA(content)) {
1030
- content.forEach(child => {
1106
+ for (var ci = 0; ci < content.length; ci++) {
1107
+ var child = content[ci];
1031
1108
  if (child != null) {
1032
- var childEl = bw.createDOM(child, childOpts);
1109
+ var childEl = _createNode(child, childOpts);
1033
1110
  el.appendChild(childEl);
1034
- // Build local refs for addressable children
1035
1111
  var childRefId = (child && child.a) ? (child.a.id || bw.getUUID(child)) : null;
1036
1112
  if (childRefId) {
1037
1113
  if (!el._bw_refs) el._bw_refs = {};
1038
1114
  el._bw_refs[childRefId] = childEl;
1039
1115
  }
1040
- // Bubble up grandchild refs (flatten one level)
1041
1116
  if (childEl._bw_refs) {
1042
1117
  if (!el._bw_refs) el._bw_refs = {};
1043
1118
  for (var rk in childEl._bw_refs) {
@@ -1047,23 +1122,22 @@ bw.createDOM = function(taco, options = {}) {
1047
1122
  }
1048
1123
  }
1049
1124
  }
1050
- });
1125
+ }
1051
1126
  } else if (_is(content, 'object') && content.__bw_raw) {
1052
- // Raw HTML content — inject via innerHTML
1053
1127
  el.innerHTML = content.v;
1054
1128
  } else if (_is(content, 'object') && content.t) {
1055
- var childEl = bw.createDOM(content, childOpts);
1056
- el.appendChild(childEl);
1057
- var childRefId = content.a ? (content.a.id || bw.getUUID(content)) : null;
1058
- if (childRefId) {
1129
+ var childEl2 = _createNode(content, childOpts);
1130
+ el.appendChild(childEl2);
1131
+ var childRefId2 = content.a ? (content.a.id || bw.getUUID(content)) : null;
1132
+ if (childRefId2) {
1059
1133
  if (!el._bw_refs) el._bw_refs = {};
1060
- el._bw_refs[childRefId] = childEl;
1134
+ el._bw_refs[childRefId2] = childEl2;
1061
1135
  }
1062
- if (childEl._bw_refs) {
1136
+ if (childEl2._bw_refs) {
1063
1137
  if (!el._bw_refs) el._bw_refs = {};
1064
- for (var rk in childEl._bw_refs) {
1065
- if (_hop.call(childEl._bw_refs, rk)) {
1066
- el._bw_refs[rk] = childEl._bw_refs[rk];
1138
+ for (var rk2 in childEl2._bw_refs) {
1139
+ if (_hop.call(childEl2._bw_refs, rk2)) {
1140
+ el._bw_refs[rk2] = childEl2._bw_refs[rk2];
1067
1141
  }
1068
1142
  }
1069
1143
  }
@@ -1072,218 +1146,718 @@ bw.createDOM = function(taco, options = {}) {
1072
1146
  }
1073
1147
  }
1074
1148
 
1075
- // Register element in node cache if it has an id attribute
1076
- if (attrs.id) {
1077
- bw._registerNode(el, null);
1078
- }
1149
+ // ── Hydrate: wire o.* onto the element ──
1150
+ // No registration, no mounted(), no rAF. That's mountTree's job.
1151
+ _hydrateElement(el, opts);
1079
1152
 
1080
- // Register UUID class in node cache (bw_uuid_* tokens in class string)
1081
- // SVG elements have SVGAnimatedString for className; use getAttribute instead
1082
- var clsStr = svgCtx ? (el.getAttribute('class') || '') : el.className;
1083
- if (clsStr) {
1084
- var uuidMatch = clsStr.match(_UUID_RE);
1085
- if (uuidMatch) {
1086
- bw._nodeMap[uuidMatch[0]] = el;
1087
- }
1088
- }
1153
+ return el;
1154
+ }
1155
+
1156
+ // v2.1: bw.createDOM, bw.renderComponent, bw.compileProps fully removed.
1089
1157
 
1090
- // Store component type metadata (e.g., 'card', 'tabs') for introspection.
1091
- // BCCL factories set o.type; custom components can too.
1158
+ /**
1159
+ * Internal: wire a TACO's o.* options onto an existing DOM element.
1160
+ * Used by both bw.create (inline) and bw.hydrate (standalone).
1161
+ * Idempotent: if el already has bw handle, skip.
1162
+ * @private
1163
+ */
1164
+ function _hydrateElement(el, opts) {
1165
+ if (!opts || typeof opts !== 'object') return;
1166
+
1167
+ // Check for any o.* key that makes this a component
1168
+ var hasLifecycle = opts.mounted || opts.unmount || opts.render || opts.state
1169
+ || opts.handle || opts.slots || opts.type;
1170
+ if (!hasLifecycle) return;
1171
+
1172
+ // Idempotency: if el.bw already exists, skip (re-hydrate is no-op)
1173
+ if (el.bw) return;
1174
+
1175
+ // Store component type
1092
1176
  if (opts.type) {
1093
1177
  el._bw_type = opts.type;
1094
1178
  }
1095
1179
 
1096
- // Handle lifecycle hooks and state
1097
- if (opts.mounted || opts.unmount || opts.render || opts.state) {
1098
- // Ensure element has a UUID class for identity
1099
- var uuid = bw.getUUID(el) || bw.uuid('uuid');
1100
- el.classList.add(uuid);
1101
- el.classList.add(_BW_LC);
1180
+ // Stamp UUID on the element (not on the input TACO)
1181
+ var uuid = bw.getUUID(el) || bw.uuid('uuid');
1182
+ if (!el.classList.contains(uuid)) el.classList.add(uuid);
1102
1183
 
1103
- // Register in node cache under UUID class
1104
- bw._registerNode(el, uuid);
1184
+ // Component markers
1185
+ el.classList.add(_BW_LC);
1186
+ el.classList.add('bw_is_component');
1187
+ if (opts.type) {
1188
+ el.classList.add('bw_is_component_' + opts.type);
1189
+ }
1105
1190
 
1106
- // Store state
1107
- if (opts.state) {
1108
- el._bw_state = opts.state;
1109
- }
1191
+ // Store state (clone so TACO isn't retained)
1192
+ if (opts.state) {
1193
+ el._bw_state = opts.state;
1194
+ }
1195
+
1196
+ // Store render function
1197
+ if (opts.render) {
1198
+ el._bw_render = opts.render;
1199
+ }
1200
+
1201
+ // Store mounted function on element (fired later by mountTree)
1202
+ if (opts.mounted) {
1203
+ el._bw_mounted_fn = opts.mounted;
1204
+ } else if (opts.render && !opts.mounted) {
1205
+ // Auto-mount: if render exists but no mounted, auto-call render at mount
1206
+ el._bw_mounted_fn = function(mountEl, state) {
1207
+ opts.render(mountEl, state);
1208
+ };
1209
+ }
1110
1210
 
1111
- // o.render store the render function for bw.update()
1112
- if (opts.render) {
1113
- el._bw_render = opts.render;
1211
+ // Store unmount closure on the element itself (not in a global Map)
1212
+ if (opts.unmount) {
1213
+ el._bw_unmount_fn = opts.unmount;
1214
+ }
1215
+
1216
+ // Component handle: attach methods to el.bw namespace
1217
+ if (!el.bw) el.bw = {};
1218
+
1219
+ // Auto-generate getState()
1220
+ el.bw.getState = function() {
1221
+ return Object.assign({}, el._bw_state || {});
1222
+ };
1223
+
1224
+ // Explicit handle methods: fn(el, ...args) -> el.bw.method(...args)
1225
+ if (opts.handle) {
1226
+ for (var hk in opts.handle) {
1227
+ if (_hop.call(opts.handle, hk)) {
1228
+ el.bw[hk] = opts.handle[hk].bind(null, el);
1229
+ }
1114
1230
  }
1231
+ }
1115
1232
 
1116
- // Determine what to call on mount:
1117
- // - If o.mounted exists, call it (it can call el._bw_render() for initial render)
1118
- // - Otherwise if o.render exists, auto-call it as a convenience shorthand
1119
- var mountFn = opts.mounted || (opts.render ? function(mountEl) {
1120
- opts.render(mountEl, mountEl._bw_state || {});
1121
- } : null);
1122
-
1123
- if (mountFn) {
1124
- if (document.body.contains(el)) {
1125
- try { mountFn(el, el._bw_state || {}); }
1126
- catch (e) { _cw('o.mounted error: ' + e.message); }
1127
- } else {
1128
- requestAnimationFrame(() => {
1129
- if (document.body.contains(el)) {
1130
- try { mountFn(el, el._bw_state || {}); }
1131
- catch (e) { _cw('o.mounted error: ' + e.message); }
1132
- }
1133
- });
1233
+ // Slot declarations: lazy-cached on first use, refresh invalidates cache
1234
+ if (opts.slots) {
1235
+ el._bw_slots = opts.slots;
1236
+ el._bw_slot_cache = null; // lazy
1237
+ for (var sk in opts.slots) {
1238
+ if (_hop.call(opts.slots, sk)) {
1239
+ (function(name, selector) {
1240
+ var cap = name.charAt(0).toUpperCase() + name.slice(1);
1241
+ el.bw['set' + cap] = function(value) {
1242
+ if (!el._bw_slot_cache) el._bw_slot_cache = {};
1243
+ if (!el._bw_slot_cache[name]) {
1244
+ el._bw_slot_cache[name] = el.querySelector(selector);
1245
+ }
1246
+ var target = el._bw_slot_cache[name];
1247
+ if (!target) return;
1248
+ // Always unmount existing children before replacing
1249
+ bw.unmountChildren(target);
1250
+ if (value != null && typeof value === 'object' && value.t) {
1251
+ // TACO through slots runs mount pipeline
1252
+ target.innerHTML = '';
1253
+ var child = bw.create(value);
1254
+ target.appendChild(child);
1255
+ if (el.isConnected) bw.mountTree(child);
1256
+ } else {
1257
+ target.textContent = (value != null) ? String(value) : '';
1258
+ }
1259
+ };
1260
+ el.bw['get' + cap] = function() {
1261
+ if (!el._bw_slot_cache) el._bw_slot_cache = {};
1262
+ if (!el._bw_slot_cache[name]) {
1263
+ el._bw_slot_cache[name] = el.querySelector(selector);
1264
+ }
1265
+ var target = el._bw_slot_cache[name];
1266
+ return target ? target.textContent : '';
1267
+ };
1268
+ })(sk, opts.slots[sk]);
1134
1269
  }
1135
1270
  }
1271
+ }
1272
+ }
1136
1273
 
1137
- // Store unmount callback keyed by UUID class
1138
- if (opts.unmount) {
1139
- bw._unmountCallbacks.set(uuid, () => {
1140
- try { opts.unmount(el, el._bw_state || {}); }
1141
- catch (e) { _cw('o.unmount error: ' + e.message); }
1142
- });
1274
+ /**
1275
+ * Wire lifecycle from taco.o onto an existing DOM node. Idempotent.
1276
+ * Used for Path S adoption: html() output → mountTree → hydrate adds behavior.
1277
+ *
1278
+ * @param {Element} el - Existing DOM element
1279
+ * @param {Object} taco - TACO object whose o.* to wire
1280
+ * @category DOM Generation
1281
+ */
1282
+ bw.hydrate = function(el, taco) {
1283
+ if (!el || !taco) return;
1284
+ var opts = taco.o || {};
1285
+ _hydrateElement(el, opts);
1286
+ };
1287
+
1288
+ /**
1289
+ * Walk a subtree, register every addressable node, fire mounted() hooks.
1290
+ * Idempotent: already-registered nodes (same element) are skipped silently.
1291
+ *
1292
+ * Mounted fires synchronously, parent before children.
1293
+ *
1294
+ * @param {Element} el - Root of subtree to mount
1295
+ * @category DOM Generation
1296
+ */
1297
+ bw.mountTree = function(el) {
1298
+ if (!el || el.nodeType !== 1) return;
1299
+
1300
+ // Lazy-install janitor observer on first mount
1301
+ if (bw.janitor && bw.janitor._ensureObserver) bw.janitor._ensureObserver();
1302
+ // Lazy-install action dispatcher if enabled (handles document changes in test envs)
1303
+ if (bw.actions && bw.actions._ensureInstalled) bw.actions._ensureInstalled();
1304
+
1305
+ // Process this node
1306
+ _mountNode(el);
1307
+
1308
+ // Process descendants in document order (parent-first = querySelectorAll order)
1309
+ var descendants = el.querySelectorAll('*');
1310
+ for (var i = 0; i < descendants.length; i++) {
1311
+ _mountNode(descendants[i]);
1312
+ }
1313
+ };
1314
+
1315
+ /**
1316
+ * Internal: mount a single node — register and fire mounted if needed.
1317
+ * @private
1318
+ */
1319
+ function _mountNode(el) {
1320
+ /* c8 ignore next -- _mountNode only called with valid elements from mountTree */
1321
+ if (!el || el.nodeType !== 1) return;
1322
+
1323
+ var uuid = bw.getUUID(el);
1324
+ if (!uuid) {
1325
+ // Still register by id if present
1326
+ /* c8 ignore next -- all DOM elements in test env have getAttribute */
1327
+ var htmlId = el.getAttribute ? el.getAttribute('id') : null;
1328
+ if (htmlId) bw._nodeMap[htmlId] = el;
1329
+ return;
1330
+ }
1331
+
1332
+ // Idempotent: if already registered to this same element, skip entirely
1333
+ if (bw._nodeMap[uuid] === el && bw._mounted[uuid]) return;
1334
+
1335
+ // Collision detection: UUID already registered to a DIFFERENT element
1336
+ if (bw._nodeMap[uuid] && bw._nodeMap[uuid] !== el) {
1337
+ // Remint: generate a new UUID for this element
1338
+ var oldUuid = uuid;
1339
+ var newUuid = bw.uuid('uuid');
1340
+ // Replace on element
1341
+ el.classList.remove(oldUuid);
1342
+ el.classList.add(newUuid);
1343
+ uuid = newUuid;
1344
+ // Re-key parent's _bw_refs if applicable
1345
+ if (el.parentNode && el.parentNode._bw_refs) {
1346
+ if (el.parentNode._bw_refs[oldUuid] === el) {
1347
+ delete el.parentNode._bw_refs[oldUuid];
1348
+ el.parentNode._bw_refs[newUuid] = el;
1349
+ }
1143
1350
  }
1351
+ bw.pub('bw:diag', { code: 'uuid_collision', uuid: oldUuid, ref: newUuid,
1352
+ msg: 'UUID collision detected; reminted to ' + newUuid });
1144
1353
  }
1145
1354
 
1146
- // Component handle: attach methods to el.bw namespace
1147
- if (opts.handle || opts.slots) {
1148
- if (!el.bw) el.bw = {};
1149
-
1150
- // Explicit handle methods: fn(el, ...args) -> el.bw.method(...args)
1151
- if (opts.handle) {
1152
- for (var hk in opts.handle) {
1153
- if (_hop.call(opts.handle, hk)) {
1154
- el.bw[hk] = opts.handle[hk].bind(null, el);
1155
- }
1355
+ // Register UUID
1356
+ bw._nodeMap[uuid] = el;
1357
+
1358
+ // Register id attribute
1359
+ /* c8 ignore next -- all DOM elements in test env have getAttribute */
1360
+ htmlId = el.getAttribute ? el.getAttribute('id') : null;
1361
+ if (htmlId) {
1362
+ bw._nodeMap[htmlId] = el;
1363
+ }
1364
+
1365
+ // Clear detach exemption on reconnect
1366
+ if (bw._detached[uuid]) {
1367
+ delete bw._detached[uuid];
1368
+ }
1369
+
1370
+ // Fire mounted() — only for lifecycle components, only once per identity
1371
+ if (el.classList.contains(_BW_LC) && !bw._mounted[uuid]) {
1372
+ bw._mounted[uuid] = true;
1373
+
1374
+ if (el._bw_mounted_fn) {
1375
+ try { el._bw_mounted_fn(el, el._bw_state || {}); }
1376
+ catch (e) {
1377
+ _cw('o.mounted error: ' + e.message);
1378
+ bw.pub('bw:diag', { code: 'mounted_hook_error', uuid: uuid, msg: e.message });
1156
1379
  }
1157
1380
  }
1158
1381
 
1159
- // Slot declarations: auto-generate setX/getX pairs
1160
- // The target element is cached at creation time to avoid repeated
1161
- // querySelector calls on every get/set invocation.
1162
- if (opts.slots) {
1163
- for (var sk in opts.slots) {
1164
- if (_hop.call(opts.slots, sk)) {
1165
- (function(name, selector) {
1166
- var target = el.querySelector(selector);
1167
- var cap = name.charAt(0).toUpperCase() + name.slice(1);
1168
- el.bw['set' + cap] = function(value) {
1169
- if (!target) return;
1170
- if (value != null && typeof value === 'object' && value.t) {
1171
- target.innerHTML = '';
1172
- target.appendChild(bw.createDOM(value));
1173
- } else {
1174
- target.textContent = (value != null) ? String(value) : '';
1175
- }
1176
- };
1177
- el.bw['get' + cap] = function() {
1178
- return target ? target.textContent : '';
1179
- };
1180
- })(sk, opts.slots[sk]);
1382
+ // Emit bw:mount CustomEvent (bubbles)
1383
+ try {
1384
+ el.dispatchEvent(new CustomEvent('bw:mount', {
1385
+ bubbles: true,
1386
+ detail: { uuid: uuid, type: el._bw_type || null }
1387
+ }));
1388
+ } catch (e) { /* jsdom edge case */ }
1389
+
1390
+ // Mirror to bw:lifecycle pub/sub
1391
+ bw.pub('bw:lifecycle', { event: 'mount', uuid: uuid, type: el._bw_type || null });
1392
+ }
1393
+ }
1394
+
1395
+ /**
1396
+ * Unmount an element and its entire subtree. Fire unmount hooks self-first,
1397
+ * then descendants in document order. Strip ALL bitwrench properties.
1398
+ * Deregister from _nodeMap.
1399
+ *
1400
+ * @param {Element} el - Element to unmount
1401
+ * @category DOM Generation
1402
+ */
1403
+ bw.unmount = function(el) {
1404
+ if (!el || el.nodeType !== 1) return;
1405
+
1406
+ // Collect all addressable nodes: self + descendants in document order
1407
+ var nodes = [el];
1408
+ var desc = el.querySelectorAll('.' + _BW_LC + ', [class*="bw_uuid_"], [id]');
1409
+ for (var i = 0; i < desc.length; i++) {
1410
+ nodes.push(desc[i]);
1411
+ }
1412
+
1413
+ // Fire unmount hooks and strip, self-first
1414
+ for (var n = 0; n < nodes.length; n++) {
1415
+ _unmountNode(nodes[n]);
1416
+ }
1417
+ };
1418
+
1419
+ /**
1420
+ * Internal: unmount a single node — fire hook, strip everything, deregister.
1421
+ * @private
1422
+ */
1423
+ function _unmountNode(el) {
1424
+ /* c8 ignore next -- _unmountNode only called with valid elements */
1425
+ if (!el || el.nodeType !== 1) return;
1426
+
1427
+ var uuid = bw.getUUID(el);
1428
+ /* c8 ignore next -- all DOM elements in test env have getAttribute */
1429
+ var htmlId = el.getAttribute ? el.getAttribute('id') : null;
1430
+
1431
+ // If element has neither uuid nor id nor lifecycle, nothing to do
1432
+ if (!uuid && !htmlId && !el.classList.contains(_BW_LC)) return;
1433
+
1434
+ // Emit bw:unmount BEFORE stripping (so listeners can read state)
1435
+ if (uuid && el.classList.contains(_BW_LC)) {
1436
+ try {
1437
+ el.dispatchEvent(new CustomEvent('bw:unmount', {
1438
+ bubbles: true,
1439
+ detail: { uuid: uuid, type: el._bw_type || null }
1440
+ }));
1441
+ } catch (e) { /* jsdom edge case */ }
1442
+ }
1443
+
1444
+ // Fire unmount closure
1445
+ if (el._bw_unmount_fn) {
1446
+ try { el._bw_unmount_fn(el, el._bw_state || {}); }
1447
+ catch (e) {
1448
+ _cw('o.unmount error: ' + e.message);
1449
+ bw.pub('bw:diag', { code: 'unmount_hook_error', uuid: uuid, msg: e.message });
1450
+ }
1451
+ }
1452
+
1453
+ // Clean up pub/sub subscriptions tied to this element
1454
+ if (el._bw_subs) {
1455
+ for (var si = 0; si < el._bw_subs.length; si++) {
1456
+ try { el._bw_subs[si](); } catch (e) {}
1457
+ }
1458
+ delete el._bw_subs;
1459
+ }
1460
+
1461
+ // Deregister from node cache — remove all entries pointing to this element
1462
+ // (covers uuid, id, and selector-based cache entries like "#foo")
1463
+ for (var nk in bw._nodeMap) {
1464
+ if (_hop.call(bw._nodeMap, nk) && bw._nodeMap[nk] === el) {
1465
+ delete bw._nodeMap[nk];
1466
+ }
1467
+ }
1468
+ if (uuid) {
1469
+ delete bw._mounted[uuid];
1470
+ delete bw._detached[uuid];
1471
+ }
1472
+
1473
+ // Strip ALL bitwrench properties
1474
+ delete el.bw;
1475
+ delete el._bw_state;
1476
+ delete el._bw_render;
1477
+ delete el._bw_refs;
1478
+ delete el._bw_type;
1479
+ delete el._bw_unmount_fn;
1480
+ delete el._bw_mounted_fn;
1481
+ delete el._bw_slots;
1482
+ delete el._bw_slot_cache;
1483
+
1484
+ // Strip marker classes and uuid tokens
1485
+ if (el.classList) {
1486
+ el.classList.remove(_BW_LC);
1487
+ el.classList.remove('bw_is_component');
1488
+ // Remove typed marker
1489
+ var cls = el.className;
1490
+ if (typeof cls === 'string') {
1491
+ var classes = cls.split(/\s+/);
1492
+ for (var ci = 0; ci < classes.length; ci++) {
1493
+ if (classes[ci].indexOf('bw_is_component_') === 0 ||
1494
+ classes[ci].indexOf('bw_uuid_') === 0) {
1495
+ el.classList.remove(classes[ci]);
1181
1496
  }
1182
1497
  }
1183
1498
  }
1184
1499
  }
1500
+ }
1185
1501
 
1186
- return el;
1502
+ /**
1503
+ * Unmount descendants only; the element's own state/subs/registration are untouched.
1504
+ *
1505
+ * @param {Element} el - Parent element whose children to unmount
1506
+ * @category DOM Generation
1507
+ */
1508
+ bw.unmountChildren = function(el) {
1509
+ if (!el || el.nodeType !== 1) return;
1510
+
1511
+ // Find all direct and nested lifecycle/addressable nodes inside children
1512
+ var childNodes = el.querySelectorAll('.' + _BW_LC + ', [class*="bw_uuid_"]');
1513
+ for (var i = 0; i < childNodes.length; i++) {
1514
+ _unmountNode(childNodes[i]);
1515
+ }
1187
1516
  };
1188
1517
 
1189
1518
  /**
1190
- * Mount a TACO object into a DOM element, replacing its contents (browser only).
1519
+ * Remove an element from the DOM and clean it up. Convenience compound:
1520
+ * unmount(el) + el.remove().
1191
1521
  *
1192
- * This is the primary way to render bitwrench UI to the page. It cleans up
1193
- * any existing children (calling unmount hooks), then renders the TACO into
1194
- * the target. The target element itself is preserved — only its children change.
1522
+ * @param {string|Element} ref - Element reference
1523
+ * @category DOM Generation
1524
+ */
1525
+ bw.remove = function(ref) {
1526
+ var el = bw.el(ref);
1527
+ if (!el) return;
1528
+ bw.unmount(el);
1529
+ if (el.parentNode) el.parentNode.removeChild(el);
1530
+ };
1531
+
1532
+ /**
1533
+ * Detach an element from the DOM but keep it registered (keep-alive).
1534
+ * The element stays addressable and its subscriptions keep delivering.
1535
+ * Janitor will not reap detach-exempt elements.
1195
1536
  *
1196
- * @param {string|Element} target - CSS selector or DOM element to mount into
1197
- * @param {Object} taco - TACO object to render
1198
- * @param {Object} [options] - Mount options
1199
- * @returns {Element} Target element
1537
+ * @param {Element} el - Element to detach
1200
1538
  * @category DOM Generation
1201
- * @see bw.html
1202
- * @see bw.createDOM
1203
- * @see bw.cleanup
1204
- * @example
1205
- * bw.DOM('#app', {
1206
- * t: 'div', a: { class: 'card' },
1207
- * c: [
1208
- * { t: 'h2', c: 'Hello' },
1209
- * { t: 'p', c: 'Built with bitwrench.' }
1210
- * ]
1211
- * });
1212
1539
  */
1213
- bw.DOM = function(target, taco, options = {}) {
1214
- if (!bw._isBrowser) {
1215
- throw new Error('bw.DOM requires a DOM environment (document/window). Use bw.html() instead.');
1540
+ bw.detach = function(el) {
1541
+ if (!el) return;
1542
+ var uuid = bw.getUUID(el);
1543
+ if (uuid) {
1544
+ bw._detached[uuid] = true;
1216
1545
  }
1217
-
1218
- // Get target element (use cache-backed lookup)
1219
- const targetEl = bw.el(target);
1220
-
1221
- if (!targetEl) {
1222
- _ce('bw.DOM: Target element not found:', target);
1223
- return null;
1546
+ if (el.parentNode) el.parentNode.removeChild(el);
1547
+ };
1548
+
1549
+ // v2.1: bw.cleanup fully removed.
1550
+
1551
+ /**
1552
+ * Janitor: document-level cleanup for ungraceful teardown.
1553
+ * Detects rude el.remove() / innerHTML='' and fires full unmount.
1554
+ *
1555
+ * flush() = synchronous process all pending disconnected nodes.
1556
+ * enable()/disable() toggle monitoring. ON by default.
1557
+ */
1558
+ bw.janitor = (function() {
1559
+ var _pending = []; // elements pending liveness check
1560
+ var _enabled = true;
1561
+ var _reapedList = []; // recently reaped elements for tripwire check
1562
+ var _observer = null;
1563
+ var _flushScheduled = false;
1564
+
1565
+ function _scheduleFlush() {
1566
+ if (_flushScheduled) return;
1567
+ _flushScheduled = true;
1568
+ // In jsdom, MutationObserver callbacks fire synchronously during DOM
1569
+ // mutation. We use Promise.resolve() to defer flush to a microtask,
1570
+ // giving same-stack moves time to complete (same-task appendChild
1571
+ // after remove = move, not removal). The flush runs as a microtask
1572
+ // which fires before setTimeout callbacks.
1573
+ Promise.resolve().then(_doFlush);
1224
1574
  }
1225
-
1226
- // Clean up existing children (but preserve the target's own state, render, and subs —
1227
- // the target is the mount point, not the content being replaced)
1228
- const savedState = targetEl._bw_state;
1229
- const savedRender = targetEl._bw_render;
1230
- const savedUuid = bw.getUUID(targetEl);
1231
- const savedSubs = targetEl._bw_subs;
1232
-
1233
- // Temporarily remove _bw_subs so cleanup doesn't call them
1234
- // (children's subs will still be cleaned up normally)
1235
- delete targetEl._bw_subs;
1236
-
1237
- bw.cleanup(targetEl);
1238
-
1239
- // Restore the target's own state/render/subs after cleanup
1240
- if (savedState !== undefined) targetEl._bw_state = savedState;
1241
- if (savedRender) targetEl._bw_render = savedRender;
1242
- if (savedUuid) {
1243
- // UUID class stays on element through cleanup; re-register in cache
1244
- bw._registerNode(targetEl, savedUuid);
1245
- }
1246
- if (savedSubs) targetEl._bw_subs = savedSubs;
1247
-
1248
- // Clear and mount new content
1249
- targetEl.innerHTML = '';
1250
-
1251
- if (taco != null) {
1252
- // Handle arrays
1253
- if (_isA(taco)) {
1254
- taco.forEach(t => {
1255
- if (t != null) {
1256
- targetEl.appendChild(bw.createDOM(t, options));
1575
+
1576
+ function _doFlush() {
1577
+ _flushScheduled = false;
1578
+ _processPending();
1579
+ }
1580
+
1581
+ // Install MutationObserver if available (detects rude removals)
1582
+ function _installObserver() {
1583
+ if (!bw._isBrowser || typeof MutationObserver === 'undefined') return;
1584
+ /* c8 ignore start -- observer body: MutationObserver callbacks are async and not triggered in synchronous tests */
1585
+ if (_observer) return;
1586
+ try {
1587
+ _observer = new MutationObserver(function(mutations) {
1588
+ if (!_enabled) return;
1589
+ var removedSet = [];
1590
+ var addedSet = [];
1591
+ // Collect all removals and additions in this batch
1592
+ for (var m = 0; m < mutations.length; m++) {
1593
+ var mut = mutations[m];
1594
+ for (var r = 0; r < mut.removedNodes.length; r++) {
1595
+ var rn = mut.removedNodes[r];
1596
+ if (rn.nodeType === 1) removedSet.push(rn);
1597
+ }
1598
+ for (var a = 0; a < mut.addedNodes.length; a++) {
1599
+ var an = mut.addedNodes[a];
1600
+ if (an.nodeType === 1) addedSet.push(an);
1601
+ }
1602
+ }
1603
+ // Same-stack moves: if a node appears in both removed and added,
1604
+ // it's a move (not a removal). Don't add to pending.
1605
+ var found = false;
1606
+ for (var i = 0; i < removedSet.length; i++) {
1607
+ var node = removedSet[i];
1608
+ if (addedSet.indexOf(node) !== -1) continue; // same-stack move
1609
+ var uuid = bw.getUUID(node);
1610
+ if (uuid && bw._nodeMap[uuid]) {
1611
+ if (_pending.indexOf(node) === -1) _pending.push(node);
1612
+ found = true;
1613
+ }
1614
+ if (node.querySelectorAll) {
1615
+ var desc = node.querySelectorAll('.' + _BW_LC + ', [class*="bw_uuid_"]');
1616
+ for (var d = 0; d < desc.length; d++) {
1617
+ if (addedSet.indexOf(desc[d]) === -1 && _pending.indexOf(desc[d]) === -1) {
1618
+ _pending.push(desc[d]);
1619
+ found = true;
1620
+ }
1621
+ }
1622
+ }
1257
1623
  }
1624
+ if (found) _scheduleFlush();
1258
1625
  });
1626
+ _observer.observe(document.body, { childList: true, subtree: true });
1627
+ } catch (e) { /* observer setup failed */ }
1628
+ /* c8 ignore stop */
1629
+ }
1630
+
1631
+ // Try to install immediately; re-install when DOM becomes available
1632
+ if (bw._isBrowser && typeof document !== 'undefined' && document.body) {
1633
+ _installObserver();
1634
+ }
1635
+
1636
+ function _processPending() {
1637
+ // Take snapshot from observer-reported removals and clear
1638
+ var batch = _pending.splice(0);
1639
+ var observerCount = batch.length; // items from observer are confirmed removals
1640
+
1641
+ // Also scan entire registry for disconnected nodes (fallback for non-observer removals)
1642
+ for (var key in bw._nodeMap) {
1643
+ if (_hop.call(bw._nodeMap, key)) {
1644
+ var el = bw._nodeMap[key];
1645
+ if (el && el.nodeType === 1 && !el.isConnected) {
1646
+ if (batch.indexOf(el) === -1) batch.push(el);
1647
+ }
1648
+ }
1649
+ }
1650
+
1651
+ // Clear detach exemptions for reconnected elements
1652
+ for (var uuid in bw._detached) {
1653
+ if (_hop.call(bw._detached, uuid)) {
1654
+ var detEl = bw._nodeMap[uuid];
1655
+ if (detEl && detEl.isConnected) {
1656
+ delete bw._detached[uuid];
1657
+ }
1658
+ }
1659
+ }
1660
+
1661
+ // Check for reaped-reinserted tripwire — check if any recently reaped elements are back in DOM
1662
+ var nextReaped = [];
1663
+ for (var ri = 0; ri < _reapedList.length; ri++) {
1664
+ var rEl = _reapedList[ri];
1665
+ if (rEl.isConnected) {
1666
+ bw.pub('bw:diag', { code: 'reaped_reinserted',
1667
+ msg: 'a reaped element was reinserted — use bw.detach() for keep-alive' });
1668
+ // Don't carry forward — warn once
1669
+ } else {
1670
+ nextReaped.push(rEl); // keep tracking for next flush
1671
+ }
1672
+ }
1673
+ _reapedList = nextReaped;
1674
+
1675
+ // Process elements. Elements from _pending (MutationObserver) are confirmed
1676
+ // removals that weren't same-stack moves — reap them even if reconnected
1677
+ // (async reinsert without bw.detach). Elements from registry scan only
1678
+ // reap if still disconnected.
1679
+ for (var i = 0; i < batch.length; i++) {
1680
+ var node = batch[i];
1681
+ var fromObserver = batch.indexOf(node) < observerCount;
1682
+ /* c8 ignore next -- registry-scan reconnected nodes only occur with async observer batching */
1683
+ if (!fromObserver && node.isConnected) continue; // registry-scan hit: still alive
1684
+
1685
+ var nodeUuid = bw.getUUID(node);
1686
+ if (nodeUuid && bw._detached[nodeUuid]) continue; // exempt
1687
+
1688
+ // Check if this is a lifecycle component or plain addressable node
1689
+ var isComponent = node.classList && node.classList.contains(_BW_LC);
1690
+ var isAddressable = nodeUuid && bw._nodeMap[nodeUuid] === node;
1691
+
1692
+ if (isComponent) {
1693
+ // Capture type before unmount strips it
1694
+ var nodeType = node._bw_type || null;
1695
+ // Full unmount for components
1696
+ bw.unmount(node);
1697
+ _reapedList.push(node);
1698
+ // Emit lifecycle mirror (DOM events can't bubble from detached tree)
1699
+ bw.pub('bw:lifecycle', { event: 'unmount', uuid: nodeUuid, type: nodeType });
1700
+ bw.pub('bw:diag', { code: 'janitor_reap', uuid: nodeUuid });
1701
+ } else if (isAddressable) {
1702
+ // Deregister plain addressable node
1703
+ _unmountNode(node);
1704
+ _reapedList.push(node);
1705
+ bw.pub('bw:diag', { code: 'janitor_reap', uuid: nodeUuid });
1706
+ }
1707
+
1708
+ // Also check for lifecycle children inside this node
1709
+ /* c8 ignore start -- janitor children iteration: parent unmount already covers descendants */
1710
+ if (node.querySelectorAll) {
1711
+ var children = node.querySelectorAll('.' + _BW_LC + ', [class*="bw_uuid_"]');
1712
+ for (var ci = 0; ci < children.length; ci++) {
1713
+ var child = children[ci];
1714
+ var childUuid = bw.getUUID(child);
1715
+ if (childUuid && bw._nodeMap[childUuid] === child) {
1716
+ if (child.classList.contains(_BW_LC)) {
1717
+ bw.unmount(child);
1718
+ if (_reapedList) _reapedList.push(child);
1719
+ bw.pub('bw:diag', { code: 'janitor_reap', uuid: childUuid });
1720
+ } else {
1721
+ _unmountNode(child);
1722
+ bw.pub('bw:diag', { code: 'janitor_reap', uuid: childUuid });
1723
+ }
1724
+ }
1725
+ }
1726
+ }
1727
+ /* c8 ignore stop */
1259
1728
  }
1260
- // Handle TACO objects
1261
- else {
1262
- targetEl.appendChild(bw.createDOM(taco, options));
1729
+ }
1730
+
1731
+ return {
1732
+ flush: function() {
1733
+ _flushScheduled = false;
1734
+ // Lazy-install observer on first flush
1735
+ if (!_observer && bw._isBrowser && typeof document !== 'undefined' && document.body) {
1736
+ _installObserver();
1737
+ }
1738
+ _processPending();
1739
+ },
1740
+ enable: function() { _enabled = true; },
1741
+ disable: function() { _enabled = false; },
1742
+ _addPending: function(el) {
1743
+ if (_pending.indexOf(el) === -1) _pending.push(el);
1744
+ },
1745
+ _ensureObserver: function() {
1746
+ if (!_observer && bw._isBrowser && typeof document !== 'undefined' && document.body) {
1747
+ _installObserver();
1748
+ }
1749
+ },
1750
+ _reset: function() {
1751
+ _pending.length = 0;
1752
+ _reapedList.length = 0;
1753
+ _flushScheduled = false;
1754
+ _enabled = true;
1755
+ /* c8 ignore start -- observer not installed in jsdom test env */
1756
+ if (_observer) {
1757
+ _observer.disconnect();
1758
+ _observer = null;
1759
+ }
1760
+ /* c8 ignore stop */
1761
+ },
1762
+ _getPendingCount: function() {
1763
+ // Count pending plus any disconnected registry entries
1764
+ var count = _pending.length;
1765
+ for (var key in bw._nodeMap) {
1766
+ if (_hop.call(bw._nodeMap, key)) {
1767
+ var el = bw._nodeMap[key];
1768
+ if (el && el.nodeType === 1 && !el.isConnected) {
1769
+ var uuid = bw.getUUID(el);
1770
+ if (!uuid || !bw._detached[uuid]) count++;
1771
+ }
1772
+ }
1773
+ }
1774
+ return count;
1263
1775
  }
1776
+ };
1777
+ })();
1778
+
1779
+ /**
1780
+ * Debug introspection for tests. Returns counts of internal state.
1781
+ * @returns {Object} {registered, detached, janitorPending, topics}
1782
+ * @category Internal
1783
+ */
1784
+ bw._debug = function() {
1785
+ var regCount = 0;
1786
+ for (var k in bw._nodeMap) {
1787
+ if (_hop.call(bw._nodeMap, k)) regCount++;
1264
1788
  }
1265
-
1266
- return targetEl;
1789
+ var detCount = 0;
1790
+ for (var d in bw._detached) {
1791
+ if (_hop.call(bw._detached, d)) detCount++;
1792
+ }
1793
+ return {
1794
+ registered: regCount,
1795
+ detached: detCount,
1796
+ janitorPending: bw.janitor._getPendingCount(),
1797
+ topics: _keys(bw._topics).length
1798
+ };
1267
1799
  };
1268
1800
 
1269
- // Deprecation stubs for removed ComponentHandle APIs
1270
- bw.compileProps = function() { throw new Error('bw.compileProps() removed in v2.0.19. Use o.handle/o.slots instead.'); };
1271
- bw.renderComponent = function() { throw new Error('bw.renderComponent() removed in v2.0.19. Use bw.mount() with o.handle/o.slots instead.'); };
1801
+ /**
1802
+ * Hard reset all singleton state for test isolation.
1803
+ * @category Internal
1804
+ */
1805
+ bw._resetForTest = function() {
1806
+ // Clear node registry
1807
+ for (var k in bw._nodeMap) {
1808
+ if (_hop.call(bw._nodeMap, k)) delete bw._nodeMap[k];
1809
+ }
1810
+ // Clear topics
1811
+ for (var t in bw._topics) {
1812
+ if (_hop.call(bw._topics, t)) delete bw._topics[t];
1813
+ }
1814
+ // Clear detached set
1815
+ for (var d in bw._detached) {
1816
+ if (_hop.call(bw._detached, d)) delete bw._detached[d];
1817
+ }
1818
+ // Clear mounted set
1819
+ for (var m in bw._mounted) {
1820
+ if (_hop.call(bw._mounted, m)) delete bw._mounted[m];
1821
+ }
1822
+ bw._subIdCounter = 0;
1823
+ bw._idCounter = 0;
1824
+ // Reset janitor
1825
+ if (bw.janitor) {
1826
+ if (typeof bw.janitor._reset === 'function') bw.janitor._reset();
1827
+ }
1828
+ // Reset actions — clear installed state, keep enabled (actions are ON by default)
1829
+ if (bw.actions) {
1830
+ if (typeof bw.actions._reset === 'function') bw.actions._reset();
1831
+ // Re-enable (actions are ON by default), but don't install yet
1832
+ // (install will happen lazily on next mountTree or enable call)
1833
+ bw.actions.enable();
1834
+ }
1835
+ // Reset remote/wire
1836
+ bw.remote = null;
1837
+ for (var wl in bw._wireListeners) {
1838
+ if (_hop.call(bw._wireListeners, wl)) {
1839
+ try { bw._wireListeners[wl](); } catch (e) {}
1840
+ delete bw._wireListeners[wl];
1841
+ }
1842
+ }
1843
+ for (var cr in bw._clientRemotes) {
1844
+ if (_hop.call(bw._clientRemotes, cr)) delete bw._clientRemotes[cr];
1845
+ }
1846
+ };
1272
1847
 
1273
1848
  /**
1274
- * Mount a TACO into a target element and return the created root element.
1275
- * Like bw.DOM() but returns the root element of the TACO (not the container),
1276
- * giving direct access to el.bw handle methods.
1277
- *
1278
- * @param {string|Element} target - CSS selector or DOM element
1279
- * @param {Object} taco - TACO to render
1280
- * @param {Object} [options] - Mount options
1281
- * @returns {Element} The created root element
1849
+ * Mount a TACO into a target element. Returns the root element (single root),
1850
+ * first node (array), or null. This is the primary compound verb for putting
1851
+ * UI on the page.
1852
+ *
1853
+ * Composes atomics: unmountChildren(target) clear create(content)
1854
+ * insert mountTree(target).
1855
+ *
1856
+ * @param {string|Element} target - CSS selector or DOM element to mount into
1857
+ * @param {Object|Array} taco - TACO object or array to render
1858
+ * @param {Object} [options] - Creation options
1859
+ * @returns {Element|null} The root element, or null
1282
1860
  * @category DOM Generation
1283
- * @example
1284
- * var el = bw.mount('#app', bw.makeCarousel({ items: slides }));
1285
- * el.bw.goToSlide(2);
1286
- * el.bw.next();
1287
1861
  */
1288
1862
  bw.mount = function(target, taco, options) {
1289
1863
  var container = _is(target, 'string') ? bw.$(target)[0] : target;
@@ -1291,136 +1865,192 @@ bw.mount = function(target, taco, options) {
1291
1865
  _cw('bw.mount: target not found');
1292
1866
  return null;
1293
1867
  }
1294
- bw.cleanup(container);
1868
+
1869
+ // Teardown existing children (compound calls atomic)
1870
+ bw.unmountChildren(container);
1295
1871
  container.innerHTML = '';
1296
- var el = bw.createDOM(taco, options || {});
1297
- container.appendChild(el);
1298
- return el;
1872
+
1873
+ if (taco == null) return null;
1874
+
1875
+ var firstEl = null;
1876
+ var created = [];
1877
+ if (_isA(taco)) {
1878
+ for (var i = 0; i < taco.length; i++) {
1879
+ if (taco[i] != null) {
1880
+ var child = bw.create(taco[i], options || {});
1881
+ container.appendChild(child);
1882
+ created.push(child);
1883
+ if (!firstEl) firstEl = child;
1884
+ }
1885
+ }
1886
+ } else {
1887
+ firstEl = bw.create(taco, options || {});
1888
+ container.appendChild(firstEl);
1889
+ created.push(firstEl);
1890
+ }
1891
+
1892
+ // Walk each created child to register and fire mounted (not the container itself)
1893
+ for (var ci = 0; ci < created.length; ci++) {
1894
+ bw.mountTree(created[ci]);
1895
+ }
1896
+
1897
+ return firstEl;
1299
1898
  };
1300
1899
 
1900
+ // bw.DOM is an exact alias of bw.mount (v2.1 §2)
1901
+ bw.DOM = bw.mount;
1902
+
1301
1903
  /**
1302
- * Clean up a DOM element and all its children by calling unmount callbacks,
1303
- * removing pub/sub subscriptions, and clearing state/render references.
1904
+ * Append content to a target. create insert (respecting opts.before) mountTree.
1905
+ * Returns the new child element.
1304
1906
  *
1305
- * Called automatically by `bw.DOM()` before re-rendering. Call manually when
1306
- * removing elements to prevent memory leaks from orphaned callbacks.
1907
+ * @param {string|Element} target - Container
1908
+ * @param {Object} content - TACO to append
1909
+ * @param {Object} [opts] - {before: Element|number} for positioning
1910
+ * @returns {Element|null} The appended element
1911
+ * @category DOM Generation
1912
+ */
1913
+ bw.append = function(target, content, opts) {
1914
+ var container = _is(target, 'string') ? bw.$(target)[0] : target;
1915
+ if (!container) return null;
1916
+ var child = bw.create(content);
1917
+ if (opts && opts.before !== undefined) {
1918
+ var ref = opts.before;
1919
+ if (typeof ref === 'number') ref = container.children[ref] || null;
1920
+ container.insertBefore(child, ref);
1921
+ } else {
1922
+ container.appendChild(child);
1923
+ }
1924
+ bw.mountTree(child);
1925
+ return child;
1926
+ };
1927
+
1928
+ /**
1929
+ * Replace an existing element with new content. unmount(old) → create(taco) →
1930
+ * insert at position → mountTree. Returns new element. null taco = remove.
1307
1931
  *
1308
- * @param {Element} element - DOM element to clean up
1932
+ * @param {Element} ref - Element to replace
1933
+ * @param {Object|null} taco - Replacement TACO, or null to just remove
1934
+ * @returns {Element|null} The new element, or null
1309
1935
  * @category DOM Generation
1310
- * @see bw.DOM
1311
- * @example
1312
- * var el = document.querySelector('#my-widget');
1313
- * bw.cleanup(el); // runs unmount hooks, clears _bw_state, _bw_render
1314
- * el.remove(); // safe to remove from DOM now
1315
1936
  */
1316
- bw.cleanup = function(element) {
1317
- if (!bw._isBrowser || !element) return;
1318
-
1319
- // Deregister UUID classes from node cache for non-lifecycle UUID elements
1320
- var uuidEls = element.querySelectorAll('[class*="bw_uuid_"]');
1321
- uuidEls.forEach(function(uel) {
1322
- var uc = typeof uel.className === 'string' ? uel.className : (uel.getAttribute('class') || '');
1323
- var m = uc && uc.match(_UUID_RE);
1324
- if (m) delete bw._nodeMap[m[0]];
1325
- });
1937
+ bw.replace = function(ref, taco) {
1938
+ if (!ref) return null;
1939
+ var parent = ref.parentNode;
1940
+ var next = ref.nextSibling;
1326
1941
 
1327
- // Find all lifecycle-managed elements (have bw_lc marker class)
1328
- const elements = element.querySelectorAll('.' + _BW_LC);
1942
+ bw.unmount(ref);
1943
+ if (ref.parentNode) ref.parentNode.removeChild(ref);
1329
1944
 
1330
- elements.forEach(el => {
1331
- var uuid = bw.getUUID(el);
1945
+ if (taco == null) return null;
1332
1946
 
1333
- if (uuid) {
1334
- const callback = bw._unmountCallbacks.get(uuid);
1335
- if (callback) {
1336
- callback();
1337
- bw._unmountCallbacks.delete(uuid);
1338
- }
1947
+ var neo = bw.create(taco);
1948
+ if (parent) {
1949
+ if (next) parent.insertBefore(neo, next);
1950
+ else parent.appendChild(neo);
1951
+ }
1952
+ bw.mountTree(neo);
1953
+ return neo;
1954
+ };
1339
1955
 
1340
- // Deregister from node cache
1341
- bw._deregisterNode(el, uuid);
1342
- }
1956
+ /**
1957
+ * Refresh a component: unmountChildren → re-render → mountTree.
1958
+ * Render throw propagates. Emits bw:refresh.
1959
+ *
1960
+ * @param {string|Element} ref - Component to refresh
1961
+ * @returns {Element|null} The element
1962
+ * @category DOM Generation
1963
+ */
1964
+ bw.refresh = function(ref) {
1965
+ var el = bw.el(ref);
1966
+ if (!el) return null;
1343
1967
 
1344
- // Clean up pub/sub subscriptions tied to this element
1345
- if (el._bw_subs) {
1346
- el._bw_subs.forEach(function(unsub) { unsub(); });
1347
- delete el._bw_subs;
1348
- }
1968
+ bw.unmountChildren(el);
1969
+ el.innerHTML = '';
1970
+ // Invalidate slot cache so slots re-resolve after rebuild
1971
+ if (el._bw_slot_cache) el._bw_slot_cache = null;
1349
1972
 
1350
- // Clean up state, render, and local refs
1351
- delete el._bw_state;
1352
- delete el._bw_render;
1353
- delete el._bw_refs;
1354
- });
1973
+ if (el._bw_render) {
1974
+ // Let throw propagate (§3 error policy)
1975
+ el._bw_render(el, el._bw_state || {});
1976
+ // Walk newly created children
1977
+ bw.mountTree(el);
1978
+ }
1355
1979
 
1356
- // Check element itself
1357
- var selfUuid = bw.getUUID(element);
1358
- if (selfUuid) {
1359
- delete bw._nodeMap[selfUuid];
1980
+ // Emit bw:refresh
1981
+ try {
1982
+ el.dispatchEvent(new CustomEvent('bw:refresh', {
1983
+ bubbles: true,
1984
+ detail: { uuid: bw.getUUID(el), type: el._bw_type || null }
1985
+ }));
1986
+ } catch (e) { /* jsdom */ }
1360
1987
 
1361
- const callback = bw._unmountCallbacks.get(selfUuid);
1362
- if (callback) {
1363
- callback();
1364
- bw._unmountCallbacks.delete(selfUuid);
1365
- }
1988
+ return el;
1989
+ };
1366
1990
 
1367
- // Deregister from node cache
1368
- bw._deregisterNode(element, selfUuid);
1991
+ /**
1992
+ * Update a component by dispatching to el.bw.update(data) if defined.
1993
+ * Emits bw:statechange. If no update handle, emits specific diag warning.
1994
+ * NEVER falls back to refresh.
1995
+ *
1996
+ * @param {string|Element} ref - Component to update
1997
+ * @param {*} data - Data to pass to el.bw.update
1998
+ * @returns {Element|null} The element
1999
+ * @category State Management
2000
+ */
2001
+ bw.update = function(ref, data) {
2002
+ var el = bw.el(ref);
2003
+ if (!el) return null;
1369
2004
 
1370
- // Clean up pub/sub subscriptions tied to element itself
1371
- if (element._bw_subs) {
1372
- element._bw_subs.forEach(function(unsub) { unsub(); });
1373
- delete element._bw_subs;
1374
- }
1375
- delete element._bw_state;
1376
- delete element._bw_render;
1377
- delete element._bw_refs;
2005
+ if (el.bw && typeof el.bw.update === 'function') {
2006
+ el.bw.update(data);
2007
+ // Emit statechange via pub/sub lifecycle topic
2008
+ bw.pub('bw:lifecycle', { event: 'statechange', uuid: bw.getUUID(el), data: data });
2009
+ bw.emit(el, 'statechange', el._bw_state);
2010
+ return el;
2011
+ }
1378
2012
 
2013
+ // No update handle — emit appropriate diag warning
2014
+ if (el._bw_render) {
2015
+ bw.pub('bw:diag', { code: 'update_use_refresh', uuid: bw.getUUID(el),
2016
+ msg: 'component has o.render but no update handle — use bw.refresh() instead' });
1379
2017
  } else {
1380
- // No UUID on element itself, but still check for _bw_subs (from bw.sub())
1381
- if (element._bw_subs) {
1382
- element._bw_subs.forEach(function(unsub) { unsub(); });
1383
- delete element._bw_subs;
1384
- }
2018
+ bw.pub('bw:diag', { code: 'update_no_handle', uuid: bw.getUUID(el),
2019
+ msg: 'component has no update handle and no render — nothing to do' });
1385
2020
  }
2021
+ return el;
1386
2022
  };
1387
2023
 
1388
- // ===================================================================================
1389
- // State Management: update, patch, emit/on
1390
- // ===================================================================================
1391
-
1392
2024
  /**
1393
- * Trigger re-render of a component by calling its stored `o.render` function.
1394
- *
1395
- * This is the recommended way to update a component after changing its state.
1396
- * Calls `el._bw_render(el, state)` and emits `bw:statechange` so other
1397
- * components can react without tight coupling.
2025
+ * Update a specific slot on a component by reference.
1398
2026
  *
1399
- * @param {string|Element} target - Element ID, bw_uuid_* class, CSS selector, or DOM element
1400
- * @returns {Element|null} The element, or null if not found / no render function
1401
- * @category State Management
1402
- * @see bw.patch
1403
- * @example
1404
- * // Given a counter element with o.render
1405
- * el._bw_state.count++;
1406
- * bw.update(el); // re-renders, emits bw:statechange
2027
+ * @param {string|Element} ref - Component reference
2028
+ * @param {string} name - Slot name
2029
+ * @param {*} value - Value to set
2030
+ * @returns {boolean} True if slot was updated
2031
+ * @category DOM Generation
1407
2032
  */
1408
- bw.update = function(target) {
1409
- var el = bw.el(target);
1410
- if (el && el._bw_render) {
1411
- try { el._bw_render(el, el._bw_state || {}); }
1412
- catch (e) { _cw('o.render error: ' + e.message); }
1413
- bw.emit(el, 'statechange', el._bw_state);
1414
- }
1415
- return el || null;
2033
+ bw.updateSlot = function(ref, name, value) {
2034
+ var el = bw.el(ref);
2035
+ if (!el || !el.bw) return false;
2036
+ var setter = 'set' + name.charAt(0).toUpperCase() + name.slice(1);
2037
+ if (typeof el.bw[setter] !== 'function') return false;
2038
+ el.bw[setter](value);
2039
+ return true;
1416
2040
  };
1417
2041
 
2042
+ // ===================================================================================
2043
+ // State Management: update, patch, emit/on
2044
+ // ===================================================================================
2045
+
2046
+ // v2.1: old bw.update (render-based) replaced by new bw.update (dispatch-based) above.
2047
+
1418
2048
  /**
1419
2049
  * Targeted DOM update by element ID — change one element's content or attribute
1420
2050
  * without rebuilding the entire component tree.
1421
2051
  *
1422
2052
  * Use `bw.patch()` for lightweight value updates (scores, labels, counters)
1423
- * and `bw.update()` for full structural re-renders.
2053
+ * and `bw.refresh()` for full structural re-renders.
1424
2054
  *
1425
2055
  * @param {string|Element} id - Element ID, bw_uuid_* class, CSS selector, or DOM element.
1426
2056
  * Uses node cache for O(1) lookup; falls back to DOM query on cache miss.
@@ -1439,25 +2069,47 @@ bw.patch = function(id, content, attr) {
1439
2069
  var el = bw.el(id);
1440
2070
  if (!el) return null;
1441
2071
 
2072
+ // v2.1 discriminated patch:
2073
+ // - string/number → text content
2074
+ // - plain object without .t → attributes
2075
+ // - TACO (has .t) → unmountChildren + create + mountTree
2076
+ // - array → unmountChildren + create each + mountTree
2077
+ // - legacy 3rd arg: explicit attribute set
2078
+
1442
2079
  if (attr) {
1443
- // Patch an attribute
2080
+ // Legacy: explicit attribute patch
1444
2081
  el.setAttribute(attr, String(content));
2082
+ } else if (_is(content, 'string') || _is(content, 'number')) {
2083
+ // Text patch
2084
+ el.textContent = String(content);
1445
2085
  } else if (_isA(content)) {
1446
- // Patch with array of children (strings and/or TACOs)
2086
+ // Array of children: full pipeline
2087
+ bw.unmountChildren(el);
1447
2088
  el.innerHTML = '';
1448
- content.forEach(function(item) {
1449
- if (_is(item, 'string') || _is(item, 'number')) {
1450
- el.appendChild(document.createTextNode(String(item)));
1451
- } else if (item && item.t) {
1452
- el.appendChild(bw.createDOM(item));
2089
+ for (var i = 0; i < content.length; i++) {
2090
+ var item = content[i];
2091
+ if (item != null) {
2092
+ if (_is(item, 'object') && item.t) {
2093
+ el.appendChild(bw.create(item));
2094
+ } else {
2095
+ el.appendChild(document.createTextNode(String(item)));
2096
+ }
1453
2097
  }
1454
- });
1455
- } else if (_is(content, 'object') && content.t) {
1456
- // Patch with a TACO replace children
2098
+ }
2099
+ bw.mountTree(el);
2100
+ } else if (_is(content, 'object') && content !== null && content.t) {
2101
+ // TACO content: full pipeline
2102
+ bw.unmountChildren(el);
1457
2103
  el.innerHTML = '';
1458
- el.appendChild(bw.createDOM(content));
2104
+ el.appendChild(bw.create(content));
2105
+ bw.mountTree(el);
2106
+ } else if (_is(content, 'object') && content !== null) {
2107
+ // Plain object without .t → attribute patch
2108
+ var attrKeys = _keys(content);
2109
+ for (var ak = 0; ak < attrKeys.length; ak++) {
2110
+ el.setAttribute(attrKeys[ak], String(content[attrKeys[ak]]));
2111
+ }
1459
2112
  } else {
1460
- // Patch text content
1461
2113
  el.textContent = String(content);
1462
2114
  }
1463
2115
  return el;
@@ -1490,6 +2142,90 @@ bw.patchAll = function(patches) {
1490
2142
  return results;
1491
2143
  };
1492
2144
 
2145
+ /**
2146
+ * Keyed reconciliation: match existing children by `el._bw_key`, move/add/remove
2147
+ * to match `items` order. Moved nodes are the SAME DOM nodes (state/focus survives).
2148
+ *
2149
+ * @param {Element} parentEl - Container element
2150
+ * @param {Array} items - Data array for desired children
2151
+ * @param {Object} opts - {key: fn(item)→string, create: fn(item)→TACO, update: fn(el, item)}
2152
+ * @category DOM Generation
2153
+ */
2154
+ bw.syncChildren = function(parentEl, items, opts) {
2155
+ if (!parentEl || !items || !opts) return;
2156
+ var keyFn = opts.key;
2157
+ var createFn = opts.create;
2158
+ var updateFn = opts.update;
2159
+
2160
+ // Save focus to restore after reorder (insertBefore can blur in some environments)
2161
+ /* c8 ignore next -- document is always defined in jsdom test env */
2162
+ var focused = (typeof document !== 'undefined') ? document.activeElement : null;
2163
+ if (focused && !parentEl.contains(focused)) focused = null;
2164
+
2165
+ // Build map of existing keyed children
2166
+ var existingByKey = {};
2167
+ var child = parentEl.firstElementChild;
2168
+ while (child) {
2169
+ if (child._bw_key != null) {
2170
+ existingByKey[child._bw_key] = child;
2171
+ }
2172
+ child = child.nextElementSibling;
2173
+ }
2174
+
2175
+ // Determine which keys are in the new items
2176
+ var newKeys = {};
2177
+ for (var i = 0; i < items.length; i++) {
2178
+ newKeys[keyFn(items[i])] = true;
2179
+ }
2180
+
2181
+ // Remove absent keys (unmount + remove)
2182
+ var toRemove = [];
2183
+ for (var ek in existingByKey) {
2184
+ if (_hop.call(existingByKey, ek) && !newKeys[ek]) {
2185
+ toRemove.push(existingByKey[ek]);
2186
+ }
2187
+ }
2188
+ for (var ri = 0; ri < toRemove.length; ri++) {
2189
+ bw.unmount(toRemove[ri]);
2190
+ if (toRemove[ri].parentNode) toRemove[ri].parentNode.removeChild(toRemove[ri]);
2191
+ delete existingByKey[toRemove[ri]._bw_key];
2192
+ }
2193
+
2194
+ // Process items in order: create new, update existing, reorder
2195
+ var prevNode = null;
2196
+ for (var j = 0; j < items.length; j++) {
2197
+ var k = keyFn(items[j]);
2198
+ var existing = existingByKey[k];
2199
+
2200
+ if (existing) {
2201
+ // Update existing
2202
+ if (updateFn) updateFn(existing, items[j]);
2203
+ // Move into correct position if needed
2204
+ var expectedAfter = prevNode ? prevNode.nextSibling : parentEl.firstChild;
2205
+ if (existing !== expectedAfter) {
2206
+ parentEl.insertBefore(existing, expectedAfter);
2207
+ }
2208
+ prevNode = existing;
2209
+ } else {
2210
+ // Create new
2211
+ var taco = createFn(items[j]);
2212
+ var neo = bw.create(taco);
2213
+ neo._bw_key = k;
2214
+ var insertBefore = prevNode ? prevNode.nextSibling : parentEl.firstChild;
2215
+ parentEl.insertBefore(neo, insertBefore);
2216
+ bw.mountTree(neo);
2217
+ existingByKey[k] = neo;
2218
+ prevNode = neo;
2219
+ }
2220
+ }
2221
+
2222
+ // Restore focus if it was lost during reorder
2223
+ /* c8 ignore next 2 -- focus management not exercised in jsdom */
2224
+ if (focused && focused.isConnected && document.activeElement !== focused) {
2225
+ try { focused.focus(); } catch (e) {}
2226
+ }
2227
+ };
2228
+
1493
2229
  /**
1494
2230
  * Emit a custom DOM event on an element.
1495
2231
  *
@@ -1538,12 +2274,13 @@ bw.emit = function(target, eventName, detail) {
1538
2274
  */
1539
2275
  bw.on = function(target, eventName, handler) {
1540
2276
  var el = bw.el(target);
1541
- if (el) {
1542
- el.addEventListener('bw:' + eventName, function(e) {
1543
- handler(e.detail, e);
1544
- });
1545
- }
1546
- return el || null;
2277
+ if (!el) return function() {};
2278
+ var wrapped = function(e) { handler(e.detail, e); };
2279
+ el.addEventListener('bw:' + eventName, wrapped);
2280
+ // v2.1: return off() function (not the element)
2281
+ return function() {
2282
+ el.removeEventListener('bw:' + eventName, wrapped);
2283
+ };
1547
2284
  };
1548
2285
 
1549
2286
  // ===================================================================================
@@ -1573,29 +2310,48 @@ bw.on = function(target, eventName, handler) {
1573
2310
  */
1574
2311
  bw.pub = function(topic, detail) {
1575
2312
  var called = 0;
1576
- // Exact-match subscribers
1577
- var subs = bw._topics[topic];
1578
- if (subs && subs.length > 0) {
2313
+
2314
+ function _deliver(subs, topicKey) {
2315
+ if (!subs || subs.length === 0) return;
1579
2316
  var snapshot = subs.slice();
2317
+ var pruned = false;
2318
+ var ghostDiags = [];
1580
2319
  for (var i = 0; i < snapshot.length; i++) {
1581
- try { snapshot[i].handler(detail, topic); called++; }
1582
- catch (err) { _cw('bw.pub: subscriber error on topic "' + topic + '":', err); }
2320
+ var sub = snapshot[i];
2321
+ // Liveness check: tied element that is disconnected and not detach-exempt
2322
+ // Skip liveness check for diag/lifecycle topics to avoid recursion
2323
+ if (sub.tiedEl && topic !== 'bw:diag' && topic !== 'bw:lifecycle') {
2324
+ var tiedUuid = bw.getUUID(sub.tiedEl);
2325
+ if (!sub.tiedEl.isConnected && !(tiedUuid && bw._detached[tiedUuid])) {
2326
+ // Ghost: prune this subscription
2327
+ var idx = subs.indexOf(sub);
2328
+ if (idx !== -1) subs.splice(idx, 1);
2329
+ pruned = true;
2330
+ ghostDiags.push({ code: 'ghost_prune', uuid: tiedUuid, topic: topicKey });
2331
+ continue;
2332
+ }
2333
+ }
2334
+ try { sub.handler(detail, topic); called++; }
2335
+ catch (err) { _cw('bw.pub: subscriber error on topic "' + topicKey + '":', err); }
2336
+ }
2337
+ if (pruned && subs.length === 0) delete bw._topics[topicKey];
2338
+ // Emit ghost_prune diags AFTER delivery loop to avoid re-entrancy
2339
+ for (var g = 0; g < ghostDiags.length; g++) {
2340
+ bw.pub('bw:diag', ghostDiags[g]);
1583
2341
  }
1584
2342
  }
2343
+
2344
+ // Exact-match subscribers
2345
+ _deliver(bw._topics[topic], topic);
2346
+
1585
2347
  // Wildcard subscribers -- patterns ending with '*'
1586
2348
  var keys = Object.keys(bw._topics);
1587
2349
  for (var k = 0; k < keys.length; k++) {
1588
2350
  var pat = keys[k];
1589
2351
  if (pat.charAt(pat.length - 1) !== '*') continue;
1590
- var prefix = pat.slice(0, -1); // strip trailing '*'
2352
+ var prefix = pat.slice(0, -1);
1591
2353
  if (topic.length >= prefix.length && topic.substring(0, prefix.length) === prefix && topic !== pat) {
1592
- var wsubs = bw._topics[pat];
1593
- if (!wsubs) continue;
1594
- var wsnap = wsubs.slice();
1595
- for (var w = 0; w < wsnap.length; w++) {
1596
- try { wsnap[w].handler(detail, topic); called++; }
1597
- catch (err) { _cw('bw.pub: wildcard subscriber error on "' + pat + '" for topic "' + topic + '":', err); }
1598
- }
2354
+ _deliver(bw._topics[pat], pat);
1599
2355
  }
1600
2356
  }
1601
2357
  return called;
@@ -1610,7 +2366,7 @@ bw.pub = function(topic, detail) {
1610
2366
  * receives `(detail, topic)` so it can distinguish which topic fired.
1611
2367
  *
1612
2368
  * Optional third argument ties the subscription to a DOM element's lifecycle --
1613
- * when `bw.cleanup()` is called on that element, the subscription is automatically
2369
+ * when `bw.unmount()` is called on that element, the subscription is automatically
1614
2370
  * removed, preventing memory leaks.
1615
2371
  *
1616
2372
  * @param {string} topic - Topic name, or wildcard pattern ending in '*'
@@ -1634,7 +2390,10 @@ bw.pub = function(topic, detail) {
1634
2390
  bw.sub = function(topic, handler, el) {
1635
2391
  var id = ++bw._subIdCounter;
1636
2392
  if (!bw._topics[topic]) bw._topics[topic] = [];
1637
- bw._topics[topic].push({ handler: handler, id: id });
2393
+ var entry = { handler: handler, id: id };
2394
+ // Track tied element for liveness checks in bw.pub
2395
+ if (el) entry.tiedEl = el;
2396
+ bw._topics[topic].push(entry);
1638
2397
 
1639
2398
  var unsub = function() {
1640
2399
  var subs = bw._topics[topic];
@@ -1647,7 +2406,7 @@ bw.sub = function(topic, handler, el) {
1647
2406
  if (el) {
1648
2407
  if (!el._bw_subs) el._bw_subs = [];
1649
2408
  el._bw_subs.push(unsub);
1650
- // Ensure element has UUID + bw_lc so bw.cleanup() finds it
2409
+ // Ensure element has UUID + bw_lc so unmount finds it
1651
2410
  if (!bw.getUUID(el)) {
1652
2411
  el.classList.add(bw.uuid('uuid'));
1653
2412
  }
@@ -1712,6 +2471,88 @@ bw.once = function(topic, handler, el) {
1712
2471
  return unsub;
1713
2472
  };
1714
2473
 
2474
+ /**
2475
+ * Declared dataflow: recompute fn(inputs...) on any input publish.
2476
+ * Returns a disposer function. Optionally ties to an element lifecycle.
2477
+ *
2478
+ * @param {Array<string>} inputs - Topic names to subscribe to
2479
+ * @param {Function} fn - Combiner: fn(...latestValues) → result
2480
+ * @param {string} outTopic - Topic to publish result on
2481
+ * @param {Object} [opts] - {seed: [], immediate: bool, el: Element}
2482
+ * @returns {Function} Disposer
2483
+ * @category Pub/Sub
2484
+ */
2485
+ bw.derive = function(inputs, fn, outTopic, opts) {
2486
+ opts = opts || {};
2487
+ var values = new Array(inputs.length);
2488
+ var ready = new Array(inputs.length);
2489
+ var disposed = false;
2490
+ var unsubs = [];
2491
+
2492
+ // Validate seed length
2493
+ if (opts.seed) {
2494
+ if (opts.seed.length !== inputs.length) {
2495
+ throw new TypeError('bw.derive: seed length (' + opts.seed.length + ') must match inputs length (' + inputs.length + ')');
2496
+ }
2497
+ for (var si = 0; si < opts.seed.length; si++) {
2498
+ values[si] = opts.seed[si];
2499
+ ready[si] = true;
2500
+ }
2501
+ }
2502
+
2503
+ // Cycle detection
2504
+ if (inputs.indexOf(outTopic) !== -1) {
2505
+ bw.pub('bw:diag', { code: 'derive_cycle', inputs: inputs, outTopic: outTopic });
2506
+ }
2507
+
2508
+ function _allReady() {
2509
+ for (var r = 0; r < ready.length; r++) { if (!ready[r]) return false; }
2510
+ return true;
2511
+ }
2512
+
2513
+ function _compute() {
2514
+ /* c8 ignore next -- disposed guard: race condition safety net */
2515
+ if (disposed) return;
2516
+ try {
2517
+ var result = fn.apply(null, values);
2518
+ bw.pub(outTopic, result);
2519
+ } catch (e) {
2520
+ bw.pub('bw:diag', { code: 'derive_error', outTopic: outTopic, msg: e.message });
2521
+ }
2522
+ }
2523
+
2524
+ for (var i = 0; i < inputs.length; i++) {
2525
+ (function(idx) {
2526
+ var unsub = bw.sub(inputs[idx], function(v) {
2527
+ values[idx] = v;
2528
+ ready[idx] = true;
2529
+ if (_allReady()) _compute();
2530
+ });
2531
+ unsubs.push(unsub);
2532
+ })(i);
2533
+ }
2534
+
2535
+ // Immediate: publish once at creation if ready
2536
+ if (opts.immediate && _allReady()) _compute();
2537
+
2538
+ function dispose() {
2539
+ if (disposed) return;
2540
+ disposed = true;
2541
+ for (var u = 0; u < unsubs.length; u++) {
2542
+ /* c8 ignore next -- unsub() never throws in practice */
2543
+ try { unsubs[u](); } catch (e) {}
2544
+ }
2545
+ }
2546
+
2547
+ // Tie to element lifecycle
2548
+ if (opts.el) {
2549
+ if (!opts.el._bw_subs) opts.el._bw_subs = [];
2550
+ opts.el._bw_subs.push(dispose);
2551
+ }
2552
+
2553
+ return dispose;
2554
+ };
2555
+
1715
2556
  // ===================================================================================
1716
2557
  // Function Registry (revived from v1 for string dispatch contexts)
1717
2558
  // ===================================================================================
@@ -1734,7 +2575,9 @@ bw._fnIDCounter = 0;
1734
2575
  * @see bw.funcGetDispatchStr
1735
2576
  */
1736
2577
  bw.funcRegister = function(fn, name) {
2578
+ /* c8 ignore next -- non-function guard: tests always pass valid functions */
1737
2579
  if (!_is(fn, 'function')) return '';
2580
+ /* c8 ignore next -- ternary branches: both name and auto-generated paths tested elsewhere */
1738
2581
  var fnID = (_is(name, 'string') && name.length > 0) ? name : ('bw_fn_' + bw._fnIDCounter++);
1739
2582
  bw._fnRegistry[fnID] = fn;
1740
2583
  return fnID;
@@ -1779,10 +2622,12 @@ bw.funcGetDispatchStr = function(name, argStr) {
1779
2622
  * @category Function Registry
1780
2623
  */
1781
2624
  bw.funcUnregister = function(name) {
2625
+ /* c8 ignore start -- funcUnregister: tested via htmlPage/funcRegister integration */
1782
2626
  if (name in bw._fnRegistry) {
1783
2627
  delete bw._fnRegistry[name];
1784
2628
  return true;
1785
2629
  }
2630
+ /* c8 ignore stop */
1786
2631
  return false;
1787
2632
  };
1788
2633
 
@@ -1905,28 +2750,9 @@ bw._resolveTemplate = function(str, state, compile) {
1905
2750
  return result;
1906
2751
  };
1907
2752
 
1908
- // ===================================================================================
1909
- // Deprecation stubs for removed ComponentHandle APIs (v2.0.19)
1910
- // ===================================================================================
1911
-
1912
- bw._extractDeps = undefined;
1913
- bw._dirtyComponents = undefined;
1914
- bw._flushScheduled = undefined;
1915
- bw._scheduleFlush = undefined;
1916
- bw._doFlush = undefined;
1917
- bw._ComponentHandle = undefined;
1918
-
1919
- /**
1920
- * No-op flush (ComponentHandle removed in v2.0.19).
1921
- * Kept as no-op for backward compatibility.
1922
- * @category Component
1923
- */
1924
- bw.flush = function() {};
1925
-
1926
-
1927
- bw.when = function() { throw new Error('bw.when() removed in v2.0.19. Use conditional logic in o.render instead.'); };
1928
- bw.each = function() { throw new Error('bw.each() removed in v2.0.19. Use array mapping in o.render instead.'); };
1929
- bw.component = function() { throw new Error('bw.component() removed in v2.0.19. Use o.handle/o.slots on TACO options instead.'); };
2753
+ // v2.1: ComponentHandle APIs (_extractDeps, _dirtyComponents, _flushScheduled,
2754
+ // _scheduleFlush, _doFlush, _ComponentHandle, flush, when, each, component)
2755
+ // fully removed — no stubs needed in v2.1.
1930
2756
 
1931
2757
 
1932
2758
  // ===================================================================================
@@ -1953,7 +2779,10 @@ bw.component = function() { throw new Error('bw.component() removed in v2.0.19.
1953
2779
  */
1954
2780
  bw.message = function(target, action, data) {
1955
2781
  var el = bw.el(target);
1956
- if (!el) el = bw.$('.' + target)[0];
2782
+ // Fallback: try class selector, but only for safe selector strings
2783
+ if (!el && _is(target, 'string') && target.indexOf('#') !== 0 && target.indexOf('.') !== 0) {
2784
+ try { el = bw.$('.' + target)[0]; } catch (e) { /* invalid selector */ }
2785
+ }
1957
2786
  if (!el || !el.bw || typeof el.bw[action] !== 'function') {
1958
2787
  _cw('bw.message: no handle method "' + action + '" on ' + target);
1959
2788
  return false;
@@ -2168,20 +2997,12 @@ bw.jsonPatch = function(obj, ops) {
2168
2997
  // ===================================================================================
2169
2998
 
2170
2999
  /**
2171
- * Registry of named functions sent via register messages.
2172
- * Populated by bw.apply({ type: 'register', name, body }).
2173
- * Invoked by bw.apply({ type: 'call', name, args }).
3000
+ * Registry of named functions for backward compat with code that checks _clientFunctions.
3001
+ * v2.1: exec/register are rejected at the protocol level; use bw.registerRemote() instead.
2174
3002
  * @private
2175
3003
  */
2176
3004
  bw._clientFunctions = {};
2177
3005
 
2178
- /**
2179
- * Whether exec messages are allowed. Set by bwclient connect opts.allowExec.
2180
- * Default false — exec messages are rejected unless explicitly opted in.
2181
- * @private
2182
- */
2183
- bw._allowExec = false;
2184
-
2185
3006
  /**
2186
3007
  * Parse a bwserve protocol message string, supporting both strict JSON
2187
3008
  * and r-prefixed relaxed JSON (single-quoted strings, trailing commas).
@@ -2278,16 +3099,19 @@ bw.parseJSONFlex = function(str) {
2278
3099
  /**
2279
3100
  * Apply a bwserve protocol message to the DOM.
2280
3101
  *
2281
- * Dispatches one of 9 message types:
2282
- * replace — bw.DOM(target, node)
2283
- * append target.appendChild(bw.createDOM(node))
2284
- * remove — bw.cleanup(target); target.remove()
2285
- * patch — bw.patch(target, content, attr)
3102
+ * Dispatches one of 12 v:1 message types:
3103
+ * mount — bw.mount(ref, taco)
3104
+ * patch — bw.patch(ref, text/attrs/content)
3105
+ * append — bw.append(ref, taco)
3106
+ * replace — bw.replace(ref, taco)
3107
+ * remove — bw.remove(ref)
3108
+ * refresh — bw.refresh(ref)
3109
+ * update — bw.update(ref, data)
3110
+ * message — bw.message(ref, action, data)
2286
3111
  * batch — iterate ops, call bw.apply for each
2287
- * message bw.message(target, action, data)
2288
- * registerstore a named function for later call()
2289
- * call — invoke a registered function
2290
- * exec — execute arbitrary JS (requires allowExec)
3112
+ * listen subscribe to a pub/sub topic
3113
+ * unlistenunsubscribe from a topic
3114
+ * call — invoke a registered remote function
2291
3115
  *
2292
3116
  * Target resolution:
2293
3117
  * Starts with '#' or '.' → CSS selector (querySelector)
@@ -2297,85 +3121,242 @@ bw.parseJSONFlex = function(str) {
2297
3121
  * @returns {boolean} true if the message was applied successfully
2298
3122
  * @category Core
2299
3123
  */
3124
+ // ===================================================================================
3125
+ // bw.actions — document-level delegated action dispatcher (§5.4)
3126
+ // ===================================================================================
3127
+
3128
+ bw.actions = (function() {
3129
+ var _enabled = false;
3130
+ var _installed = false;
3131
+
3132
+ function _findActionToken(el) {
3133
+ /* c8 ignore next -- el always has classList in DOM event handlers */
3134
+ if (!el || !el.classList) return null;
3135
+ var cls = el.classList;
3136
+ var tokens = [];
3137
+ /* c8 ignore next 2 -- action token extraction: only hit via real DOM clicks */
3138
+ for (var i = 0; i < cls.length; i++) {
3139
+ if (cls[i].indexOf('bw_act_') === 0) tokens.push(cls[i].substring(7));
3140
+ }
3141
+ return tokens;
3142
+ }
3143
+
3144
+ function _findOwner(el) {
3145
+ var node = el;
3146
+ while (node) {
3147
+ if (node._bw_type) {
3148
+ return { uuid: bw.getUUID(node), type: node._bw_type };
3149
+ }
3150
+ node = node.parentElement;
3151
+ }
3152
+ return null;
3153
+ }
3154
+
3155
+ function _handleEvent(e) {
3156
+ /* c8 ignore next -- guard only reachable if disabled between install and event */
3157
+ if (!_enabled) return;
3158
+ var node = e.target;
3159
+ /* c8 ignore start -- action dispatch: requires real DOM event delegation not exercised in unit tests */
3160
+ while (node && node !== document) {
3161
+ var tokens = _findActionToken(node);
3162
+ if (tokens && tokens.length > 0) {
3163
+ var action = tokens[0];
3164
+ if (tokens.length > 1) {
3165
+ bw.pub('bw:diag', { code: 'act_multiple', tokens: tokens });
3166
+ }
3167
+ var tag = node.tagName ? node.tagName.toLowerCase() : '';
3168
+ if (tag === 'a' || tag === 'form') e.preventDefault();
3169
+ var value = null;
3170
+ var name = null;
3171
+ var form = null;
3172
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') {
3173
+ value = node.value;
3174
+ name = node.getAttribute('name');
3175
+ }
3176
+ if (tag === 'form') {
3177
+ form = {};
3178
+ try {
3179
+ var fd = new FormData(node);
3180
+ fd.forEach(function(v, k) { form[k] = v; });
3181
+ } catch (ex) {}
3182
+ }
3183
+ var ref = bw.getUUID(node) || node.getAttribute('id') || null;
3184
+ var owner = _findOwner(node.parentElement);
3185
+ var payload = { action: action, value: value, name: name, ref: ref, owner: owner };
3186
+ if (form) payload.form = form;
3187
+ bw.pub('act:' + action, payload);
3188
+ if (bw.remote && typeof bw.remote.send === 'function') {
3189
+ bw.remote.send({ v: 1, type: 'event', action: action, value: value, name: name, ref: ref, owner: owner });
3190
+ }
3191
+ return;
3192
+ }
3193
+ node = node.parentElement;
3194
+ }
3195
+ /* c8 ignore stop */
3196
+ }
3197
+
3198
+ var _installedDoc = null;
3199
+
3200
+ function _install() {
3201
+ /* c8 ignore next -- document always available in test env */
3202
+ if (typeof document === 'undefined') return;
3203
+ // Re-install if document changed (jsdom test isolation)
3204
+ if (_installedDoc === document) return;
3205
+ _installedDoc = document;
3206
+ document.addEventListener('click', _handleEvent, true);
3207
+ document.addEventListener('change', _handleEvent, true);
3208
+ document.addEventListener('input', _handleEvent, true);
3209
+ document.addEventListener('submit', _handleEvent, true);
3210
+ }
3211
+
3212
+ return {
3213
+ enable: function() { _enabled = true; _install(); },
3214
+ disable: function() { _enabled = false; },
3215
+ _ensureInstalled: function() { if (_enabled) _install(); },
3216
+ _reset: function() { _enabled = false; _installedDoc = null; }
3217
+ };
3218
+ })();
3219
+
3220
+ bw.remote = null;
3221
+ bw._clientRemotes = {};
3222
+ bw._wireListeners = {};
3223
+
3224
+ bw.registerRemote = function(name, fn) { bw._clientRemotes[name] = fn; };
3225
+
3226
+ bw.connect = function(url) {
3227
+ bw.pub('bw:diag', { code: 'remote_status', status: 'connecting', url: url });
3228
+ var es = new EventSource(url);
3229
+ var remote = {
3230
+ send: function(msg) {
3231
+ try {
3232
+ var xhr = new XMLHttpRequest();
3233
+ xhr.open('POST', url.replace('/events/', '/apply/'), true);
3234
+ xhr.setRequestHeader('Content-Type', 'application/json');
3235
+ xhr.send(JSON.stringify(msg));
3236
+ } catch (e) {}
3237
+ },
3238
+ close: function() { es.close(); }
3239
+ };
3240
+ es.onopen = function() { bw.pub('bw:diag', { code: 'remote_status', status: 'connected' }); };
3241
+ es.onmessage = function(e) { try { bw.apply(JSON.parse(e.data)); } catch (ex) {} };
3242
+ es.onerror = function() { bw.pub('bw:diag', { code: 'remote_status', status: 'disconnected' }); };
3243
+ bw.remote = remote;
3244
+ return remote;
3245
+ };
3246
+
3247
+ function _sanitizeWireTaco(taco) {
3248
+ if (!taco || typeof taco !== 'object') return taco;
3249
+ if (taco.a) {
3250
+ var cleanAttrs = {};
3251
+ var akeys = _keys(taco.a);
3252
+ for (var ai = 0; ai < akeys.length; ai++) {
3253
+ if (akeys[ai].substring(0, 2).toLowerCase() === 'on' && typeof taco.a[akeys[ai]] === 'string') continue;
3254
+ cleanAttrs[akeys[ai]] = taco.a[akeys[ai]];
3255
+ }
3256
+ taco = Object.assign({}, taco, { a: cleanAttrs });
3257
+ }
3258
+ if (_isA(taco.c)) {
3259
+ taco = Object.assign({}, taco, { c: taco.c.map(_sanitizeWireTaco) });
3260
+ } else if (taco.c && typeof taco.c === 'object' && taco.c.t) {
3261
+ taco = Object.assign({}, taco, { c: _sanitizeWireTaco(taco.c) });
3262
+ }
3263
+ return taco;
3264
+ }
3265
+
2300
3266
  bw.apply = function(msg) {
2301
3267
  if (!msg || !msg.type) return false;
2302
-
3268
+ if (msg.type === 'hello') return true; // handshake -- no-op ack
3269
+ if (msg.type !== 'batch' && msg.v !== 1) {
3270
+ bw.pub('bw:diag', { code: 'wire_rejected', msg: 'missing or unknown version', v: msg.v });
3271
+ return false;
3272
+ }
2303
3273
  var type = msg.type;
2304
- var target = msg.target;
3274
+ var ref = msg.ref;
3275
+ if (msg.target !== undefined || msg.node !== undefined) {
3276
+ bw.pub('bw:diag', { code: 'wire_rejected', msg: 'v:1 uses ref/taco, not target/node' });
3277
+ return false;
3278
+ }
2305
3279
 
2306
- if (type === 'replace') {
2307
- var el = bw.el(target);
2308
- if (!el) return false;
2309
- bw.DOM(el, msg.node);
3280
+ if (type === 'mount') {
3281
+ var mountTarget = bw.el(ref);
3282
+ if (!mountTarget) return false;
3283
+ bw.mount(mountTarget, _sanitizeWireTaco(msg.taco));
2310
3284
  return true;
2311
-
2312
3285
  } else if (type === 'patch') {
2313
- var patched = bw.patch(target, msg.content, msg.attr);
2314
- return patched !== null;
2315
-
3286
+ var patchEl = bw.el(ref);
3287
+ if (!patchEl) return false;
3288
+ if (msg.text !== undefined) bw.patch(patchEl, msg.text);
3289
+ else if (msg.attrs) bw.patch(patchEl, msg.attrs);
3290
+ else if (msg.content) bw.patch(patchEl, _sanitizeWireTaco(msg.content));
3291
+ return true;
2316
3292
  } else if (type === 'append') {
2317
- var parent = bw.el(target);
2318
- if (!parent) return false;
2319
- var child = bw.createDOM(msg.node);
2320
- parent.appendChild(child);
3293
+ var appendTarget = bw.el(ref);
3294
+ if (!appendTarget) return false;
3295
+ bw.append(appendTarget, _sanitizeWireTaco(msg.taco));
3296
+ return true;
3297
+ } else if (type === 'replace') {
3298
+ var replaceEl = bw.el(ref);
3299
+ if (!replaceEl) return false;
3300
+ bw.replace(replaceEl, _sanitizeWireTaco(msg.taco));
2321
3301
  return true;
2322
-
2323
3302
  } else if (type === 'remove') {
2324
- var toRemove = bw.el(target);
2325
- if (!toRemove) return false;
2326
- if (_is(bw.cleanup, 'function')) bw.cleanup(toRemove);
2327
- toRemove.remove();
3303
+ var removeEl = bw.el(ref);
3304
+ if (!removeEl) return false;
3305
+ bw.remove(removeEl);
2328
3306
  return true;
2329
-
3307
+ } else if (type === 'refresh') {
3308
+ var refreshEl = bw.el(ref);
3309
+ if (!refreshEl) return false;
3310
+ if (!refreshEl._bw_render) {
3311
+ bw.pub('bw:diag', { code: 'refresh_no_render', ref: ref });
3312
+ return false;
3313
+ }
3314
+ bw.refresh(refreshEl);
3315
+ return true;
3316
+ } else if (type === 'update') {
3317
+ var updateEl = bw.el(ref);
3318
+ if (!updateEl) return false;
3319
+ bw.update(updateEl, msg.data);
3320
+ return true;
3321
+ } else if (type === 'message') {
3322
+ return bw.message(ref, msg.action, msg.data) !== false;
2330
3323
  } else if (type === 'batch') {
2331
3324
  if (!_isA(msg.ops)) return false;
2332
3325
  var allOk = true;
2333
- msg.ops.forEach(function(op) {
2334
- if (!bw.apply(op)) allOk = false;
2335
- });
2336
- return allOk;
2337
-
2338
- } else if (type === 'message') {
2339
- return bw.message(msg.target, msg.action, msg.data);
2340
-
2341
- } else if (type === 'register') {
2342
- if (!msg.name || !msg.body) return false;
2343
- try {
2344
- bw._clientFunctions[msg.name] = new Function('return ' + msg.body)();
2345
- return true;
2346
- } catch (e) {
2347
- _ce('[bw] register error:', msg.name, e);
2348
- return false;
3326
+ for (var bi = 0; bi < msg.ops.length; bi++) {
3327
+ /* c8 ignore next 2 -- batch catch: bw.apply() handles errors internally */
3328
+ try { if (!bw.apply(msg.ops[bi])) allOk = false; }
3329
+ catch (e) { allOk = false; }
2349
3330
  }
2350
-
3331
+ return allOk;
3332
+ } else if (type === 'listen') {
3333
+ if (!msg.topic) return false;
3334
+ if (bw._wireListeners[msg.topic]) return true;
3335
+ bw._wireListeners[msg.topic] = bw.sub(msg.topic, function(d) {
3336
+ if (bw.remote && typeof bw.remote.send === 'function') {
3337
+ bw.remote.send({ v: 1, type: 'topic', topic: msg.topic, data: d });
3338
+ }
3339
+ });
3340
+ return true;
3341
+ } else if (type === 'unlisten') {
3342
+ if (!msg.topic || !bw._wireListeners[msg.topic]) return false;
3343
+ bw._wireListeners[msg.topic]();
3344
+ delete bw._wireListeners[msg.topic];
3345
+ return true;
2351
3346
  } else if (type === 'call') {
2352
3347
  if (!msg.name) return false;
2353
- var fn = bw._clientFunctions[msg.name];
3348
+ var fn = bw._clientRemotes[msg.name] || bw._clientFunctions[msg.name];
2354
3349
  if (!_is(fn, 'function')) return false;
2355
3350
  try {
2356
3351
  var args = _isA(msg.args) ? msg.args : [];
2357
- fn.apply(null, args);
2358
- return true;
2359
- } catch (e) {
2360
- _ce('[bw] call error:', msg.name, e);
2361
- return false;
2362
- }
2363
-
2364
- } else if (type === 'exec') {
2365
- if (!bw._allowExec) {
2366
- _cw('[bw] exec rejected: allowExec is not enabled');
2367
- return false;
2368
- }
2369
- if (!msg.code) return false;
2370
- try {
2371
- new Function(msg.code)();
2372
- return true;
2373
- } catch (e) {
2374
- _ce('[bw] exec error:', e);
2375
- return false;
2376
- }
3352
+ fn.apply(null, args);
3353
+ return true;
3354
+ } catch (e) { return false; }
3355
+ } else if (type === 'exec' || type === 'register') {
3356
+ bw.pub('bw:diag', { code: 'wire_rejected', msg: type + ' is not a valid v:1 verb' });
3357
+ return false;
2377
3358
  }
2378
-
3359
+ bw.pub('bw:diag', { code: 'wire_rejected', msg: 'unknown type: ' + type });
2379
3360
  return false;
2380
3361
  };
2381
3362
 
@@ -2425,10 +3406,13 @@ bw.inspect = function(target, depth) {
2425
3406
  if (depth === undefined || depth === null) depth = 3;
2426
3407
 
2427
3408
  function walk(node, d) {
3409
+ /* c8 ignore next -- null node guard: children iteration always passes valid nodes */
2428
3410
  if (!node) return null;
2429
3411
  // Skip non-element nodes (text, comment, etc.)
3412
+ /* c8 ignore next -- nodeType guard: el.children only contains elements */
2430
3413
  if (node.nodeType !== 1) return null;
2431
3414
 
3415
+ /* c8 ignore next -- tagName always exists on elements; #text fallback is defensive */
2432
3416
  var info = { tag: node.tagName ? node.tagName.toLowerCase() : '#text' };
2433
3417
 
2434
3418
  // Identity
@@ -2562,29 +3546,59 @@ bw.injectCSS = function(css, options = {}) {
2562
3546
  _cw('bw.injectCSS requires a DOM environment');
2563
3547
  return null;
2564
3548
  }
2565
-
3549
+
2566
3550
  const { id = 'bw_styles', append = true } = options;
2567
-
3551
+
3552
+ // Warn if user is using bw_style_* reserved namespace
3553
+ if (id && /^bw_style_/.test(id) && !options._internal) {
3554
+ bw.pub('bw:diag', { code: 'css_reserved_id', ref: id, msg: 'id "' + id + '" is in the reserved bw_style_* namespace' });
3555
+ }
3556
+
2568
3557
  // Get or create style element
2569
3558
  let styleEl = document.getElementById(id);
2570
-
3559
+
2571
3560
  if (!styleEl) {
2572
3561
  styleEl = document.createElement('style');
2573
3562
  styleEl.id = id;
2574
3563
  styleEl.type = 'text/css';
2575
- document.head.appendChild(styleEl);
3564
+ // Layer ordering: insert bw_style_* elements in deterministic order
3565
+ if (/^bw_style_/.test(id)) {
3566
+ var _layerOrder = ['bw_style_reset', 'bw_style_structural', 'bw_style_global'];
3567
+ var myIdx = _layerOrder.indexOf(id);
3568
+ if (myIdx === -1) myIdx = _layerOrder.length; // scoped styles after global
3569
+ // Find the first existing bw_style_* element that should come after this one
3570
+ var inserted = false;
3571
+ var headStyles = document.head.querySelectorAll('style[id^="bw_style_"]');
3572
+ for (var si = 0; si < headStyles.length; si++) {
3573
+ var thatIdx = _layerOrder.indexOf(headStyles[si].id);
3574
+ if (thatIdx === -1) thatIdx = _layerOrder.length;
3575
+ if (thatIdx > myIdx) {
3576
+ document.head.insertBefore(styleEl, headStyles[si]);
3577
+ inserted = true;
3578
+ break;
3579
+ }
3580
+ }
3581
+ if (!inserted) document.head.appendChild(styleEl);
3582
+ } else {
3583
+ document.head.appendChild(styleEl);
3584
+ }
2576
3585
  }
2577
-
3586
+
3587
+ // Apply CSP nonce if configured
3588
+ if (bw.config && bw.config.cspNonce) {
3589
+ styleEl.setAttribute('nonce', bw.config.cspNonce);
3590
+ }
3591
+
2578
3592
  // Convert CSS if needed
2579
3593
  const cssStr = _is(css, 'string') ? css : bw.css(css, options);
2580
-
3594
+
2581
3595
  // Set or append CSS
2582
3596
  if (append && styleEl.textContent) {
2583
3597
  styleEl.textContent += '\n' + cssStr;
2584
3598
  } else {
2585
3599
  styleEl.textContent = cssStr;
2586
3600
  }
2587
-
3601
+
2588
3602
  return styleEl;
2589
3603
  };
2590
3604
 
@@ -2698,7 +3712,7 @@ bw.clip = _clip;
2698
3712
  * every matched element (same apply rules as `bw.el()`):
2699
3713
  * - string/number: sets `el.textContent`
2700
3714
  * - function: calls `apply(el)` for each element
2701
- * - TACO object: clears children, mounts TACO via `bw.createDOM()`
3715
+ * - TACO object: clears children, mounts TACO via `bw.create()`
2702
3716
  * - array: clears children, appends each item
2703
3717
  *
2704
3718
  * @param {string|Element|Array} selector - CSS selector, element, or array
@@ -2713,35 +3727,36 @@ bw.clip = _clip;
2713
3727
  * el.style.opacity = '0.5';
2714
3728
  * })
2715
3729
  */
2716
- if (bw._isBrowser) {
2717
- bw.$ = function(selector, apply) {
2718
- var els;
2719
- if (!selector) {
2720
- els = [];
2721
- } else if (_isA(selector)) {
2722
- els = selector;
2723
- } else if (selector.nodeType) {
2724
- els = [selector];
2725
- } else if (selector.length !== undefined && !_is(selector, 'string')) {
2726
- els = Array.from(selector);
2727
- } else if (_is(selector, 'string')) {
2728
- els = Array.from(document.querySelectorAll(selector));
2729
- } else {
2730
- els = [];
2731
- }
3730
+ // Always define bw.$ — use dynamic _isBrowser check so it works when
3731
+ // jsdom globals are injected after module load (test environments).
3732
+ bw.$ = function(selector, apply) {
3733
+ if (!bw._isBrowser) return [];
3734
+ var els;
3735
+ if (!selector) {
3736
+ els = [];
3737
+ } else if (_isA(selector)) {
3738
+ els = selector;
3739
+ } else if (selector.nodeType) {
3740
+ els = [selector];
3741
+ } else if (selector.length !== undefined && !_is(selector, 'string')) {
3742
+ els = Array.from(selector);
3743
+ } else if (_is(selector, 'string')) {
3744
+ els = Array.from(document.querySelectorAll(selector));
3745
+ } else {
3746
+ els = [];
3747
+ }
2732
3748
 
2733
- if (apply !== undefined) {
2734
- for (var i = 0; i < els.length; i++) _applyTo(els[i], apply);
2735
- }
3749
+ if (apply !== undefined) {
3750
+ for (var i = 0; i < els.length; i++) _applyTo(els[i], apply);
3751
+ }
2736
3752
 
2737
- return els;
2738
- };
3753
+ return els;
3754
+ };
2739
3755
 
2740
- // Convenience single element selector
2741
- bw.$.one = function(selector) {
2742
- return bw.$(selector)[0] || null;
2743
- };
2744
- }
3756
+ // Convenience single element selector
3757
+ bw.$.one = function(selector) {
3758
+ return bw.$(selector)[0] || null;
3759
+ };
2745
3760
 
2746
3761
 
2747
3762
  // =========================================================================
@@ -2757,8 +3772,12 @@ if (bw._isBrowser) {
2757
3772
  function _scopeToStyleId(scope) {
2758
3773
  if (!scope || scope === '' || scope === 'global') return 'bw_style_global';
2759
3774
  if (scope === 'reset') return 'bw_style_reset';
2760
- // Strip leading # or . and convert - to _
2761
- var clean = scope.replace(/^[#.]/, '').replace(/-/g, '_');
3775
+ if (scope === 'structural') return 'bw_style_structural';
3776
+ // Preserve sigil distinction: '#dash' → 'id_dash', '.dash' → 'cls_dash'
3777
+ var clean = scope;
3778
+ if (clean.charAt(0) === '#') clean = 'id_' + clean.substring(1);
3779
+ else if (clean.charAt(0) === '.') clean = 'cls_' + clean.substring(1);
3780
+ clean = clean.replace(/-/g, '_');
2762
3781
  return 'bw_style_' + clean;
2763
3782
  }
2764
3783
 
@@ -2787,6 +3806,18 @@ bw.makeStyles = function(config) {
2787
3806
  var fullConfig = Object.assign({}, DEFAULT_PALETTE_CONFIG, config || {});
2788
3807
  if (config && !config.tertiary) fullConfig.tertiary = fullConfig.primary;
2789
3808
 
3809
+ // Contrast check: warn if primary/secondary seeds are too close
3810
+ if (fullConfig.primary && fullConfig.secondary) {
3811
+ var l1 = relativeLuminance(fullConfig.primary);
3812
+ var l2 = relativeLuminance(fullConfig.secondary);
3813
+ var bright = Math.max(l1, l2);
3814
+ var dark = Math.min(l1, l2);
3815
+ var ratio = (bright + 0.05) / (dark + 0.05);
3816
+ if (ratio < 1.5) {
3817
+ bw.pub('bw:diag', { code: 'contrast_aa', msg: 'primary/secondary seeds have near-identical luminance (ratio ' + ratio.toFixed(2) + ')' });
3818
+ }
3819
+ }
3820
+
2790
3821
  // Derive primary palette
2791
3822
  var palette = derivePalette(fullConfig);
2792
3823
 
@@ -2807,6 +3838,7 @@ bw.makeStyles = function(config) {
2807
3838
 
2808
3839
  // Add body-level surface overrides for the alternate palette.
2809
3840
  // When .bw_theme_alt is on <html>, ".bw_theme_alt body" correctly matches.
3841
+ /* c8 ignore next 3 -- altPalette.surface always provided by derivePalette */
2810
3842
  altRawRules['body'] = {
2811
3843
  'color': altPalette.dark.base,
2812
3844
  'background-color': altPalette.surface || altPalette.light.base
@@ -2853,6 +3885,8 @@ bw.applyStyles = function(styles, scope) {
2853
3885
  _cw('bw.applyStyles: invalid styles object');
2854
3886
  return null;
2855
3887
  }
3888
+ // Reject complex/comma scopes
3889
+ if (scope && !_validateThemeScope(scope)) return null;
2856
3890
 
2857
3891
  var styleId = _scopeToStyleId(scope);
2858
3892
 
@@ -2865,9 +3899,25 @@ bw.applyStyles = function(styles, scope) {
2865
3899
  // Wrap alternate rules with .bw_theme_alt
2866
3900
  var altRules = styles.alternateRules;
2867
3901
  if (altRules) {
3902
+ // When scoped: remove the raw 'body' rule (dead as descendant of scope)
3903
+ // and add a self-rule on the scope root instead
3904
+ var bodyDecls = null;
3905
+ if (scope && altRules['body']) {
3906
+ bodyDecls = altRules['body'];
3907
+ // Work on a shallow copy so we don't mutate the original
3908
+ var altCopy = {};
3909
+ for (var k in altRules) {
3910
+ if (Object.prototype.hasOwnProperty.call(altRules, k) && k !== 'body') altCopy[k] = altRules[k];
3911
+ }
3912
+ altRules = altCopy;
3913
+ }
2868
3914
  if (scope) {
2869
3915
  // Scoped compound: #scope.bw_theme_alt .bw_card
2870
3916
  altRules = scopeRulesUnder(altRules, scope + '.bw_theme_alt');
3917
+ // Add self surface rule for the scope root
3918
+ if (bodyDecls) {
3919
+ altRules[scope + '.bw_theme_alt'] = bodyDecls;
3920
+ }
2871
3921
  } else {
2872
3922
  // Global: .bw_theme_alt .bw_card
2873
3923
  altRules = scopeRulesUnder(altRules, '.bw_theme_alt');
@@ -2880,7 +3930,7 @@ bw.applyStyles = function(styles, scope) {
2880
3930
  combined += '\n' + bw.css(altRules);
2881
3931
  }
2882
3932
 
2883
- return bw.injectCSS(combined, { id: styleId, append: false });
3933
+ return bw.injectCSS(combined, { id: styleId, append: false, _internal: true });
2884
3934
  };
2885
3935
 
2886
3936
  /**
@@ -2901,19 +3951,29 @@ bw.applyStyles = function(styles, scope) {
2901
3951
  * bw.loadStyles({ primary: '#4f46e5' }, '#my-dashboard'); // custom, scoped
2902
3952
  */
2903
3953
  bw.loadStyles = function(config, scope) {
2904
- // Also inject structural CSS first (only once)
2905
- if (bw._isBrowser) {
2906
- var existing = document.getElementById('bw_structural');
2907
- if (!existing) {
2908
- var structuralCSS = bw.css(getStructuralStyles());
2909
- bw.injectCSS(structuralCSS, { id: 'bw_structural', append: false });
2910
- }
2911
- }
3954
+ // Inject structural CSS first (only once)
3955
+ bw.loadStructural();
2912
3956
  var styles = bw.makeStyles(config);
2913
3957
  bw.applyStyles(styles, scope);
2914
3958
  return styles;
2915
3959
  };
2916
3960
 
3961
+ /**
3962
+ * Inject structural (theme-independent) CSS only. Idempotent.
3963
+ *
3964
+ * @returns {Element|null} The `<style>` element, or null in Node.js
3965
+ * @category CSS & Styling
3966
+ * @see bw.loadStyles
3967
+ * @see bw.clearStyles
3968
+ */
3969
+ bw.loadStructural = function() {
3970
+ if (!bw._isBrowser) return null;
3971
+ var existing = document.getElementById('bw_style_structural');
3972
+ if (existing) return existing;
3973
+ var structuralCSS = bw.css(getStructuralStyles());
3974
+ return bw.injectCSS(structuralCSS, { id: 'bw_style_structural', append: false, _internal: true });
3975
+ };
3976
+
2917
3977
  /**
2918
3978
  * Prefix every selector in a rules object with a scope selector.
2919
3979
  * Useful for wrapping site-level CSS under `.bw_theme_alt` for dark mode.
@@ -2945,7 +4005,7 @@ bw.loadReset = function() {
2945
4005
  if (!bw._isBrowser) return null;
2946
4006
  var existing = document.getElementById('bw_style_reset');
2947
4007
  if (existing) return existing;
2948
- return bw.injectCSS(bw.css(getResetStyles()), { id: 'bw_style_reset', append: false });
4008
+ return bw.injectCSS(bw.css(getResetStyles()), { id: 'bw_style_reset', append: false, _internal: true });
2949
4009
  };
2950
4010
 
2951
4011
  /**
@@ -2965,8 +4025,67 @@ bw.loadReset = function() {
2965
4025
  * bw.toggleThemeMode('#my-dashboard'); // scoped toggle
2966
4026
  * bw.toggleThemeMode('.panel'); // toggle on ALL .panel elements
2967
4027
  */
4028
+ /**
4029
+ * Validate a scope selector. Rejects complex/comma selectors.
4030
+ * @private
4031
+ * @param {string} scope
4032
+ * @returns {boolean}
4033
+ */
4034
+ function _validateThemeScope(scope) {
4035
+ if (!scope) return true;
4036
+ // Reject comma-separated selectors
4037
+ if (scope.indexOf(',') !== -1) {
4038
+ bw.pub('bw:diag', { code: 'scope_rejected', ref: scope, msg: 'comma selectors not allowed' });
4039
+ return false;
4040
+ }
4041
+ // Reject descendant selectors (space-separated compound)
4042
+ if (/\s/.test(scope.trim())) {
4043
+ bw.pub('bw:diag', { code: 'scope_rejected', ref: scope, msg: 'complex selectors not allowed' });
4044
+ return false;
4045
+ }
4046
+ return true;
4047
+ }
4048
+
4049
+ /**
4050
+ * Set the theme mode on all matching elements.
4051
+ *
4052
+ * @param {string} mode - 'primary' or 'alternate'
4053
+ * @param {string} [scope] - Selector. Omit for global (<html>).
4054
+ * @returns {Object} { mode, count } — the mode set and number of elements affected
4055
+ * @category CSS & Styling
4056
+ */
4057
+ bw.setThemeMode = function(mode, scope) {
4058
+ if (!bw._isBrowser) return { mode: 'primary', count: 0 };
4059
+ if (!_validateThemeScope(scope)) return { mode: mode, count: 0 };
4060
+ var els;
4061
+ if (scope) {
4062
+ els = bw.$(scope);
4063
+ } else {
4064
+ els = [document.documentElement];
4065
+ }
4066
+ for (var i = 0; i < els.length; i++) {
4067
+ if (mode === 'alternate') {
4068
+ els[i].classList.add('bw_theme_alt');
4069
+ } else {
4070
+ els[i].classList.remove('bw_theme_alt');
4071
+ }
4072
+ }
4073
+ var result = { mode: mode, count: els.length };
4074
+ bw.pub('bw:thememode', { mode: mode, scope: scope || 'html', count: els.length });
4075
+ return result;
4076
+ };
4077
+
4078
+ /**
4079
+ * Toggle between primary and alternate theme palettes.
4080
+ * Determines current mode from first matched element, then sets inverse on all.
4081
+ *
4082
+ * @param {string|Element} [scope] - Selector or element. Omit for global.
4083
+ * @returns {string} Active mode after toggle: 'primary' or 'alternate' (based on first element)
4084
+ * @category CSS & Styling
4085
+ */
2968
4086
  bw.toggleThemeMode = function(scope) {
2969
4087
  if (!bw._isBrowser) return 'primary';
4088
+ if (!_validateThemeScope(scope)) return 'primary';
2970
4089
  var els;
2971
4090
  if (scope) {
2972
4091
  els = bw.$(scope);
@@ -2975,21 +4094,20 @@ bw.toggleThemeMode = function(scope) {
2975
4094
  }
2976
4095
  if (!els.length) return 'primary';
2977
4096
 
2978
- var mode;
4097
+ // Determine inverse based on first element
4098
+ var firstHasAlt = els[0].classList.contains('bw_theme_alt');
4099
+ var newMode = firstHasAlt ? 'primary' : 'alternate';
4100
+ // Set all to the same mode
2979
4101
  for (var i = 0; i < els.length; i++) {
2980
- var hasAlt = els[i].classList.contains('bw_theme_alt');
2981
- if (hasAlt) {
2982
- els[i].classList.remove('bw_theme_alt');
2983
- } else {
4102
+ if (newMode === 'alternate') {
2984
4103
  els[i].classList.add('bw_theme_alt');
4104
+ } else {
4105
+ els[i].classList.remove('bw_theme_alt');
2985
4106
  }
2986
- if (i === 0) mode = hasAlt ? 'primary' : 'alternate';
2987
4107
  }
2988
- return mode;
4108
+ return newMode;
2989
4109
  };
2990
4110
 
2991
- // Alias — kept for one release cycle. Use bw.toggleThemeMode() instead.
2992
- bw.toggleStyles = bw.toggleThemeMode;
2993
4111
 
2994
4112
  /**
2995
4113
  * Remove injected styles for a given scope.
@@ -3012,10 +4130,12 @@ bw.clearStyles = function(scope) {
3012
4130
  var el = document.getElementById(styleId);
3013
4131
  if (el) el.remove();
3014
4132
 
3015
- // Also remove bw_theme_alt from the relevant element
3016
- if (scope && scope !== 'reset' && scope !== 'global') {
4133
+ // Also remove bw_theme_alt from ALL relevant elements
4134
+ if (scope && scope !== 'reset' && scope !== 'structural' && scope !== 'global') {
3017
4135
  var targets = bw.$(scope);
3018
- if (targets[0]) targets[0].classList.remove('bw_theme_alt');
4136
+ for (var i = 0; i < targets.length; i++) {
4137
+ targets[i].classList.remove('bw_theme_alt');
4138
+ }
3019
4139
  } else if (!scope || scope === 'global') {
3020
4140
  document.documentElement.classList.remove('bw_theme_alt');
3021
4141
  }
@@ -3034,6 +4154,10 @@ bw.harmonize = harmonize;
3034
4154
  bw.deriveAlternateSeed = deriveAlternateSeed;
3035
4155
  bw.deriveAlternateConfig = deriveAlternateConfig;
3036
4156
  bw.isLightPalette = isLightPalette;
4157
+ bw.colorParse = _colorParse;
4158
+ bw.colorRgbToHsl = _colorRgbToHsl;
4159
+ bw.colorHslToRgb = _colorHslToRgb;
4160
+ bw.colorInterp = _colorInterp;
3037
4161
 
3038
4162
  // Expose layout and theme presets
3039
4163
  bw.SPACING_PRESETS = SPACING_PRESETS;
@@ -3058,15 +4182,6 @@ bw.arrayBinA = _arrayBinA;
3058
4182
  /** @see bitwrench-utils.js for implementation */
3059
4183
  bw.arrayBNotInA = _arrayBNotInA;
3060
4184
 
3061
- /** @see bitwrench-utils.js for implementation — wraps _colorInterp with bw.colorParse */
3062
- bw.colorInterp = function(x, in0, in1, colors, stretch) {
3063
- return _colorInterp(x, in0, in1, colors, stretch, colorParse);
3064
- };
3065
-
3066
- // Color conversion functions — imported from bitwrench-color-utils.js (single source of truth)
3067
- bw.colorHslToRgb = colorHslToRgb;
3068
- bw.colorRgbToHsl = colorRgbToHsl;
3069
- bw.colorParse = colorParse;
3070
4185
 
3071
4186
  /**
3072
4187
  * Set a browser cookie with expiration and options.
@@ -3114,11 +4229,13 @@ bw.getCookie = function(cname, defaultValue) {
3114
4229
  const name = cname + "=";
3115
4230
  const ca = document.cookie.split(";");
3116
4231
 
4232
+ /* c8 ignore start -- cookie parsing: jsdom doesn't support document.cookie in unit tests */
3117
4233
  for (let i = 0; i < ca.length; i++) {
3118
4234
  let c = ca[i];
3119
4235
  while (c.charAt(0) === " ") c = c.substring(1);
3120
4236
  if (c.indexOf(name) === 0) return c.substring(name.length, c.length);
3121
4237
  }
4238
+ /* c8 ignore stop */
3122
4239
 
3123
4240
  return defaultValue;
3124
4241
  };
@@ -3149,10 +4266,13 @@ bw.getURLParam = function(key, defaultValue) {
3149
4266
  return result;
3150
4267
  }
3151
4268
 
4269
+ /* c8 ignore next -- params.has() branch: jsdom window.location.search is always empty */
3152
4270
  return params.has(key) ? (params.get(key) || true) : defaultValue;
4271
+ /* c8 ignore start -- URLSearchParams never throws in test env */
3153
4272
  } catch (e) {
3154
4273
  return defaultValue;
3155
4274
  }
4275
+ /* c8 ignore stop */
3156
4276
  };
3157
4277
 
3158
4278
 
@@ -3188,7 +4308,7 @@ bw.copyToClipboard = function(text) {
3188
4308
 
3189
4309
  // Fallback for older browsers
3190
4310
  return new Promise((resolve, reject) => {
3191
- const textarea = bw.createDOM({
4311
+ const textarea = bw.create({
3192
4312
  t: 'textarea',
3193
4313
  a: {
3194
4314
  value: text,
@@ -3282,16 +4402,17 @@ bw.makeTable = function(config) {
3282
4402
  sortDirection = 'asc',
3283
4403
  selectable = false,
3284
4404
  onRowClick,
4405
+ rowKey,
3285
4406
  pageSize,
3286
4407
  currentPage = 1,
3287
4408
  onPageChange
3288
4409
  } = config;
3289
4410
 
3290
- // Build class list: always include bw_table, add striped/hover/selectable, append user className
3291
- let cls = 'bw_table';
3292
- if (striped) cls += ' bw_table_striped';
3293
- if (hover || selectable) cls += ' bw_table_hover';
3294
- if (selectable) cls += ' bw_table_selectable';
4411
+ // Build class list: always include bw_bccl_table, add striped/hover/selectable, append user className
4412
+ let cls = 'bw_bccl_table';
4413
+ if (striped) cls += ' bw_bccl_table_striped';
4414
+ if (hover || selectable) cls += ' bw_bccl_table_hover';
4415
+ if (selectable) cls += ' bw_bccl_table_selectable';
3295
4416
  if (className) cls += ' ' + className;
3296
4417
  cls = cls.trim();
3297
4418
 
@@ -3337,42 +4458,35 @@ bw.makeTable = function(config) {
3337
4458
  sortedData = sortedData.slice(start, start + pageSize);
3338
4459
  }
3339
4460
 
3340
- // Create sort handler
3341
- const handleSort = (column) => {
3342
- if (!sortable) return;
3343
-
3344
- if (currentSortColumn === column) {
3345
- currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc';
3346
- } else {
3347
- currentSortColumn = column;
3348
- currentSortDirection = 'asc';
3349
- }
3350
-
3351
- if (onSort) {
3352
- onSort(column, currentSortDirection);
3353
- }
3354
- };
3355
-
3356
- // Build table header
4461
+ // Build table header with scope="col" and aria-sort support
3357
4462
  const thead = {
3358
4463
  t: 'thead',
3359
4464
  c: {
3360
4465
  t: 'tr',
3361
- c: cols.map(col => ({
3362
- t: 'th',
3363
- a: sortable ? {
3364
- style: { cursor: 'pointer', userSelect: 'none' },
3365
- onclick: () => handleSort(col.key)
3366
- } : {},
3367
- c: [
3368
- col.label,
3369
- sortable && currentSortColumn === col.key && {
3370
- t: 'span',
3371
- a: { style: { marginLeft: '5px' } },
3372
- c: currentSortDirection === 'asc' ? '▲' : '▼'
3373
- }
3374
- ].filter(Boolean)
3375
- }))
4466
+ c: cols.map(col => {
4467
+ var thAttrs = {
4468
+ scope: 'col',
4469
+ 'data-col-key': col.key
4470
+ };
4471
+ if (sortable) {
4472
+ thAttrs.style = { cursor: 'pointer', userSelect: 'none' };
4473
+ }
4474
+ if (currentSortColumn === col.key) {
4475
+ thAttrs['aria-sort'] = currentSortDirection === 'asc' ? 'ascending' : 'descending';
4476
+ }
4477
+ return {
4478
+ t: 'th',
4479
+ a: thAttrs,
4480
+ c: [
4481
+ col.label,
4482
+ sortable && currentSortColumn === col.key && {
4483
+ t: 'span',
4484
+ a: { style: { marginLeft: '5px' } },
4485
+ c: currentSortDirection === 'asc' ? '\u25B2' : '\u25BC'
4486
+ }
4487
+ ].filter(Boolean)
4488
+ };
4489
+ })
3376
4490
  }
3377
4491
  };
3378
4492
 
@@ -3382,13 +4496,15 @@ bw.makeTable = function(config) {
3382
4496
  c: sortedData.map((row, idx) => {
3383
4497
  const globalIdx = pageSize ? (page - 1) * pageSize + idx : idx;
3384
4498
  const rowAttrs = {};
4499
+ if (rowKey && row[rowKey] !== undefined) {
4500
+ rowAttrs['data-row-key'] = String(row[rowKey]);
4501
+ }
3385
4502
  if (selectable || onRowClick) {
3386
4503
  rowAttrs.style = 'cursor:pointer;';
3387
4504
  rowAttrs.onclick = function(e) {
3388
4505
  if (selectable) {
3389
- // Toggle selected class on this row
3390
4506
  var tr = e.currentTarget;
3391
- tr.classList.toggle('bw_table_row_selected');
4507
+ tr.classList.toggle('bw_bccl_table_row_selected');
3392
4508
  }
3393
4509
  if (onRowClick) {
3394
4510
  onRowClick(row, globalIdx, e);
@@ -3406,10 +4522,149 @@ bw.makeTable = function(config) {
3406
4522
  })
3407
4523
  };
3408
4524
 
4525
+ // Shared helper: sort the live table DOM
4526
+ function _sortTableDOM(el, column, direction) {
4527
+ var ths = el.querySelectorAll('th[data-col-key]');
4528
+ // Remove all aria-sort
4529
+ for (var h = 0; h < ths.length; h++) {
4530
+ ths[h].removeAttribute('aria-sort');
4531
+ }
4532
+ // Set aria-sort on the sorted column
4533
+ for (var h2 = 0; h2 < ths.length; h2++) {
4534
+ if (ths[h2].getAttribute('data-col-key') === column) {
4535
+ ths[h2].setAttribute('aria-sort', direction === 'asc' ? 'ascending' : 'descending');
4536
+ break;
4537
+ }
4538
+ }
4539
+ }
4540
+
4541
+ // Shared helper: rebuild tbody rows from new data
4542
+ function _rebuildTbody(el, newData, colsDef, rKey) {
4543
+ var tbodyEl = el.querySelector('tbody');
4544
+ if (!tbodyEl) return;
4545
+
4546
+ if (rKey) {
4547
+ // Keyed reconciliation: reuse existing row nodes
4548
+ var existingRows = {};
4549
+ var rows = tbodyEl.querySelectorAll('tr');
4550
+ for (var r = 0; r < rows.length; r++) {
4551
+ var k = rows[r].getAttribute('data-row-key');
4552
+ if (k !== null) existingRows[k] = rows[r];
4553
+ }
4554
+
4555
+ // Build new order
4556
+ var frag = el.ownerDocument.createDocumentFragment();
4557
+ for (var d = 0; d < newData.length; d++) {
4558
+ var rowData = newData[d];
4559
+ var keyVal = String(rowData[rKey]);
4560
+ if (existingRows[keyVal]) {
4561
+ // Reuse existing row, update cells
4562
+ var tr = existingRows[keyVal];
4563
+ var cells = tr.querySelectorAll('td');
4564
+ for (var ci = 0; ci < colsDef.length; ci++) {
4565
+ if (cells[ci]) {
4566
+ var newText = colsDef[ci].render
4567
+ ? colsDef[ci].render(rowData[colsDef[ci].key], rowData)
4568
+ : String(rowData[colsDef[ci].key] || '');
4569
+ if (cells[ci].textContent !== newText) cells[ci].textContent = newText;
4570
+ }
4571
+ }
4572
+ frag.appendChild(tr);
4573
+ } else {
4574
+ // Create new row
4575
+ var newTr = el.ownerDocument.createElement('tr');
4576
+ newTr.setAttribute('data-row-key', keyVal);
4577
+ for (var ci2 = 0; ci2 < colsDef.length; ci2++) {
4578
+ var td = el.ownerDocument.createElement('td');
4579
+ td.textContent = colsDef[ci2].render
4580
+ ? colsDef[ci2].render(rowData[colsDef[ci2].key], rowData)
4581
+ : String(rowData[colsDef[ci2].key] || '');
4582
+ newTr.appendChild(td);
4583
+ }
4584
+ frag.appendChild(newTr);
4585
+ }
4586
+ }
4587
+ // Replace tbody contents
4588
+ while (tbodyEl.firstChild) tbodyEl.removeChild(tbodyEl.firstChild);
4589
+ tbodyEl.appendChild(frag);
4590
+ } else {
4591
+ // Full rebuild
4592
+ while (tbodyEl.firstChild) tbodyEl.removeChild(tbodyEl.firstChild);
4593
+ for (var d2 = 0; d2 < newData.length; d2++) {
4594
+ var tr2 = el.ownerDocument.createElement('tr');
4595
+ for (var ci3 = 0; ci3 < colsDef.length; ci3++) {
4596
+ var td2 = el.ownerDocument.createElement('td');
4597
+ td2.textContent = colsDef[ci3].render
4598
+ ? colsDef[ci3].render(newData[d2][colsDef[ci3].key], newData[d2])
4599
+ : String(newData[d2][colsDef[ci3].key] || '');
4600
+ tr2.appendChild(td2);
4601
+ }
4602
+ tbodyEl.appendChild(tr2);
4603
+ }
4604
+ }
4605
+ }
4606
+
3409
4607
  const table = {
3410
4608
  t: 'table',
3411
4609
  a: { class: cls },
3412
- c: [thead, tbody]
4610
+ c: [thead, tbody],
4611
+ o: {
4612
+ type: 'table',
4613
+ state: {
4614
+ data: data,
4615
+ columns: cols,
4616
+ sortColumn: currentSortColumn,
4617
+ sortDirection: currentSortDirection,
4618
+ rowKey: rowKey
4619
+ },
4620
+ handle: {
4621
+ sort: function(el, column, dir) {
4622
+ var state = el._bw_state || {};
4623
+ if (!dir) {
4624
+ if (state.sortColumn === column) {
4625
+ dir = state.sortDirection === 'asc' ? 'desc' : 'asc';
4626
+ } else {
4627
+ dir = 'asc';
4628
+ }
4629
+ }
4630
+ state.sortColumn = column;
4631
+ state.sortDirection = dir;
4632
+ _sortTableDOM(el, column, dir);
4633
+
4634
+ // Re-sort and rebuild rows
4635
+ var d = state.data ? [...state.data] : [];
4636
+ d.sort(function(a, b) {
4637
+ var aVal = a[column];
4638
+ var bVal = b[column];
4639
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
4640
+ return dir === 'asc' ? aVal - bVal : bVal - aVal;
4641
+ }
4642
+ var aStr = String(aVal || '').toLowerCase();
4643
+ var bStr = String(bVal || '').toLowerCase();
4644
+ return dir === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
4645
+ });
4646
+ _rebuildTbody(el, d, state.columns || cols, state.rowKey);
4647
+
4648
+ if (onSort) onSort(column, dir);
4649
+ },
4650
+ update: function(el, newConfig) {
4651
+ if (!newConfig) return;
4652
+ var state = el._bw_state || {};
4653
+ if (newConfig.data) {
4654
+ state.data = newConfig.data;
4655
+ _rebuildTbody(el, newConfig.data, state.columns || cols, state.rowKey);
4656
+ }
4657
+ },
4658
+ setData: function(el, newData) {
4659
+ var state = el._bw_state || {};
4660
+ state.data = newData;
4661
+ _rebuildTbody(el, newData, state.columns || cols, state.rowKey);
4662
+ },
4663
+ getData: function(el) {
4664
+ return (el._bw_state && el._bw_state.data) || [];
4665
+ }
4666
+ }
4667
+ }
3413
4668
  };
3414
4669
 
3415
4670
  // If no pagination, return table directly
@@ -3417,27 +4672,24 @@ bw.makeTable = function(config) {
3417
4672
 
3418
4673
  // Build pagination controls
3419
4674
  const pageButtons = [];
3420
- // Previous button
3421
4675
  pageButtons.push({
3422
4676
  t: 'button',
3423
4677
  a: {
3424
- class: 'bw_btn bw_btn_sm',
4678
+ class: 'bw_bccl_btn bw_bccl_btn_sm',
3425
4679
  disabled: page <= 1 ? 'disabled' : undefined,
3426
4680
  onclick: page > 1 && onPageChange ? function() { onPageChange(page - 1); } : undefined
3427
4681
  },
3428
4682
  c: 'Prev'
3429
4683
  });
3430
- // Page info
3431
4684
  pageButtons.push({
3432
4685
  t: 'span',
3433
4686
  a: { style: 'margin:0 0.5rem;font-size:0.875rem;' },
3434
4687
  c: 'Page ' + page + ' of ' + totalPages
3435
4688
  });
3436
- // Next button
3437
4689
  pageButtons.push({
3438
4690
  t: 'button',
3439
4691
  a: {
3440
- class: 'bw_btn bw_btn_sm',
4692
+ class: 'bw_bccl_btn bw_bccl_btn_sm',
3441
4693
  disabled: page >= totalPages ? 'disabled' : undefined,
3442
4694
  onclick: page < totalPages && onPageChange ? function() { onPageChange(page + 1); } : undefined
3443
4695
  },
@@ -3446,12 +4698,12 @@ bw.makeTable = function(config) {
3446
4698
 
3447
4699
  return {
3448
4700
  t: 'div',
3449
- a: { class: 'bw_table_paginated' },
4701
+ a: { class: 'bw_bccl_table_paginated' },
3450
4702
  c: [
3451
4703
  table,
3452
4704
  {
3453
4705
  t: 'div',
3454
- a: { class: 'bw_table_pagination', style: 'display:flex;align-items:center;justify-content:flex-end;padding:0.5rem 0;gap:0.25rem;' },
4706
+ a: { class: 'bw_bccl_table_pagination', style: 'display:flex;align-items:center;justify-content:flex-end;padding:0.5rem 0;gap:0.25rem;' },
3455
4707
  c: pageButtons
3456
4708
  }
3457
4709
  ]
@@ -3690,317 +4942,64 @@ bw.makeDataTable = function(config) {
3690
4942
 
3691
4943
  return {
3692
4944
  t: 'div',
3693
- a: { class: 'table-container' },
4945
+ a: { class: 'bw_bccl_dataTable table-container' },
3694
4946
  c: content
3695
4947
  };
3696
4948
  };
3697
4949
 
3698
4950
  /**
3699
- * Component registry for tracking rendered components
3700
- * @private
3701
- */
3702
- bw._componentRegistry = new Map();
3703
-
3704
- /**
3705
- * Render a TACO object into the DOM at a specific position, returning a component handle.
4951
+ * Render a TACO into the DOM at a specific position relative to a target.
3706
4952
  *
3707
- * The handle provides full lifecycle control: state management, re-rendering,
3708
- * class manipulation, show/hide, event binding, and destroy. Components are
3709
- * tracked in a registry for later retrieval via `bw.getComponent()`.
4953
+ * Thin convenience factory over `bw.append()` / `bw.replace()`. Every code
4954
+ * path goes through the v2.1 lifecycle pipeline (create → insert → mountTree),
4955
+ * so mounted/unmount hooks, state, handles, and the janitor all work
4956
+ * automatically.
3710
4957
  *
3711
- * @param {Element|string} element - Target element or CSS selector
3712
- * @param {string} position - Position: 'replace', 'prepend', 'append', 'before', 'after'
4958
+ * @param {Element|string} target - Target element or CSS selector
4959
+ * @param {string} position - 'append', 'prepend', 'replace', 'before', 'after'
3713
4960
  * @param {Object} taco - TACO object to render
3714
- * @returns {Object} Component handle with element, setState, update, destroy, etc.
4961
+ * @returns {{ el: Element|null, ok: boolean, error: string|null }}
3715
4962
  * @category DOM Generation
3716
- * @see bw.getComponent
3717
- * @see bw.DOM
4963
+ * @see bw.append
4964
+ * @see bw.replace
3718
4965
  * @example
3719
- * var handle = bw.render('#app', 'append', {
4966
+ * var r = bw.render('#app', 'append', {
3720
4967
  * t: 'button', a: { class: 'bw_btn' }, c: 'Click Me',
3721
4968
  * o: { state: { clicks: 0 } }
3722
4969
  * });
3723
- * handle.setState({ clicks: 1 });
3724
- * handle.destroy();
4970
+ * if (r.ok) r.el.bw.myMethod(); // use component handle
3725
4971
  */
3726
- bw.render = function(element, position, taco) {
3727
- // Get target element
3728
- const targetEl = _is(element, 'string')
3729
- ? document.querySelector(element)
3730
- : element;
3731
-
3732
- if (!targetEl) {
3733
- return {
3734
- object_type: 'error',
3735
- component_id: null,
3736
- object_handle_in_dom: null,
3737
- status_code: 'error=target_element_not_found'
3738
- };
3739
- }
3740
-
3741
- // Generate unique UUID class if not provided
3742
- const componentId = taco.o?.id || bw.uuid('uuid');
3743
-
3744
- // Create DOM element
3745
- let domElement;
4972
+ bw.render = function(target, position, taco) {
3746
4973
  try {
3747
- domElement = bw.createDOM(taco);
3748
- } catch(e) {
3749
- return {
3750
- object_type: 'error',
3751
- component_id: componentId,
3752
- object_handle_in_dom: null,
3753
- status_code: `error=render_failed:${e.message}`
3754
- };
3755
- }
3756
-
3757
- // Add component ID as class + lifecycle marker
3758
- domElement.classList.add(componentId);
3759
- domElement.classList.add(_BW_LC);
4974
+ var targetEl = _is(target, 'string') ? bw.$(target)[0] : target;
4975
+ if (!targetEl) return { el: null, ok: false, error: 'target not found' };
3760
4976
 
3761
- // Insert into DOM based on position
3762
- try {
3763
- switch(position) {
3764
- case 'replace':
3765
- targetEl.parentNode.replaceChild(domElement, targetEl);
4977
+ var el;
4978
+ switch (position) {
4979
+ case 'append':
4980
+ el = bw.append(targetEl, taco);
3766
4981
  break;
3767
4982
  case 'prepend':
3768
- targetEl.insertBefore(domElement, targetEl.firstChild);
4983
+ el = bw.append(targetEl, taco, { before: 0 });
3769
4984
  break;
3770
- case 'append':
3771
- targetEl.appendChild(domElement);
4985
+ case 'replace':
4986
+ el = bw.replace(targetEl, taco);
3772
4987
  break;
3773
4988
  case 'before':
3774
- targetEl.parentNode.insertBefore(domElement, targetEl);
4989
+ if (!targetEl.parentNode) return { el: null, ok: false, error: 'no parent for before' };
4990
+ el = bw.append(targetEl.parentNode, taco, { before: targetEl });
3775
4991
  break;
3776
4992
  case 'after':
3777
- targetEl.parentNode.insertBefore(domElement, targetEl.nextSibling);
4993
+ if (!targetEl.parentNode) return { el: null, ok: false, error: 'no parent for after' };
4994
+ el = bw.append(targetEl.parentNode, taco, { before: targetEl.nextSibling });
3778
4995
  break;
3779
4996
  default:
3780
- throw new Error(`Invalid position: ${position}`);
3781
- }
3782
- } catch(e) {
3783
- return {
3784
- object_type: 'error',
3785
- component_id: componentId,
3786
- object_handle_in_dom: null,
3787
- status_code: `error=insertion_failed:${e.message}`
3788
- };
3789
- }
3790
-
3791
- // Create component handle
3792
- const handle = {
3793
- object_type: taco.t || 'element',
3794
- component_id: componentId,
3795
- object_handle_in_dom: domElement,
3796
- status_code: 'success',
3797
-
3798
- // Reference to original TACO
3799
- _taco: { ...taco },
3800
- _state: { ...(taco.o?.state || {}) },
3801
- _mounted: true,
3802
-
3803
- // Get DOM element
3804
- get element() {
3805
- return this.object_handle_in_dom;
3806
- },
3807
-
3808
- // Get/set state
3809
- getState() {
3810
- return { ...this._state };
3811
- },
3812
-
3813
- setState(updates) {
3814
- this._state = { ...this._state, ...updates };
3815
- if (this._taco.o?.onStateChange) {
3816
- this._taco.o.onStateChange(this._state, updates);
3817
- }
3818
- return this;
3819
- },
3820
-
3821
- // Update component (re-render)
3822
- update() {
3823
- if (!this._mounted || !this.element) return this;
3824
-
3825
- const parent = this.element.parentNode;
3826
-
3827
- // Update TACO with current state
3828
- if (this._taco.o) {
3829
- this._taco.o.state = this._state;
3830
- }
3831
-
3832
- // Re-render
3833
- const newElement = bw.createDOM(this._taco);
3834
- newElement.classList.add(componentId);
3835
- newElement.classList.add(_BW_LC);
3836
-
3837
- // Replace in DOM
3838
- parent.replaceChild(newElement, this.element);
3839
- this.object_handle_in_dom = newElement;
3840
-
3841
- // Call update lifecycle
3842
- if (this._taco.o?.onUpdate) {
3843
- this._taco.o.onUpdate(newElement, this._state);
3844
- }
3845
-
3846
- return this;
3847
- },
3848
-
3849
- // Get/set properties
3850
- getProp(key) {
3851
- return this._taco.a?.[key];
3852
- },
3853
-
3854
- setProp(key, value) {
3855
- if (!this._taco.a) this._taco.a = {};
3856
- this._taco.a[key] = value;
3857
-
3858
- // Update DOM attribute
3859
- if (this.element) {
3860
- if (value === null || value === undefined) {
3861
- this.element.removeAttribute(key);
3862
- } else if (value === true) {
3863
- this.element.setAttribute(key, '');
3864
- } else {
3865
- this.element.setAttribute(key, String(value));
3866
- }
3867
- }
3868
-
3869
- return this;
3870
- },
3871
-
3872
- // Get/set content
3873
- getContent() {
3874
- return this._taco.c;
3875
- },
3876
-
3877
- setContent(content) {
3878
- this._taco.c = content;
3879
- if (this.element) {
3880
- if (_is(content, 'string')) {
3881
- this.element.textContent = content;
3882
- } else {
3883
- // Re-render for complex content
3884
- this.update();
3885
- }
3886
- }
3887
- return this;
3888
- },
3889
-
3890
- // Add/remove CSS classes
3891
- addClass(className) {
3892
- if (this.element) {
3893
- this.element.classList.add(className);
3894
- }
3895
- return this;
3896
- },
3897
-
3898
- removeClass(className) {
3899
- if (this.element) {
3900
- this.element.classList.remove(className);
3901
- }
3902
- return this;
3903
- },
3904
-
3905
- toggleClass(className) {
3906
- if (this.element) {
3907
- this.element.classList.toggle(className);
3908
- }
3909
- return this;
3910
- },
3911
-
3912
- hasClass(className) {
3913
- return this.element ? this.element.classList.contains(className) : false;
3914
- },
3915
-
3916
- // Show/hide
3917
- show() {
3918
- if (this.element) {
3919
- this.element.style.display = '';
3920
- }
3921
- return this;
3922
- },
3923
-
3924
- hide() {
3925
- if (this.element) {
3926
- this.element.style.display = 'none';
3927
- }
3928
- return this;
3929
- },
3930
-
3931
- // Event handling
3932
- on(event, handler) {
3933
- if (this.element) {
3934
- this.element.addEventListener(event, handler);
3935
- }
3936
- return this;
3937
- },
3938
-
3939
- off(event, handler) {
3940
- if (this.element) {
3941
- this.element.removeEventListener(event, handler);
3942
- }
3943
- return this;
3944
- },
3945
-
3946
- // Destroy component
3947
- destroy() {
3948
- if (!this._mounted) return this;
3949
-
3950
- // Call unmount lifecycle
3951
- if (this._taco.o?.unmount) {
3952
- this._taco.o.unmount(this.element);
3953
- }
3954
-
3955
- // Remove from DOM
3956
- if (this.element && this.element.parentNode) {
3957
- this.element.parentNode.removeChild(this.element);
3958
- }
3959
-
3960
- // Remove from registry
3961
- bw._componentRegistry.delete(componentId);
3962
-
3963
- // Clean up
3964
- this._mounted = false;
3965
- this.object_handle_in_dom = null;
3966
- this.status_code = 'destroyed';
3967
-
3968
- return this;
4997
+ return { el: null, ok: false, error: 'invalid position: ' + position };
3969
4998
  }
3970
- };
3971
-
3972
- // Store in registry
3973
- bw._componentRegistry.set(componentId, handle);
3974
-
3975
- // Call mounted lifecycle
3976
- if (taco.o?.mounted) {
3977
- taco.o.mounted(domElement, handle);
4999
+ return { el: el, ok: true, error: null };
5000
+ } catch (e) {
5001
+ return { el: null, ok: false, error: e.message };
3978
5002
  }
3979
-
3980
- return handle;
3981
- };
3982
-
3983
- /**
3984
- * Get a component handle by its ID from the component registry.
3985
- *
3986
- * @param {string} id - Component ID (from bw.render)
3987
- * @returns {Object|null} Component handle or null if not found
3988
- * @category DOM Generation
3989
- * @see bw.render
3990
- */
3991
- bw.getComponent = function(id) {
3992
- return bw._componentRegistry.get(id) || null;
3993
- };
3994
-
3995
- /**
3996
- * Get all registered component handles as a Map.
3997
- *
3998
- * @returns {Map} Map of componentId → component handle
3999
- * @category DOM Generation
4000
- * @see bw.getComponent
4001
- */
4002
- bw.getAllComponents = function() {
4003
- return new Map(bw._componentRegistry);
4004
5003
  };
4005
5004
 
4006
5005
  // =========================================================================
@@ -4027,18 +5026,16 @@ bw.make = components.make;
4027
5026
  // Component registry: bw.BCCL lists all available component types
4028
5027
  bw.BCCL = components.BCCL;
4029
5028
 
5029
+ // Register makeTable (defined in bitwrench.js) in the shared BCCL registry
5030
+ bw.BCCL.table = { make: bw.makeTable };
5031
+ bw.BCCL.tableFromArray = { make: bw.makeTableFromArray };
5032
+ bw.BCCL.dataTable = { make: bw.makeDataTable };
5033
+ bw.BCCL.barChart = { make: bw.makeBarChart };
5034
+
4030
5035
  // Variant class helper: bw.variantClass('primary') → 'bw_primary'
4031
5036
  bw.variantClass = components.variantClass;
4032
5037
 
4033
- // Create functions that return DOM elements (createCard, createTable, etc.)
4034
- Object.entries(components).forEach(([name, fn]) => {
4035
- if (name.startsWith('make')) {
4036
- const createName = 'create' + name.substring(4);
4037
- bw[createName] = function(props) {
4038
- return bw.createDOM(fn(props));
4039
- };
4040
- }
4041
- });
5038
+ // v2.1: codegen create* family removed (§12). Use bw.create(bw.makeX(props)) instead.
4042
5039
 
4043
5040
  /**
4044
5041
  * Query the BCCL component registry. Returns metadata about registered
@@ -4094,7 +5091,16 @@ bw.catalog = function(type) {
4094
5091
  // Export for different environments
4095
5092
  export default bw;
4096
5093
 
4097
- // Also attach to global in browsers
5094
+ // Also attach to global in browsers, with double-load guard
4098
5095
  if (bw._isBrowser && typeof window !== 'undefined') {
5096
+ /* c8 ignore start -- double-load guard: only triggers when bitwrench is loaded twice */
5097
+ if (window.__bitwrench) {
5098
+ console.warn(
5099
+ 'bitwrench: already loaded (v' + window.__bitwrench + '); ' +
5100
+ 'loading v' + bw.version + ' over it.'
5101
+ );
5102
+ }
5103
+ /* c8 ignore stop */
5104
+ window.__bitwrench = bw.version;
4099
5105
  window.bw = bw;
4100
5106
  }