bitwrench 1.2.15 → 2.0.7

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 (119) hide show
  1. package/README.md +160 -158
  2. package/bin/bitwrench.js +3 -0
  3. package/dist/bitwrench-code-edit.cjs.js +639 -0
  4. package/dist/bitwrench-code-edit.es5.js +875 -0
  5. package/dist/bitwrench-code-edit.es5.min.js +15 -0
  6. package/dist/bitwrench-code-edit.esm.js +628 -0
  7. package/dist/bitwrench-code-edit.esm.min.js +15 -0
  8. package/dist/bitwrench-code-edit.umd.js +645 -0
  9. package/dist/bitwrench-code-edit.umd.min.js +15 -0
  10. package/dist/bitwrench.cjs.js +6983 -0
  11. package/dist/bitwrench.cjs.min.js +62 -0
  12. package/dist/bitwrench.css +5100 -0
  13. package/dist/bitwrench.es5.js +8446 -0
  14. package/dist/bitwrench.es5.min.js +31 -0
  15. package/dist/bitwrench.esm.js +6981 -0
  16. package/dist/bitwrench.esm.min.js +62 -0
  17. package/dist/bitwrench.umd.js +6989 -0
  18. package/dist/bitwrench.umd.min.js +62 -0
  19. package/dist/builds.json +127 -0
  20. package/dist/sri.json +18 -0
  21. package/package.json +86 -24
  22. package/readme.html +288 -0
  23. package/src/bitwrench-code-edit.js +627 -0
  24. package/src/bitwrench-color-utils.js +311 -0
  25. package/src/bitwrench-component-base.js +736 -0
  26. package/src/bitwrench-components-inline.js +374 -0
  27. package/src/bitwrench-components-v2.js +1879 -0
  28. package/src/bitwrench-components.js +610 -0
  29. package/src/bitwrench-styles.js +3240 -0
  30. package/src/bitwrench.js +3367 -0
  31. package/src/cli/convert.js +205 -0
  32. package/src/cli/index.js +122 -0
  33. package/src/cli/inject.js +55 -0
  34. package/src/cli/layout-default.js +142 -0
  35. package/src/generate-css.js +381 -0
  36. package/src/vendor/quikdown.js +654 -0
  37. package/src/version.js +16 -0
  38. package/.eslintrc.json +0 -27
  39. package/.github/workflows/codeql-analysis.yml +0 -72
  40. package/.travis.yml +0 -34
  41. package/bitwrench.css +0 -92
  42. package/bitwrench.js +0 -3348
  43. package/bitwrench.js_sri.txt +0 -1
  44. package/bitwrench.min.js +0 -1
  45. package/bitwrench.min.js_sri.txt +0 -1
  46. package/bitwrench_ESM.js +0 -3207
  47. package/dev/bitwrench-todo.md +0 -215
  48. package/dev/css-arrows.md +0 -23
  49. package/dev/docStringDev.js +0 -124
  50. package/dev/docStringParseDev.js +0 -171
  51. package/dev/figures.html +0 -37
  52. package/dev/html_gen.js +0 -349
  53. package/dev/htmld.md +0 -250
  54. package/dev/htmldev.html +0 -45
  55. package/dev/index-old.html +0 -87
  56. package/dev/misc-notes.md +0 -21
  57. package/dev/notes.md +0 -2
  58. package/dev/sizes.html +0 -49
  59. package/dev/universal-js-module.js +0 -37
  60. package/examples/example1.html +0 -78
  61. package/examples/example10.html +0 -84
  62. package/examples/example2.html +0 -44
  63. package/examples/example3.html +0 -50
  64. package/examples/example4.html +0 -22
  65. package/examples/example5.html +0 -82
  66. package/examples/example6.html +0 -128
  67. package/examples/example7.html +0 -91
  68. package/examples/example8.html +0 -27
  69. package/examples/example9.html +0 -102
  70. package/icon/bitwrench-dark-tall.png +0 -0
  71. package/icon/bitwrench-dark.png +0 -0
  72. package/icon/bitwrench-icon-lt-grey.png +0 -0
  73. package/icon/bitwrench-icon.vsd +0 -0
  74. package/icon/bitwrench-logo-dark.png +0 -0
  75. package/icon/bitwrench-logo-full.png +0 -0
  76. package/icon/bitwrench-logo-green.png +0 -0
  77. package/icon/bitwrench-logo-grey.png +0 -0
  78. package/icon/bitwrench-logo-white.png +0 -0
  79. package/icon/bitwrench-logos-colors.png +0 -0
  80. package/icon/bitwrench-thick-logo.png +0 -0
  81. package/icon/bitwrench-thick-teal/android-chrome-192x192.png +0 -0
  82. package/icon/bitwrench-thick-teal/android-chrome-512x512.png +0 -0
  83. package/icon/bitwrench-thick-teal/apple-touch-icon.png +0 -0
  84. package/icon/bitwrench-thick-teal/browserconfig.xml +0 -9
  85. package/icon/bitwrench-thick-teal/favicon-16x16.png +0 -0
  86. package/icon/bitwrench-thick-teal/favicon-32x32.png +0 -0
  87. package/icon/bitwrench-thick-teal/favicon.ico +0 -0
  88. package/icon/bitwrench-thick-teal/mstile-144x144.png +0 -0
  89. package/icon/bitwrench-thick-teal/mstile-150x150.png +0 -0
  90. package/icon/bitwrench-thick-teal/mstile-310x150.png +0 -0
  91. package/icon/bitwrench-thick-teal/mstile-310x310.png +0 -0
  92. package/icon/bitwrench-thick-teal/mstile-70x70.png +0 -0
  93. package/icon/bitwrench-thick-teal/site.webmanifest +0 -19
  94. package/icon/bitwrench-thick-teal.ico +0 -0
  95. package/icon/bitwrench-thick-teal.svg +0 -44
  96. package/icon/bitwrench-thick-teal.zip +0 -0
  97. package/icon/favicon-test.html +0 -20
  98. package/icon/logos-test.PNG +0 -0
  99. package/images/bitwrench-512x512.png +0 -0
  100. package/images/bitwrench-logo-med.png +0 -0
  101. package/images/bitwrench-thick-logo.png +0 -0
  102. package/images/bitwrench-thick-logo.svg +0 -64
  103. package/images/bitwrench-thick-teal.ico +0 -0
  104. package/images/favicon.ico +0 -0
  105. package/index.html +0 -256
  106. package/instr_tmp/bitwrench.js +0 -1350
  107. package/karma.conf.js +0 -140
  108. package/makefile +0 -21
  109. package/quick-docs.html +0 -206
  110. package/test/bitwrench_test.js +0 -1255
  111. package/test/karma-test.js +0 -1081
  112. package/tools/bw_deprecatedNames.js +0 -19
  113. package/tools/bwconsole.js +0 -20
  114. package/tools/createSimpleHTMLPage.js +0 -41
  115. package/tools/emitreadme.sh +0 -4
  116. package/tools/export-bw-default-css.js +0 -41
  117. package/tools/umd2ModuleHack.js +0 -32
  118. package/tools/update-bw-package.js +0 -36
  119. package/tools/updatereadme.js +0 -34
@@ -0,0 +1,3367 @@
1
+ /**
2
+ * Bitwrench v2 Core
3
+ * Zero-dependency UI library using JavaScript objects
4
+ * Works in browsers (IE11+) and Node.js
5
+ *
6
+ * @license BSD-2-Clause
7
+ * @author M A Chatterjee <deftio [at] deftio [dot] com>
8
+ */
9
+
10
+ import { VERSION_INFO } from './version.js';
11
+ import { getStructuralStyles, theme, updateTheme, generateDarkModeCSS,
12
+ generateThemedCSS, derivePalette as _derivePalette,
13
+ DEFAULT_PALETTE_CONFIG, SPACING_PRESETS, RADIUS_PRESETS, THEME_PRESETS,
14
+ resolveLayout, addUnderscoreAliases } from './bitwrench-styles.js';
15
+ import { hexToHsl, hslToHex, adjustLightness, mixColor,
16
+ relativeLuminance, textOnColor, deriveShades,
17
+ derivePalette } from './bitwrench-color-utils.js';
18
+
19
+ // Environment-aware module loader for optional Node.js built-ins (fs).
20
+ // Strategy: try require() first (CJS/UMD), fall back to import() (ESM).
21
+ // import() is wrapped in Function() to avoid parse errors in ES5/IE11 environments.
22
+
23
+ // Core bitwrench namespace
24
+ const bw = {
25
+ // Version info from generated file
26
+ version: VERSION_INFO.version,
27
+ versionInfo: VERSION_INFO,
28
+
29
+ /**
30
+ * Get version metadata object (v1-compatible callable API).
31
+ *
32
+ * Returns a copy of the build-time version info including version string,
33
+ * name, build date, and git hash.
34
+ *
35
+ * @returns {Object} Copy of VERSION_INFO with version, name, buildDate, etc.
36
+ * @category Core
37
+ */
38
+ getVersion: function() {
39
+ return { ...VERSION_INFO };
40
+ },
41
+
42
+ // Internal state
43
+ _idCounter: 0,
44
+ _unmountCallbacks: new Map(),
45
+ _topics: {}, // topic → [{handler, id}] (plain object for IE11 compat)
46
+ _subIdCounter: 0, // monotonic ID for subscriptions
47
+
48
+ // Monkey patch for testing (same as v1)
49
+ __monkey_patch_is_nodejs__: {
50
+ _value: 'ignore',
51
+ set: function(x) {
52
+ this._value = (typeof x === 'boolean') ? x : 'ignore';
53
+ },
54
+ get: function() {
55
+ return this._value;
56
+ }
57
+ }
58
+ };
59
+
60
+ /**
61
+ * Detect if running in Node.js environment.
62
+ *
63
+ * Useful for writing isomorphic code that behaves differently in Node.js vs browser.
64
+ * Uses `process.versions.node` for reliable detection that works in both CJS and ESM.
65
+ *
66
+ * @returns {boolean} True if Node.js, false if browser
67
+ * @category Core
68
+ * @example
69
+ * if (bw.isNodeJS()) {
70
+ * console.log('Running in Node.js');
71
+ * } else {
72
+ * console.log('Running in browser');
73
+ * }
74
+ */
75
+ bw.isNodeJS = function() {
76
+ // Check monkey patch first (for testing)
77
+ if (bw.__monkey_patch_is_nodejs__.get() !== 'ignore') {
78
+ return bw.__monkey_patch_is_nodejs__.get();
79
+ }
80
+
81
+ // Reliable Node.js detection: works in both CJS and ESM
82
+ // - `process.versions.node` exists in Node.js but not browsers
83
+ // - `typeof window` alone is unreliable (jsdom, Electron, Deno)
84
+ return typeof process !== 'undefined'
85
+ && process.versions != null
86
+ && process.versions.node != null;
87
+ };
88
+
89
+ // Set runtime flags based on detection
90
+ // _isNode: Node.js APIs (fs, process) available — static, won't change at runtime
91
+ // _isBrowser: DOM APIs (document, window) available — dynamic getter because
92
+ // globals may be set up after module init (e.g., jsdom in test environments)
93
+ // These are NOT mutually exclusive: jsdom provides DOM in Node.js
94
+ bw._isNode = bw.isNodeJS();
95
+ Object.defineProperty(bw, '_isBrowser', {
96
+ get: function() { return typeof document !== 'undefined' && typeof window !== 'undefined'; },
97
+ configurable: true
98
+ });
99
+
100
+ /**
101
+ * Lazy-resolve Node.js `fs` module.
102
+ * Tries require('fs') first (available in CJS/UMD Node.js builds),
103
+ * then falls back to dynamic import('fs') for ESM.
104
+ * The import() call is wrapped in Function() so ES5 parsers (IE11) don't
105
+ * choke on the syntax — it's only evaluated at runtime in Node.js.
106
+ * Returns a Promise resolving to the fs module or null in browsers.
107
+ * Result is cached after first resolution.
108
+ * @private
109
+ * @returns {Promise<Object|null>} - Promise resolving to Node fs module or null
110
+ */
111
+ bw._fsCache = undefined; // undefined = not yet resolved, null = resolved but unavailable
112
+ bw._getFs = function() {
113
+ if (bw._fsCache !== undefined) return Promise.resolve(bw._fsCache);
114
+ if (!bw.isNodeJS()) { bw._fsCache = null; return Promise.resolve(null); }
115
+
116
+ // Strategy 1: synchronous require (CJS / UMD in Node.js)
117
+ if (typeof require === 'function') {
118
+ try {
119
+ bw._fsCache = require('fs');
120
+ return Promise.resolve(bw._fsCache);
121
+ } catch(e) { /* require not available or failed, try import */ }
122
+ }
123
+
124
+ // Strategy 2: dynamic import (ESM in Node.js)
125
+ // Wrapped in Function() so the import() keyword isn't parsed by ES5 engines
126
+ try {
127
+ var _importDynamic = new Function('m', 'return import(m)');
128
+ return _importDynamic('fs').then(function(mod) {
129
+ bw._fsCache = mod.default || mod;
130
+ return bw._fsCache;
131
+ }).catch(function() {
132
+ bw._fsCache = null;
133
+ return null;
134
+ });
135
+ } catch(e) {
136
+ // Function() construction failed (shouldn't happen, but safety net)
137
+ bw._fsCache = null;
138
+ return Promise.resolve(null);
139
+ }
140
+ };
141
+
142
+ /**
143
+ * Enhanced type detection that distinguishes arrays, dates, regexps, and more.
144
+ *
145
+ * Goes beyond `typeof` by using `Object.prototype.toString` to identify
146
+ * specific object types. Returns lowercase strings for primitives and arrays,
147
+ * PascalCase for built-in classes (Date, RegExp, Map, Set, etc.).
148
+ *
149
+ * @param {*} x - Value to examine
150
+ * @param {boolean} [baseTypeOnly=false] - If true, return only the base type ("object" for all objects)
151
+ * @returns {string} Type name as shown in table below
152
+ * @category Core
153
+ * @example
154
+ * // Primitives (lowercase):
155
+ * bw.typeOf("hello") // => "string"
156
+ * bw.typeOf(42) // => "number"
157
+ * bw.typeOf(true) // => "boolean"
158
+ * bw.typeOf(undefined) // => "undefined"
159
+ * bw.typeOf(null) // => "null"
160
+ * bw.typeOf(Symbol('x')) // => "symbol"
161
+ * bw.typeOf(42n) // => "bigint"
162
+ * bw.typeOf(() => {}) // => "function"
163
+ *
164
+ * // Arrays (lowercase):
165
+ * bw.typeOf([1, 2, 3]) // => "array"
166
+ *
167
+ * // Built-in classes (PascalCase):
168
+ * bw.typeOf(new Date()) // => "Date"
169
+ * bw.typeOf(/abc/) // => "RegExp"
170
+ * bw.typeOf(new Error()) // => "Error"
171
+ * bw.typeOf(new Map()) // => "Map"
172
+ * bw.typeOf(new Set()) // => "Set"
173
+ * bw.typeOf(new WeakMap()) // => "WeakMap"
174
+ * bw.typeOf(new WeakSet()) // => "WeakSet"
175
+ * bw.typeOf(Promise.resolve()) // => "Promise"
176
+ *
177
+ * // Typed arrays (PascalCase):
178
+ * bw.typeOf(new Uint8Array()) // => "Uint8Array"
179
+ * bw.typeOf(new Float64Array()) // => "Float64Array"
180
+ * bw.typeOf(new ArrayBuffer(8)) // => "ArrayBuffer"
181
+ *
182
+ * // Plain objects and custom classes:
183
+ * bw.typeOf({a: 1}) // => "Object"
184
+ * bw.typeOf(new MyClass()) // => "MyClass" (constructor.name)
185
+ *
186
+ * // baseTypeOnly mode:
187
+ * bw.typeOf([1,2], true) // => "object"
188
+ */
189
+ bw.typeOf = function(x, baseTypeOnly) {
190
+ if (x === null) return "null";
191
+
192
+ const basic = typeof x;
193
+
194
+ if (basic !== "object") {
195
+ return basic; // covers: string, number, boolean, undefined, function, symbol, bigint
196
+ }
197
+
198
+ if (baseTypeOnly) return basic;
199
+
200
+ const stringTag = Object.prototype.toString.call(x);
201
+
202
+ const typeMap = {
203
+ '[object Array]': 'array',
204
+ '[object Date]': 'Date',
205
+ '[object RegExp]': 'RegExp',
206
+ '[object Error]': 'Error',
207
+ '[object Promise]': 'Promise',
208
+ '[object Map]': 'Map',
209
+ '[object Set]': 'Set',
210
+ '[object WeakMap]': 'WeakMap',
211
+ '[object WeakSet]': 'WeakSet',
212
+ '[object ArrayBuffer]': 'ArrayBuffer',
213
+ '[object DataView]': 'DataView',
214
+ '[object Int8Array]': 'Int8Array',
215
+ '[object Uint8Array]': 'Uint8Array',
216
+ '[object Uint8ClampedArray]': 'Uint8ClampedArray',
217
+ '[object Int16Array]': 'Int16Array',
218
+ '[object Uint16Array]': 'Uint16Array',
219
+ '[object Int32Array]': 'Int32Array',
220
+ '[object Uint32Array]': 'Uint32Array',
221
+ '[object Float32Array]': 'Float32Array',
222
+ '[object Float64Array]': 'Float64Array'
223
+ };
224
+
225
+ if (typeMap[stringTag]) {
226
+ return typeMap[stringTag];
227
+ }
228
+
229
+ // Check for custom bitwrench types
230
+ if (x._bw_type) {
231
+ return x._bw_type;
232
+ }
233
+
234
+ // Try constructor name
235
+ if (x.constructor && x.constructor.name) {
236
+ return x.constructor.name;
237
+ }
238
+
239
+ return basic;
240
+ };
241
+
242
+ // Alias
243
+ bw.to = bw.typeOf;
244
+
245
+ /**
246
+ * Generate a unique identifier string for DOM elements or application use.
247
+ *
248
+ * Uses `crypto.randomUUID()` when available (modern browsers), otherwise
249
+ * falls back to a timestamp + counter + random combination. Optional prefix
250
+ * creates namespaced IDs like `bw_card_<hex>` for easier debugging.
251
+ *
252
+ * @param {string} [prefix] - Optional namespace prefix (e.g. "card", "todo")
253
+ * @returns {string} Unique identifier (e.g. "bw_card_a1b2c3d4")
254
+ * @category Identifiers
255
+ * @example
256
+ * bw.uuid() // => "bw_m3x9k_1_7f2h4j6a8"
257
+ * bw.uuid('card') // => "bw_card_a1b2c3d4e5f6"
258
+ */
259
+ bw.uuid = function(prefix) {
260
+ // Optional prefix creates IDs like bw_card_<hex>, bw_todo_<hex>, etc.
261
+ // Without prefix: bw_<hex>
262
+ var tag = prefix ? 'bw_' + prefix + '_' : 'bw_';
263
+
264
+ // Use crypto.randomUUID if available (modern browsers)
265
+ if (bw._isBrowser && crypto && crypto.randomUUID) {
266
+ return tag + crypto.randomUUID().replace(/-/g, '');
267
+ }
268
+
269
+ // Fallback for older browsers and Node.js
270
+ const timestamp = Date.now().toString(36);
271
+ const counter = (++bw._idCounter).toString(36);
272
+ const random = Math.random().toString(36).substring(2, 11);
273
+
274
+ return `${tag}${timestamp}_${counter}_${random}`;
275
+ };
276
+
277
+ /**
278
+ * Escape HTML special characters to prevent XSS.
279
+ *
280
+ * Converts &, <, >, ", ', and / to their HTML entity equivalents.
281
+ * Used automatically by `bw.html()` unless raw mode is enabled.
282
+ *
283
+ * @param {string} str - String to escape
284
+ * @returns {string} Escaped string safe for HTML insertion
285
+ * @category Identifiers
286
+ * @see bw.html
287
+ * @example
288
+ * bw.escapeHTML('<b>Hello</b> & "world"')
289
+ * // => '&lt;b&gt;Hello&lt;&#x2F;b&gt; &amp; &quot;world&quot;'
290
+ */
291
+ bw.escapeHTML = function(str) {
292
+ if (typeof str !== 'string') return '';
293
+
294
+ const escapeMap = {
295
+ '&': '&amp;',
296
+ '<': '&lt;',
297
+ '>': '&gt;',
298
+ '"': '&quot;',
299
+ "'": '&#39;',
300
+ '/': '&#x2F;'
301
+ };
302
+
303
+ return str.replace(/[&<>"'/]/g, (char) => escapeMap[char]);
304
+ };
305
+
306
+ /**
307
+ * Normalize CSS class names by converting underscores to hyphens for bw-prefixed classes.
308
+ *
309
+ * Allows users to write either `bw_card` or `bw-card` and get consistent
310
+ * hyphenated output. Only converts the `bw_` prefix — other underscores are untouched.
311
+ *
312
+ * @param {string} classStr - Class string to normalize
313
+ * @returns {string} Normalized class string with hyphens
314
+ * @category Identifiers
315
+ * @example
316
+ * bw.normalizeClass('bw_card bw_btn') // => 'bw-card bw-btn'
317
+ * bw.normalizeClass('my_class') // => 'my_class' (unchanged)
318
+ */
319
+ bw.normalizeClass = function(classStr) {
320
+ if (typeof classStr !== 'string') return classStr;
321
+ return classStr.replace(/\bbw_/g, 'bw-');
322
+ };
323
+
324
+ /**
325
+ * Convert a TACO object (or array of TACOs) to an HTML string.
326
+ *
327
+ * This is the core rendering function — it works in both Node.js and browsers.
328
+ * Use it for server-side rendering, static site generation, or generating
329
+ * HTML snippets. Content is HTML-escaped by default; pass `{ raw: true }`
330
+ * to insert raw HTML.
331
+ *
332
+ * @param {Object|Array|string} taco - TACO object, array of TACOs, or string
333
+ * @param {Object} [options] - Rendering options
334
+ * @param {boolean} [options.raw=false] - If true, skip HTML escaping on content
335
+ * @returns {string} HTML string
336
+ * @category DOM Generation
337
+ * @see bw.createDOM
338
+ * @see bw.DOM
339
+ * @example
340
+ * bw.html({ t: 'h1', c: 'Hello' })
341
+ * // => '<h1>Hello</h1>'
342
+ *
343
+ * bw.html({ t: 'div', a: { class: 'card' }, c: [
344
+ * { t: 'p', c: 'Content here' }
345
+ * ]})
346
+ * // => '<div class="card"><p>Content here</p></div>'
347
+ */
348
+ bw.html = function(taco, options = {}) {
349
+ // Handle null/undefined
350
+ if (taco == null) return '';
351
+
352
+ // Handle arrays of TACOs
353
+ if (Array.isArray(taco)) {
354
+ return taco.map(t => bw.html(t, options)).join('');
355
+ }
356
+
357
+ // Handle primitives and non-TACO objects
358
+ if (typeof taco !== 'object' || !taco.t) {
359
+ return options.raw ? String(taco) : bw.escapeHTML(String(taco));
360
+ }
361
+
362
+ const { t: tag, a: attrs = {}, c: content, o: opts = {} } = taco;
363
+
364
+ // Self-closing tags
365
+ const selfClosing = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img',
366
+ 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
367
+ const isSelfClosing = selfClosing.includes(tag.toLowerCase());
368
+
369
+ // Build attributes string
370
+ let attrStr = '';
371
+
372
+ for (const [key, value] of Object.entries(attrs)) {
373
+ // Skip null, undefined, false
374
+ if (value == null || value === false) continue;
375
+
376
+ // Skip event handlers (they're for DOM only)
377
+ if (key.startsWith('on')) continue;
378
+
379
+ if (key === 'style' && typeof value === 'object') {
380
+ // Convert style object to string
381
+ const styleStr = Object.entries(value)
382
+ .filter(([, v]) => v != null)
383
+ .map(([k, v]) => `${k}:${v}`)
384
+ .join(';');
385
+ if (styleStr) {
386
+ attrStr += ` style="${bw.escapeHTML(styleStr)}"`;
387
+ }
388
+ } else if (key === 'class') {
389
+ // Handle class as array or string, normalize bw_ to bw-
390
+ const classStr = bw.normalizeClass(
391
+ Array.isArray(value)
392
+ ? value.filter(Boolean).join(' ')
393
+ : String(value)
394
+ );
395
+ if (classStr) {
396
+ attrStr += ` class="${bw.escapeHTML(classStr)}"`;
397
+ }
398
+ } else if (value === true) {
399
+ // Boolean attributes
400
+ attrStr += ` ${key}`;
401
+ } else {
402
+ // Regular attributes
403
+ attrStr += ` ${key}="${bw.escapeHTML(String(value))}"`;
404
+ }
405
+ }
406
+
407
+ // Add bw-id as a class if lifecycle hooks present
408
+ if ((opts.mounted || opts.unmount) && !attrs.class?.includes('bw-id-')) {
409
+ const id = opts.bw_id || bw.uuid();
410
+ attrStr = attrStr.replace(/class="([^"]*)"/, (_match, classes) => {
411
+ return `class="${classes} bw-id-${id}"`.trim();
412
+ });
413
+ if (!attrStr.includes('class=')) {
414
+ attrStr += ` class="bw-id-${id}"`;
415
+ }
416
+ }
417
+
418
+ // Build HTML
419
+ if (isSelfClosing) {
420
+ return `<${tag}${attrStr} />`;
421
+ }
422
+
423
+ // Process content recursively
424
+ const contentStr = content != null ? bw.html(content, options) : '';
425
+
426
+ return `<${tag}${attrStr}>${contentStr}</${tag}>`;
427
+ };
428
+
429
+ /**
430
+ * Create a live DOM element from a TACO object (browser only).
431
+ *
432
+ * Unlike `bw.html()` which returns a string, this creates real DOM elements
433
+ * with event handlers, lifecycle hooks (mounted/unmount), and state. Used
434
+ * internally by `bw.DOM()`. Throws in Node.js — use `bw.html()` instead.
435
+ *
436
+ * @param {Object} taco - TACO object with {t, a, c, o}
437
+ * @param {Object} [options] - Creation options
438
+ * @returns {Element|Text} DOM element or text node
439
+ * @category DOM Generation
440
+ * @see bw.html
441
+ * @see bw.DOM
442
+ * @example
443
+ * var el = bw.createDOM({
444
+ * t: 'button',
445
+ * a: { class: 'bw-btn', onclick: () => alert('clicked') },
446
+ * c: 'Click Me'
447
+ * });
448
+ * document.body.appendChild(el);
449
+ */
450
+ bw.createDOM = function(taco, options = {}) {
451
+ if (!bw._isBrowser) {
452
+ throw new Error('bw.createDOM requires a DOM environment (document/window). Use bw.html() instead.');
453
+ }
454
+
455
+ // Handle null/undefined
456
+ if (taco == null) return document.createTextNode('');
457
+
458
+ // Handle text nodes
459
+ if (typeof taco !== 'object' || !taco.t) {
460
+ return document.createTextNode(String(taco));
461
+ }
462
+
463
+ const { t: tag, a: attrs = {}, c: content, o: opts = {} } = taco;
464
+
465
+ // Create element
466
+ const el = document.createElement(tag);
467
+
468
+ // Set attributes
469
+ for (const [key, value] of Object.entries(attrs)) {
470
+ if (value == null || value === false) continue;
471
+
472
+ if (key === 'style' && typeof value === 'object') {
473
+ // Apply styles directly
474
+ Object.assign(el.style, value);
475
+ } else if (key === 'class') {
476
+ // Handle class as array or string, normalize bw_ to bw-
477
+ const classStr = bw.normalizeClass(
478
+ Array.isArray(value)
479
+ ? value.filter(Boolean).join(' ')
480
+ : String(value)
481
+ );
482
+ if (classStr) {
483
+ el.className = classStr;
484
+ }
485
+ } else if (key.startsWith('on') && typeof value === 'function') {
486
+ // Event handlers
487
+ const eventName = key.slice(2).toLowerCase();
488
+ el.addEventListener(eventName, value);
489
+ } else if (key === 'value' && tag === 'input') {
490
+ // Special handling for input value
491
+ el.value = value;
492
+ } else if (value === true) {
493
+ // Boolean attributes
494
+ el.setAttribute(key, '');
495
+ } else {
496
+ // Regular attributes
497
+ el.setAttribute(key, String(value));
498
+ }
499
+ }
500
+
501
+ // Add children
502
+ if (content != null) {
503
+ if (Array.isArray(content)) {
504
+ content.forEach(child => {
505
+ if (child != null) {
506
+ el.appendChild(bw.createDOM(child, options));
507
+ }
508
+ });
509
+ } else if (typeof content === 'object' && content.t) {
510
+ el.appendChild(bw.createDOM(content, options));
511
+ } else {
512
+ el.textContent = String(content);
513
+ }
514
+ }
515
+
516
+ // Handle lifecycle hooks and state
517
+ if (opts.mounted || opts.unmount || opts.render || opts.state) {
518
+ const id = attrs['data-bw-id'] || bw.uuid();
519
+ el.setAttribute('data-bw-id', id);
520
+
521
+ // Store state
522
+ if (opts.state) {
523
+ el._bw_state = opts.state;
524
+ }
525
+
526
+ // o.render — first-class render function (replaces mounted boilerplate)
527
+ if (opts.render) {
528
+ el._bw_render = opts.render;
529
+
530
+ if (opts.mounted) {
531
+ console.warn('bw.createDOM: o.render and o.mounted are mutually exclusive. o.render wins.');
532
+ }
533
+
534
+ // Queue initial render (same timing as mounted)
535
+ if (document.body.contains(el)) {
536
+ opts.render(el, el._bw_state || {});
537
+ } else {
538
+ requestAnimationFrame(() => {
539
+ if (document.body.contains(el)) {
540
+ opts.render(el, el._bw_state || {});
541
+ }
542
+ });
543
+ }
544
+ } else if (opts.mounted) {
545
+ // Queue mounted callback (legacy pattern)
546
+ if (document.body.contains(el)) {
547
+ opts.mounted(el, el._bw_state || {});
548
+ } else {
549
+ requestAnimationFrame(() => {
550
+ if (document.body.contains(el)) {
551
+ opts.mounted(el, el._bw_state || {});
552
+ }
553
+ });
554
+ }
555
+ }
556
+
557
+ // Store unmount callback
558
+ if (opts.unmount) {
559
+ bw._unmountCallbacks.set(id, () => {
560
+ opts.unmount(el, el._bw_state || {});
561
+ });
562
+ }
563
+ }
564
+
565
+ return el;
566
+ };
567
+
568
+ /**
569
+ * Mount a TACO object into a DOM element, replacing its contents (browser only).
570
+ *
571
+ * This is the primary way to render bitwrench UI to the page. It cleans up
572
+ * any existing children (calling unmount hooks), then renders the TACO into
573
+ * the target. The target element itself is preserved — only its children change.
574
+ *
575
+ * @param {string|Element} target - CSS selector or DOM element to mount into
576
+ * @param {Object} taco - TACO object to render
577
+ * @param {Object} [options] - Mount options
578
+ * @returns {Element} Target element
579
+ * @category DOM Generation
580
+ * @see bw.html
581
+ * @see bw.createDOM
582
+ * @see bw.cleanup
583
+ * @example
584
+ * bw.DOM('#app', {
585
+ * t: 'div', a: { class: 'card' },
586
+ * c: [
587
+ * { t: 'h2', c: 'Hello' },
588
+ * { t: 'p', c: 'Built with bitwrench.' }
589
+ * ]
590
+ * });
591
+ */
592
+ bw.DOM = function(target, taco, options = {}) {
593
+ if (!bw._isBrowser) {
594
+ throw new Error('bw.DOM requires a DOM environment (document/window). Use bw.html() instead.');
595
+ }
596
+
597
+ // Get target element
598
+ const targetEl = typeof target === 'string'
599
+ ? document.querySelector(target)
600
+ : target;
601
+
602
+ if (!targetEl) {
603
+ console.error('bw.DOM: Target element not found:', target);
604
+ return null;
605
+ }
606
+
607
+ // Clean up existing children (but preserve the target's own state, render, and subs —
608
+ // the target is the mount point, not the content being replaced)
609
+ const savedState = targetEl._bw_state;
610
+ const savedRender = targetEl._bw_render;
611
+ const savedBwId = targetEl.getAttribute('data-bw-id');
612
+ const savedSubs = targetEl._bw_subs;
613
+
614
+ // Temporarily remove _bw_subs so cleanup doesn't call them
615
+ // (children's subs will still be cleaned up normally)
616
+ delete targetEl._bw_subs;
617
+
618
+ bw.cleanup(targetEl);
619
+
620
+ // Restore the target's own state/render/subs after cleanup
621
+ if (savedState !== undefined) targetEl._bw_state = savedState;
622
+ if (savedRender) targetEl._bw_render = savedRender;
623
+ if (savedBwId) targetEl.setAttribute('data-bw-id', savedBwId);
624
+ if (savedSubs) targetEl._bw_subs = savedSubs;
625
+
626
+ // Clear and mount new content
627
+ targetEl.innerHTML = '';
628
+
629
+ if (taco != null) {
630
+ // Handle component handles (objects with element property)
631
+ if (taco.element instanceof Element) {
632
+ targetEl.appendChild(taco.element);
633
+ }
634
+ // Handle arrays
635
+ else if (Array.isArray(taco)) {
636
+ taco.forEach(t => {
637
+ if (t != null) {
638
+ if (t.element instanceof Element) {
639
+ targetEl.appendChild(t.element);
640
+ } else {
641
+ targetEl.appendChild(bw.createDOM(t, options));
642
+ }
643
+ }
644
+ });
645
+ }
646
+ // Handle TACO objects
647
+ else {
648
+ targetEl.appendChild(bw.createDOM(taco, options));
649
+ }
650
+ }
651
+
652
+ return targetEl;
653
+ };
654
+
655
+ /**
656
+ * Compile props into getter/setter functions for reactive updates.
657
+ *
658
+ * Used internally by `bw.renderComponent()`. Creates a proxy-like object
659
+ * where setting a property triggers `handle.onPropChange()`.
660
+ *
661
+ * @param {Object} handle - Component handle
662
+ * @param {Object} props - Initial props
663
+ * @returns {Object} Compiled props object with getters/setters
664
+ * @category DOM Generation
665
+ */
666
+ bw.compileProps = function(handle, props = {}) {
667
+ const compiledProps = {};
668
+
669
+ Object.keys(props).forEach(key => {
670
+ // Create getter/setter for each prop
671
+ Object.defineProperty(compiledProps, key, {
672
+ get() {
673
+ return handle._props[key];
674
+ },
675
+ set(value) {
676
+ const oldValue = handle._props[key];
677
+ if (oldValue !== value) {
678
+ handle._props[key] = value;
679
+ // Trigger update if prop changed
680
+ if (handle.onPropChange) {
681
+ handle.onPropChange(key, value, oldValue);
682
+ }
683
+ }
684
+ },
685
+ enumerable: true,
686
+ configurable: true
687
+ });
688
+ });
689
+
690
+ return compiledProps;
691
+ };
692
+
693
+ /**
694
+ * Render a TACO component and return an enhanced handle object.
695
+ *
696
+ * The handle provides compiled props, state management, child registration,
697
+ * and a destroy method. Used internally by `bw.createCard()`, `bw.createTable()`, etc.
698
+ *
699
+ * @param {Object} taco - TACO object to render
700
+ * @param {Object} [options] - Render options
701
+ * @returns {Object} Component handle with element, props, state, update(), destroy()
702
+ * @category DOM Generation
703
+ */
704
+ bw.renderComponent = function(taco, options = {}) {
705
+ const element = bw.createDOM(taco, options);
706
+
707
+ // Enhanced handle with prop compilation
708
+ const handle = {
709
+ element,
710
+ taco,
711
+ _props: { ...taco.a }, // Store props internally
712
+ _state: taco.o?.state || {},
713
+ _children: {}, // Store child component references
714
+
715
+ // Get compiled props with getters/setters
716
+ get props() {
717
+ if (!this._compiledProps) {
718
+ this._compiledProps = bw.compileProps(this, this._props);
719
+ }
720
+ return this._compiledProps;
721
+ },
722
+
723
+ /**
724
+ * Query all matching elements within this component
725
+ * @param {string} selector - CSS selector
726
+ * @returns {NodeList} Matching elements
727
+ */
728
+ $(selector) {
729
+ return this.element.querySelectorAll(selector);
730
+ },
731
+
732
+ /**
733
+ * Query the first matching element within this component
734
+ * @param {string} selector - CSS selector
735
+ * @returns {Element|null} First matching element or null
736
+ */
737
+ $first(selector) {
738
+ return this.element.querySelector(selector);
739
+ },
740
+
741
+ /**
742
+ * Update component with new props and re-render in place
743
+ * @param {Object} newProps - Properties to merge into current props
744
+ * @returns {Object} this handle (for chaining)
745
+ */
746
+ update(newProps) {
747
+ // Update internal props
748
+ Object.assign(this._props, newProps);
749
+
750
+ // Rebuild TACO with new props
751
+ const newTaco = { ...this.taco, a: { ...this.taco.a, ...newProps } };
752
+ const newElement = bw.createDOM(newTaco, options);
753
+
754
+ // Replace in DOM
755
+ this.element.replaceWith(newElement);
756
+ this.element = newElement;
757
+ this.taco = newTaco;
758
+
759
+ return this;
760
+ },
761
+
762
+ /**
763
+ * Re-render the component from its current TACO, replacing the DOM element
764
+ * @returns {Object} this handle (for chaining)
765
+ */
766
+ render() {
767
+ const newElement = bw.createDOM(this.taco, options);
768
+ this.element.replaceWith(newElement);
769
+ this.element = newElement;
770
+ return this;
771
+ },
772
+
773
+ /**
774
+ * Called when a compiled prop value changes. Override to customize behavior.
775
+ * Default implementation triggers a full re-render.
776
+ * @param {string} key - Property name that changed
777
+ * @param {*} newValue - New property value
778
+ * @param {*} oldValue - Previous property value
779
+ */
780
+ onPropChange(_key, _newValue, _oldValue) {
781
+ // Auto re-render on prop change by default
782
+ this.render();
783
+ },
784
+
785
+ // State management
786
+ get state() {
787
+ return this._state;
788
+ },
789
+
790
+ set state(newState) {
791
+ this._state = newState;
792
+ this.render();
793
+ },
794
+
795
+ /**
796
+ * Merge state updates and re-render the component
797
+ * @param {Object} updates - State properties to merge
798
+ * @returns {Object} this handle (for chaining)
799
+ */
800
+ setState(updates) {
801
+ Object.assign(this._state, updates);
802
+ this.render();
803
+ return this;
804
+ },
805
+
806
+ /**
807
+ * Register a child component under a name for later retrieval
808
+ * @param {string} name - Child name key
809
+ * @param {Object} component - Child component handle
810
+ * @returns {Object} this handle (for chaining)
811
+ */
812
+ addChild(name, component) {
813
+ this._children[name] = component;
814
+ return this;
815
+ },
816
+
817
+ /**
818
+ * Retrieve a registered child component by name
819
+ * @param {string} name - Child name key
820
+ * @returns {Object|undefined} Child component handle
821
+ */
822
+ getChild(name) {
823
+ return this._children[name];
824
+ },
825
+
826
+ /**
827
+ * Destroy this component and all registered children
828
+ *
829
+ * Calls destroy() recursively on children, runs bw.cleanup(),
830
+ * removes the element from DOM, and clears all internal references.
831
+ */
832
+ destroy() {
833
+ // Destroy children first
834
+ Object.values(this._children).forEach(child => {
835
+ if (child && child.destroy) child.destroy();
836
+ });
837
+
838
+ // Clean up this component
839
+ bw.cleanup(this.element);
840
+ this.element.remove();
841
+
842
+ // Clear references
843
+ this._children = {};
844
+ this._props = {};
845
+ this._state = {};
846
+ this._compiledProps = null;
847
+ }
848
+ };
849
+
850
+ // Store handle reference on element
851
+ element._bwHandle = handle;
852
+
853
+ return handle;
854
+ };
855
+
856
+ /**
857
+ * Clean up a DOM element and all its children by calling unmount callbacks,
858
+ * removing pub/sub subscriptions, and clearing state/render references.
859
+ *
860
+ * Called automatically by `bw.DOM()` before re-rendering. Call manually when
861
+ * removing elements to prevent memory leaks from orphaned callbacks.
862
+ *
863
+ * @param {Element} element - DOM element to clean up
864
+ * @category DOM Generation
865
+ * @see bw.DOM
866
+ * @example
867
+ * var el = document.querySelector('#my-widget');
868
+ * bw.cleanup(el); // runs unmount hooks, clears _bw_state, _bw_render
869
+ * el.remove(); // safe to remove from DOM now
870
+ */
871
+ bw.cleanup = function(element) {
872
+ if (!bw._isBrowser || !element) return;
873
+
874
+ // Find all elements with data-bw-id
875
+ const elements = element.querySelectorAll('[data-bw-id]');
876
+
877
+ elements.forEach(el => {
878
+ const id = el.getAttribute('data-bw-id');
879
+ const callback = bw._unmountCallbacks.get(id);
880
+
881
+ if (callback) {
882
+ callback();
883
+ bw._unmountCallbacks.delete(id);
884
+ }
885
+
886
+ // Clean up pub/sub subscriptions tied to this element
887
+ if (el._bw_subs) {
888
+ el._bw_subs.forEach(function(unsub) { unsub(); });
889
+ delete el._bw_subs;
890
+ }
891
+
892
+ // Clean up state and render
893
+ delete el._bw_state;
894
+ delete el._bw_render;
895
+ });
896
+
897
+ // Check element itself
898
+ const id = element.getAttribute('data-bw-id');
899
+ if (id) {
900
+ const callback = bw._unmountCallbacks.get(id);
901
+ if (callback) {
902
+ callback();
903
+ bw._unmountCallbacks.delete(id);
904
+ }
905
+ // Clean up pub/sub subscriptions tied to element itself
906
+ if (element._bw_subs) {
907
+ element._bw_subs.forEach(function(unsub) { unsub(); });
908
+ delete element._bw_subs;
909
+ }
910
+ delete element._bw_state;
911
+ delete element._bw_render;
912
+ }
913
+ };
914
+
915
+ // ===================================================================================
916
+ // State Management: update, patch, emit/on
917
+ // ===================================================================================
918
+
919
+ /**
920
+ * Trigger re-render of a component by calling its stored `o.render` function.
921
+ *
922
+ * This is the recommended way to update a component after changing its state.
923
+ * Calls `el._bw_render(el, state)` and emits `bw:statechange` so other
924
+ * components can react without tight coupling.
925
+ *
926
+ * @param {string|Element} target - CSS selector or DOM element with _bw_render
927
+ * @returns {Element|null} The element, or null if not found / no render function
928
+ * @category State Management
929
+ * @see bw.patch
930
+ * @example
931
+ * // Given a counter element with o.render
932
+ * el._bw_state.count++;
933
+ * bw.update(el); // re-renders, emits bw:statechange
934
+ */
935
+ bw.update = function(target) {
936
+ var el = typeof target === 'string' ? document.querySelector(target) : target;
937
+ if (el && el._bw_render) {
938
+ el._bw_render(el, el._bw_state || {});
939
+ bw.emit(el, 'statechange', el._bw_state);
940
+ }
941
+ return el || null;
942
+ };
943
+
944
+ /**
945
+ * Targeted DOM update by element ID — change one element's content or attribute
946
+ * without rebuilding the entire component tree.
947
+ *
948
+ * Use `bw.patch()` for lightweight value updates (scores, labels, counters)
949
+ * and `bw.update()` for full structural re-renders.
950
+ *
951
+ * @param {string|Element} id - Element ID string or DOM element
952
+ * @param {string|Object} content - New text content, or TACO object to replace children
953
+ * @param {string} [attr] - If provided, sets this attribute instead of content
954
+ * @returns {Element|null} The patched element, or null if not found
955
+ * @category State Management
956
+ * @see bw.patchAll
957
+ * @see bw.update
958
+ * @example
959
+ * bw.patch('score-display', '42'); // update text content
960
+ * bw.patch('status', 'active', 'class'); // update an attribute
961
+ * bw.patch('info', { t: 'em', c: 'new' }); // replace children with TACO
962
+ */
963
+ bw.patch = function(id, content, attr) {
964
+ var el = typeof id === 'string' ? document.getElementById(id) : id;
965
+ if (!el) return null;
966
+
967
+ if (attr) {
968
+ // Patch an attribute
969
+ el.setAttribute(attr, String(content));
970
+ } else if (typeof content === 'object' && content !== null && content.t) {
971
+ // Patch with a TACO — replace children
972
+ el.innerHTML = '';
973
+ el.appendChild(bw.createDOM(content));
974
+ } else {
975
+ // Patch text content
976
+ el.textContent = String(content);
977
+ }
978
+ return el;
979
+ };
980
+
981
+ /**
982
+ * Batch version of `bw.patch()` — update multiple elements in one call.
983
+ *
984
+ * Useful for updating several independent values simultaneously,
985
+ * such as a dashboard with multiple counters.
986
+ *
987
+ * @param {Object} patches - Map of { elementId: newContent, ... }
988
+ * @returns {Object} Map of { elementId: patchedElement|null, ... }
989
+ * @category State Management
990
+ * @see bw.patch
991
+ * @example
992
+ * bw.patchAll({
993
+ * 'cpu-display': '78%',
994
+ * 'mem-display': '4.2 GB',
995
+ * 'disk-display': '120 GB free'
996
+ * });
997
+ */
998
+ bw.patchAll = function(patches) {
999
+ var results = {};
1000
+ for (var id in patches) {
1001
+ if (Object.prototype.hasOwnProperty.call(patches, id)) {
1002
+ results[id] = bw.patch(id, patches[id]);
1003
+ }
1004
+ }
1005
+ return results;
1006
+ };
1007
+
1008
+ /**
1009
+ * Emit a custom DOM event on an element.
1010
+ *
1011
+ * Events are prefixed with `bw:` to avoid collision with native events and
1012
+ * bubble by default so ancestor elements can listen. Use with `bw.on()` for
1013
+ * DOM-scoped communication between components.
1014
+ *
1015
+ * @param {string|Element} target - CSS selector or DOM element
1016
+ * @param {string} eventName - Event name (will be prefixed with 'bw:')
1017
+ * @param {*} [detail] - Data to pass with the event
1018
+ * @category Events (DOM)
1019
+ * @see bw.on
1020
+ * @example
1021
+ * bw.emit('#my-widget', 'statechange', { count: 42 });
1022
+ * // Dispatches CustomEvent 'bw:statechange' on the element
1023
+ */
1024
+ bw.emit = function(target, eventName, detail) {
1025
+ var el = typeof target === 'string' ? document.querySelector(target) : target;
1026
+ if (el) {
1027
+ el.dispatchEvent(new CustomEvent('bw:' + eventName, {
1028
+ bubbles: true,
1029
+ detail: detail || {}
1030
+ }));
1031
+ }
1032
+ };
1033
+
1034
+ /**
1035
+ * Listen for a custom bitwrench event on a DOM element.
1036
+ *
1037
+ * Handler receives `(detail, event)` for convenience — the detail object
1038
+ * is the first argument so you don't need to destructure `e.detail`.
1039
+ * Events bubble, so you can listen on an ancestor element.
1040
+ *
1041
+ * @param {string|Element} target - CSS selector or DOM element
1042
+ * @param {string} eventName - Event name (will be prefixed with 'bw:')
1043
+ * @param {Function} handler - Called with (detail, event)
1044
+ * @returns {Element|null} The element (for chaining), or null if not found
1045
+ * @category Events (DOM)
1046
+ * @see bw.emit
1047
+ * @example
1048
+ * bw.on(document.body, 'statechange', function(detail) {
1049
+ * console.log('State changed:', detail);
1050
+ * });
1051
+ */
1052
+ bw.on = function(target, eventName, handler) {
1053
+ var el = typeof target === 'string' ? document.querySelector(target) : target;
1054
+ if (el) {
1055
+ el.addEventListener('bw:' + eventName, function(e) {
1056
+ handler(e.detail, e);
1057
+ });
1058
+ }
1059
+ return el || null;
1060
+ };
1061
+
1062
+ // ===================================================================================
1063
+ // Topic-Based Pub/Sub: bw.pub(), bw.sub(), bw.unsub()
1064
+ //
1065
+ // Separate from emit/on (DOM-scoped CustomEvents). Pub/sub is application-scoped,
1066
+ // topic-based, and decoupled from the DOM tree. Try/catch per subscriber so one
1067
+ // bad handler can't break others.
1068
+ // ===================================================================================
1069
+
1070
+ /**
1071
+ * Publish to a topic, calling all subscribers in registration order.
1072
+ *
1073
+ * Application-scoped pub/sub decoupled from the DOM tree. Each subscriber
1074
+ * is wrapped in try/catch so one bad handler can't break others.
1075
+ * Use `bw.pub()`/`bw.sub()` for app-wide communication; use `bw.emit()`/`bw.on()`
1076
+ * for DOM-scoped events.
1077
+ *
1078
+ * @param {string} topic - Topic name (plain string, no prefix)
1079
+ * @param {*} [detail] - Data to pass to subscribers
1080
+ * @returns {number} Count of successfully called subscribers
1081
+ * @category Pub/Sub
1082
+ * @see bw.sub
1083
+ * @example
1084
+ * bw.pub('score:updated', { player: 'X', score: 10 });
1085
+ */
1086
+ bw.pub = function(topic, detail) {
1087
+ var subs = bw._topics[topic];
1088
+ if (!subs || subs.length === 0) return 0;
1089
+ var snapshot = subs.slice(); // safe against unsub during iteration
1090
+ var called = 0;
1091
+ for (var i = 0; i < snapshot.length; i++) {
1092
+ try {
1093
+ snapshot[i].handler(detail);
1094
+ called++;
1095
+ } catch (err) {
1096
+ console.warn('bw.pub: subscriber error on topic "' + topic + '":', err);
1097
+ }
1098
+ }
1099
+ return called;
1100
+ };
1101
+
1102
+ /**
1103
+ * Subscribe to a topic. Returns an unsub() function.
1104
+ *
1105
+ * Optional third argument ties the subscription to a DOM element's lifecycle —
1106
+ * when `bw.cleanup()` is called on that element, the subscription is automatically
1107
+ * removed, preventing memory leaks.
1108
+ *
1109
+ * @param {string} topic - Topic name
1110
+ * @param {Function} handler - Called with (detail) on each publish
1111
+ * @param {Element} [el] - Optional DOM element to tie lifecycle to
1112
+ * @returns {Function} Call to unsubscribe
1113
+ * @category Pub/Sub
1114
+ * @see bw.pub
1115
+ * @see bw.unsub
1116
+ * @example
1117
+ * var unsub = bw.sub('score:updated', function(detail) {
1118
+ * console.log(detail.player, 'scored', detail.score);
1119
+ * });
1120
+ * // Later: unsub() to stop listening
1121
+ */
1122
+ bw.sub = function(topic, handler, el) {
1123
+ var id = ++bw._subIdCounter;
1124
+ if (!bw._topics[topic]) bw._topics[topic] = [];
1125
+ bw._topics[topic].push({ handler: handler, id: id });
1126
+
1127
+ var unsub = function() {
1128
+ var subs = bw._topics[topic];
1129
+ if (!subs) return;
1130
+ bw._topics[topic] = subs.filter(function(s) { return s.id !== id; });
1131
+ if (bw._topics[topic].length === 0) delete bw._topics[topic];
1132
+ };
1133
+
1134
+ // Tie to element lifecycle if provided
1135
+ if (el) {
1136
+ if (!el._bw_subs) el._bw_subs = [];
1137
+ el._bw_subs.push(unsub);
1138
+ // Ensure element has data-bw-id so bw.cleanup() finds it
1139
+ if (!el.getAttribute('data-bw-id')) {
1140
+ var bwId = 'bw_sub_' + id;
1141
+ el.setAttribute('data-bw-id', bwId);
1142
+ }
1143
+ }
1144
+
1145
+ return unsub;
1146
+ };
1147
+
1148
+ /**
1149
+ * Unsubscribe a handler by reference from a topic.
1150
+ *
1151
+ * Removes ALL instances of the given handler on the topic.
1152
+ * Alternative to calling the unsub function returned by `bw.sub()`.
1153
+ *
1154
+ * @param {string} topic - Topic name
1155
+ * @param {Function} handler - The handler to remove (by reference equality)
1156
+ * @returns {number} Count of removed subscriptions
1157
+ * @category Pub/Sub
1158
+ * @see bw.sub
1159
+ */
1160
+ bw.unsub = function(topic, handler) {
1161
+ var subs = bw._topics[topic];
1162
+ if (!subs) return 0;
1163
+ var before = subs.length;
1164
+ bw._topics[topic] = subs.filter(function(s) { return s.handler !== handler; });
1165
+ var removed = before - bw._topics[topic].length;
1166
+ if (bw._topics[topic].length === 0) delete bw._topics[topic];
1167
+ return removed;
1168
+ };
1169
+
1170
+ /**
1171
+ * Generate CSS from JavaScript objects.
1172
+ *
1173
+ * Converts an object of `{ selector: { prop: value } }` rules into a CSS string.
1174
+ * CamelCase property names are auto-converted to kebab-case (e.g. `fontSize` → `font-size`).
1175
+ * Accepts nested arrays of rule objects.
1176
+ *
1177
+ * @param {Object|Array|string} rules - CSS rules as JS objects, array of rule objects, or raw CSS string
1178
+ * @param {Object} [options] - Generation options
1179
+ * @param {boolean} [options.minify=false] - Minify output (no whitespace)
1180
+ * @returns {string} CSS string
1181
+ * @category CSS & Styling
1182
+ * @see bw.injectCSS
1183
+ * @example
1184
+ * bw.css({
1185
+ * '.card': { padding: '1rem', fontSize: '14px', borderRadius: '8px' }
1186
+ * })
1187
+ * // => '.card {\n padding: 1rem;\n font-size: 14px;\n border-radius: 8px;\n}'
1188
+ */
1189
+ bw.css = function(rules, options = {}) {
1190
+ const { minify = false, pretty = !minify } = options;
1191
+
1192
+ if (typeof rules === 'string') return rules;
1193
+
1194
+ let css = '';
1195
+ const indent = pretty ? ' ' : '';
1196
+ const newline = pretty ? '\n' : '';
1197
+ const space = pretty ? ' ' : '';
1198
+
1199
+ if (Array.isArray(rules)) {
1200
+ css = rules.map(rule => bw.css(rule, options)).join(newline);
1201
+ } else if (typeof rules === 'object') {
1202
+ Object.entries(rules).forEach(([selector, styles]) => {
1203
+ if (typeof styles === 'object' && !Array.isArray(styles)) {
1204
+ // Handle @media, @keyframes, @supports — recurse into nested block
1205
+ if (selector.charAt(0) === '@') {
1206
+ const inner = bw.css(styles, options);
1207
+ if (inner) {
1208
+ css += `${selector}${space}{${newline}${inner}${newline}}${newline}`;
1209
+ }
1210
+ return;
1211
+ }
1212
+ const declarations = Object.entries(styles)
1213
+ .filter(([, value]) => value != null)
1214
+ .map(([prop, value]) => {
1215
+ // Convert camelCase to kebab-case
1216
+ const kebabProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
1217
+ return `${indent}${kebabProp}:${space}${value};`;
1218
+ })
1219
+ .join(newline);
1220
+
1221
+ if (declarations) {
1222
+ css += `${selector}${space}{${newline}${declarations}${newline}}${newline}`;
1223
+ }
1224
+ }
1225
+ });
1226
+ }
1227
+
1228
+ return css.trim();
1229
+ };
1230
+
1231
+ /**
1232
+ * Inject CSS into the document head (browser only).
1233
+ *
1234
+ * Creates or reuses a `<style>` element (identified by `id`). Can accept
1235
+ * raw CSS strings or JS rule objects (which are converted via `bw.css()`).
1236
+ * By default appends to existing content; set `append: false` to replace.
1237
+ *
1238
+ * @param {string|Object|Array} css - CSS string, or JS rule objects to convert
1239
+ * @param {Object} [options] - Injection options
1240
+ * @param {string} [options.id='bw-styles'] - ID for the style element
1241
+ * @param {boolean} [options.append=true] - Append to existing CSS (false to replace)
1242
+ * @returns {Element} The style element
1243
+ * @category CSS & Styling
1244
+ * @see bw.css
1245
+ * @see bw.loadDefaultStyles
1246
+ * @example
1247
+ * bw.injectCSS('.my-class { color: red; }');
1248
+ * bw.injectCSS({ '.card': { padding: '1rem' } }, { id: 'card-styles' });
1249
+ */
1250
+ bw.injectCSS = function(css, options = {}) {
1251
+ if (!bw._isBrowser) {
1252
+ console.warn('bw.injectCSS requires a DOM environment');
1253
+ return null;
1254
+ }
1255
+
1256
+ const { id = 'bw-styles', append = true } = options;
1257
+
1258
+ // Get or create style element
1259
+ let styleEl = document.getElementById(id);
1260
+
1261
+ if (!styleEl) {
1262
+ styleEl = document.createElement('style');
1263
+ styleEl.id = id;
1264
+ styleEl.type = 'text/css';
1265
+ document.head.appendChild(styleEl);
1266
+ }
1267
+
1268
+ // Convert CSS if needed
1269
+ const cssStr = typeof css === 'string' ? css : bw.css(css, options);
1270
+
1271
+ // Set or append CSS
1272
+ if (append && styleEl.textContent) {
1273
+ styleEl.textContent += '\n' + cssStr;
1274
+ } else {
1275
+ styleEl.textContent = cssStr;
1276
+ }
1277
+
1278
+ return styleEl;
1279
+ };
1280
+
1281
+ /**
1282
+ * Merge multiple style objects into one (left-to-right).
1283
+ *
1284
+ * Like `Object.assign()` for styles, but filters out null/undefined arguments.
1285
+ * Compose inline styles or CSS rule objects without mutation.
1286
+ *
1287
+ * @param {...Object} styles - Style objects to merge (left-to-right)
1288
+ * @returns {Object} Merged style object
1289
+ * @category CSS & Styling
1290
+ * @see bw.u
1291
+ * @example
1292
+ * var style = bw.s(bw.u.flex, bw.u.gap4, { color: 'red' });
1293
+ * // => { display: 'flex', gap: '1rem', color: 'red' }
1294
+ */
1295
+ bw.s = function() {
1296
+ var result = {};
1297
+ for (var i = 0; i < arguments.length; i++) {
1298
+ var arg = arguments[i];
1299
+ if (arg && typeof arg === 'object') Object.assign(result, arg);
1300
+ }
1301
+ return result;
1302
+ };
1303
+
1304
+ /**
1305
+ * Pre-built CSS utility objects (like Tailwind utilities, but in JS).
1306
+ *
1307
+ * Compose with `bw.s()` to build inline styles without writing raw CSS strings.
1308
+ * Includes flex, padding, margin, typography, color, border, and transition utilities.
1309
+ *
1310
+ * @category CSS & Styling
1311
+ * @see bw.s
1312
+ * @example
1313
+ * { t: 'div', a: { style: bw.s(bw.u.flex, bw.u.gap4, bw.u.p4) },
1314
+ * c: 'Flexbox with 1rem gap and padding' }
1315
+ */
1316
+ bw.u = {
1317
+ // Display
1318
+ flex: { display: 'flex' },
1319
+ flexCol: { display: 'flex', flexDirection: 'column' },
1320
+ flexRow: { display: 'flex', flexDirection: 'row' },
1321
+ flexWrap: { display: 'flex', flexWrap: 'wrap' },
1322
+ block: { display: 'block' },
1323
+ inline: { display: 'inline' },
1324
+ hidden: { display: 'none' },
1325
+
1326
+ // Flex alignment
1327
+ justifyCenter: { justifyContent: 'center' },
1328
+ justifyBetween: { justifyContent: 'space-between' },
1329
+ justifyEnd: { justifyContent: 'flex-end' },
1330
+ alignCenter: { alignItems: 'center' },
1331
+ alignStart: { alignItems: 'flex-start' },
1332
+ alignEnd: { alignItems: 'flex-end' },
1333
+
1334
+ // Gap (0.25rem increments)
1335
+ gap1: { gap: '0.25rem' },
1336
+ gap2: { gap: '0.5rem' },
1337
+ gap3: { gap: '0.75rem' },
1338
+ gap4: { gap: '1rem' },
1339
+ gap6: { gap: '1.5rem' },
1340
+ gap8: { gap: '2rem' },
1341
+
1342
+ // Padding
1343
+ p0: { padding: '0' },
1344
+ p1: { padding: '0.25rem' },
1345
+ p2: { padding: '0.5rem' },
1346
+ p3: { padding: '0.75rem' },
1347
+ p4: { padding: '1rem' },
1348
+ p6: { padding: '1.5rem' },
1349
+ p8: { padding: '2rem' },
1350
+ px4: { paddingLeft: '1rem', paddingRight: '1rem' },
1351
+ py2: { paddingTop: '0.5rem', paddingBottom: '0.5rem' },
1352
+ py4: { paddingTop: '1rem', paddingBottom: '1rem' },
1353
+
1354
+ // Margin (same scale)
1355
+ m0: { margin: '0' },
1356
+ m4: { margin: '1rem' },
1357
+ mt2: { marginTop: '0.5rem' },
1358
+ mt4: { marginTop: '1rem' },
1359
+ mb2: { marginBottom: '0.5rem' },
1360
+ mb4: { marginBottom: '1rem' },
1361
+ mx_auto: { marginLeft: 'auto', marginRight: 'auto' },
1362
+
1363
+ // Typography
1364
+ textSm: { fontSize: '0.875rem' },
1365
+ textBase: { fontSize: '1rem' },
1366
+ textLg: { fontSize: '1.125rem' },
1367
+ textXl: { fontSize: '1.25rem' },
1368
+ text2xl: { fontSize: '1.5rem' },
1369
+ text3xl: { fontSize: '1.875rem' },
1370
+ bold: { fontWeight: '700' },
1371
+ semibold: { fontWeight: '600' },
1372
+ italic: { fontStyle: 'italic' },
1373
+ textCenter: { textAlign: 'center' },
1374
+ textRight: { textAlign: 'right' },
1375
+
1376
+ // Colors (from design tokens)
1377
+ bgWhite: { background: '#ffffff' },
1378
+ bgTeal: { background: '#006666', color: '#ffffff' },
1379
+ textWhite: { color: '#ffffff' },
1380
+ textTeal: { color: '#006666' },
1381
+ textMuted: { color: '#888' },
1382
+
1383
+ // Borders
1384
+ rounded: { borderRadius: '0.375rem' },
1385
+ roundedLg: { borderRadius: '0.5rem' },
1386
+ roundedFull: { borderRadius: '9999px' },
1387
+ border: { border: '1px solid #d8d8d8' },
1388
+
1389
+ // Sizing
1390
+ wFull: { width: '100%' },
1391
+ hFull: { height: '100%' },
1392
+
1393
+ // Transitions
1394
+ transition: { transition: 'all 0.2s ease' }
1395
+ };
1396
+
1397
+ /**
1398
+ * Generate responsive CSS with media query breakpoints.
1399
+ *
1400
+ * Produces a CSS string with `@media` rules for sm (640px), md (768px),
1401
+ * lg (1024px), and xl (1280px) breakpoints. Pass the result to `bw.injectCSS()`.
1402
+ *
1403
+ * @param {string} selector - CSS selector
1404
+ * @param {Object} breakpoints - Object with keys: base, sm, md, lg, xl
1405
+ * @returns {string} Generated CSS string (pass to bw.injectCSS)
1406
+ * @category CSS & Styling
1407
+ * @see bw.css
1408
+ * @see bw.injectCSS
1409
+ * @example
1410
+ * var css = bw.responsive('.grid', {
1411
+ * base: { gridTemplateColumns: '1fr' },
1412
+ * md: { gridTemplateColumns: '1fr 1fr' },
1413
+ * lg: { gridTemplateColumns: '1fr 1fr 1fr' }
1414
+ * });
1415
+ * bw.injectCSS(css);
1416
+ */
1417
+ bw.responsive = function(selector, breakpoints) {
1418
+ var sizes = { sm: '640px', md: '768px', lg: '1024px', xl: '1280px' };
1419
+ var parts = [];
1420
+ Object.keys(breakpoints).forEach(function(key) {
1421
+ var rules = {};
1422
+ if (key === 'base') {
1423
+ rules[selector] = breakpoints[key];
1424
+ parts.push(bw.css(rules));
1425
+ } else if (sizes[key]) {
1426
+ rules[selector] = breakpoints[key];
1427
+ parts.push('@media (min-width: ' + sizes[key] + ') {\n' + bw.css(rules) + '\n}');
1428
+ }
1429
+ });
1430
+ return parts.join('\n');
1431
+ };
1432
+
1433
+ /**
1434
+ * Map/scale a value from one range to another (linear interpolation).
1435
+ *
1436
+ * Useful for converting sensor data, normalizing values, or creating
1437
+ * visual scales. Supports optional clamping and exponential scaling.
1438
+ *
1439
+ * @param {number} x - Input value
1440
+ * @param {number} in0 - Input range start
1441
+ * @param {number} in1 - Input range end
1442
+ * @param {number} out0 - Output range start
1443
+ * @param {number} out1 - Output range end
1444
+ * @param {Object} [options] - Mapping options
1445
+ * @param {boolean} [options.clip=false] - Clamp result to output range
1446
+ * @param {number} [options.expScale=1] - Exponential scaling factor
1447
+ * @returns {number} Mapped value
1448
+ * @category Math
1449
+ * @see bw.clip
1450
+ * @example
1451
+ * bw.mapScale(50, 0, 100, 0, 1) // => 0.5
1452
+ * bw.mapScale(75, 0, 100, 0, 255) // => 191.25
1453
+ */
1454
+ bw.mapScale = function(x, in0, in1, out0, out1, options = {}) {
1455
+ const { clip = false, expScale = 1 } = options;
1456
+
1457
+ // Normalize to 0-1
1458
+ let normalized = (x - in0) / (in1 - in0);
1459
+
1460
+ // Apply exponential scaling
1461
+ if (expScale !== 1) {
1462
+ normalized = Math.pow(normalized, expScale);
1463
+ }
1464
+
1465
+ // Map to output range
1466
+ let result = normalized * (out1 - out0) + out0;
1467
+
1468
+ // Clip if requested
1469
+ if (clip) {
1470
+ const min = Math.min(out0, out1);
1471
+ const max = Math.max(out0, out1);
1472
+ result = Math.max(min, Math.min(max, result));
1473
+ }
1474
+
1475
+ return result;
1476
+ };
1477
+
1478
+ /**
1479
+ * Clamp a value between min and max bounds.
1480
+ *
1481
+ * @param {number} value - Value to clamp
1482
+ * @param {number} min - Minimum allowed value
1483
+ * @param {number} max - Maximum allowed value
1484
+ * @returns {number} Clamped value
1485
+ * @category Math
1486
+ * @see bw.mapScale
1487
+ * @example
1488
+ * bw.clip(150, 0, 100) // => 100
1489
+ * bw.clip(-5, 0, 100) // => 0
1490
+ * bw.clip(50, 0, 100) // => 50
1491
+ */
1492
+ bw.clip = function(value, min, max) {
1493
+ return Math.max(min, Math.min(max, value));
1494
+ };
1495
+
1496
+ /**
1497
+ * DOM selection helper that always returns an array (browser only).
1498
+ *
1499
+ * Wraps `querySelectorAll` and normalizes the result to a plain Array
1500
+ * so you can use `.map()`, `.filter()`, etc. directly. Accepts CSS selectors,
1501
+ * single elements, NodeLists, or arrays.
1502
+ *
1503
+ * @param {string|Element|Array} selector - CSS selector, element, or array
1504
+ * @returns {Array} Array of DOM elements
1505
+ * @category DOM Selection
1506
+ * @example
1507
+ * bw.$('.card') // => [div.card, div.card, ...]
1508
+ * bw.$(myElement) // => [myElement]
1509
+ * bw.$('.card').map(el => el.textContent)
1510
+ */
1511
+ if (bw._isBrowser) {
1512
+ bw.$ = function(selector) {
1513
+ if (!selector) return [];
1514
+
1515
+ // Already an array
1516
+ if (Array.isArray(selector)) return selector;
1517
+
1518
+ // Single element
1519
+ if (selector.nodeType) return [selector];
1520
+
1521
+ // NodeList or HTMLCollection
1522
+ if (selector.length !== undefined && typeof selector !== 'string') {
1523
+ return Array.from(selector);
1524
+ }
1525
+
1526
+ // CSS selector string
1527
+ if (typeof selector === 'string') {
1528
+ return Array.from(document.querySelectorAll(selector));
1529
+ }
1530
+
1531
+ return [];
1532
+ };
1533
+
1534
+ // Convenience single element selector
1535
+ bw.$.one = function(selector) {
1536
+ return bw.$(selector)[0] || null;
1537
+ };
1538
+ }
1539
+
1540
+ /**
1541
+ * Load the built-in Bootstrap-inspired default stylesheet.
1542
+ *
1543
+ * Injects bitwrench's batteries-included CSS (buttons, cards, grids, forms,
1544
+ * alerts, badges, nav, tabs, etc.) into the document head. Call once at app startup.
1545
+ * Returns null in Node.js (no DOM).
1546
+ *
1547
+ * @param {Object} [options] - Style loading options
1548
+ * @param {boolean} [options.minify=true] - Minify the CSS output
1549
+ * @returns {Element|null} Style element if in browser, null in Node.js
1550
+ * @category CSS & Styling
1551
+ * @see bw.setTheme
1552
+ * @see bw.toggleDarkMode
1553
+ * @example
1554
+ * bw.loadDefaultStyles(); // inject all default CSS
1555
+ */
1556
+ bw.loadDefaultStyles = function(options = {}) {
1557
+ const { minify = true, palette } = options;
1558
+
1559
+ // 1. Inject structural CSS (layout, sizing — never changes with theme)
1560
+ if (bw._isBrowser) {
1561
+ var structuralCSS = bw.css(getStructuralStyles());
1562
+ bw.injectCSS(structuralCSS, { id: 'bw-structural', append: false, minify: minify });
1563
+ }
1564
+
1565
+ // 2. Inject cosmetic CSS via generateTheme (colors, shadows, radii)
1566
+ var paletteConfig = Object.assign({}, DEFAULT_PALETTE_CONFIG, palette || {});
1567
+ var result = bw.generateTheme('', Object.assign({}, paletteConfig, { inject: true }));
1568
+ return result;
1569
+ };
1570
+
1571
+ /**
1572
+ * Get the current theme configuration as a deep copy.
1573
+ *
1574
+ * @returns {Object} Theme object with colors, fonts, spacing, etc.
1575
+ * @category CSS & Styling
1576
+ * @see bw.setTheme
1577
+ */
1578
+ bw.getTheme = function() {
1579
+ if (typeof console !== 'undefined' && console.warn) {
1580
+ console.warn('bw.getTheme() is deprecated. Use bw.generateTheme() instead.');
1581
+ }
1582
+ return JSON.parse(JSON.stringify(theme));
1583
+ };
1584
+
1585
+ /**
1586
+ * Set theme overrides and optionally re-inject CSS custom properties.
1587
+ *
1588
+ * Merges your overrides into the current theme and updates `--bw-*` CSS
1589
+ * custom properties on `<html>` so all components pick up the changes live.
1590
+ *
1591
+ * @param {Object} overrides - Partial theme object to merge (e.g. { colors: { primary: '#ff0000' } })
1592
+ * @param {Object} [options] - Options
1593
+ * @param {boolean} [options.inject=true] - Whether to re-inject CSS (browser only)
1594
+ * @returns {Object} Updated theme
1595
+ * @category CSS & Styling
1596
+ * @see bw.getTheme
1597
+ * @see bw.loadDefaultStyles
1598
+ * @example
1599
+ * bw.setTheme({ colors: { primary: '#ff6600' } });
1600
+ */
1601
+ bw.setTheme = function(overrides, options = {}) {
1602
+ if (typeof console !== 'undefined' && console.warn) {
1603
+ console.warn('bw.setTheme() is deprecated. Use bw.generateTheme() instead.');
1604
+ }
1605
+ const { inject = true } = options;
1606
+ updateTheme(overrides);
1607
+
1608
+ // Update CSS custom properties if colors changed and we're in browser
1609
+ if (inject && bw._isBrowser && overrides.colors) {
1610
+ const root = document.documentElement;
1611
+ for (const [name, value] of Object.entries(overrides.colors)) {
1612
+ root.style.setProperty('--bw-' + name, value);
1613
+ }
1614
+ }
1615
+
1616
+ return bw.getTheme();
1617
+ };
1618
+
1619
+ /**
1620
+ * Toggle dark mode on/off.
1621
+ *
1622
+ * Adds/removes the `bw-dark` class on `<html>` and injects dark mode CSS
1623
+ * overrides. Pass `true`/`false` to force a mode, or omit to toggle.
1624
+ *
1625
+ * @param {boolean} [force] - Force dark (true) or light (false). Omit to toggle.
1626
+ * @returns {boolean} Whether dark mode is now active
1627
+ * @category CSS & Styling
1628
+ * @see bw.setTheme
1629
+ * @example
1630
+ * bw.toggleDarkMode(); // toggle
1631
+ * bw.toggleDarkMode(true); // force dark
1632
+ * bw.toggleDarkMode(false); // force light
1633
+ */
1634
+ bw.toggleDarkMode = function(force) {
1635
+ const isDark = force !== undefined ? force : !theme.darkMode;
1636
+ theme.darkMode = isDark;
1637
+
1638
+ if (bw._isBrowser) {
1639
+ const root = document.documentElement;
1640
+ if (isDark) {
1641
+ root.classList.add('bw-dark');
1642
+ // Generate palette-aware dark mode CSS, or fall back to default
1643
+ var palette = bw._activePalette || derivePalette(DEFAULT_PALETTE_CONFIG);
1644
+ var darkRules = generateDarkModeCSS(palette);
1645
+ var darkCSS = bw.css(darkRules);
1646
+
1647
+ // Remove existing dark styles to allow regeneration
1648
+ var existing = document.getElementById('bw-dark-styles');
1649
+ if (existing) existing.remove();
1650
+
1651
+ var styleEl = document.createElement('style');
1652
+ styleEl.id = 'bw-dark-styles';
1653
+ styleEl.textContent = darkCSS;
1654
+ document.head.appendChild(styleEl);
1655
+ } else {
1656
+ root.classList.remove('bw-dark');
1657
+ // Remove dark mode styles when switching to light
1658
+ var darkEl = document.getElementById('bw-dark-styles');
1659
+ if (darkEl) darkEl.remove();
1660
+ }
1661
+ }
1662
+
1663
+ return isDark;
1664
+ };
1665
+
1666
+ /**
1667
+ * Generate a complete, scoped theme from seed colors.
1668
+ *
1669
+ * Produces CSS for all themed components (buttons, alerts, badges, cards,
1670
+ * forms, nav, tables, tabs, list groups, pagination, progress, hero, utilities)
1671
+ * scoped under `.name` class. Multiple themes can coexist in the stylesheet.
1672
+ * Swap themes by changing the class on a container element.
1673
+ *
1674
+ * @param {string} name - CSS scope class (e.g. 'ocean'). Empty string = unscoped global.
1675
+ * @param {Object} config - Theme configuration
1676
+ * @param {string} config.primary - Primary brand color hex
1677
+ * @param {string} config.secondary - Secondary color hex
1678
+ * @param {string} [config.tertiary] - Tertiary/accent color hex (defaults to primary)
1679
+ * @param {string} [config.success='#198754'] - Success color hex
1680
+ * @param {string} [config.danger='#dc3545'] - Danger color hex
1681
+ * @param {string} [config.warning='#ffc107'] - Warning color hex
1682
+ * @param {string} [config.info='#0dcaf0'] - Info color hex
1683
+ * @param {string} [config.light='#f8f9fa'] - Light color hex
1684
+ * @param {string} [config.dark='#212529'] - Dark color hex
1685
+ * @param {string} [config.spacing='normal'] - 'compact' | 'normal' | 'spacious'
1686
+ * @param {string} [config.radius='md'] - 'none' | 'sm' | 'md' | 'lg' | 'pill'
1687
+ * @param {number} [config.fontSize=1.0] - Base font size scale factor
1688
+ * @param {boolean} [config.inject=true] - Inject into DOM (browser only)
1689
+ * @returns {Object} { css, palette, name }
1690
+ * @category CSS & Styling
1691
+ * @see bw.loadDefaultStyles
1692
+ * @example
1693
+ * // Generate and inject an ocean theme
1694
+ * bw.generateTheme('ocean', {
1695
+ * primary: '#0077b6',
1696
+ * secondary: '#90e0ef',
1697
+ * tertiary: '#00b4d8'
1698
+ * });
1699
+ *
1700
+ * // Apply to a container
1701
+ * document.getElementById('app').classList.add('ocean');
1702
+ *
1703
+ * // Generate CSS for static export (Node.js)
1704
+ * var result = bw.generateTheme('sunset', {
1705
+ * primary: '#e76f51',
1706
+ * secondary: '#264653',
1707
+ * tertiary: '#e9c46a',
1708
+ * inject: false
1709
+ * });
1710
+ * fs.writeFileSync('sunset.css', result.css);
1711
+ */
1712
+ bw.generateTheme = function(name, config) {
1713
+ if (!config || !config.primary || !config.secondary) {
1714
+ throw new Error('bw.generateTheme requires config.primary and config.secondary');
1715
+ }
1716
+
1717
+ // Merge with defaults; if user didn't supply tertiary, default to their primary
1718
+ var fullConfig = Object.assign({}, DEFAULT_PALETTE_CONFIG, config);
1719
+ if (!config.tertiary) fullConfig.tertiary = fullConfig.primary;
1720
+
1721
+ // Derive palette
1722
+ var palette = derivePalette(fullConfig);
1723
+
1724
+ // Store active palette for dark mode
1725
+ bw._activePalette = palette;
1726
+
1727
+ // Resolve layout
1728
+ var layout = resolveLayout(fullConfig);
1729
+
1730
+ // Generate themed CSS rules
1731
+ var themedRules = generateThemedCSS(name, palette, layout);
1732
+
1733
+ // Add underscore aliases
1734
+ var aliasedRules = addUnderscoreAliases(themedRules);
1735
+
1736
+ // Convert to CSS string
1737
+ var cssStr = bw.css(aliasedRules);
1738
+
1739
+ // Inject into DOM if requested and in browser
1740
+ var shouldInject = config.inject !== false;
1741
+ if (shouldInject && bw._isBrowser) {
1742
+ var styleId = name ? 'bw-theme-' + name : 'bw-theme-default';
1743
+ bw.injectCSS(cssStr, { id: styleId, append: false });
1744
+ }
1745
+
1746
+ // Update bw.u color entries to reflect the palette
1747
+ if (!name) {
1748
+ bw.u.bgTeal = { background: palette.primary.base, color: palette.primary.textOn };
1749
+ bw.u.textTeal = { color: palette.primary.base };
1750
+ bw.u.bgWhite = { background: '#ffffff' };
1751
+ bw.u.textWhite = { color: '#ffffff' };
1752
+ }
1753
+
1754
+ return { css: cssStr, palette: palette, name: name };
1755
+ };
1756
+
1757
+ // Expose color utility functions on bw namespace
1758
+ bw.hexToHsl = hexToHsl;
1759
+ bw.hslToHex = hslToHex;
1760
+ bw.adjustLightness = adjustLightness;
1761
+ bw.mixColor = mixColor;
1762
+ bw.relativeLuminance = relativeLuminance;
1763
+ bw.textOnColor = textOnColor;
1764
+ bw.deriveShades = deriveShades;
1765
+ bw.derivePalette = derivePalette;
1766
+
1767
+ // Expose layout and theme presets
1768
+ bw.SPACING_PRESETS = SPACING_PRESETS;
1769
+ bw.RADIUS_PRESETS = RADIUS_PRESETS;
1770
+ bw.DEFAULT_PALETTE_CONFIG = DEFAULT_PALETTE_CONFIG;
1771
+ bw.THEME_PRESETS = THEME_PRESETS;
1772
+
1773
+ // ===================================================================================
1774
+ // Legacy v1 Functions - Useful utilities retained from bitwrench v1
1775
+ // ===================================================================================
1776
+
1777
+ /**
1778
+ * Use a dictionary as a switch statement, with support for function values.
1779
+ *
1780
+ * Looks up `x` in `choices`. If the value is a function, calls it with `x` as argument.
1781
+ * Returns `def` if the key is not found.
1782
+ *
1783
+ * @param {*} x - Key to look up
1784
+ * @param {Object} choices - Dictionary of choices (values can be functions)
1785
+ * @param {*} def - Default value if key not found
1786
+ * @returns {*} Value or function result
1787
+ * @category Array Utilities
1788
+ * @example
1789
+ * var colors = { red: 1, blue: 2, aqua: function(z) { return z + 'marine'; } };
1790
+ * bw.choice('red', colors, '0') // => 1
1791
+ * bw.choice('aqua', colors) // => 'aquamarine'
1792
+ * bw.choice('pink', colors, 'n/a') // => 'n/a'
1793
+ */
1794
+ bw.choice = function(x, choices, def) {
1795
+ const z = (x in choices) ? choices[x] : def;
1796
+ return bw.typeOf(z) === "function" ? z(x) : z;
1797
+ };
1798
+
1799
+ /**
1800
+ * Return unique elements of an array (preserves first occurrence order).
1801
+ *
1802
+ * @param {Array} x - Input array
1803
+ * @returns {Array} Array with unique elements
1804
+ * @category Array Utilities
1805
+ * @example
1806
+ * bw.arrayUniq([1, 2, 2, 3, 1]) // => [1, 2, 3]
1807
+ */
1808
+ bw.arrayUniq = function(x) {
1809
+ if (bw.typeOf(x) !== "array") return [];
1810
+ return x.filter((v, i, arr) => arr.indexOf(v) === i);
1811
+ };
1812
+
1813
+ /**
1814
+ * Return the intersection of two arrays (elements present in both).
1815
+ *
1816
+ * @param {Array} a - First array
1817
+ * @param {Array} b - Second array
1818
+ * @returns {Array} Unique elements found in both a and b
1819
+ * @category Array Utilities
1820
+ * @see bw.arrayBNotInA
1821
+ * @example
1822
+ * bw.arrayBinA([1, 2, 3], [2, 3, 4]) // => [2, 3]
1823
+ */
1824
+ bw.arrayBinA = function(a, b) {
1825
+ if (bw.typeOf(a) !== "array" || bw.typeOf(b) !== "array") return [];
1826
+ return bw.arrayUniq(a.filter(n => b.indexOf(n) !== -1));
1827
+ };
1828
+
1829
+ /**
1830
+ * Return elements of b that are not present in a (set difference).
1831
+ *
1832
+ * @param {Array} a - First array (the "exclude" set)
1833
+ * @param {Array} b - Second array (source of results)
1834
+ * @returns {Array} Unique elements in b but not in a
1835
+ * @category Array Utilities
1836
+ * @see bw.arrayBinA
1837
+ * @example
1838
+ * bw.arrayBNotInA([1, 2, 3], [2, 3, 4, 5]) // => [4, 5]
1839
+ */
1840
+ bw.arrayBNotInA = function(a, b) {
1841
+ if (bw.typeOf(a) !== "array" || bw.typeOf(b) !== "array") return [];
1842
+ return bw.arrayUniq(b.filter(n => a.indexOf(n) < 0));
1843
+ };
1844
+
1845
+ /**
1846
+ * Interpolate between an array of colors based on a value in a range.
1847
+ *
1848
+ * Maps a value from [in0..in1] across a gradient of colors, smoothly blending
1849
+ * between adjacent stops. Useful for heatmaps, gauges, and data visualization.
1850
+ *
1851
+ * @param {number} x - Value to interpolate
1852
+ * @param {number} in0 - Input range start
1853
+ * @param {number} in1 - Input range end
1854
+ * @param {Array} colors - Array of CSS color strings to interpolate between
1855
+ * @param {number} [stretch] - Exponential scaling factor (1 = linear)
1856
+ * @returns {Array} Interpolated color as [r, g, b, a, "rgb"]
1857
+ * @category Color
1858
+ * @see bw.colorParse
1859
+ * @see bw.mapScale
1860
+ * @example
1861
+ * bw.colorInterp(50, 0, 100, ['#ff0000', '#00ff00'])
1862
+ * // => [128, 128, 0, 255, "rgb"] (yellow midpoint)
1863
+ */
1864
+ bw.colorInterp = function(x, in0, in1, colors, stretch) {
1865
+ let c = Array.isArray(colors) ? colors : ["#000", "#fff"];
1866
+ c = c.length === 0 ? ["#000", "#fff"] : c;
1867
+ if (c.length === 1) return c[0];
1868
+
1869
+ // Convert all colors to RGB format
1870
+ c = c.map(col => bw.colorParse(col));
1871
+
1872
+ const a = bw.mapScale(x, in0, in1, 0, c.length - 1, { clip: true, expScale: stretch });
1873
+ const i = bw.clip(Math.floor(a), 0, c.length - 2);
1874
+ const r = a - i;
1875
+
1876
+ const interp = (idx) => bw.mapScale(r, 0, 1, c[i][idx], c[i + 1][idx], { clip: true });
1877
+ return [interp(0), interp(1), interp(2), interp(3), "rgb"];
1878
+ };
1879
+
1880
+ /**
1881
+ * Convert an HSL color to RGB.
1882
+ *
1883
+ * Accepts individual h, s, l values or a bitwrench color array [h, s, l, a, "hsl"].
1884
+ *
1885
+ * @param {number|Array} h - Hue [0..360] or [h,s,l,a,"hsl"] array
1886
+ * @param {number} s - Saturation [0..100]
1887
+ * @param {number} l - Lightness [0..100]
1888
+ * @param {number} [a=255] - Alpha [0..255]
1889
+ * @param {boolean} [rnd=true] - Round results to integers
1890
+ * @returns {Array} RGB as [r, g, b, a, "rgb"]
1891
+ * @category Color
1892
+ * @see bw.colorRgbToHsl
1893
+ * @example
1894
+ * bw.colorHslToRgb(0, 100, 50) // => [255, 0, 0, 255, "rgb"]
1895
+ * bw.colorHslToRgb(120, 100, 50) // => [0, 255, 0, 255, "rgb"]
1896
+ */
1897
+ bw.colorHslToRgb = function(h, s, l, a = 255, rnd = true) {
1898
+ if (bw.typeOf(h) === "array") {
1899
+ s = h[1]; l = h[2]; a = h[3]; h = h[0];
1900
+ }
1901
+
1902
+ const hNorm = h / 360;
1903
+ const sNorm = s / 100;
1904
+ const lNorm = l / 100;
1905
+
1906
+ let r, g, b;
1907
+
1908
+ if (sNorm === 0) {
1909
+ r = g = b = lNorm * 255;
1910
+ } else {
1911
+ const hue2rgb = (p, q, t) => {
1912
+ if (t < 0) t += 1;
1913
+ if (t > 1) t -= 1;
1914
+ if (t < 1/6) return p + (q - p) * 6 * t;
1915
+ if (t < 1/2) return q;
1916
+ if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
1917
+ return p;
1918
+ };
1919
+
1920
+ const q = lNorm < 0.5 ? lNorm * (1 + sNorm) : lNorm + sNorm - lNorm * sNorm;
1921
+ const p = 2 * lNorm - q;
1922
+
1923
+ r = hue2rgb(p, q, hNorm + 1/3) * 255;
1924
+ g = hue2rgb(p, q, hNorm) * 255;
1925
+ b = hue2rgb(p, q, hNorm - 1/3) * 255;
1926
+ }
1927
+
1928
+ if (rnd) {
1929
+ r = Math.round(r);
1930
+ g = Math.round(g);
1931
+ b = Math.round(b);
1932
+ a = Math.round(a);
1933
+ }
1934
+
1935
+ return [r, g, b, a, "rgb"];
1936
+ };
1937
+
1938
+ /**
1939
+ * Convert an RGB color to HSL.
1940
+ *
1941
+ * Accepts individual r, g, b values or a bitwrench color array [r, g, b, a, "rgb"].
1942
+ *
1943
+ * @param {number|Array} r - Red [0..255] or [r,g,b,a,"rgb"] array
1944
+ * @param {number} g - Green [0..255]
1945
+ * @param {number} b - Blue [0..255]
1946
+ * @param {number} [a=255] - Alpha [0..255]
1947
+ * @param {boolean} [rnd=true] - Round results to integers
1948
+ * @returns {Array} HSL as [h, s, l, a, "hsl"]
1949
+ * @category Color
1950
+ * @see bw.colorHslToRgb
1951
+ * @example
1952
+ * bw.colorRgbToHsl(255, 0, 0) // => [0, 100, 50, 255, "hsl"]
1953
+ * bw.colorRgbToHsl(0, 0, 255) // => [240, 100, 50, 255, "hsl"]
1954
+ */
1955
+ bw.colorRgbToHsl = function(r, g, b, a = 255, rnd = true) {
1956
+ if (bw.typeOf(r) === "array") {
1957
+ g = r[1]; b = r[2]; a = r[3]; r = r[0];
1958
+ }
1959
+
1960
+ r /= 255;
1961
+ g /= 255;
1962
+ b /= 255;
1963
+
1964
+ const max = Math.max(r, g, b);
1965
+ const min = Math.min(r, g, b);
1966
+ let h, s, l = (max + min) / 2;
1967
+
1968
+ if (max === min) {
1969
+ h = s = 0; // achromatic
1970
+ } else {
1971
+ const d = max - min;
1972
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
1973
+
1974
+ switch (max) {
1975
+ case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
1976
+ case g: h = ((b - r) / d + 2) / 6; break;
1977
+ case b: h = ((r - g) / d + 4) / 6; break;
1978
+ }
1979
+ }
1980
+
1981
+ h *= 360;
1982
+ s *= 100;
1983
+ l *= 100;
1984
+
1985
+ if (rnd) {
1986
+ h = Math.round(h);
1987
+ s = Math.round(s);
1988
+ l = Math.round(l);
1989
+ a = Math.round(a);
1990
+ }
1991
+
1992
+ return [h, s, l, a, "hsl"];
1993
+ };
1994
+
1995
+ /**
1996
+ * Parse a CSS color string into bitwrench's internal array format.
1997
+ *
1998
+ * Supports hex (#rgb, #rrggbb, #rrggbbaa), rgb(), rgba(), hsl(), and hsla().
1999
+ * Also accepts existing bitwrench color arrays (pass-through).
2000
+ *
2001
+ * @param {string|Array} s - CSS color string (e.g. "#ff0000", "rgb(255,0,0)") or color array
2002
+ * @param {number} [defAlpha=255] - Default alpha value
2003
+ * @returns {Array} Color as [c0, c1, c2, a, "rgb"|"hsl"]
2004
+ * @category Color
2005
+ * @see bw.colorInterp
2006
+ * @example
2007
+ * bw.colorParse('#ff0000') // => [255, 0, 0, 255, "rgb"]
2008
+ * bw.colorParse('rgb(0,128,255)') // => [0, 128, 255, 255, "rgb"]
2009
+ */
2010
+ bw.colorParse = function(s, defAlpha = 255) {
2011
+ let r = [0, 0, 0, defAlpha, "rgb"]; // default return
2012
+
2013
+ if (bw.typeOf(s) === "array") {
2014
+ // Handle bitwrench color array
2015
+ const df = [0, 0, 0, 255, "rgb"];
2016
+ for (let p = 0; p < s.length && p < df.length; p++) {
2017
+ df[p] = s[p];
2018
+ }
2019
+ return df;
2020
+ }
2021
+
2022
+ s = String(s).replace(/\s/g, "");
2023
+
2024
+ // Handle hex colors
2025
+ if (s[0] === "#") {
2026
+ const hex = s.slice(1);
2027
+ if (hex.length === 3 || hex.length === 4) {
2028
+ // #rgb or #rgba
2029
+ for (let i = 0; i < hex.length; i++) {
2030
+ r[i] = parseInt(hex[i] + hex[i], 16);
2031
+ }
2032
+ } else if (hex.length === 6 || hex.length === 8) {
2033
+ // #rrggbb or #rrggbbaa
2034
+ for (let i = 0; i < hex.length; i += 2) {
2035
+ r[i / 2] = parseInt(hex.substring(i, i + 2), 16);
2036
+ }
2037
+ }
2038
+ } else {
2039
+ // Handle rgb() rgba() hsl() hsla()
2040
+ const match = s.match(/^(rgb|hsl)a?\(([^)]+)\)$/i);
2041
+ if (match) {
2042
+ const type = match[1].toLowerCase();
2043
+ const values = match[2].split(",").map(v => parseFloat(v));
2044
+
2045
+ if (type === "rgb") {
2046
+ r[0] = values[0] || 0;
2047
+ r[1] = values[1] || 0;
2048
+ r[2] = values[2] || 0;
2049
+ r[3] = values[3] !== undefined ? values[3] * 255 : defAlpha;
2050
+ r[4] = "rgb";
2051
+ } else if (type === "hsl") {
2052
+ const rgb = bw.colorHslToRgb(values[0] || 0, values[1] || 0, values[2] || 0,
2053
+ values[3] !== undefined ? values[3] * 255 : defAlpha);
2054
+ return rgb;
2055
+ }
2056
+ }
2057
+ }
2058
+
2059
+ return r;
2060
+ };
2061
+
2062
+ /**
2063
+ * Set a browser cookie with expiration and options.
2064
+ *
2065
+ * @param {string} cname - Cookie name
2066
+ * @param {string} cvalue - Cookie value
2067
+ * @param {number} exdays - Expiration in days from now
2068
+ * @param {Object} [options] - Additional cookie options
2069
+ * @param {string} [options.path] - Cookie path
2070
+ * @param {string} [options.domain] - Cookie domain
2071
+ * @param {boolean} [options.secure] - Secure flag
2072
+ * @param {string} [options.sameSite] - SameSite attribute
2073
+ * @category Browser Utilities
2074
+ * @see bw.getCookie
2075
+ */
2076
+ bw.setCookie = function(cname, cvalue, exdays, options = {}) {
2077
+ if (!bw._isBrowser) return;
2078
+
2079
+ const d = new Date();
2080
+ d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));
2081
+
2082
+ let cookie = `${cname}=${cvalue}; expires=${d.toUTCString()}`;
2083
+
2084
+ // Add additional options
2085
+ if (options.path) cookie += `; path=${options.path}`;
2086
+ if (options.domain) cookie += `; domain=${options.domain}`;
2087
+ if (options.secure) cookie += '; secure';
2088
+ if (options.sameSite) cookie += `; samesite=${options.sameSite}`;
2089
+
2090
+ document.cookie = cookie;
2091
+ };
2092
+
2093
+ /**
2094
+ * Get a browser cookie value by name.
2095
+ *
2096
+ * @param {string} cname - Cookie name
2097
+ * @param {*} defaultValue - Default value if cookie not found
2098
+ * @returns {*} Cookie value or default
2099
+ * @category Browser Utilities
2100
+ * @see bw.setCookie
2101
+ */
2102
+ bw.getCookie = function(cname, defaultValue) {
2103
+ if (!bw._isBrowser) return defaultValue;
2104
+
2105
+ const name = cname + "=";
2106
+ const ca = document.cookie.split(";");
2107
+
2108
+ for (let i = 0; i < ca.length; i++) {
2109
+ let c = ca[i];
2110
+ while (c.charAt(0) === " ") c = c.substring(1);
2111
+ if (c.indexOf(name) === 0) return c.substring(name.length, c.length);
2112
+ }
2113
+
2114
+ return defaultValue;
2115
+ };
2116
+
2117
+ /**
2118
+ * Get a URL query parameter value from the current page URL.
2119
+ *
2120
+ * Pass no key to get all parameters as an object. Returns `true` for
2121
+ * present-but-empty parameters.
2122
+ *
2123
+ * @param {string} [key] - Parameter name (omit to get all params)
2124
+ * @param {*} defaultValue - Default if not found
2125
+ * @returns {*} Parameter value, true (present but empty), or default
2126
+ * @category Browser Utilities
2127
+ */
2128
+ bw.getURLParam = function(key, defaultValue) {
2129
+ if (!bw._isBrowser || typeof window !== "object") return defaultValue;
2130
+
2131
+ try {
2132
+ const params = new URLSearchParams(window.location.search);
2133
+
2134
+ if (!key) {
2135
+ // Return all params as object
2136
+ const result = {};
2137
+ for (const [k, v] of params) {
2138
+ result[k] = v || true;
2139
+ }
2140
+ return result;
2141
+ }
2142
+
2143
+ return params.has(key) ? (params.get(key) || true) : defaultValue;
2144
+ } catch (e) {
2145
+ return defaultValue;
2146
+ }
2147
+ };
2148
+
2149
+ /**
2150
+ * Create an HTML table string from a 2D data array.
2151
+ *
2152
+ * Legacy v1 API — returns an HTML string, not a TACO. First row is used
2153
+ * as headers by default. For TACO-based tables, use `bw.makeTable()` instead.
2154
+ *
2155
+ * @param {Array} data - 2D array of table data
2156
+ * @param {Object} [opts] - Table options
2157
+ * @param {boolean} [opts.useFirstRowAsHeaders=true] - Use first row as headers
2158
+ * @param {string} [opts.caption] - Table caption
2159
+ * @returns {string} HTML table string
2160
+ * @category Legacy (v1)
2161
+ * @see bw.makeTable
2162
+ */
2163
+ bw.htmlTable = function(data, opts = {}) {
2164
+ if (bw.typeOf(data) !== "array" || data.length < 1) return "";
2165
+
2166
+ const dopts = {
2167
+ useFirstRowAsHeaders: true,
2168
+ caption: null,
2169
+ atr: { class: "table" },
2170
+ thead_atr: {},
2171
+ th_atr: {},
2172
+ tbody_atr: {},
2173
+ tr_atr: {},
2174
+ td_atr: {}
2175
+ };
2176
+
2177
+ Object.assign(dopts, opts);
2178
+
2179
+ let html = `<table${bw._attrsToStr(dopts.atr)}>`;
2180
+
2181
+ if (dopts.caption) {
2182
+ html += `<caption>${bw.escapeHTML(dopts.caption)}</caption>`;
2183
+ }
2184
+
2185
+ let startRow = 0;
2186
+
2187
+ // Handle header row
2188
+ if (dopts.useFirstRowAsHeaders && data.length > 0) {
2189
+ html += `<thead${bw._attrsToStr(dopts.thead_atr)}>`;
2190
+ html += `<tr${bw._attrsToStr(dopts.tr_atr)}>`;
2191
+
2192
+ data[0].forEach(cell => {
2193
+ html += `<th${bw._attrsToStr(dopts.th_atr)}>${bw.escapeHTML(String(cell))}</th>`;
2194
+ });
2195
+
2196
+ html += "</tr></thead>";
2197
+ startRow = 1;
2198
+ }
2199
+
2200
+ // Body rows
2201
+ if (data.length > startRow) {
2202
+ html += `<tbody${bw._attrsToStr(dopts.tbody_atr)}>`;
2203
+
2204
+ for (let i = startRow; i < data.length; i++) {
2205
+ html += `<tr${bw._attrsToStr(dopts.tr_atr)}>`;
2206
+
2207
+ data[i].forEach(cell => {
2208
+ html += `<td${bw._attrsToStr(dopts.td_atr)}>${bw.escapeHTML(String(cell))}</td>`;
2209
+ });
2210
+
2211
+ html += "</tr>";
2212
+ }
2213
+
2214
+ html += "</tbody>";
2215
+ }
2216
+
2217
+ html += "</table>";
2218
+
2219
+ return html;
2220
+ };
2221
+
2222
+ /**
2223
+ * Convert an attributes object to an HTML attribute string
2224
+ *
2225
+ * Handles boolean attributes (key only), null/undefined/false (skipped),
2226
+ * and regular string values (HTML-escaped). Used internally by bw.htmlTable()
2227
+ * and bw.htmlTabs().
2228
+ *
2229
+ * @param {Object} attrs - Attribute key-value pairs
2230
+ * @returns {string} HTML attribute string with leading space, or empty string
2231
+ * @private
2232
+ */
2233
+ bw._attrsToStr = function(attrs) {
2234
+ if (!attrs || typeof attrs !== "object") return "";
2235
+
2236
+ let str = "";
2237
+ for (const [key, value] of Object.entries(attrs)) {
2238
+ if (value != null && value !== false) {
2239
+ if (value === true) {
2240
+ str += ` ${key}`;
2241
+ } else {
2242
+ str += ` ${key}="${bw.escapeHTML(String(value))}"`;
2243
+ }
2244
+ }
2245
+ }
2246
+
2247
+ return str;
2248
+ };
2249
+
2250
+ /**
2251
+ * Create an HTML tabs structure from an array of [title, content] pairs.
2252
+ *
2253
+ * Legacy v1 API — returns an HTML string. For TACO-based tabs,
2254
+ * use `bw.makeTabs()` instead.
2255
+ *
2256
+ * @param {Array} tabData - Array of [title, content] pairs
2257
+ * @param {Object} [opts] - Tab options
2258
+ * @returns {string} HTML tabs string
2259
+ * @category Legacy (v1)
2260
+ * @see bw.makeTabs
2261
+ */
2262
+ bw.htmlTabs = function(tabData, opts = {}) {
2263
+ if (bw.typeOf(tabData) !== "array" || tabData.length < 1) return "";
2264
+
2265
+ const dopts = {
2266
+ atr: { class: "bw-tab-container" },
2267
+ tab_atr: { class: "bw-tab-item-list" },
2268
+ tabc_atr: { class: "bw-tab-content-list" }
2269
+ };
2270
+
2271
+ Object.assign(dopts, opts);
2272
+
2273
+ // Create tab items
2274
+ const tabItems = tabData.map((tab, idx) => ({
2275
+ t: "li",
2276
+ a: {
2277
+ class: idx === 0 ? "bw-tab-item bw-tab-active" : "bw-tab-item",
2278
+ onclick: "bw.selectTabContent(this)"
2279
+ },
2280
+ c: tab[0]
2281
+ }));
2282
+
2283
+ // Create tab content
2284
+ const tabContent = tabData.map((tab, idx) => ({
2285
+ t: "div",
2286
+ a: { class: idx === 0 ? "bw-tab-content bw-show" : "bw-tab-content" },
2287
+ c: tab[1]
2288
+ }));
2289
+
2290
+ return bw.html({
2291
+ t: "div",
2292
+ a: dopts.atr,
2293
+ c: [
2294
+ { t: "ul", a: dopts.tab_atr, c: tabItems },
2295
+ { t: "div", a: dopts.tabc_atr, c: tabContent }
2296
+ ]
2297
+ });
2298
+ };
2299
+
2300
+ /**
2301
+ * Tab selection handler — shows the clicked tab's content and hides others.
2302
+ *
2303
+ * Used internally by `bw.htmlTabs()`. You generally don't call this directly.
2304
+ *
2305
+ * @param {Element} tabElement - Clicked tab element
2306
+ * @category Legacy (v1)
2307
+ */
2308
+ bw.selectTabContent = function(tabElement) {
2309
+ if (!bw._isBrowser || !tabElement) return;
2310
+
2311
+ const container = tabElement.closest(".bw-tab-container");
2312
+ if (!container) return;
2313
+
2314
+ // Remove active class from all tabs
2315
+ container.querySelectorAll(".bw-tab-item").forEach(tab => {
2316
+ tab.classList.remove("bw-tab-active");
2317
+ });
2318
+
2319
+ // Add active to clicked tab
2320
+ tabElement.classList.add("bw-tab-active");
2321
+
2322
+ // Get tab index
2323
+ const tabIndex = Array.from(tabElement.parentElement.children).indexOf(tabElement);
2324
+
2325
+ // Hide all content
2326
+ container.querySelectorAll(".bw-tab-content").forEach(content => {
2327
+ content.classList.remove("bw-show");
2328
+ });
2329
+
2330
+ // Show selected content
2331
+ const contents = container.querySelectorAll(".bw-tab-content");
2332
+ if (contents[tabIndex]) {
2333
+ contents[tabIndex].classList.add("bw-show");
2334
+ }
2335
+ };
2336
+
2337
+ /**
2338
+ * Generate Lorem Ipsum placeholder text.
2339
+ *
2340
+ * Useful for prototyping layouts. Generates repeatable text from the standard
2341
+ * Lorem Ipsum passage. Omit numChars for a random length between 25-150 characters.
2342
+ *
2343
+ * @param {number} [numChars] - Number of characters (random 25-150 if not provided)
2344
+ * @param {number} [startSpot] - Starting index in Lorem text (random if undefined)
2345
+ * @param {boolean} [startWithCapitalLetter=true] - Start with a capital letter
2346
+ * @returns {string} Lorem ipsum text
2347
+ * @category Text Generation
2348
+ * @example
2349
+ * bw.loremIpsum(50)
2350
+ * // => "Lorem ipsum dolor sit amet, consectetur adipiscin"
2351
+ */
2352
+ bw.loremIpsum = function(numChars, startSpot, startWithCapitalLetter = true) {
2353
+ const lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. ";
2354
+
2355
+ // If numChars not provided, generate random length between 25-150
2356
+ if (typeof numChars !== "number") {
2357
+ numChars = Math.floor(Math.random() * 125) + 25;
2358
+ }
2359
+
2360
+ // If startSpot is undefined, randomize it
2361
+ if (startSpot === undefined) {
2362
+ startSpot = Math.floor(Math.random() * lorem.length);
2363
+ }
2364
+
2365
+ startSpot = startSpot % lorem.length;
2366
+
2367
+ // Track how many characters we skip to honor numChars
2368
+ let skippedChars = 0;
2369
+ // Move startSpot to the next non-whitespace and non-punctuation character
2370
+ while (lorem[startSpot] === ' ' || /[.,:;!?]/.test(lorem[startSpot])) {
2371
+ startSpot = (startSpot + 1) % lorem.length;
2372
+ skippedChars++;
2373
+ // Prevent infinite loop in case entire lorem is spaces/punctuation
2374
+ if (skippedChars >= lorem.length) {
2375
+ startSpot = 0;
2376
+ skippedChars = 0;
2377
+ break;
2378
+ }
2379
+ }
2380
+
2381
+ let l = lorem.substring(startSpot) + lorem.substring(0, startSpot);
2382
+
2383
+ let result = "";
2384
+ let remaining = numChars + skippedChars; // Add skipped chars to honor original numChars
2385
+
2386
+ while (remaining > 0) {
2387
+ result += remaining < l.length ? l.substring(0, remaining) : l;
2388
+ remaining -= l.length;
2389
+ }
2390
+
2391
+ // Trim to exact numChars length
2392
+ if (result.length > numChars) {
2393
+ result = result.substring(0, numChars);
2394
+ }
2395
+
2396
+ // Ensure no trailing space
2397
+ if (result[result.length - 1] === " ") {
2398
+ result = result.substring(0, result.length - 1) + ".";
2399
+ }
2400
+
2401
+ // Ensure capital letter at start if requested
2402
+ if (startWithCapitalLetter) {
2403
+ let c = result[0].toUpperCase();
2404
+ c = /[A-Z]/.test(c) ? c : "L"; // Use "L" as default if first char isn't a letter
2405
+ result = c + result.substring(1);
2406
+ }
2407
+
2408
+ return result;
2409
+ };
2410
+
2411
+ /**
2412
+ * Create a multidimensional array filled with a value or function result.
2413
+ *
2414
+ * If value is a function, it's called for each cell (useful for random data).
2415
+ *
2416
+ * @param {*} value - Value or function to fill array with
2417
+ * @param {number|Array} dims - Dimensions (number for 1D, array for multi-D)
2418
+ * @returns {Array} Multidimensional array
2419
+ * @category Array Utilities
2420
+ * @example
2421
+ * bw.multiArray(0, [4, 5]) // 4x5 array of 0s
2422
+ * bw.multiArray('test', 5) // ['test','test','test','test','test']
2423
+ * bw.multiArray(Math.random, [3, 4]) // 3x4 array of random numbers
2424
+ */
2425
+ bw.multiArray = function(value, dims) {
2426
+ const v = () => bw.typeOf(value) === "function" ? value() : value;
2427
+ dims = typeof dims === "number" ? [dims] : dims;
2428
+
2429
+ const createArray = (dim) => {
2430
+ if (dim >= dims.length) return v();
2431
+
2432
+ const arr = [];
2433
+ for (let i = 0; i < dims[dim]; i++) {
2434
+ arr[i] = createArray(dim + 1);
2435
+ }
2436
+ return arr;
2437
+ };
2438
+
2439
+ return createArray(0);
2440
+ };
2441
+
2442
+ /**
2443
+ * Natural sort comparison function for use with `Array.sort()`.
2444
+ *
2445
+ * Sorts strings with embedded numbers in human-expected order
2446
+ * (e.g. "file2" before "file10") instead of lexicographic order.
2447
+ *
2448
+ * @param {*} as - First value
2449
+ * @param {*} bs - Second value
2450
+ * @returns {number} Sort order (-1, 0, 1)
2451
+ * @category Array Utilities
2452
+ * @example
2453
+ * ['item10', 'item2', 'item1'].sort(bw.naturalCompare)
2454
+ * // => ['item1', 'item2', 'item10']
2455
+ */
2456
+ bw.naturalCompare = function(as, bs) {
2457
+ // Handle numbers
2458
+ if (isFinite(as) && isFinite(bs)) {
2459
+ return Math.sign(as - bs);
2460
+ }
2461
+
2462
+ const a = String(as).toLowerCase();
2463
+ const b = String(bs).toLowerCase();
2464
+
2465
+ if (a === b) return as > bs ? 1 : 0;
2466
+
2467
+ // If no digits, simple string compare
2468
+ if (!/\d/.test(a) || !/\d/.test(b)) {
2469
+ return a > b ? 1 : -1;
2470
+ }
2471
+
2472
+ // Split into chunks of digits/non-digits
2473
+ const aParts = a.match(/(\d+|\D+)/g) || [];
2474
+ const bParts = b.match(/(\d+|\D+)/g) || [];
2475
+
2476
+ const len = Math.min(aParts.length, bParts.length);
2477
+
2478
+ for (let i = 0; i < len; i++) {
2479
+ const aPart = aParts[i];
2480
+ const bPart = bParts[i];
2481
+
2482
+ if (aPart !== bPart) {
2483
+ // Both numeric
2484
+ if (/^\d+$/.test(aPart) && /^\d+$/.test(bPart)) {
2485
+ // Handle leading zeros
2486
+ let aNum = aPart;
2487
+ let bNum = bPart;
2488
+
2489
+ if (aPart[0] === "0") aNum = "0." + aPart;
2490
+ if (bPart[0] === "0") bNum = "0." + bPart;
2491
+
2492
+ return parseFloat(aNum) - parseFloat(bNum);
2493
+ }
2494
+
2495
+ // String comparison
2496
+ return aPart > bPart ? 1 : -1;
2497
+ }
2498
+ }
2499
+
2500
+ // Different lengths
2501
+ return aParts.length - bParts.length;
2502
+ };
2503
+
2504
+ /**
2505
+ * Run `setInterval` with a maximum number of repetitions.
2506
+ *
2507
+ * Like `setInterval` but automatically clears after N calls.
2508
+ *
2509
+ * @param {Function} callback - Function to call (receives iteration index)
2510
+ * @param {number} delay - Delay between calls in ms
2511
+ * @param {number} repetitions - Maximum number of times to call
2512
+ * @returns {number} Interval ID (can be passed to clearInterval)
2513
+ * @category Timing
2514
+ * @example
2515
+ * bw.setIntervalX(function(i) {
2516
+ * console.log('Iteration', i);
2517
+ * }, 1000, 5); // Runs 5 times, 1 second apart
2518
+ */
2519
+ bw.setIntervalX = function(callback, delay, repetitions) {
2520
+ let count = 0;
2521
+ const intervalID = setInterval(function() {
2522
+ callback(count);
2523
+
2524
+ if (++count >= repetitions) {
2525
+ clearInterval(intervalID);
2526
+ }
2527
+ }, delay);
2528
+
2529
+ return intervalID;
2530
+ };
2531
+
2532
+ /**
2533
+ * Repeat a test function until it returns truthy, or give up after max attempts.
2534
+ *
2535
+ * Useful for polling (waiting for an element to appear, an API to respond, etc.).
2536
+ *
2537
+ * @param {Function} testFn - Test function that returns truthy when done
2538
+ * @param {Function} successFn - Called with test result when test passes
2539
+ * @param {Function} [failFn] - Called on each failed test attempt
2540
+ * @param {number} [delay=250] - Delay between attempts in ms
2541
+ * @param {number} [maxReps=10] - Maximum number of attempts
2542
+ * @param {Function} [lastFn] - Called when done with (success, count)
2543
+ * @returns {string|number} "err" if invalid params, otherwise interval ID
2544
+ * @category Timing
2545
+ * @example
2546
+ * bw.repeatUntil(
2547
+ * function() { return document.getElementById('myDiv'); },
2548
+ * function() { console.log('Element found!'); },
2549
+ * null, 100, 30
2550
+ * );
2551
+ */
2552
+ bw.repeatUntil = function(testFn, successFn, failFn, delay = 250, maxReps = 10, lastFn) {
2553
+ if (typeof testFn !== "function") return "err";
2554
+
2555
+ let count = 0;
2556
+
2557
+ const intervalID = setInterval(function() {
2558
+ const result = testFn();
2559
+ count++;
2560
+
2561
+ if (result) {
2562
+ clearInterval(intervalID);
2563
+ if (successFn) successFn(result);
2564
+ if (lastFn) lastFn(true, count);
2565
+ } else if (count >= maxReps) {
2566
+ clearInterval(intervalID);
2567
+ if (failFn) failFn();
2568
+ if (lastFn) lastFn(false, count);
2569
+ } else {
2570
+ if (failFn) failFn();
2571
+ }
2572
+ }, delay);
2573
+
2574
+ return intervalID;
2575
+ };
2576
+
2577
+ // ===================================================================================
2578
+ // File I/O Functions - Works in both Node.js and browser
2579
+ // ===================================================================================
2580
+
2581
+ /**
2582
+ * Save data to a file. Works in both Node.js (fs.writeFile) and browser (download link).
2583
+ *
2584
+ * @param {string} fname - Filename to save as
2585
+ * @param {*} data - Data to save (string or buffer)
2586
+ * @category File I/O
2587
+ * @see bw.saveClientJSON
2588
+ */
2589
+ bw.saveClientFile = function(fname, data) {
2590
+ if (bw.isNodeJS()) {
2591
+ bw._getFs().then(function(fs) {
2592
+ if (!fs) { console.error('bw.saveClientFile: fs module not available'); return; }
2593
+ fs.writeFile(fname, data, function(err) {
2594
+ if (err) {
2595
+ console.error("Error saving file:", err);
2596
+ }
2597
+ });
2598
+ });
2599
+ } else {
2600
+ // Browser environment
2601
+ const blob = new Blob([data], { type: "application/octet-stream" });
2602
+ const url = window.URL.createObjectURL(blob);
2603
+ const a = bw.createDOM({
2604
+ t: 'a',
2605
+ a: {
2606
+ href: url,
2607
+ download: fname,
2608
+ style: 'display: none'
2609
+ }
2610
+ });
2611
+ document.body.appendChild(a);
2612
+ a.click();
2613
+ window.URL.revokeObjectURL(url);
2614
+ document.body.removeChild(a);
2615
+ }
2616
+ };
2617
+
2618
+ /**
2619
+ * Save data as a JSON file with pretty formatting.
2620
+ *
2621
+ * @param {string} fname - Filename to save as
2622
+ * @param {*} data - Data to serialize as JSON
2623
+ * @category File I/O
2624
+ * @see bw.saveClientFile
2625
+ */
2626
+ bw.saveClientJSON = function(fname, data) {
2627
+ bw.saveClientFile(fname, JSON.stringify(data, null, 2));
2628
+ };
2629
+
2630
+ /**
2631
+ * Load a file by path (Node.js) or URL (browser via XHR).
2632
+ *
2633
+ * @param {string} fname - File path (Node) or URL (browser)
2634
+ * @param {Function} callback - Called with (data, error). data is null on error.
2635
+ * @param {Object} [options] - Options
2636
+ * @param {string} [options.parser="raw"] - "raw" for string, "JSON" to auto-parse
2637
+ * @returns {string} "BW_OK"
2638
+ * @category File I/O
2639
+ * @see bw.loadClientJSON
2640
+ */
2641
+ bw.loadClientFile = function(fname, callback, options) {
2642
+ var opts = { parser: 'raw' };
2643
+ if (options && options.parser) { opts.parser = options.parser; }
2644
+ var parse = (opts.parser === 'JSON') ? JSON.parse : function(s) { return s; };
2645
+
2646
+ if (bw.isNodeJS()) {
2647
+ bw._getFs().then(function(fs) {
2648
+ if (!fs) { callback(null, new Error('fs module not available')); return; }
2649
+ fs.readFile(fname, 'utf8', function(err, data) {
2650
+ if (err) { callback(null, err); }
2651
+ else {
2652
+ try { callback(parse(data), null); }
2653
+ catch (e) { callback(null, e); }
2654
+ }
2655
+ });
2656
+ });
2657
+ } else {
2658
+ var x = new XMLHttpRequest();
2659
+ x.open('GET', fname, true);
2660
+ x.onreadystatechange = function() {
2661
+ if (x.readyState === 4) {
2662
+ if (x.status >= 200 && x.status < 300) {
2663
+ try { callback(parse(x.responseText), null); }
2664
+ catch (e) { callback(null, e); }
2665
+ } else {
2666
+ callback(null, new Error('HTTP ' + x.status + ': ' + fname));
2667
+ }
2668
+ }
2669
+ };
2670
+ x.send(null);
2671
+ }
2672
+ return 'BW_OK';
2673
+ };
2674
+
2675
+ /**
2676
+ * Load a JSON file by path (Node.js) or URL (browser). Convenience wrapper
2677
+ * around `bw.loadClientFile()` with `parser: "JSON"`.
2678
+ *
2679
+ * @param {string} fname - File path (Node) or URL (browser)
2680
+ * @param {Function} callback - Called with (parsedData, error)
2681
+ * @returns {string} "BW_OK"
2682
+ * @category File I/O
2683
+ * @see bw.loadClientFile
2684
+ */
2685
+ bw.loadClientJSON = function(fname, callback) {
2686
+ return bw.loadClientFile(fname, callback, { parser: 'JSON' });
2687
+ };
2688
+
2689
+ /**
2690
+ * Prompt user to pick a local file via file dialog (browser only).
2691
+ *
2692
+ * Opens a native file picker and reads the selected file.
2693
+ *
2694
+ * @param {Function} callback - Called with (data, filename, error)
2695
+ * @param {Object} [options] - Options
2696
+ * @param {string} [options.accept] - File type filter (e.g. ".json,.txt")
2697
+ * @param {string} [options.parser="raw"] - "raw" for string, "JSON" to auto-parse
2698
+ * @category File I/O
2699
+ * @see bw.loadLocalJSON
2700
+ */
2701
+ bw.loadLocalFile = function(callback, options) {
2702
+ var opts = { parser: 'raw', accept: '' };
2703
+ if (options) {
2704
+ if (options.parser) { opts.parser = options.parser; }
2705
+ if (options.accept) { opts.accept = options.accept; }
2706
+ }
2707
+ var parse = (opts.parser === 'JSON') ? JSON.parse : function(s) { return s; };
2708
+
2709
+ if (bw.isNodeJS()) {
2710
+ callback(null, '', new Error('bw.loadLocalFile is browser-only. Use bw.loadClientFile() in Node.'));
2711
+ return;
2712
+ }
2713
+
2714
+ var input = bw.createDOM({
2715
+ t: 'input',
2716
+ a: {
2717
+ type: 'file',
2718
+ accept: opts.accept,
2719
+ style: 'display: none'
2720
+ }
2721
+ });
2722
+ input.addEventListener('change', function() {
2723
+ var file = input.files[0];
2724
+ if (!file) { callback(null, '', new Error('No file selected')); return; }
2725
+ var reader = new FileReader();
2726
+ reader.onload = function(e) {
2727
+ try { callback(parse(e.target.result), file.name, null); }
2728
+ catch (err) { callback(null, file.name, err); }
2729
+ };
2730
+ reader.onerror = function() { callback(null, file.name, reader.error); };
2731
+ reader.readAsText(file);
2732
+ input.remove();
2733
+ });
2734
+ document.body.appendChild(input);
2735
+ input.click();
2736
+ };
2737
+
2738
+ /**
2739
+ * Prompt user to pick a local JSON file via file dialog (browser only).
2740
+ *
2741
+ * @param {Function} callback - Called with (parsedData, filename, error)
2742
+ * @category File I/O
2743
+ * @see bw.loadLocalFile
2744
+ */
2745
+ bw.loadLocalJSON = function(callback) {
2746
+ bw.loadLocalFile(callback, { parser: 'JSON', accept: '.json' });
2747
+ };
2748
+
2749
+ /**
2750
+ * Copy text to the system clipboard (browser only).
2751
+ *
2752
+ * Uses the modern Clipboard API when available, falls back to `document.execCommand('copy')`.
2753
+ *
2754
+ * @param {string} text - Text to copy
2755
+ * @returns {Promise} Promise that resolves when copy is complete
2756
+ * @category Browser Utilities
2757
+ */
2758
+ bw.copyToClipboard = function(text) {
2759
+ // Modern clipboard API
2760
+ if (navigator.clipboard && navigator.clipboard.writeText) {
2761
+ return navigator.clipboard.writeText(text);
2762
+ }
2763
+
2764
+ // Fallback for older browsers
2765
+ return new Promise((resolve, reject) => {
2766
+ const textarea = bw.createDOM({
2767
+ t: 'textarea',
2768
+ a: {
2769
+ value: text,
2770
+ style: {
2771
+ position: 'fixed',
2772
+ top: '-999px',
2773
+ left: '-999px',
2774
+ width: '2em',
2775
+ height: '2em',
2776
+ padding: 0,
2777
+ border: 'none',
2778
+ outline: 'none',
2779
+ boxShadow: 'none',
2780
+ background: 'transparent'
2781
+ }
2782
+ }
2783
+ });
2784
+
2785
+ document.body.appendChild(textarea);
2786
+ textarea.focus();
2787
+ textarea.select();
2788
+
2789
+ try {
2790
+ const successful = document.execCommand('copy');
2791
+ document.body.removeChild(textarea);
2792
+
2793
+ if (successful) {
2794
+ resolve();
2795
+ } else {
2796
+ reject(new Error('Copy command failed'));
2797
+ }
2798
+ } catch (err) {
2799
+ document.body.removeChild(textarea);
2800
+ reject(err);
2801
+ }
2802
+ });
2803
+ };
2804
+
2805
+ /**
2806
+ * Create a sortable TACO table from an array of row objects.
2807
+ *
2808
+ * Auto-detects columns from data keys if not specified. Supports click-to-sort
2809
+ * headers with ascending/descending indicators. Returns a TACO object —
2810
+ * render with `bw.DOM()` or `bw.html()`.
2811
+ *
2812
+ * @param {Object} config - Table configuration
2813
+ * @param {Array<Object>} config.data - Array of row objects to display
2814
+ * @param {Array<Object>} [config.columns] - Column definitions with key, label, render
2815
+ * @param {string} [config.className='table'] - CSS class for table element
2816
+ * @param {boolean} [config.sortable=true] - Enable click-to-sort headers
2817
+ * @param {Function} [config.onSort] - Sort callback (column, direction)
2818
+ * @returns {Object} TACO object for table
2819
+ * @category Component Builders
2820
+ * @see bw.makeDataTable
2821
+ * @example
2822
+ * bw.makeTable({
2823
+ * data: [
2824
+ * { name: 'Alice', age: 30 },
2825
+ * { name: 'Bob', age: 25 }
2826
+ * ],
2827
+ * columns: [
2828
+ * { key: 'name', label: 'Name' },
2829
+ * { key: 'age', label: 'Age' }
2830
+ * ]
2831
+ * });
2832
+ */
2833
+ bw.makeTable = function(config) {
2834
+ const {
2835
+ data = [],
2836
+ columns,
2837
+ className = "table",
2838
+ sortable = true,
2839
+ onSort,
2840
+ sortColumn,
2841
+ sortDirection = 'asc'
2842
+ } = config;
2843
+
2844
+ // Auto-detect columns if not provided
2845
+ const cols = columns || (data.length > 0
2846
+ ? Object.keys(data[0]).map(key => ({ key, label: key }))
2847
+ : []);
2848
+
2849
+ // Current sort state
2850
+ let currentSortColumn = sortColumn || null;
2851
+ let currentSortDirection = sortDirection;
2852
+
2853
+ // Sort data if column specified
2854
+ let sortedData = [...data];
2855
+ if (currentSortColumn) {
2856
+ sortedData.sort((a, b) => {
2857
+ const aVal = a[currentSortColumn];
2858
+ const bVal = b[currentSortColumn];
2859
+
2860
+ // Handle different types
2861
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
2862
+ return currentSortDirection === 'asc' ? aVal - bVal : bVal - aVal;
2863
+ }
2864
+
2865
+ // String comparison
2866
+ const aStr = String(aVal || '').toLowerCase();
2867
+ const bStr = String(bVal || '').toLowerCase();
2868
+
2869
+ if (currentSortDirection === 'asc') {
2870
+ return aStr.localeCompare(bStr);
2871
+ } else {
2872
+ return bStr.localeCompare(aStr);
2873
+ }
2874
+ });
2875
+ }
2876
+
2877
+ // Create sort handler
2878
+ const handleSort = (column) => {
2879
+ if (!sortable) return;
2880
+
2881
+ if (currentSortColumn === column) {
2882
+ currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc';
2883
+ } else {
2884
+ currentSortColumn = column;
2885
+ currentSortDirection = 'asc';
2886
+ }
2887
+
2888
+ if (onSort) {
2889
+ onSort(column, currentSortDirection);
2890
+ }
2891
+ };
2892
+
2893
+ // Build table header
2894
+ const thead = {
2895
+ t: 'thead',
2896
+ c: {
2897
+ t: 'tr',
2898
+ c: cols.map(col => ({
2899
+ t: 'th',
2900
+ a: sortable ? {
2901
+ style: { cursor: 'pointer', userSelect: 'none' },
2902
+ onclick: () => handleSort(col.key)
2903
+ } : {},
2904
+ c: [
2905
+ col.label,
2906
+ sortable && currentSortColumn === col.key && {
2907
+ t: 'span',
2908
+ a: { style: { marginLeft: '5px' } },
2909
+ c: currentSortDirection === 'asc' ? '▲' : '▼'
2910
+ }
2911
+ ].filter(Boolean)
2912
+ }))
2913
+ }
2914
+ };
2915
+
2916
+ // Build table body
2917
+ const tbody = {
2918
+ t: 'tbody',
2919
+ c: sortedData.map(row => ({
2920
+ t: 'tr',
2921
+ c: cols.map(col => ({
2922
+ t: 'td',
2923
+ c: col.render ? col.render(row[col.key], row) : String(row[col.key] || '')
2924
+ }))
2925
+ }))
2926
+ };
2927
+
2928
+ return {
2929
+ t: 'table',
2930
+ a: { class: className },
2931
+ c: [thead, tbody]
2932
+ };
2933
+ };
2934
+
2935
+ /**
2936
+ * Create a responsive data table with title and optional wrapper
2937
+ *
2938
+ * Wraps bw.makeTable() output in a responsive container div.
2939
+ * Adds an optional title heading above the table.
2940
+ *
2941
+ * @param {Object} config - Table configuration
2942
+ * @param {string} [config.title] - Table title heading
2943
+ * @param {Array<Object>} config.data - Array of row objects
2944
+ * @param {Array<Object>} [config.columns] - Column definitions
2945
+ * @param {string} [config.className="table table-striped table-hover"] - Table CSS class
2946
+ * @param {boolean} [config.responsive=true] - Wrap table in responsive overflow div
2947
+ * @returns {Object} TACO object for table with wrapper
2948
+ * @example
2949
+ * const table = bw.makeDataTable({
2950
+ * title: "Users",
2951
+ * data: [{ name: "Alice", role: "Admin" }],
2952
+ * responsive: true
2953
+ * });
2954
+ */
2955
+ bw.makeDataTable = function(config) {
2956
+ const {
2957
+ title,
2958
+ data,
2959
+ columns,
2960
+ className = "table table-striped table-hover",
2961
+ responsive = true,
2962
+ ...tableConfig
2963
+ } = config;
2964
+
2965
+ const table = bw.makeTable({
2966
+ data,
2967
+ columns,
2968
+ className,
2969
+ ...tableConfig
2970
+ });
2971
+
2972
+ const content = [];
2973
+
2974
+ if (title) {
2975
+ content.push({
2976
+ t: 'h5',
2977
+ a: { class: 'mb-3' },
2978
+ c: title
2979
+ });
2980
+ }
2981
+
2982
+ if (responsive) {
2983
+ content.push({
2984
+ t: 'div',
2985
+ a: { class: 'table-responsive' },
2986
+ c: table
2987
+ });
2988
+ } else {
2989
+ content.push(table);
2990
+ }
2991
+
2992
+ return {
2993
+ t: 'div',
2994
+ a: { class: 'table-container' },
2995
+ c: content
2996
+ };
2997
+ };
2998
+
2999
+ /**
3000
+ * Component registry for tracking rendered components
3001
+ * @private
3002
+ */
3003
+ bw._componentRegistry = new Map();
3004
+
3005
+ /**
3006
+ * Render a TACO object into the DOM at a specific position, returning a component handle.
3007
+ *
3008
+ * The handle provides full lifecycle control: state management, re-rendering,
3009
+ * class manipulation, show/hide, event binding, and destroy. Components are
3010
+ * tracked in a registry for later retrieval via `bw.getComponent()`.
3011
+ *
3012
+ * @param {Element|string} element - Target element or CSS selector
3013
+ * @param {string} position - Position: 'replace', 'prepend', 'append', 'before', 'after'
3014
+ * @param {Object} taco - TACO object to render
3015
+ * @returns {Object} Component handle with element, setState, update, destroy, etc.
3016
+ * @category DOM Generation
3017
+ * @see bw.getComponent
3018
+ * @see bw.DOM
3019
+ * @example
3020
+ * var handle = bw.render('#app', 'append', {
3021
+ * t: 'button', a: { class: 'bw-btn' }, c: 'Click Me',
3022
+ * o: { state: { clicks: 0 } }
3023
+ * });
3024
+ * handle.setState({ clicks: 1 });
3025
+ * handle.destroy();
3026
+ */
3027
+ bw.render = function(element, position, taco) {
3028
+ // Get target element
3029
+ const targetEl = typeof element === 'string'
3030
+ ? document.querySelector(element)
3031
+ : element;
3032
+
3033
+ if (!targetEl) {
3034
+ return {
3035
+ object_type: 'error',
3036
+ component_id: null,
3037
+ object_handle_in_dom: null,
3038
+ status_code: 'error=target_element_not_found'
3039
+ };
3040
+ }
3041
+
3042
+ // Generate unique ID if not provided
3043
+ const componentId = taco.o?.id || bw.uuid();
3044
+
3045
+ // Create DOM element
3046
+ let domElement;
3047
+ try {
3048
+ domElement = bw.createDOM(taco);
3049
+ } catch(e) {
3050
+ return {
3051
+ object_type: 'error',
3052
+ component_id: componentId,
3053
+ object_handle_in_dom: null,
3054
+ status_code: `error=render_failed:${e.message}`
3055
+ };
3056
+ }
3057
+
3058
+ // Add component ID to element
3059
+ domElement.setAttribute('data-bw-id', componentId);
3060
+
3061
+ // Insert into DOM based on position
3062
+ try {
3063
+ switch(position) {
3064
+ case 'replace':
3065
+ targetEl.parentNode.replaceChild(domElement, targetEl);
3066
+ break;
3067
+ case 'prepend':
3068
+ targetEl.insertBefore(domElement, targetEl.firstChild);
3069
+ break;
3070
+ case 'append':
3071
+ targetEl.appendChild(domElement);
3072
+ break;
3073
+ case 'before':
3074
+ targetEl.parentNode.insertBefore(domElement, targetEl);
3075
+ break;
3076
+ case 'after':
3077
+ targetEl.parentNode.insertBefore(domElement, targetEl.nextSibling);
3078
+ break;
3079
+ default:
3080
+ throw new Error(`Invalid position: ${position}`);
3081
+ }
3082
+ } catch(e) {
3083
+ return {
3084
+ object_type: 'error',
3085
+ component_id: componentId,
3086
+ object_handle_in_dom: null,
3087
+ status_code: `error=insertion_failed:${e.message}`
3088
+ };
3089
+ }
3090
+
3091
+ // Create component handle
3092
+ const handle = {
3093
+ object_type: taco.t || 'element',
3094
+ component_id: componentId,
3095
+ object_handle_in_dom: domElement,
3096
+ status_code: 'success',
3097
+
3098
+ // Reference to original TACO
3099
+ _taco: { ...taco },
3100
+ _state: { ...(taco.o?.state || {}) },
3101
+ _mounted: true,
3102
+
3103
+ // Get DOM element
3104
+ get element() {
3105
+ return this.object_handle_in_dom;
3106
+ },
3107
+
3108
+ // Get/set state
3109
+ getState() {
3110
+ return { ...this._state };
3111
+ },
3112
+
3113
+ setState(updates) {
3114
+ this._state = { ...this._state, ...updates };
3115
+ if (this._taco.o?.onStateChange) {
3116
+ this._taco.o.onStateChange(this._state, updates);
3117
+ }
3118
+ return this;
3119
+ },
3120
+
3121
+ // Update component (re-render)
3122
+ update() {
3123
+ if (!this._mounted || !this.element) return this;
3124
+
3125
+ const parent = this.element.parentNode;
3126
+
3127
+ // Update TACO with current state
3128
+ if (this._taco.o) {
3129
+ this._taco.o.state = this._state;
3130
+ }
3131
+
3132
+ // Re-render
3133
+ const newElement = bw.createDOM(this._taco);
3134
+ newElement.setAttribute('data-bw-id', componentId);
3135
+
3136
+ // Replace in DOM
3137
+ parent.replaceChild(newElement, this.element);
3138
+ this.object_handle_in_dom = newElement;
3139
+
3140
+ // Call update lifecycle
3141
+ if (this._taco.o?.onUpdate) {
3142
+ this._taco.o.onUpdate(newElement, this._state);
3143
+ }
3144
+
3145
+ return this;
3146
+ },
3147
+
3148
+ // Get/set properties
3149
+ getProp(key) {
3150
+ return this._taco.a?.[key];
3151
+ },
3152
+
3153
+ setProp(key, value) {
3154
+ if (!this._taco.a) this._taco.a = {};
3155
+ this._taco.a[key] = value;
3156
+
3157
+ // Update DOM attribute
3158
+ if (this.element) {
3159
+ if (value === null || value === undefined) {
3160
+ this.element.removeAttribute(key);
3161
+ } else if (value === true) {
3162
+ this.element.setAttribute(key, '');
3163
+ } else {
3164
+ this.element.setAttribute(key, String(value));
3165
+ }
3166
+ }
3167
+
3168
+ return this;
3169
+ },
3170
+
3171
+ // Get/set content
3172
+ getContent() {
3173
+ return this._taco.c;
3174
+ },
3175
+
3176
+ setContent(content) {
3177
+ this._taco.c = content;
3178
+ if (this.element) {
3179
+ if (typeof content === 'string') {
3180
+ this.element.textContent = content;
3181
+ } else {
3182
+ // Re-render for complex content
3183
+ this.update();
3184
+ }
3185
+ }
3186
+ return this;
3187
+ },
3188
+
3189
+ // Add/remove CSS classes
3190
+ addClass(className) {
3191
+ if (this.element) {
3192
+ this.element.classList.add(className);
3193
+ }
3194
+ return this;
3195
+ },
3196
+
3197
+ removeClass(className) {
3198
+ if (this.element) {
3199
+ this.element.classList.remove(className);
3200
+ }
3201
+ return this;
3202
+ },
3203
+
3204
+ toggleClass(className) {
3205
+ if (this.element) {
3206
+ this.element.classList.toggle(className);
3207
+ }
3208
+ return this;
3209
+ },
3210
+
3211
+ hasClass(className) {
3212
+ return this.element ? this.element.classList.contains(className) : false;
3213
+ },
3214
+
3215
+ // Show/hide
3216
+ show() {
3217
+ if (this.element) {
3218
+ this.element.style.display = '';
3219
+ }
3220
+ return this;
3221
+ },
3222
+
3223
+ hide() {
3224
+ if (this.element) {
3225
+ this.element.style.display = 'none';
3226
+ }
3227
+ return this;
3228
+ },
3229
+
3230
+ // Event handling
3231
+ on(event, handler) {
3232
+ if (this.element) {
3233
+ this.element.addEventListener(event, handler);
3234
+ }
3235
+ return this;
3236
+ },
3237
+
3238
+ off(event, handler) {
3239
+ if (this.element) {
3240
+ this.element.removeEventListener(event, handler);
3241
+ }
3242
+ return this;
3243
+ },
3244
+
3245
+ // Destroy component
3246
+ destroy() {
3247
+ if (!this._mounted) return this;
3248
+
3249
+ // Call unmount lifecycle
3250
+ if (this._taco.o?.unmount) {
3251
+ this._taco.o.unmount(this.element);
3252
+ }
3253
+
3254
+ // Remove from DOM
3255
+ if (this.element && this.element.parentNode) {
3256
+ this.element.parentNode.removeChild(this.element);
3257
+ }
3258
+
3259
+ // Remove from registry
3260
+ bw._componentRegistry.delete(componentId);
3261
+
3262
+ // Clean up
3263
+ this._mounted = false;
3264
+ this.object_handle_in_dom = null;
3265
+ this.status_code = 'destroyed';
3266
+
3267
+ return this;
3268
+ }
3269
+ };
3270
+
3271
+ // Store in registry
3272
+ bw._componentRegistry.set(componentId, handle);
3273
+
3274
+ // Call mounted lifecycle
3275
+ if (taco.o?.mounted) {
3276
+ taco.o.mounted(domElement, handle);
3277
+ }
3278
+
3279
+ return handle;
3280
+ };
3281
+
3282
+ /**
3283
+ * Get a component handle by its ID from the component registry.
3284
+ *
3285
+ * @param {string} id - Component ID (from bw.render)
3286
+ * @returns {Object|null} Component handle or null if not found
3287
+ * @category DOM Generation
3288
+ * @see bw.render
3289
+ */
3290
+ bw.getComponent = function(id) {
3291
+ return bw._componentRegistry.get(id) || null;
3292
+ };
3293
+
3294
+ /**
3295
+ * Get all registered component handles as a Map.
3296
+ *
3297
+ * @returns {Map} Map of componentId → component handle
3298
+ * @category DOM Generation
3299
+ * @see bw.getComponent
3300
+ */
3301
+ bw.getAllComponents = function() {
3302
+ return new Map(bw._componentRegistry);
3303
+ };
3304
+
3305
+ // =========================================================================
3306
+ // Import and register all components
3307
+ // =========================================================================
3308
+ import * as components from './bitwrench-components-v2.js';
3309
+
3310
+ // Register all make functions
3311
+ Object.entries(components).forEach(([name, fn]) => {
3312
+ if (name.startsWith('make')) {
3313
+ bw[name] = fn;
3314
+ }
3315
+ });
3316
+
3317
+ // Register component handles
3318
+ bw._componentHandles = components.componentHandles || {};
3319
+
3320
+ // Create functions that return handles
3321
+ Object.entries(components).forEach(([name, fn]) => {
3322
+ if (name.startsWith('make')) {
3323
+ const componentType = name.substring(4).toLowerCase(); // Remove 'make' prefix
3324
+ const createName = 'create' + name.substring(4); // createCard, createTable, etc.
3325
+
3326
+ bw[createName] = function(props) {
3327
+ const taco = fn(props);
3328
+ const handle = bw.renderComponent(taco);
3329
+
3330
+ // Use specialized handle class if available
3331
+ const HandleClass = bw._componentHandles[componentType];
3332
+ if (HandleClass) {
3333
+ const specializedHandle = new HandleClass(handle.element, taco);
3334
+ // Copy base handle properties
3335
+ Object.setPrototypeOf(specializedHandle, handle);
3336
+ return specializedHandle;
3337
+ }
3338
+
3339
+ return handle;
3340
+ };
3341
+ }
3342
+ });
3343
+
3344
+ // Manual registration for functions defined in this file
3345
+ // createTable
3346
+ bw.createTable = function(data, options = {}) {
3347
+ const taco = bw.makeTable({ data, ...options });
3348
+ const handle = bw.renderComponent(taco);
3349
+
3350
+ // Use specialized TableHandle
3351
+ const TableHandle = bw._componentHandles.table;
3352
+ if (TableHandle) {
3353
+ const specializedHandle = new TableHandle(handle.element, taco);
3354
+ Object.setPrototypeOf(specializedHandle, handle);
3355
+ return specializedHandle;
3356
+ }
3357
+
3358
+ return handle;
3359
+ };
3360
+
3361
+ // Export for different environments
3362
+ export default bw;
3363
+
3364
+ // Also attach to global in browsers
3365
+ if (bw._isBrowser && typeof window !== 'undefined') {
3366
+ window.bw = bw;
3367
+ }