bitwrench 2.0.17 → 2.0.18

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 (67) hide show
  1. package/README.md +127 -38
  2. package/dist/bitwrench-bccl.cjs.js +8 -8
  3. package/dist/bitwrench-bccl.cjs.min.js +3 -3
  4. package/dist/bitwrench-bccl.esm.js +8 -8
  5. package/dist/bitwrench-bccl.esm.min.js +3 -3
  6. package/dist/bitwrench-bccl.umd.js +8 -8
  7. package/dist/bitwrench-bccl.umd.min.js +2 -2
  8. package/dist/bitwrench-code-edit.cjs.js +1 -1
  9. package/dist/bitwrench-code-edit.cjs.min.js +1 -1
  10. package/dist/bitwrench-code-edit.es5.js +1 -1
  11. package/dist/bitwrench-code-edit.es5.min.js +1 -1
  12. package/dist/bitwrench-code-edit.esm.js +1 -1
  13. package/dist/bitwrench-code-edit.esm.min.js +1 -1
  14. package/dist/bitwrench-code-edit.umd.js +1 -1
  15. package/dist/bitwrench-code-edit.umd.min.js +1 -1
  16. package/dist/bitwrench-lean.cjs.js +941 -775
  17. package/dist/bitwrench-lean.cjs.min.js +20 -20
  18. package/dist/bitwrench-lean.es5.js +1012 -961
  19. package/dist/bitwrench-lean.es5.min.js +18 -18
  20. package/dist/bitwrench-lean.esm.js +941 -775
  21. package/dist/bitwrench-lean.esm.min.js +20 -20
  22. package/dist/bitwrench-lean.umd.js +941 -775
  23. package/dist/bitwrench-lean.umd.min.js +20 -20
  24. package/dist/bitwrench-util-css.cjs.js +236 -0
  25. package/dist/bitwrench-util-css.cjs.min.js +22 -0
  26. package/dist/bitwrench-util-css.es5.js +414 -0
  27. package/dist/bitwrench-util-css.es5.min.js +21 -0
  28. package/dist/bitwrench-util-css.esm.js +230 -0
  29. package/dist/bitwrench-util-css.esm.min.js +21 -0
  30. package/dist/bitwrench-util-css.umd.js +242 -0
  31. package/dist/bitwrench-util-css.umd.min.js +21 -0
  32. package/dist/bitwrench.cjs.js +948 -782
  33. package/dist/bitwrench.cjs.min.js +21 -21
  34. package/dist/bitwrench.css +456 -132
  35. package/dist/bitwrench.es5.js +1024 -970
  36. package/dist/bitwrench.es5.min.js +19 -19
  37. package/dist/bitwrench.esm.js +949 -783
  38. package/dist/bitwrench.esm.min.js +21 -21
  39. package/dist/bitwrench.min.css +1 -1
  40. package/dist/bitwrench.umd.js +948 -782
  41. package/dist/bitwrench.umd.min.js +21 -21
  42. package/dist/builds.json +178 -90
  43. package/dist/bwserve.cjs.js +514 -68
  44. package/dist/bwserve.esm.js +513 -69
  45. package/dist/sri.json +44 -36
  46. package/package.json +3 -2
  47. package/readme.html +136 -49
  48. package/src/bitwrench-bccl.js +7 -7
  49. package/src/bitwrench-color-utils.js +31 -9
  50. package/src/bitwrench-esm-entry.js +11 -0
  51. package/src/bitwrench-styles.js +439 -232
  52. package/src/bitwrench-util-css.js +229 -0
  53. package/src/bitwrench.js +483 -485
  54. package/src/bwserve/attach.js +57 -0
  55. package/src/bwserve/bwclient.js +141 -0
  56. package/src/bwserve/bwshell.js +102 -0
  57. package/src/bwserve/client.js +151 -1
  58. package/src/bwserve/index.js +127 -28
  59. package/src/cli/attach.js +555 -0
  60. package/src/cli/convert.js +2 -5
  61. package/src/cli/index.js +7 -0
  62. package/src/cli/inject.js +1 -1
  63. package/src/cli/serve.js +6 -2
  64. package/src/generate-css.js +11 -4
  65. package/src/vendor/html2canvas.min.js +20 -0
  66. package/src/version.js +3 -3
  67. package/src/bwserve/shell.js +0 -106
package/src/bitwrench.js CHANGED
@@ -8,11 +8,11 @@
8
8
  */
9
9
 
10
10
  import { VERSION_INFO } from './version.js';
11
- import { getStructuralStyles,
12
- generateThemedCSS, generateAlternateCSS, derivePalette as _derivePalette,
11
+ import { getStructuralStyles, getResetStyles,
12
+ generateThemedCSS, derivePalette as _derivePalette,
13
13
  DEFAULT_PALETTE_CONFIG, SPACING_PRESETS, RADIUS_PRESETS, THEME_PRESETS,
14
14
  TYPE_RATIO_PRESETS, ELEVATION_PRESETS, MOTION_PRESETS, generateTypeScale,
15
- resolveLayout } from './bitwrench-styles.js';
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,
@@ -364,7 +364,12 @@ bw._el = function(id) {
364
364
  el = document.querySelector('[data-bw_id="' + id + '"]');
365
365
  }
366
366
 
367
- // 5. Cache the result for next time
367
+ // 5. Try class-based lookup for bw_uuid_* tokens (UUID addressing)
368
+ if (!el && id.indexOf('bw_uuid_') === 0) {
369
+ el = document.querySelector('.' + id);
370
+ }
371
+
372
+ // 6. Cache the result for next time
368
373
  if (el) {
369
374
  bw._nodeMap[id] = el;
370
375
  }
@@ -417,6 +422,84 @@ bw._deregisterNode = function(el, bwId) {
417
422
  }
418
423
  };
419
424
 
425
+ // ===================================================================================
426
+ // bw.assignUUID() / bw.getUUID() — Explicit UUID addressing for TACO objects
427
+ // ===================================================================================
428
+
429
+ /**
430
+ * Regex to match a bw_uuid_* token in a class string.
431
+ * @private
432
+ */
433
+ var _UUID_RE = /\bbw_uuid_[a-z0-9_]+\b/;
434
+
435
+ /**
436
+ * Assign a UUID to a TACO object by appending a `bw_uuid_*` token to `taco.a.class`.
437
+ *
438
+ * Idempotent by default — calling twice returns the same UUID. Pass `forceNew=true`
439
+ * to replace an existing UUID (useful in loops where each TACO needs a unique ID).
440
+ *
441
+ * @param {Object} taco - A TACO object `{t, a, c, o}`
442
+ * @param {boolean} [forceNew=false] - If true, replaces any existing UUID with a new one
443
+ * @returns {string} The UUID string (e.g. 'bw_uuid_a1b2c3d4e5')
444
+ * @category Identifiers
445
+ * @example
446
+ * var card = bw.makeStatCard({ value: '0', label: 'Scans' });
447
+ * var uuid = bw.assignUUID(card); // 'bw_uuid_a1b2c3d4e5'
448
+ * var same = bw.assignUUID(card); // same UUID (idempotent)
449
+ * var diff = bw.assignUUID(card, true); // new UUID (forced)
450
+ */
451
+ bw.assignUUID = function(taco, forceNew) {
452
+ if (!taco || !_is(taco, 'object')) return null;
453
+
454
+ // Ensure taco.a exists
455
+ if (!taco.a) taco.a = {};
456
+ if (!_is(taco.a.class, 'string')) taco.a.class = taco.a.class ? String(taco.a.class) : '';
457
+
458
+ var existing = taco.a.class.match(_UUID_RE);
459
+
460
+ if (existing && !forceNew) {
461
+ return existing[0];
462
+ }
463
+
464
+ // Remove old UUID if forceNew
465
+ if (existing) {
466
+ taco.a.class = taco.a.class.replace(_UUID_RE, '').replace(/\s+/g, ' ').trim();
467
+ }
468
+
469
+ var uuid = bw.uuid('uuid');
470
+ taco.a.class = (taco.a.class ? taco.a.class + ' ' : '') + uuid;
471
+ return uuid;
472
+ };
473
+
474
+ /**
475
+ * Read the UUID from a TACO object or DOM element. Pure getter, no side effects.
476
+ *
477
+ * @param {Object|Element} tacoOrElement - A TACO object or DOM element
478
+ * @returns {string|null} The UUID string, or null if none assigned
479
+ * @category Identifiers
480
+ * @example
481
+ * bw.getUUID(card) // 'bw_uuid_a1b2c3d4e5' (from TACO)
482
+ * bw.getUUID(domEl) // 'bw_uuid_a1b2c3d4e5' (from DOM element)
483
+ * bw.getUUID({t:'div'}) // null (no UUID)
484
+ */
485
+ bw.getUUID = function(tacoOrElement) {
486
+ if (!tacoOrElement) return null;
487
+
488
+ var classStr;
489
+ // DOM element: check className
490
+ if (tacoOrElement.className !== undefined && tacoOrElement.tagName) {
491
+ classStr = tacoOrElement.className;
492
+ }
493
+ // TACO object: check a.class
494
+ else if (tacoOrElement.a && _is(tacoOrElement.a.class, 'string')) {
495
+ classStr = tacoOrElement.a.class;
496
+ }
497
+
498
+ if (!classStr) return null;
499
+ var match = classStr.match(_UUID_RE);
500
+ return match ? match[0] : null;
501
+ };
502
+
420
503
  /**
421
504
  * Escape HTML special characters to prevent XSS.
422
505
  *
@@ -466,6 +549,42 @@ bw.raw = function(str) {
466
549
  return { __bw_raw: true, v: String(str) };
467
550
  };
468
551
 
552
+ /**
553
+ * Hyperscript-style TACO constructor.
554
+ *
555
+ * A convenience helper that returns a canonical TACO object from positional
556
+ * arguments. The return value is a plain object — serializable, works with
557
+ * bwserve, and accepted everywhere TACO is accepted.
558
+ *
559
+ * @param {string} tag - HTML tag name (e.g. 'div', 'p', 'section')
560
+ * @param {Object|null} [attrs] - HTML attributes object. Pass null or omit to skip.
561
+ * @param {*} [content] - Content: string, number, TACO object, or array of children.
562
+ * @param {Object} [options] - TACO options (state, lifecycle hooks, render fn).
563
+ * @returns {Object} Plain TACO object {t, a?, c?, o?}
564
+ * @category Utilities
565
+ * @see bw.html
566
+ * @see bw.createDOM
567
+ * @see bw.DOM
568
+ * @example
569
+ * bw.h('div')
570
+ * // => { t: 'div' }
571
+ *
572
+ * bw.h('p', { class: 'bw_text_muted' }, 'Hello')
573
+ * // => { t: 'p', a: { class: 'bw_text_muted' }, c: 'Hello' }
574
+ *
575
+ * bw.h('ul', null, [
576
+ * bw.h('li', null, 'one'),
577
+ * bw.h('li', null, 'two')
578
+ * ])
579
+ * // => { t: 'ul', c: [{ t: 'li', c: 'one' }, { t: 'li', c: 'two' }] }
580
+ */
581
+ bw.h = function(tag, attrs, content, options) {
582
+ var taco = { t: String(tag) };
583
+ if (attrs !== null && attrs !== undefined) taco.a = attrs;
584
+ if (content !== undefined) taco.c = content;
585
+ if (options !== undefined) taco.o = options;
586
+ return taco;
587
+ };
469
588
 
470
589
  /**
471
590
  * Convert a TACO object (or array of TACOs) to an HTML string.
@@ -730,7 +849,7 @@ bw.htmlPage = function(opts) {
730
849
  ? (THEME_PRESETS[theme.toLowerCase()] || null)
731
850
  : theme;
732
851
  if (themeConfig) {
733
- var themeResult = bw.generateTheme('', Object.assign({}, themeConfig, { inject: false }));
852
+ var themeResult = bw.makeStyles(themeConfig);
734
853
  themeCSS = themeResult.css;
735
854
  }
736
855
  }
@@ -756,14 +875,14 @@ bw.htmlPage = function(opts) {
756
875
  // Combine all CSS
757
876
  var allCSS = (themeCSS ? themeCSS + '\n' : '') + css;
758
877
 
759
- // Body-end script: registry entries + optional loadDefaultStyles
878
+ // Body-end script: registry entries + optional loadStyles
760
879
  var bodyEndScript = '';
761
880
  var bodyEndParts = [];
762
881
  if (registryEntries) {
763
882
  bodyEndParts.push(registryEntries);
764
883
  }
765
884
  if (runtime === 'inline' || runtime === 'cdn') {
766
- bodyEndParts.push('if(typeof bw!=="undefined"){bw.loadDefaultStyles();}');
885
+ bodyEndParts.push('if(typeof bw!=="undefined"){bw.loadStyles();}');
767
886
  }
768
887
  if (bodyEndParts.length > 0) {
769
888
  bodyEndScript = '<script>\n' + bodyEndParts.join('\n') + '\n</script>';
@@ -937,6 +1056,14 @@ bw.createDOM = function(taco, options = {}) {
937
1056
  bw._registerNode(el, null);
938
1057
  }
939
1058
 
1059
+ // Register UUID class in node cache (bw_uuid_* tokens in class string)
1060
+ if (el.className) {
1061
+ var uuidMatch = el.className.match(_UUID_RE);
1062
+ if (uuidMatch) {
1063
+ bw._nodeMap[uuidMatch[0]] = el;
1064
+ }
1065
+ }
1066
+
940
1067
  // Handle lifecycle hooks and state
941
1068
  if (opts.mounted || opts.unmount || opts.render || opts.state) {
942
1069
  const id = attrs['data-bw_id'] || bw.uuid();
@@ -1309,6 +1436,16 @@ bw.renderComponent = function(taco, options = {}) {
1309
1436
  bw.cleanup = function(element) {
1310
1437
  if (!bw._isBrowser || !element) return;
1311
1438
 
1439
+ // Deregister UUID classes from node cache (element + descendants)
1440
+ // Covers elements that have UUID but no data-bw_id
1441
+ var selfUuidMatch = element.className && element.className.match(_UUID_RE);
1442
+ if (selfUuidMatch) delete bw._nodeMap[selfUuidMatch[0]];
1443
+ var uuidEls = element.querySelectorAll('[class*="bw_uuid_"]');
1444
+ uuidEls.forEach(function(uel) {
1445
+ var m = uel.className && uel.className.match(_UUID_RE);
1446
+ if (m) delete bw._nodeMap[m[0]];
1447
+ });
1448
+
1312
1449
  // Find all elements with data-bw_id
1313
1450
  const elements = element.querySelectorAll('[data-bw_id]');
1314
1451
 
@@ -1324,6 +1461,10 @@ bw.cleanup = function(element) {
1324
1461
  // Deregister from node cache
1325
1462
  bw._deregisterNode(el, id);
1326
1463
 
1464
+ // Deregister UUID class from node cache
1465
+ var uuidMatch = el.className && el.className.match(_UUID_RE);
1466
+ if (uuidMatch) delete bw._nodeMap[uuidMatch[0]];
1467
+
1327
1468
  // Clean up pub/sub subscriptions tied to this element
1328
1469
  if (el._bw_subs) {
1329
1470
  el._bw_subs.forEach(function(unsub) { unsub(); });
@@ -1348,6 +1489,10 @@ bw.cleanup = function(element) {
1348
1489
  // Deregister from node cache
1349
1490
  bw._deregisterNode(element, id);
1350
1491
 
1492
+ // Deregister UUID class from node cache
1493
+ var elemUuidMatch = element.className && element.className.match(_UUID_RE);
1494
+ if (elemUuidMatch) delete bw._nodeMap[elemUuidMatch[0]];
1495
+
1351
1496
  // Clean up pub/sub subscriptions tied to element itself
1352
1497
  if (element._bw_subs) {
1353
1498
  element._bw_subs.forEach(function(unsub) { unsub(); });
@@ -1959,7 +2104,7 @@ function ComponentHandle(taco) {
1959
2104
  willMount: o.willMount || null,
1960
2105
  mounted: o.mounted || null,
1961
2106
  willUpdate: o.willUpdate || null,
1962
- onUpdate: o.onUpdate || null,
2107
+ onUpdate: o.onUpdate || o.updated || null,
1963
2108
  unmount: o.unmount || null,
1964
2109
  willDestroy: o.willDestroy || null
1965
2110
  };
@@ -2898,7 +3043,7 @@ bw.component = function(taco) {
2898
3043
  * and calls the named method. This is the bitwrench equivalent of
2899
3044
  * Win32 SendMessage(hwnd, msg, wParam, lParam).
2900
3045
  *
2901
- * @param {string} target - Component UUID (data-bw_comp_id) or user tag (CSS class)
3046
+ * @param {string} target - Component UUID (bw_uuid_*), comp ID (data-bw_comp_id), or user tag (CSS class)
2902
3047
  * @param {string} action - Method name to call on the component
2903
3048
  * @param {*} data - Data to pass to the method
2904
3049
  * @returns {boolean} True if message was dispatched successfully
@@ -2915,9 +3060,14 @@ bw.component = function(taco) {
2915
3060
  * };
2916
3061
  */
2917
3062
  bw.message = function(target, action, data) {
2918
- // Try data-bw_comp_id attribute first, then CSS class (user tag)
2919
- var el = bw.$('[data-bw_comp_id="' + target + '"]')[0];
2920
- if (!el) {
3063
+ // Try bw._el() first (handles UUID class, nodeMap cache, getElementById)
3064
+ var el = bw._el(target);
3065
+ // Then try data-bw_comp_id attribute
3066
+ if (!el || !el._bwComponentHandle) {
3067
+ el = bw.$('[data-bw_comp_id="' + target + '"]')[0];
3068
+ }
3069
+ // Then try CSS class (user tag)
3070
+ if (!el || !el._bwComponentHandle) {
2921
3071
  el = bw.$('.' + target)[0];
2922
3072
  }
2923
3073
  if (!el || !el._bwComponentHandle) return false;
@@ -2931,59 +3081,24 @@ bw.message = function(target, action, data) {
2931
3081
  };
2932
3082
 
2933
3083
  // ===================================================================================
2934
- // bw.clientApply() / bw.clientConnect() — Server-driven UI protocol
3084
+ // bw.apply() / bw.parseJSONFlex() — Server-driven UI protocol
2935
3085
  // ===================================================================================
2936
3086
 
2937
3087
  /**
2938
3088
  * Registry of named functions sent via register messages.
2939
- * Populated by clientApply({ type: 'register', name, body }).
2940
- * Invoked by clientApply({ type: 'call', name, args }).
3089
+ * Populated by bw.apply({ type: 'register', name, body }).
3090
+ * Invoked by bw.apply({ type: 'call', name, args }).
2941
3091
  * @private
2942
3092
  */
2943
3093
  bw._clientFunctions = {};
2944
3094
 
2945
3095
  /**
2946
- * Whether exec messages are allowed. Set by clientConnect opts.allowExec.
3096
+ * Whether exec messages are allowed. Set by bwclient connect opts.allowExec.
2947
3097
  * Default false — exec messages are rejected unless explicitly opted in.
2948
3098
  * @private
2949
3099
  */
2950
3100
  bw._allowExec = false;
2951
3101
 
2952
- /**
2953
- * Built-in client functions available via call() without registration.
2954
- * @private
2955
- */
2956
- bw._builtinClientFunctions = {
2957
- scrollTo: function(selector) {
2958
- var el = bw._el(selector);
2959
- if (el) el.scrollTop = el.scrollHeight;
2960
- },
2961
- focus: function(selector) {
2962
- var el = bw._el(selector);
2963
- if (el && _is(el.focus, 'function')) el.focus();
2964
- },
2965
- download: function(filename, content, mimeType) {
2966
- if (typeof document === 'undefined') return;
2967
- var blob = new Blob([content], { type: mimeType || 'text/plain' });
2968
- var a = document.createElement('a');
2969
- a.href = URL.createObjectURL(blob);
2970
- a.download = filename;
2971
- a.click();
2972
- URL.revokeObjectURL(a.href);
2973
- },
2974
- clipboard: function(text) {
2975
- if (typeof navigator !== 'undefined' && navigator.clipboard) {
2976
- navigator.clipboard.writeText(text);
2977
- }
2978
- },
2979
- redirect: function(url) {
2980
- if (typeof window !== 'undefined') window.location.href = url;
2981
- },
2982
- log: function() {
2983
- console.log.apply(console, arguments);
2984
- }
2985
- };
2986
-
2987
3102
  /**
2988
3103
  * Parse a bwserve protocol message string, supporting both strict JSON
2989
3104
  * and r-prefixed relaxed JSON (single-quoted strings, trailing commas).
@@ -2998,9 +3113,9 @@ bw._builtinClientFunctions = {
2998
3113
  * @param {string} str - JSON or r-prefixed relaxed JSON string
2999
3114
  * @returns {Object} Parsed message object
3000
3115
  * @throws {SyntaxError} If the string is not valid JSON or relaxed JSON
3001
- * @category Server
3116
+ * @category Core
3002
3117
  */
3003
- bw.clientParse = function(str) {
3118
+ bw.parseJSONFlex = function(str) {
3004
3119
  str = (str || '').trim();
3005
3120
  if (str.charAt(0) !== 'r') return JSON.parse(str);
3006
3121
  str = str.slice(1);
@@ -3085,10 +3200,10 @@ bw.clientParse = function(str) {
3085
3200
  * append — target.appendChild(bw.createDOM(node))
3086
3201
  * remove — bw.cleanup(target); target.remove()
3087
3202
  * patch — bw.patch(target, content, attr)
3088
- * batch — iterate ops, call clientApply for each
3203
+ * batch — iterate ops, call bw.apply for each
3089
3204
  * message — bw.message(target, action, data)
3090
3205
  * register — store a named function for later call()
3091
- * call — invoke a registered or built-in function
3206
+ * call — invoke a registered function
3092
3207
  * exec — execute arbitrary JS (requires allowExec)
3093
3208
  *
3094
3209
  * Target resolution:
@@ -3097,9 +3212,9 @@ bw.clientParse = function(str) {
3097
3212
  *
3098
3213
  * @param {Object} msg - Protocol message
3099
3214
  * @returns {boolean} true if the message was applied successfully
3100
- * @category Server
3215
+ * @category Core
3101
3216
  */
3102
- bw.clientApply = function(msg) {
3217
+ bw.apply = function(msg) {
3103
3218
  if (!msg || !msg.type) return false;
3104
3219
 
3105
3220
  var type = msg.type;
@@ -3133,7 +3248,7 @@ bw.clientApply = function(msg) {
3133
3248
  if (!_isA(msg.ops)) return false;
3134
3249
  var allOk = true;
3135
3250
  msg.ops.forEach(function(op) {
3136
- if (!bw.clientApply(op)) allOk = false;
3251
+ if (!bw.apply(op)) allOk = false;
3137
3252
  });
3138
3253
  return allOk;
3139
3254
 
@@ -3152,7 +3267,7 @@ bw.clientApply = function(msg) {
3152
3267
 
3153
3268
  } else if (type === 'call') {
3154
3269
  if (!msg.name) return false;
3155
- var fn = bw._clientFunctions[msg.name] || bw._builtinClientFunctions[msg.name];
3270
+ var fn = bw._clientFunctions[msg.name];
3156
3271
  if (!_is(fn, 'function')) return false;
3157
3272
  try {
3158
3273
  var args = _isA(msg.args) ? msg.args : [];
@@ -3181,139 +3296,6 @@ bw.clientApply = function(msg) {
3181
3296
  return false;
3182
3297
  };
3183
3298
 
3184
- /**
3185
- * Connect to a bwserve SSE endpoint and apply protocol messages automatically.
3186
- *
3187
- * Returns a connection object with sendAction(), on(), and close() methods.
3188
- *
3189
- * @param {string} url - SSE endpoint URL (e.g., '/__bw/events/client-1')
3190
- * @param {Object} [opts] - Connection options
3191
- * @param {string} [opts.transport='sse'] - Transport type: 'sse' (default) or 'poll'
3192
- * @param {number} [opts.interval=2000] - Poll interval in ms (only for 'poll' transport)
3193
- * @param {string} [opts.actionUrl] - POST endpoint for actions (default: derived from url)
3194
- * @param {boolean} [opts.reconnect=true] - Auto-reconnect on disconnect
3195
- * @param {boolean} [opts.allowExec=false] - Enable exec message type (arbitrary JS execution)
3196
- * @param {Function} [opts.onStatus] - Status callback: 'connecting'|'connected'|'disconnected'
3197
- * @param {Function} [opts.onMessage] - Raw message callback (before clientApply)
3198
- * @returns {Object} Connection object { sendAction, on, close, status }
3199
- * @category Server
3200
- */
3201
- bw.clientConnect = function(url, opts) {
3202
- opts = opts || {};
3203
- var transport = opts.transport || 'sse';
3204
- var actionUrl = opts.actionUrl || url.replace(/\/events\//, '/action/');
3205
- var reconnect = opts.reconnect !== false;
3206
- var onStatus = opts.onStatus || function() {};
3207
- var onMessage = opts.onMessage || null;
3208
- var handlers = {};
3209
- // Set the global allowExec flag from connection options
3210
- bw._allowExec = !!opts.allowExec;
3211
- var conn = {
3212
- status: 'connecting',
3213
- _es: null,
3214
- _pollTimer: null
3215
- };
3216
-
3217
- function setStatus(s) {
3218
- conn.status = s;
3219
- onStatus(s);
3220
- }
3221
-
3222
- function handleMessage(data) {
3223
- try {
3224
- var msg = _is(data, 'string') ? bw.clientParse(data) : data;
3225
- if (onMessage) onMessage(msg);
3226
- if (handlers.message) handlers.message(msg);
3227
- bw.clientApply(msg);
3228
- } catch (e) {
3229
- if (handlers.error) handlers.error(e);
3230
- }
3231
- }
3232
-
3233
- if (transport === 'sse' && typeof EventSource !== 'undefined') {
3234
- setStatus('connecting');
3235
- var es = new EventSource(url);
3236
- conn._es = es;
3237
-
3238
- es.onopen = function() {
3239
- setStatus('connected');
3240
- if (handlers.open) handlers.open();
3241
- };
3242
-
3243
- es.onmessage = function(e) {
3244
- handleMessage(e.data);
3245
- };
3246
-
3247
- es.onerror = function() {
3248
- if (conn.status === 'connected') {
3249
- setStatus('disconnected');
3250
- }
3251
- if (handlers.error) handlers.error(new Error('SSE connection error'));
3252
- if (!reconnect) {
3253
- es.close();
3254
- }
3255
- // EventSource auto-reconnects by default when reconnect=true
3256
- };
3257
- } else if (transport === 'poll') {
3258
- var interval = opts.interval || 2000;
3259
- setStatus('connected');
3260
- conn._pollTimer = setInterval(function() {
3261
- fetch(url).then(function(r) { return r.json(); }).then(function(msgs) {
3262
- if (_isA(msgs)) {
3263
- msgs.forEach(handleMessage);
3264
- } else if (msgs && msgs.type) {
3265
- handleMessage(msgs);
3266
- }
3267
- }).catch(function(e) {
3268
- if (handlers.error) handlers.error(e);
3269
- });
3270
- }, interval);
3271
- }
3272
-
3273
- /**
3274
- * Send an action to the server via POST.
3275
- * @param {string} action - Action name
3276
- * @param {Object} [data] - Action payload
3277
- */
3278
- conn.sendAction = function(action, data) {
3279
- var body = JSON.stringify({ type: 'action', action: action, data: data || {} });
3280
- fetch(actionUrl, {
3281
- method: 'POST',
3282
- headers: { 'Content-Type': 'application/json' },
3283
- body: body
3284
- }).catch(function(e) {
3285
- if (handlers.error) handlers.error(e);
3286
- });
3287
- };
3288
-
3289
- /**
3290
- * Register an event handler.
3291
- * @param {string} event - 'open'|'message'|'error'|'close'
3292
- * @param {Function} handler
3293
- */
3294
- conn.on = function(event, handler) {
3295
- handlers[event] = handler;
3296
- return conn;
3297
- };
3298
-
3299
- /**
3300
- * Close the connection.
3301
- */
3302
- conn.close = function() {
3303
- if (conn._es) {
3304
- conn._es.close();
3305
- conn._es = null;
3306
- }
3307
- if (conn._pollTimer) {
3308
- clearInterval(conn._pollTimer);
3309
- conn._pollTimer = null;
3310
- }
3311
- setStatus('disconnected');
3312
- if (handlers.close) handlers.close();
3313
- };
3314
-
3315
- return conn;
3316
- };
3317
3299
 
3318
3300
  // ===================================================================================
3319
3301
  // bw.inspect() — Debug utility
@@ -3522,7 +3504,7 @@ bw.css = function(rules, options = {}) {
3522
3504
  * @returns {Element} The style element
3523
3505
  * @category CSS & Styling
3524
3506
  * @see bw.css
3525
- * @see bw.loadDefaultStyles
3507
+ * @see bw.loadStyles
3526
3508
  * @example
3527
3509
  * bw.injectCSS('.my-class { color: red; }');
3528
3510
  * bw.injectCSS({ '.card': { padding: '1rem' } }, { id: 'card-styles' });
@@ -3567,9 +3549,8 @@ bw.injectCSS = function(css, options = {}) {
3567
3549
  * @param {...Object} styles - Style objects to merge (left-to-right)
3568
3550
  * @returns {Object} Merged style object
3569
3551
  * @category CSS & Styling
3570
- * @see bw.u
3571
3552
  * @example
3572
- * var style = bw.s(bw.u.flex, bw.u.gap4, { color: 'red' });
3553
+ * var style = bw.s({ display: 'flex' }, { gap: '1rem' }, { color: 'red' });
3573
3554
  * // => { display: 'flex', gap: '1rem', color: 'red' }
3574
3555
  */
3575
3556
  bw.s = function() {
@@ -3581,99 +3562,6 @@ bw.s = function() {
3581
3562
  return result;
3582
3563
  };
3583
3564
 
3584
- /**
3585
- * Pre-built CSS utility objects (like Tailwind utilities, but in JS).
3586
- *
3587
- * Compose with `bw.s()` to build inline styles without writing raw CSS strings.
3588
- * Includes flex, padding, margin, typography, color, border, and transition utilities.
3589
- *
3590
- * @category CSS & Styling
3591
- * @see bw.s
3592
- * @example
3593
- * { t: 'div', a: { style: bw.s(bw.u.flex, bw.u.gap4, bw.u.p4) },
3594
- * c: 'Flexbox with 1rem gap and padding' }
3595
- */
3596
- bw.u = {
3597
- // Display
3598
- flex: { display: 'flex' },
3599
- flexCol: { display: 'flex', flexDirection: 'column' },
3600
- flexRow: { display: 'flex', flexDirection: 'row' },
3601
- flexWrap: { display: 'flex', flexWrap: 'wrap' },
3602
- block: { display: 'block' },
3603
- inline: { display: 'inline' },
3604
- hidden: { display: 'none' },
3605
-
3606
- // Flex alignment
3607
- justifyCenter: { justifyContent: 'center' },
3608
- justifyBetween: { justifyContent: 'space-between' },
3609
- justifyEnd: { justifyContent: 'flex-end' },
3610
- alignCenter: { alignItems: 'center' },
3611
- alignStart: { alignItems: 'flex-start' },
3612
- alignEnd: { alignItems: 'flex-end' },
3613
-
3614
- // Gap (0.25rem increments)
3615
- gap1: { gap: '0.25rem' },
3616
- gap2: { gap: '0.5rem' },
3617
- gap3: { gap: '0.75rem' },
3618
- gap4: { gap: '1rem' },
3619
- gap6: { gap: '1.5rem' },
3620
- gap8: { gap: '2rem' },
3621
-
3622
- // Padding
3623
- p0: { padding: '0' },
3624
- p1: { padding: '0.25rem' },
3625
- p2: { padding: '0.5rem' },
3626
- p3: { padding: '0.75rem' },
3627
- p4: { padding: '1rem' },
3628
- p6: { padding: '1.5rem' },
3629
- p8: { padding: '2rem' },
3630
- px4: { paddingLeft: '1rem', paddingRight: '1rem' },
3631
- py2: { paddingTop: '0.5rem', paddingBottom: '0.5rem' },
3632
- py4: { paddingTop: '1rem', paddingBottom: '1rem' },
3633
-
3634
- // Margin (same scale)
3635
- m0: { margin: '0' },
3636
- m4: { margin: '1rem' },
3637
- mt2: { marginTop: '0.5rem' },
3638
- mt4: { marginTop: '1rem' },
3639
- mb2: { marginBottom: '0.5rem' },
3640
- mb4: { marginBottom: '1rem' },
3641
- mx_auto: { marginLeft: 'auto', marginRight: 'auto' },
3642
-
3643
- // Typography
3644
- textSm: { fontSize: '0.875rem' },
3645
- textBase: { fontSize: '1rem' },
3646
- textLg: { fontSize: '1.125rem' },
3647
- textXl: { fontSize: '1.25rem' },
3648
- text2xl: { fontSize: '1.5rem' },
3649
- text3xl: { fontSize: '1.875rem' },
3650
- bold: { fontWeight: '700' },
3651
- semibold: { fontWeight: '600' },
3652
- italic: { fontStyle: 'italic' },
3653
- textCenter: { textAlign: 'center' },
3654
- textRight: { textAlign: 'right' },
3655
-
3656
- // Colors (from design tokens)
3657
- bgWhite: { background: '#ffffff' },
3658
- bgTeal: { background: '#006666', color: '#ffffff' },
3659
- textWhite: { color: '#ffffff' },
3660
- textTeal: { color: '#006666' },
3661
- textMuted: { color: '#888' },
3662
-
3663
- // Borders
3664
- rounded: { borderRadius: '0.375rem' },
3665
- roundedLg: { borderRadius: '0.5rem' },
3666
- roundedFull: { borderRadius: '9999px' },
3667
- border: { border: '1px solid #d8d8d8' },
3668
-
3669
- // Sizing
3670
- wFull: { width: '100%' },
3671
- hFull: { height: '100%' },
3672
-
3673
- // Transitions
3674
- transition: { transition: 'all 0.2s ease' }
3675
- };
3676
-
3677
3565
  /**
3678
3566
  * Generate responsive CSS with media query breakpoints.
3679
3567
  *
@@ -3795,103 +3683,49 @@ if (bw._isBrowser) {
3795
3683
  };
3796
3684
  }
3797
3685
 
3798
- /**
3799
- * Load the built-in Bootstrap-inspired default stylesheet.
3800
- *
3801
- * Injects bitwrench's batteries-included CSS (buttons, cards, grids, forms,
3802
- * alerts, badges, nav, tabs, etc.) into the document head. Call once at app startup.
3803
- * Returns null in Node.js (no DOM).
3804
- *
3805
- * @param {Object} [options] - Style loading options
3806
- * @param {boolean} [options.minify=true] - Minify the CSS output
3807
- * @returns {Element|null} Style element if in browser, null in Node.js
3808
- * @category CSS & Styling
3809
- * @see bw.setTheme
3810
- * @see bw.applyTheme
3811
- * @see bw.toggleTheme
3812
- * @example
3813
- * bw.loadDefaultStyles(); // inject all default CSS
3814
- */
3815
- bw.loadDefaultStyles = function(options = {}) {
3816
- const { minify = true, palette } = options;
3817
3686
 
3818
- // 1. Inject structural CSS (layout, sizing — never changes with theme)
3819
- if (bw._isBrowser) {
3820
- var structuralCSS = bw.css(getStructuralStyles());
3821
- bw.injectCSS(structuralCSS, { id: 'bw_structural', append: false, minify: minify });
3822
- }
3823
-
3824
- // 2. Inject cosmetic CSS via generateTheme (colors, shadows, radii)
3825
- var paletteConfig = Object.assign({}, DEFAULT_PALETTE_CONFIG, palette || {});
3826
- var result = bw.generateTheme('', Object.assign({}, paletteConfig, { inject: true }));
3827
- return result;
3828
- };
3687
+ // =========================================================================
3688
+ // v2.0.18 Clean Styles API — makeStyles / applyStyles / loadStyles / etc.
3689
+ // =========================================================================
3829
3690
 
3691
+ /**
3692
+ * Convert a scope selector to a <style> element id.
3693
+ * @private
3694
+ * @param {string} [scope] - Scope selector (e.g. '#my-dashboard', '.preview')
3695
+ * @returns {string} Style element id (e.g. 'bw_style_my_dashboard')
3696
+ */
3697
+ function _scopeToStyleId(scope) {
3698
+ if (!scope || scope === '' || scope === 'global') return 'bw_style_global';
3699
+ if (scope === 'reset') return 'bw_style_reset';
3700
+ // Strip leading # or . and convert - to _
3701
+ var clean = scope.replace(/^[#.]/, '').replace(/-/g, '_');
3702
+ return 'bw_style_' + clean;
3703
+ }
3830
3704
 
3831
3705
  /**
3832
- * Generate a complete, scoped theme from seed colors.
3706
+ * Generate a complete styles object from seed colors and layout config.
3707
+ * Pure function — no DOM, no state, no side effects.
3833
3708
  *
3834
- * Produces CSS for all themed components (buttons, alerts, badges, cards,
3835
- * forms, nav, tables, tabs, list groups, pagination, progress, hero, utilities)
3836
- * scoped under `.name` class. Multiple themes can coexist in the stylesheet.
3837
- * Swap themes by changing the class on a container element.
3709
+ * All parameters are optional. Defaults to the bitwrench default palette.
3838
3710
  *
3839
- * @param {string} name - CSS scope class (e.g. 'ocean'). Empty string = unscoped global.
3840
- * @param {Object} config - Theme configuration
3841
- * @param {string} config.primary - Primary brand color hex
3842
- * @param {string} config.secondary - Secondary color hex
3843
- * @param {string} [config.tertiary] - Tertiary/accent color hex (defaults to primary)
3844
- * @param {string} [config.success='#198754'] - Success color hex
3845
- * @param {string} [config.danger='#dc3545'] - Danger color hex
3846
- * @param {string} [config.warning='#ffc107'] - Warning color hex
3847
- * @param {string} [config.info='#0dcaf0'] - Info color hex
3848
- * @param {string} [config.light='#f8f9fa'] - Light color hex
3849
- * @param {string} [config.dark='#212529'] - Dark color hex
3850
- * @param {string} [config.background] - Page background hex (default: '#ffffff' light, derived dark)
3851
- * @param {string} [config.surface] - Surface/card background hex (default: '#f8f9fa' light, derived dark)
3711
+ * @param {Object} [config] - Style configuration
3712
+ * @param {string} [config.primary='#006666'] - Primary brand color hex
3713
+ * @param {string} [config.secondary='#6c757d'] - Secondary color hex
3714
+ * @param {string} [config.tertiary] - Tertiary color hex (defaults to primary)
3852
3715
  * @param {string} [config.spacing='normal'] - 'compact' | 'normal' | 'spacious'
3853
3716
  * @param {string} [config.radius='md'] - 'none' | 'sm' | 'md' | 'lg' | 'pill'
3854
- * @param {number} [config.fontSize=1.0] - Base font size scale factor
3855
- * @param {string|number} [config.typeRatio='normal'] - 'tight' | 'normal' | 'relaxed' | 'dramatic' or a number
3856
- * @param {string} [config.elevation='md'] - 'flat' | 'sm' | 'md' | 'lg'
3857
- * @param {string} [config.motion='standard'] - 'reduced' | 'standard' | 'expressive'
3858
- * @param {number} [config.harmonize=0.20] - 0-1, semantic color hue shift toward primary
3859
- * @param {boolean} [config.inject=true] - Inject into DOM (browser only)
3860
- * @returns {Object} { css, palette, name, isLightPrimary, alternate: { css, palette } }
3717
+ * @returns {Object} { css, alternateCss, rules, alternateRules, palette, alternatePalette, isLightPrimary }
3861
3718
  * @category CSS & Styling
3862
- * @see bw.applyTheme
3863
- * @see bw.toggleTheme
3864
- * @see bw.loadDefaultStyles
3719
+ * @see bw.applyStyles
3720
+ * @see bw.loadStyles
3865
3721
  * @example
3866
- * // Generate and inject an ocean theme (primary + alternate)
3867
- * var theme = bw.generateTheme('ocean', {
3868
- * primary: '#0077b6',
3869
- * secondary: '#90e0ef',
3870
- * tertiary: '#00b4d8'
3871
- * });
3872
- *
3873
- * // Apply to a container
3874
- * document.getElementById('app').classList.add('ocean');
3875
- *
3876
- * // Toggle to alternate palette
3877
- * bw.toggleTheme();
3878
- *
3879
- * // Generate CSS for static export (Node.js)
3880
- * var result = bw.generateTheme('sunset', {
3881
- * primary: '#e76f51',
3882
- * secondary: '#264653',
3883
- * inject: false
3884
- * });
3885
- * fs.writeFileSync('sunset.css', result.css + result.alternate.css);
3722
+ * var styles = bw.makeStyles({ primary: '#4f46e5', secondary: '#d97706' });
3723
+ * console.log(styles.palette.primary.base); // '#4f46e5'
3724
+ * // styles.css contains all themed CSS — nothing injected
3886
3725
  */
3887
- bw.generateTheme = function(name, config) {
3888
- if (!config || !config.primary || !config.secondary) {
3889
- throw new Error('bw.generateTheme requires config.primary and config.secondary');
3890
- }
3891
-
3892
- // Merge with defaults; if user didn't supply tertiary, default to their primary
3893
- var fullConfig = Object.assign({}, DEFAULT_PALETTE_CONFIG, config);
3894
- if (!config.tertiary) fullConfig.tertiary = fullConfig.primary;
3726
+ bw.makeStyles = function(config) {
3727
+ var fullConfig = Object.assign({}, DEFAULT_PALETTE_CONFIG, config || {});
3728
+ if (config && !config.tertiary) fullConfig.tertiary = fullConfig.primary;
3895
3729
 
3896
3730
  // Derive primary palette
3897
3731
  var palette = derivePalette(fullConfig);
@@ -3899,131 +3733,207 @@ bw.generateTheme = function(name, config) {
3899
3733
  // Resolve layout
3900
3734
  var layout = resolveLayout(fullConfig);
3901
3735
 
3902
- // Generate primary themed CSS rules
3903
- var themedRules = generateThemedCSS(name, palette, layout);
3736
+ // Generate primary themed CSS rules (unscoped)
3737
+ var themedRules = generateThemedCSS('', palette, layout);
3904
3738
  var cssStr = bw.css(themedRules);
3905
3739
 
3906
3740
  // Derive alternate palette (luminance-inverted)
3907
3741
  var altConfig = deriveAlternateConfig(fullConfig);
3908
3742
  var altPalette = derivePalette(altConfig);
3909
3743
 
3910
- // Generate alternate CSS scoped under .bw_theme_alt
3911
- var altRules = generateAlternateCSS(name, altPalette, layout);
3912
- var altCssStr = bw.css(altRules);
3744
+ // Generate alternate CSS rules WITHOUT .bw_theme_alt prefix (raw rules)
3745
+ // applyStyles() wraps them appropriately based on scope
3746
+ var altRawRules = generateThemedCSS('', altPalette, layout);
3747
+
3748
+ // Add body-level surface overrides for the alternate palette.
3749
+ // When .bw_theme_alt is on <html>, ".bw_theme_alt body" correctly matches.
3750
+ altRawRules['body'] = {
3751
+ 'color': altPalette.dark.base,
3752
+ 'background-color': altPalette.surface || altPalette.light.base
3753
+ };
3754
+
3755
+ var altCssStr = bw.css(altRawRules);
3913
3756
 
3914
3757
  // Determine if primary is light-flavored
3915
3758
  var lightPrimary = isLightPalette(fullConfig);
3916
3759
 
3917
- // Inject both CSS sets into DOM if requested
3918
- var shouldInject = config.inject !== false;
3919
- if (shouldInject && bw._isBrowser) {
3920
- var safeName = name ? name.replace(/-/g, '_') : '';
3921
- var styleId = safeName ? 'bw_theme_' + safeName : 'bw_theme_default';
3922
- var altStyleId = safeName ? 'bw_theme_' + safeName + '_alt' : 'bw_theme_default_alt';
3923
-
3924
- bw.injectCSS(cssStr, { id: styleId, append: false });
3925
- bw.injectCSS(altCssStr, { id: altStyleId, append: false });
3760
+ return {
3761
+ css: cssStr,
3762
+ alternateCss: altCssStr,
3763
+ rules: themedRules,
3764
+ alternateRules: altRawRules,
3765
+ palette: palette,
3766
+ alternatePalette: altPalette,
3767
+ isLightPrimary: lightPrimary
3768
+ };
3769
+ };
3926
3770
 
3927
- bw._activeThemeStyleIds = [styleId, altStyleId];
3771
+ /**
3772
+ * Inject styles into the DOM with optional scoping.
3773
+ *
3774
+ * Takes a styles object from `makeStyles()` and creates a single `<style>`
3775
+ * element in `<head>`. If a scope selector is provided, all CSS rules are
3776
+ * wrapped under that selector. Alternate CSS is wrapped under `.bw_theme_alt`.
3777
+ *
3778
+ * @param {Object} styles - Result of `bw.makeStyles()`
3779
+ * @param {string} [scope] - Scope selector (e.g. '#my-dashboard', '.preview'). Omit for global.
3780
+ * @returns {Element|null} The `<style>` element, or null in Node.js
3781
+ * @category CSS & Styling
3782
+ * @see bw.makeStyles
3783
+ * @see bw.loadStyles
3784
+ * @see bw.clearStyles
3785
+ * @example
3786
+ * var styles = bw.makeStyles({ primary: '#4f46e5' });
3787
+ * bw.applyStyles(styles); // global
3788
+ * bw.applyStyles(styles, '#my-dashboard'); // scoped
3789
+ */
3790
+ bw.applyStyles = function(styles, scope) {
3791
+ if (!bw._isBrowser) return null;
3792
+ if (!styles || !styles.rules) {
3793
+ _cw('bw.applyStyles: invalid styles object');
3794
+ return null;
3928
3795
  }
3929
3796
 
3930
- // Update bw.u color entries to reflect the palette
3931
- if (!name) {
3932
- bw.u.bgTeal = { background: palette.primary.base, color: palette.primary.textOn };
3933
- bw.u.textTeal = { color: palette.primary.base };
3934
- bw.u.bgWhite = { background: '#ffffff' };
3935
- bw.u.textWhite = { color: '#ffffff' };
3797
+ var styleId = _scopeToStyleId(scope);
3798
+
3799
+ // Scope the primary rules if a scope is provided
3800
+ var primaryRules = styles.rules;
3801
+ if (scope) {
3802
+ primaryRules = scopeRulesUnder(primaryRules, scope);
3936
3803
  }
3937
3804
 
3938
- // Store active theme state
3939
- var result = {
3940
- css: cssStr,
3941
- palette: palette,
3942
- name: name,
3943
- isLightPrimary: lightPrimary,
3944
- alternate: {
3945
- css: altCssStr,
3946
- palette: altPalette
3805
+ // Wrap alternate rules with .bw_theme_alt
3806
+ var altRules = styles.alternateRules;
3807
+ if (altRules) {
3808
+ if (scope) {
3809
+ // Scoped compound: #scope.bw_theme_alt .bw_card
3810
+ altRules = scopeRulesUnder(altRules, scope + '.bw_theme_alt');
3811
+ } else {
3812
+ // Global: .bw_theme_alt .bw_card
3813
+ altRules = scopeRulesUnder(altRules, '.bw_theme_alt');
3947
3814
  }
3948
- };
3949
- bw._activeTheme = result;
3950
- bw._activeThemeMode = 'primary';
3815
+ }
3951
3816
 
3952
- return result;
3817
+ // Combine primary + alternate into one CSS string
3818
+ var combined = bw.css(primaryRules);
3819
+ if (altRules) {
3820
+ combined += '\n' + bw.css(altRules);
3821
+ }
3822
+
3823
+ return bw.injectCSS(combined, { id: styleId, append: false });
3953
3824
  };
3954
3825
 
3955
3826
  /**
3956
- * Apply a theme mode. Switches between primary and alternate palettes
3957
- * by adding/removing the `bw_theme_alt` class on `<html>`.
3827
+ * Generate and apply styles in one call. Convenience wrapper.
3958
3828
  *
3959
- * @param {string} mode - 'primary' | 'alternate' | 'light' | 'dark'
3960
- * @returns {string} Active mode: 'primary' or 'alternate'
3829
+ * Equivalent to: `bw.applyStyles(bw.makeStyles(config), scope)`
3830
+ *
3831
+ * @param {Object} [config] - Style configuration (same as `makeStyles`)
3832
+ * @param {string} [scope] - Scope selector (same as `applyStyles`)
3833
+ * @returns {Element|null} The `<style>` element, or null in Node.js
3961
3834
  * @category CSS & Styling
3962
- * @see bw.generateTheme
3963
- * @see bw.toggleTheme
3835
+ * @see bw.makeStyles
3836
+ * @see bw.applyStyles
3964
3837
  * @example
3965
- * bw.applyTheme('alternate'); // switch to alternate palette
3966
- * bw.applyTheme('dark'); // switch to whichever palette is darker
3967
- * bw.applyTheme('primary'); // switch back to primary palette
3838
+ * bw.loadStyles(); // defaults, global
3839
+ * bw.loadStyles({ primary: '#4f46e5' }); // custom, global
3840
+ * bw.loadStyles({ primary: '#4f46e5' }, '#my-dashboard'); // custom, scoped
3968
3841
  */
3969
- bw.applyTheme = function(mode) {
3970
- if (!bw._isBrowser) return mode || 'primary';
3971
- var root = document.documentElement;
3972
- var isLight = bw._activeTheme ? bw._activeTheme.isLightPrimary : true;
3973
-
3974
- var wantAlt;
3975
- if (mode === 'primary') wantAlt = false;
3976
- else if (mode === 'alternate') wantAlt = true;
3977
- else if (mode === 'light') wantAlt = !isLight;
3978
- else if (mode === 'dark') wantAlt = isLight;
3979
- else wantAlt = false;
3980
-
3981
- if (wantAlt) {
3982
- root.classList.add('bw_theme_alt');
3983
- } else {
3984
- root.classList.remove('bw_theme_alt');
3842
+ bw.loadStyles = function(config, scope) {
3843
+ // Also inject structural CSS first (only once)
3844
+ if (bw._isBrowser) {
3845
+ var existing = document.getElementById('bw_structural');
3846
+ if (!existing) {
3847
+ var structuralCSS = bw.css(getStructuralStyles());
3848
+ bw.injectCSS(structuralCSS, { id: 'bw_structural', append: false });
3849
+ }
3985
3850
  }
3851
+ return bw.applyStyles(bw.makeStyles(config), scope);
3852
+ };
3986
3853
 
3987
- bw._activeThemeMode = wantAlt ? 'alternate' : 'primary';
3988
- return bw._activeThemeMode;
3854
+ /**
3855
+ * Inject the CSS reset (box-sizing, html/body font, reduced-motion).
3856
+ * Idempotent — if already injected, returns the existing `<style>` element.
3857
+ *
3858
+ * @returns {Element|null} The `<style>` element, or null in Node.js
3859
+ * @category CSS & Styling
3860
+ * @see bw.loadStyles
3861
+ * @see bw.clearStyles
3862
+ * @example
3863
+ * bw.loadReset(); // inject once, safe to call multiple times
3864
+ */
3865
+ bw.loadReset = function() {
3866
+ if (!bw._isBrowser) return null;
3867
+ var existing = document.getElementById('bw_style_reset');
3868
+ if (existing) return existing;
3869
+ return bw.injectCSS(bw.css(getResetStyles()), { id: 'bw_style_reset', append: false });
3989
3870
  };
3990
3871
 
3991
3872
  /**
3992
- * Toggle between primary and alternate theme palettes.
3873
+ * Toggle between primary and alternate palettes.
3993
3874
  *
3875
+ * Adds/removes the `bw_theme_alt` class on the scoping element.
3876
+ * Without a scope, toggles on `<html>` (global).
3877
+ * With a scope, toggles on the first matching element.
3878
+ *
3879
+ * @param {string} [scope] - Scope selector (e.g. '#my-dashboard'). Omit for global.
3994
3880
  * @returns {string} Active mode after toggle: 'primary' or 'alternate'
3995
3881
  * @category CSS & Styling
3996
- * @see bw.applyTheme
3997
- * @see bw.generateTheme
3882
+ * @see bw.applyStyles
3883
+ * @see bw.clearStyles
3998
3884
  * @example
3999
- * bw.toggleTheme(); // flip between primary and alternate
3885
+ * bw.toggleStyles(); // global toggle on <html>
3886
+ * bw.toggleStyles('#my-dashboard'); // scoped toggle
4000
3887
  */
4001
- bw.toggleTheme = function() {
4002
- var current = bw._activeThemeMode || 'primary';
4003
- return bw.applyTheme(current === 'primary' ? 'alternate' : 'primary');
3888
+ bw.toggleStyles = function(scope) {
3889
+ if (!bw._isBrowser) return 'primary';
3890
+ var target;
3891
+ if (scope) {
3892
+ var els = bw.$(scope);
3893
+ target = els[0];
3894
+ } else {
3895
+ target = document.documentElement;
3896
+ }
3897
+ if (!target) return 'primary';
3898
+
3899
+ var hasAlt = target.classList.contains('bw_theme_alt');
3900
+ if (hasAlt) {
3901
+ target.classList.remove('bw_theme_alt');
3902
+ return 'primary';
3903
+ } else {
3904
+ target.classList.add('bw_theme_alt');
3905
+ return 'alternate';
3906
+ }
4004
3907
  };
4005
3908
 
4006
3909
  /**
4007
- * Remove the currently active theme's injected style elements from the DOM.
4008
- * Use this before generating a new theme with a different name to prevent
4009
- * stale CSS accumulation.
3910
+ * Remove injected styles for a given scope.
4010
3911
  *
3912
+ * Finds the `<style>` element by id and removes it. Also removes
3913
+ * the `bw_theme_alt` class from the relevant element.
3914
+ *
3915
+ * @param {string} [scope] - Scope selector. Omit to remove global styles.
4011
3916
  * @category CSS & Styling
4012
- * @see bw.generateTheme
3917
+ * @see bw.applyStyles
3918
+ * @see bw.loadStyles
4013
3919
  * @example
4014
- * bw.clearTheme(); // remove current theme styles
4015
- * bw.generateTheme('sunset', conf); // inject fresh theme
3920
+ * bw.clearStyles(); // remove global styles
3921
+ * bw.clearStyles('#my-dashboard'); // remove scoped styles
3922
+ * bw.clearStyles('reset'); // remove the CSS reset
4016
3923
  */
4017
- bw.clearTheme = function() {
4018
- if (bw._activeThemeStyleIds && bw._isBrowser) {
4019
- bw._activeThemeStyleIds.forEach(function(id) {
4020
- var el = document.getElementById(id);
4021
- if (el) el.remove();
4022
- });
4023
- bw._activeThemeStyleIds = null;
3924
+ bw.clearStyles = function(scope) {
3925
+ if (!bw._isBrowser) return;
3926
+ var styleId = _scopeToStyleId(scope);
3927
+ var el = document.getElementById(styleId);
3928
+ if (el) el.remove();
3929
+
3930
+ // Also remove bw_theme_alt from the relevant element
3931
+ if (scope && scope !== 'reset' && scope !== 'global') {
3932
+ var targets = bw.$(scope);
3933
+ if (targets[0]) targets[0].classList.remove('bw_theme_alt');
3934
+ } else if (!scope || scope === 'global') {
3935
+ document.documentElement.classList.remove('bw_theme_alt');
4024
3936
  }
4025
- bw._activeTheme = null;
4026
- bw._activeThemeMode = 'primary';
4027
3937
  };
4028
3938
 
4029
3939
  // Expose color utility functions on bw namespace
@@ -4246,10 +4156,15 @@ bw.copyToClipboard = function(text) {
4246
4156
  * @param {Object} config - Table configuration
4247
4157
  * @param {Array<Object>} config.data - Array of row objects to display
4248
4158
  * @param {Array<Object>} [config.columns] - Column definitions with key, label, render
4249
- * @param {string} [config.className='table'] - CSS class for table element
4159
+ * @param {string} [config.className=''] - Additional CSS classes for table element
4250
4160
  * @param {boolean} [config.sortable=true] - Enable click-to-sort headers
4251
4161
  * @param {Function} [config.onSort] - Sort callback (column, direction)
4252
- * @returns {Object} TACO object for table
4162
+ * @param {boolean} [config.selectable=false] - Enable row selection on click
4163
+ * @param {Function} [config.onRowClick] - Row click callback (row, index, event)
4164
+ * @param {number} [config.pageSize] - Rows per page (enables pagination when set)
4165
+ * @param {number} [config.currentPage=1] - Current page number (1-based)
4166
+ * @param {Function} [config.onPageChange] - Page change callback (newPage)
4167
+ * @returns {Object} TACO object for table (with optional pagination controls)
4253
4168
  * @category Component Builders
4254
4169
  * @see bw.makeDataTable
4255
4170
  * @example
@@ -4261,7 +4176,12 @@ bw.copyToClipboard = function(text) {
4261
4176
  * columns: [
4262
4177
  * { key: 'name', label: 'Name' },
4263
4178
  * { key: 'age', label: 'Age' }
4264
- * ]
4179
+ * ],
4180
+ * selectable: true,
4181
+ * onRowClick: function(row, i) { console.log('clicked', row.name); },
4182
+ * pageSize: 10,
4183
+ * currentPage: 1,
4184
+ * onPageChange: function(page) { console.log('page', page); }
4265
4185
  * });
4266
4186
  */
4267
4187
  bw.makeTable = function(config) {
@@ -4274,41 +4194,47 @@ bw.makeTable = function(config) {
4274
4194
  sortable = true,
4275
4195
  onSort,
4276
4196
  sortColumn,
4277
- sortDirection = 'asc'
4197
+ sortDirection = 'asc',
4198
+ selectable = false,
4199
+ onRowClick,
4200
+ pageSize,
4201
+ currentPage = 1,
4202
+ onPageChange
4278
4203
  } = config;
4279
4204
 
4280
- // Build class list: always include bw_table, add striped/hover, append user className
4205
+ // Build class list: always include bw_table, add striped/hover/selectable, append user className
4281
4206
  let cls = 'bw_table';
4282
4207
  if (striped) cls += ' bw_table_striped';
4283
- if (hover) cls += ' bw_table_hover';
4208
+ if (hover || selectable) cls += ' bw_table_hover';
4209
+ if (selectable) cls += ' bw_table_selectable';
4284
4210
  if (className) cls += ' ' + className;
4285
4211
  cls = cls.trim();
4286
-
4212
+
4287
4213
  // Auto-detect columns if not provided
4288
- const cols = columns || (data.length > 0
4214
+ const cols = columns || (data.length > 0
4289
4215
  ? _keys(data[0]).map(key => ({ key, label: key }))
4290
4216
  : []);
4291
-
4217
+
4292
4218
  // Current sort state
4293
4219
  let currentSortColumn = sortColumn || null;
4294
4220
  let currentSortDirection = sortDirection;
4295
-
4221
+
4296
4222
  // Sort data if column specified
4297
4223
  let sortedData = [...data];
4298
4224
  if (currentSortColumn) {
4299
4225
  sortedData.sort((a, b) => {
4300
4226
  const aVal = a[currentSortColumn];
4301
4227
  const bVal = b[currentSortColumn];
4302
-
4228
+
4303
4229
  // Handle different types
4304
4230
  if (_is(aVal, 'number') && _is(bVal, 'number')) {
4305
4231
  return currentSortDirection === 'asc' ? aVal - bVal : bVal - aVal;
4306
4232
  }
4307
-
4233
+
4308
4234
  // String comparison
4309
4235
  const aStr = String(aVal || '').toLowerCase();
4310
4236
  const bStr = String(bVal || '').toLowerCase();
4311
-
4237
+
4312
4238
  if (currentSortDirection === 'asc') {
4313
4239
  return aStr.localeCompare(bStr);
4314
4240
  } else {
@@ -4316,23 +4242,32 @@ bw.makeTable = function(config) {
4316
4242
  }
4317
4243
  });
4318
4244
  }
4319
-
4245
+
4246
+ // Pagination
4247
+ const totalRows = sortedData.length;
4248
+ const totalPages = pageSize ? Math.max(1, Math.ceil(totalRows / pageSize)) : 1;
4249
+ const page = Math.max(1, Math.min(currentPage, totalPages));
4250
+ if (pageSize) {
4251
+ const start = (page - 1) * pageSize;
4252
+ sortedData = sortedData.slice(start, start + pageSize);
4253
+ }
4254
+
4320
4255
  // Create sort handler
4321
4256
  const handleSort = (column) => {
4322
4257
  if (!sortable) return;
4323
-
4258
+
4324
4259
  if (currentSortColumn === column) {
4325
4260
  currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc';
4326
4261
  } else {
4327
4262
  currentSortColumn = column;
4328
4263
  currentSortDirection = 'asc';
4329
4264
  }
4330
-
4265
+
4331
4266
  if (onSort) {
4332
4267
  onSort(column, currentSortDirection);
4333
4268
  }
4334
4269
  };
4335
-
4270
+
4336
4271
  // Build table header
4337
4272
  const thead = {
4338
4273
  t: 'thead',
@@ -4355,24 +4290,87 @@ bw.makeTable = function(config) {
4355
4290
  }))
4356
4291
  }
4357
4292
  };
4358
-
4359
- // Build table body
4293
+
4294
+ // Build table body with selectable/onRowClick support
4360
4295
  const tbody = {
4361
4296
  t: 'tbody',
4362
- c: sortedData.map(row => ({
4363
- t: 'tr',
4364
- c: cols.map(col => ({
4365
- t: 'td',
4366
- c: col.render ? col.render(row[col.key], row) : String(row[col.key] || '')
4367
- }))
4368
- }))
4297
+ c: sortedData.map((row, idx) => {
4298
+ const globalIdx = pageSize ? (page - 1) * pageSize + idx : idx;
4299
+ const rowAttrs = {};
4300
+ if (selectable || onRowClick) {
4301
+ rowAttrs.style = 'cursor:pointer;';
4302
+ rowAttrs.onclick = function(e) {
4303
+ if (selectable) {
4304
+ // Toggle selected class on this row
4305
+ var tr = e.currentTarget;
4306
+ tr.classList.toggle('bw_table_row_selected');
4307
+ }
4308
+ if (onRowClick) {
4309
+ onRowClick(row, globalIdx, e);
4310
+ }
4311
+ };
4312
+ }
4313
+ return {
4314
+ t: 'tr',
4315
+ a: rowAttrs,
4316
+ c: cols.map(col => ({
4317
+ t: 'td',
4318
+ c: col.render ? col.render(row[col.key], row) : String(row[col.key] || '')
4319
+ }))
4320
+ };
4321
+ })
4369
4322
  };
4370
-
4371
- return {
4323
+
4324
+ const table = {
4372
4325
  t: 'table',
4373
4326
  a: { class: cls },
4374
4327
  c: [thead, tbody]
4375
4328
  };
4329
+
4330
+ // If no pagination, return table directly
4331
+ if (!pageSize) return table;
4332
+
4333
+ // Build pagination controls
4334
+ const pageButtons = [];
4335
+ // Previous button
4336
+ pageButtons.push({
4337
+ t: 'button',
4338
+ a: {
4339
+ class: 'bw_btn bw_btn_sm',
4340
+ disabled: page <= 1 ? 'disabled' : undefined,
4341
+ onclick: page > 1 && onPageChange ? function() { onPageChange(page - 1); } : undefined
4342
+ },
4343
+ c: 'Prev'
4344
+ });
4345
+ // Page info
4346
+ pageButtons.push({
4347
+ t: 'span',
4348
+ a: { style: 'margin:0 0.5rem;font-size:0.875rem;' },
4349
+ c: 'Page ' + page + ' of ' + totalPages
4350
+ });
4351
+ // Next button
4352
+ pageButtons.push({
4353
+ t: 'button',
4354
+ a: {
4355
+ class: 'bw_btn bw_btn_sm',
4356
+ disabled: page >= totalPages ? 'disabled' : undefined,
4357
+ onclick: page < totalPages && onPageChange ? function() { onPageChange(page + 1); } : undefined
4358
+ },
4359
+ c: 'Next'
4360
+ });
4361
+
4362
+ return {
4363
+ t: 'div',
4364
+ a: { class: 'bw_table_paginated' },
4365
+ c: [
4366
+ table,
4367
+ {
4368
+ t: 'div',
4369
+ a: { class: 'bw_table_pagination', style: 'display:flex;align-items:center;justify-content:flex-end;padding:0.5rem 0;gap:0.25rem;' },
4370
+ c: pageButtons
4371
+ }
4372
+ ]
4373
+ };
4376
4374
  };
4377
4375
 
4378
4376
  /**