bitwrench 2.0.13 → 2.0.14

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.
@@ -1,4 +1,4 @@
1
- /*! bitwrench v2.0.13 | BSD-2-Clause | https://deftio.github.com/bitwrench/pages */
1
+ /*! bitwrench v2.0.14 | BSD-2-Clause | https://deftio.github.com/bitwrench/pages */
2
2
  (function (global, factory) {
3
3
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
4
4
  typeof define === 'function' && define.amd ? define(factory) :
@@ -11,14 +11,14 @@
11
11
  */
12
12
 
13
13
  const VERSION_INFO = {
14
- version: '2.0.13',
14
+ version: '2.0.14',
15
15
  name: 'bitwrench',
16
16
  description: 'A library for javascript UI functions.',
17
17
  license: 'BSD-2-Clause',
18
18
  homepage: 'https://deftio.github.com/bitwrench/pages',
19
19
  repository: 'git+https://github.com/deftio/bitwrench.git',
20
20
  author: 'manu a. chatterjee <deftio@deftio.com> (https://deftio.com/)',
21
- buildDate: '2026-03-07T22:35:06.056Z'
21
+ buildDate: '2026-03-08T08:04:06.572Z'
22
22
  };
23
23
 
24
24
  /**
@@ -276,6 +276,29 @@
276
276
  return relativeLuminance(hex) > 0.179 ? '#000' : '#fff';
277
277
  }
278
278
 
279
+ /**
280
+ * Shift a color's hue toward a target hue by a given amount.
281
+ * Uses shortest-arc interpolation on the hue wheel.
282
+ * @param {string} sourceHex - Color to shift
283
+ * @param {string} targetHex - Color whose hue to shift toward
284
+ * @param {number} [amount=0.20] - 0 = no shift, 1 = full shift to target hue
285
+ * @returns {string} Harmonized hex color
286
+ */
287
+ function harmonize(sourceHex, targetHex, amount) {
288
+ if (amount === undefined) amount = 0.20;
289
+ if (amount === 0) return sourceHex;
290
+ var srcHsl = hexToHsl(sourceHex);
291
+ var tgtHsl = hexToHsl(targetHex);
292
+
293
+ // Shortest-arc hue interpolation
294
+ var diff = tgtHsl[0] - srcHsl[0];
295
+ if (diff > 180) diff -= 360;
296
+ if (diff < -180) diff += 360;
297
+
298
+ var newHue = (srcHsl[0] + diff * amount + 360) % 360;
299
+ return hslToHex([newHue, srcHsl[1], srcHsl[2]]);
300
+ }
301
+
279
302
  /**
280
303
  * Derive a full shade palette for a single semantic color.
281
304
  * @param {string} hex - Base color hex
@@ -295,31 +318,128 @@
295
318
  };
296
319
  }
297
320
 
321
+ /**
322
+ * Derive the alternate (luminance-inverted) version of a single seed color.
323
+ * Preserves hue, mirrors lightness, adjusts saturation for readability.
324
+ * @param {string} hex - Seed hex color
325
+ * @returns {string} Alternate hex color
326
+ */
327
+ function deriveAlternateSeed(hex) {
328
+ var hsl = hexToHsl(hex);
329
+ var h = hsl[0], s = hsl[1], l = hsl[2];
330
+ var altL, altS;
331
+
332
+ if (l > 50) {
333
+ // Light color → make dark. Map 50-100 → 30-10 range
334
+ altL = clip(100 - l - 10, 8, 40);
335
+ // Reduce saturation slightly — vivid colors at low lightness look garish
336
+ altS = clip(s * 0.85, 0, 100);
337
+ } else {
338
+ // Dark color → make light. Map 0-50 → 65-92 range
339
+ altL = clip(100 - l + 10, 60, 92);
340
+ // Slightly increase saturation for light variant
341
+ altS = clip(s * 1.1, 0, 100);
342
+ }
343
+
344
+ return hslToHex([h, altS, altL]);
345
+ }
346
+
347
+ /**
348
+ * Determine whether a palette config is "light-flavored" based on
349
+ * the average luminance of its seed colors.
350
+ * @param {Object} config - Theme config with primary, secondary hex colors
351
+ * @returns {boolean} true if the seeds are predominantly light
352
+ */
353
+ function isLightPalette(config) {
354
+ var lum = relativeLuminance(config.primary);
355
+ if (config.secondary) lum = (lum + relativeLuminance(config.secondary)) / 2;
356
+ if (config.tertiary) lum = (lum * 2 + relativeLuminance(config.tertiary)) / 3;
357
+ return lum > 0.179;
358
+ }
359
+
360
+ /**
361
+ * Derive a complete alternate config from a primary theme config.
362
+ * Each seed color is luminance-inverted; semantic colors are adjusted for
363
+ * the new luminance context.
364
+ * @param {Object} config - Primary theme config
365
+ * @returns {Object} Alternate theme config (same shape, inverted lightness)
366
+ */
367
+ function deriveAlternateConfig(config) {
368
+ var alt = {};
369
+ // Invert the user's seed colors
370
+ alt.primary = deriveAlternateSeed(config.primary);
371
+ alt.secondary = deriveAlternateSeed(config.secondary);
372
+ alt.tertiary = config.tertiary ? deriveAlternateSeed(config.tertiary) : alt.primary;
373
+
374
+ // Derive alternate surface colors from primary hue
375
+ var priHsl = hexToHsl(config.primary);
376
+ var h = priHsl[0];
377
+ var isLight = isLightPalette(config);
378
+
379
+ if (isLight) {
380
+ // Primary is light → alternate needs dark surfaces
381
+ alt.light = hslToHex([h, Math.min(priHsl[1], 15), 15]);
382
+ alt.dark = hslToHex([h, 5, 88]);
383
+ } else {
384
+ // Primary is dark → alternate needs light surfaces
385
+ alt.light = hslToHex([h, Math.min(priHsl[1], 10), 96]);
386
+ alt.dark = hslToHex([h, 10, 18]);
387
+ }
388
+
389
+ // Semantic colors: harmonize toward primary, then invert for alternate
390
+ var amt = config.harmonize !== undefined ? config.harmonize : 0.20;
391
+ var semanticDefaults = {
392
+ success: '#198754', danger: '#dc3545',
393
+ warning: '#f0ad4e', info: '#17a2b8'
394
+ };
395
+ var semantics = ['success', 'danger', 'warning', 'info'];
396
+ for (var i = 0; i < semantics.length; i++) {
397
+ var key = semantics[i];
398
+ var seed = config[key] || semanticDefaults[key];
399
+ var harmonized = harmonize(seed, config.primary, amt);
400
+ alt[key] = deriveAlternateSeed(harmonized);
401
+ }
402
+
403
+ // Semantic colors are already harmonized+inverted — don't re-harmonize in derivePalette
404
+ alt.harmonize = 0;
405
+
406
+ return alt;
407
+ }
408
+
298
409
  /**
299
410
  * Derive complete palette from a theme config object.
411
+ * Semantic colors are harmonized toward the primary hue (configurable).
412
+ * Light/dark surface colors are tinted with the primary hue.
300
413
  * @param {Object} config - Theme config with primary, secondary, tertiary, etc.
301
- * @returns {Object} Full palette with shades for all 8 semantic colors + tertiary
414
+ * @param {number} [config.harmonize=0.20] - Hue shift amount for semantic colors (0-1)
415
+ * @returns {Object} Full palette with shades for all 9 semantic colors
302
416
  */
303
417
  function derivePalette(config) {
304
- var defaults = {
305
- success: '#198754',
306
- danger: '#dc3545',
307
- warning: '#ffc107',
308
- info: '#0dcaf0',
309
- light: '#f8f9fa',
310
- dark: '#212529'
311
- };
418
+ var amt = config.harmonize !== undefined ? config.harmonize : 0.20;
419
+ var pri = config.primary;
420
+ var priHsl = hexToHsl(pri);
421
+ var h = priHsl[0];
422
+
423
+ // Semantic defaults — harmonized toward primary hue
424
+ var successBase = harmonize(config.success || '#198754', pri, amt);
425
+ var dangerBase = harmonize(config.danger || '#dc3545', pri, amt);
426
+ var warningBase = harmonize(config.warning || '#f0ad4e', pri, amt);
427
+ var infoBase = harmonize(config.info || '#17a2b8', pri, amt);
428
+
429
+ // Light/dark: derive from primary hue with low saturation (if not user-supplied)
430
+ var lightBase = config.light || hslToHex([h, 8, 97]);
431
+ var darkBase = config.dark || hslToHex([h, 10, 13]);
312
432
 
313
433
  var palette = {
314
- primary: deriveShades(config.primary),
434
+ primary: deriveShades(config.primary),
315
435
  secondary: deriveShades(config.secondary),
316
- tertiary: deriveShades(config.tertiary),
317
- success: deriveShades(config.success || defaults.success),
318
- danger: deriveShades(config.danger || defaults.danger),
319
- warning: deriveShades(config.warning || defaults.warning),
320
- info: deriveShades(config.info || defaults.info),
321
- light: deriveShades(config.light || defaults.light),
322
- dark: deriveShades(config.dark || defaults.dark)
436
+ tertiary: deriveShades(config.tertiary),
437
+ success: deriveShades(successBase),
438
+ danger: deriveShades(dangerBase),
439
+ warning: deriveShades(warningBase),
440
+ info: deriveShades(infoBase),
441
+ light: deriveShades(lightBase),
442
+ dark: deriveShades(darkBase)
323
443
  };
324
444
 
325
445
  return palette;
@@ -368,6 +488,88 @@
368
488
  pill: { btn: '50rem', card: '1rem', badge: '50rem', alert: '1rem', input: '50rem' }
369
489
  };
370
490
 
491
+ // ---- Typography scale presets ----
492
+
493
+ var TYPE_RATIO_PRESETS = {
494
+ tight: 1.125,
495
+ normal: 1.200,
496
+ relaxed: 1.250,
497
+ dramatic: 1.333
498
+ };
499
+
500
+ /**
501
+ * Generate a modular type scale from a base size and ratio.
502
+ * @param {number} base - Base font size in px (default 16)
503
+ * @param {number} ratio - Scale ratio (default 1.200)
504
+ * @returns {Object} { xs, sm, base, lg, xl, '2xl', '3xl', '4xl' } in px
505
+ */
506
+ function generateTypeScale(base, ratio) {
507
+ if (!base) base = 16;
508
+ if (!ratio) ratio = 1.200;
509
+ return {
510
+ xs: Math.round(base / (ratio * ratio)),
511
+ sm: Math.round(base / ratio),
512
+ base: base,
513
+ lg: Math.round(base * ratio),
514
+ xl: Math.round(base * ratio * ratio),
515
+ '2xl': Math.round(base * Math.pow(ratio, 3)),
516
+ '3xl': Math.round(base * Math.pow(ratio, 4)),
517
+ '4xl': Math.round(base * Math.pow(ratio, 5))
518
+ };
519
+ }
520
+
521
+ // ---- Elevation (shadow depth) presets ----
522
+
523
+ var ELEVATION_PRESETS = {
524
+ flat: {
525
+ sm: 'none',
526
+ md: 'none',
527
+ lg: 'none',
528
+ xl: 'none'
529
+ },
530
+ sm: {
531
+ sm: '0 1px 2px rgba(0,0,0,0.05)',
532
+ md: '0 1px 3px rgba(0,0,0,0.08)',
533
+ lg: '0 2px 6px rgba(0,0,0,0.10)',
534
+ xl: '0 4px 12px rgba(0,0,0,0.12)'
535
+ },
536
+ md: {
537
+ sm: '0 1px 3px rgba(0,0,0,0.08)',
538
+ md: '0 2px 6px rgba(0,0,0,0.12)',
539
+ lg: '0 4px 12px rgba(0,0,0,0.16)',
540
+ xl: '0 8px 24px rgba(0,0,0,0.20)'
541
+ },
542
+ lg: {
543
+ sm: '0 2px 4px rgba(0,0,0,0.10)',
544
+ md: '0 4px 12px rgba(0,0,0,0.16)',
545
+ lg: '0 8px 24px rgba(0,0,0,0.22)',
546
+ xl: '0 16px 48px rgba(0,0,0,0.28)'
547
+ }
548
+ };
549
+
550
+ // ---- Motion (transition) presets ----
551
+
552
+ var MOTION_PRESETS = {
553
+ reduced: {
554
+ fast: '0ms',
555
+ normal: '0ms',
556
+ slow: '0ms',
557
+ easing: 'linear'
558
+ },
559
+ standard: {
560
+ fast: '100ms',
561
+ normal: '200ms',
562
+ slow: '300ms',
563
+ easing: 'ease-out'
564
+ },
565
+ expressive: {
566
+ fast: '150ms',
567
+ normal: '300ms',
568
+ slow: '500ms',
569
+ easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)'
570
+ }
571
+ };
572
+
371
573
  /**
372
574
  * Default palette config — matches existing hardcoded colors
373
575
  */
@@ -377,8 +579,8 @@
377
579
  tertiary: '#006666',
378
580
  success: '#198754',
379
581
  danger: '#dc3545',
380
- warning: '#ffc107',
381
- info: '#0dcaf0',
582
+ warning: '#b38600',
583
+ info: '#0891b2',
382
584
  light: '#f8f9fa',
383
585
  dark: '#212529'
384
586
  };
@@ -403,18 +605,32 @@
403
605
  };
404
606
 
405
607
  /**
406
- * Resolve layout config to spacing + radius objects
407
- * @param {Object} config - { spacing, radius, fontSize }
408
- * @returns {Object} { spacing, radius, fontSize }
608
+ * Resolve layout config to spacing, radius, typeScale, elevation, and motion objects.
609
+ * @param {Object} config - { spacing, radius, fontSize, typeRatio, elevation, motion }
610
+ * @returns {Object} { spacing, radius, fontSize, typeScale, elevation, motion }
409
611
  */
410
612
  function resolveLayout(config) {
411
613
  var sp = (config && config.spacing) || 'normal';
412
614
  var rd = (config && config.radius) || 'md';
413
615
  var fs = (config && config.fontSize) || 1.0;
616
+
617
+ // typeRatio: accept preset name or number
618
+ var tr = (config && config.typeRatio) || 'normal';
619
+ var ratioNum = typeof tr === 'string' ? (TYPE_RATIO_PRESETS[tr] || TYPE_RATIO_PRESETS.normal) : tr;
620
+
621
+ // elevation: accept preset name or object
622
+ var el = (config && config.elevation) || 'md';
623
+
624
+ // motion: accept preset name or object
625
+ var mo = (config && config.motion) || 'standard';
626
+
414
627
  return {
415
628
  spacing: typeof sp === 'string' ? (SPACING_PRESETS[sp] || SPACING_PRESETS.normal) : sp,
416
629
  radius: typeof rd === 'string' ? (RADIUS_PRESETS[rd] || RADIUS_PRESETS.md) : rd,
417
- fontSize: fs
630
+ fontSize: fs,
631
+ typeScale: generateTypeScale(16, ratioNum),
632
+ elevation: typeof el === 'string' ? (ELEVATION_PRESETS[el] || ELEVATION_PRESETS.md) : el,
633
+ motion: typeof mo === 'string' ? (MOTION_PRESETS[mo] || MOTION_PRESETS.standard) : mo
418
634
  };
419
635
  }
420
636
 
@@ -438,12 +654,13 @@
438
654
  // Themed CSS generators
439
655
  // =========================================================================
440
656
 
441
- function generateTypographyThemed(scope, palette) {
657
+ function generateTypographyThemed(scope, palette, layout) {
658
+ var mot = layout.motion;
442
659
  var rules = {};
443
660
  rules[scopeSelector(scope, 'a')] = {
444
661
  'color': palette.primary.base,
445
662
  'text-decoration': 'none',
446
- 'transition': 'color 0.15s'
663
+ 'transition': 'color ' + mot.fast + ' ' + mot.easing
447
664
  };
448
665
  rules[scopeSelector(scope, 'a:hover')] = {
449
666
  'color': palette.primary.hover,
@@ -463,7 +680,8 @@
463
680
  'border-radius': rd.btn
464
681
  };
465
682
  rules[scopeSelector(scope, '.bw-btn:focus-visible')] = {
466
- 'outline': '0',
683
+ 'outline': '2px solid currentColor',
684
+ 'outline-offset': '2px',
467
685
  'box-shadow': '0 0 0 3px ' + palette.primary.focus
468
686
  };
469
687
 
@@ -553,14 +771,15 @@
553
771
  var sp = layout.spacing;
554
772
  var rd = layout.radius;
555
773
 
774
+ var elev = layout.elevation;
556
775
  rules[scopeSelector(scope, '.bw-card')] = {
557
776
  'background-color': '#fff',
558
777
  'border': '1px solid ' + palette.light.border,
559
778
  'border-radius': rd.card,
560
- 'box-shadow': '0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04)'
779
+ 'box-shadow': elev.sm
561
780
  };
562
781
  rules[scopeSelector(scope, '.bw-card:hover')] = {
563
- 'box-shadow': '0 4px 12px rgba(0,0,0,.1), 0 2px 4px rgba(0,0,0,.06)'
782
+ 'box-shadow': elev.md
564
783
  };
565
784
  rules[scopeSelector(scope, '.bw-card-body')] = {
566
785
  'padding': sp.card
@@ -607,6 +826,8 @@
607
826
  };
608
827
  rules[scopeSelector(scope, '.bw-form-control:focus')] = {
609
828
  'border-color': palette.primary.border,
829
+ 'outline': '2px solid ' + palette.primary.base,
830
+ 'outline-offset': '-1px',
610
831
  'box-shadow': '0 0 0 0.25rem ' + palette.primary.focus
611
832
  };
612
833
  rules[scopeSelector(scope, '.bw-form-control::placeholder')] = {
@@ -764,7 +985,8 @@
764
985
  'border-color': palette.light.border
765
986
  };
766
987
  rules[scopeSelector(scope, '.bw-page-link:focus')] = {
767
- 'box-shadow': '0 0 0 0.25rem ' + palette.primary.focus
988
+ 'outline': '2px solid ' + palette.primary.base,
989
+ 'outline-offset': '-2px'
768
990
  };
769
991
  rules[scopeSelector(scope, '.bw-page-item.bw-active .bw-page-link')] = {
770
992
  'color': palette.primary.textOn,
@@ -923,12 +1145,12 @@
923
1145
  return rules;
924
1146
  }
925
1147
 
926
- function generateModalThemed(scope, palette) {
1148
+ function generateModalThemed(scope, palette, layout) {
927
1149
  var rules = {};
928
1150
  rules[scopeSelector(scope, '.bw-modal-content')] = {
929
1151
  'background-color': '#fff',
930
1152
  'border-color': palette.light.border,
931
- 'box-shadow': '0 0.5rem 1rem rgba(0,0,0,0.15)'
1153
+ 'box-shadow': layout.elevation.lg
932
1154
  };
933
1155
  rules[scopeSelector(scope, '.bw-modal-header')] = {
934
1156
  'border-bottom-color': palette.light.border
@@ -942,12 +1164,12 @@
942
1164
  return rules;
943
1165
  }
944
1166
 
945
- function generateToastThemed(scope, palette) {
1167
+ function generateToastThemed(scope, palette, layout) {
946
1168
  var rules = {};
947
1169
  rules[scopeSelector(scope, '.bw-toast')] = {
948
1170
  'background-color': '#fff',
949
1171
  'border-color': 'rgba(0,0,0,0.1)',
950
- 'box-shadow': '0 0.5rem 1rem rgba(0,0,0,0.15)'
1172
+ 'box-shadow': layout.elevation.lg
951
1173
  };
952
1174
  rules[scopeSelector(scope, '.bw-toast-header')] = {
953
1175
  'border-bottom-color': 'rgba(0,0,0,0.05)'
@@ -961,12 +1183,12 @@
961
1183
  return rules;
962
1184
  }
963
1185
 
964
- function generateDropdownThemed(scope, palette) {
1186
+ function generateDropdownThemed(scope, palette, layout) {
965
1187
  var rules = {};
966
1188
  rules[scopeSelector(scope, '.bw-dropdown-menu')] = {
967
1189
  'background-color': '#fff',
968
1190
  'border-color': palette.light.border,
969
- 'box-shadow': '0 0.5rem 1rem rgba(0,0,0,0.15)'
1191
+ 'box-shadow': layout.elevation.md
970
1192
  };
971
1193
  rules[scopeSelector(scope, '.bw-dropdown-item')] = {
972
1194
  'color': palette.dark.base
@@ -1032,7 +1254,7 @@
1032
1254
  function generateThemedCSS(scopeName, palette, layout) {
1033
1255
  return Object.assign({},
1034
1256
  generateResetThemed(scopeName, palette),
1035
- generateTypographyThemed(scopeName, palette),
1257
+ generateTypographyThemed(scopeName, palette, layout),
1036
1258
  generateButtons(scopeName, palette, layout),
1037
1259
  generateAlerts(scopeName, palette, layout),
1038
1260
  generateBadges(scopeName, palette),
@@ -1051,9 +1273,9 @@
1051
1273
  generateSectionsThemed(scopeName, palette),
1052
1274
  generateAccordionThemed(scopeName, palette),
1053
1275
  generateCarouselThemed(scopeName, palette),
1054
- generateModalThemed(scopeName, palette),
1055
- generateToastThemed(scopeName, palette),
1056
- generateDropdownThemed(scopeName, palette),
1276
+ generateModalThemed(scopeName, palette, layout),
1277
+ generateToastThemed(scopeName, palette, layout),
1278
+ generateDropdownThemed(scopeName, palette, layout),
1057
1279
  generateSwitchThemed(scopeName, palette),
1058
1280
  generateSkeletonThemed(scopeName, palette),
1059
1281
  generateAvatarThemed(scopeName, palette),
@@ -1206,11 +1428,23 @@
1206
1428
  // =========================================================================
1207
1429
 
1208
1430
  /**
1209
- * Structural styles contain only layout, sizing, spacing, and behavior
1210
- * properties. No colors, backgrounds, shadows, or border-colors.
1211
- * These never change with themes.
1431
+ * Structural styles layout, sizing, spacing, positioning, and behavior.
1212
1432
  *
1213
- * @returns {Object} CSS rules object
1433
+ * POLICY: No colors, backgrounds, shadows, or border-colors in this function.
1434
+ * All cosmetic values belong in `defaultStyles.*` sections (unthemed defaults)
1435
+ * or in `generateThemedCSS()` (theme-driven colors).
1436
+ *
1437
+ * Exception: `.bw-progress-bar-striped` uses rgba(255,255,255,.15) for the
1438
+ * stripe pattern overlay. This is theme-neutral — a semi-transparent white
1439
+ * gradient that creates visible stripes on any background color.
1440
+ *
1441
+ * Architecture:
1442
+ * getStructuralStyles() → layout-only rules (never change with themes)
1443
+ * defaultStyles.* → cosmetic defaults (colors, shadows, borders)
1444
+ * generateThemedCSS() → palette-driven cosmetics from seed colors
1445
+ * generateAlternateCSS() → alternate palette (luminance-inverted)
1446
+ *
1447
+ * @returns {Object} CSS rules object (layout-only, theme-independent)
1214
1448
  */
1215
1449
  function getStructuralStyles() {
1216
1450
  var rules = {};
@@ -1261,12 +1495,12 @@
1261
1495
  'text-decoration': 'none', 'vertical-align': 'middle', 'cursor': 'pointer',
1262
1496
  'user-select': 'none', 'border': '1px solid transparent',
1263
1497
  'padding': '0.5rem 1.125rem', 'font-size': '0.875rem', 'font-family': 'inherit',
1264
- 'border-radius': '6px', 'transition': 'all 0.15s cubic-bezier(0.4, 0, 0.2, 1)',
1498
+ 'border-radius': '6px', 'transition': 'all 0.15s ease-out',
1265
1499
  'gap': '0.5rem'
1266
1500
  };
1267
1501
  rules['.bw-btn:hover'] = { 'text-decoration': 'none', 'transform': 'translateY(-1px)' };
1268
1502
  rules['.bw-btn:active'] = { 'transform': 'translateY(0)' };
1269
- rules['.bw-btn:focus-visible'] = { 'outline': '0' };
1503
+ rules['.bw-btn:focus-visible'] = { 'outline': '2px solid currentColor', 'outline-offset': '2px' };
1270
1504
  rules['.bw-btn:disabled'] = { 'opacity': '0.5', 'cursor': 'not-allowed', 'pointer-events': 'none' };
1271
1505
  rules['.bw-btn-lg'] = { 'padding': '0.625rem 1.5rem', 'font-size': '1rem', 'border-radius': '8px' };
1272
1506
  rules['.bw-btn-sm'] = { 'padding': '0.25rem 0.75rem', 'font-size': '0.8125rem', 'border-radius': '5px' };
@@ -1276,7 +1510,7 @@
1276
1510
  'position': 'relative', 'display': 'flex', 'flex-direction': 'column',
1277
1511
  'min-width': '0', 'height': '100%', 'word-wrap': 'break-word',
1278
1512
  'background-clip': 'border-box', 'border': '1px solid transparent',
1279
- 'border-radius': '8px', 'transition': 'box-shadow 0.2s cubic-bezier(0.4,0,0.2,1), transform 0.2s cubic-bezier(0.4,0,0.2,1)',
1513
+ 'border-radius': '8px', 'transition': 'box-shadow 0.2s ease-out, transform 0.2s ease-out',
1280
1514
  'margin-bottom': '1.5rem', 'overflow': 'hidden'
1281
1515
  };
1282
1516
  rules['.bw-card-body'] = { 'flex': '1 1 auto', 'padding': '1.25rem 1.5rem' };
@@ -1285,7 +1519,7 @@
1285
1519
  rules['.bw-card-text'] = { 'margin-bottom': '0', 'font-size': '0.9375rem', 'line-height': '1.6' };
1286
1520
  rules['.bw-card-header'] = { 'padding': '0.875rem 1.5rem', 'margin-bottom': '0', 'font-weight': '600', 'font-size': '0.875rem' };
1287
1521
  rules['.bw-card-footer'] = { 'padding': '0.75rem 1.5rem', 'font-size': '0.875rem' };
1288
- rules['.bw-card-hoverable'] = { 'transition': 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)' };
1522
+ rules['.bw-card-hoverable'] = { 'transition': 'all 0.3s ease-out' };
1289
1523
  rules['.bw-card-img-top'] = { 'width': '100%', 'border-top-left-radius': '7px', 'border-top-right-radius': '7px' };
1290
1524
  rules['.bw-card-img-bottom'] = { 'width': '100%', 'border-bottom-left-radius': '7px', 'border-bottom-right-radius': '7px' };
1291
1525
  rules['.bw-card-img-left'] = { 'width': '40%', 'object-fit': 'cover' };
@@ -1298,10 +1532,10 @@
1298
1532
  'font-size': '0.9375rem', 'font-weight': '400', 'line-height': '1.5',
1299
1533
  'background-clip': 'padding-box', 'appearance': 'none',
1300
1534
  'border': '1px solid transparent', 'border-radius': '6px',
1301
- 'transition': 'border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out',
1535
+ 'transition': 'border-color 0.15s ease-out, box-shadow 0.15s ease-out',
1302
1536
  'font-family': 'inherit'
1303
1537
  };
1304
- rules['.bw-form-control:focus'] = { 'outline': '0' };
1538
+ rules['.bw-form-control:focus'] = { 'outline': '2px solid currentColor', 'outline-offset': '-1px' };
1305
1539
  rules['.bw-form-control::placeholder'] = { 'opacity': '1' };
1306
1540
  rules['.bw-form-label'] = { 'display': 'block', 'margin-bottom': '0.375rem', 'font-size': '0.875rem', 'font-weight': '600' };
1307
1541
  rules['.bw-form-group'] = { 'margin-bottom': '1.25rem' };
@@ -1313,6 +1547,10 @@
1313
1547
  };
1314
1548
  rules['textarea.bw-form-control'] = { 'min-height': '5rem', 'resize': 'vertical' };
1315
1549
 
1550
+ // Form validation (structural)
1551
+ rules['.bw-valid-feedback'] = { 'display': 'block', 'font-size': '0.875rem', 'margin-top': '0.25rem' };
1552
+ rules['.bw-invalid-feedback'] = { 'display': 'block', 'font-size': '0.875rem', 'margin-top': '0.25rem' };
1553
+
1316
1554
  // Form checks (structural)
1317
1555
  Object.assign(rules, {
1318
1556
  '.bw-form-check': { 'display': 'flex', 'align-items': 'center', 'gap': '0.5rem', 'min-height': '1.5rem', 'margin-bottom': '0.25rem' },
@@ -1371,13 +1609,13 @@
1371
1609
 
1372
1610
  // Badges (structural)
1373
1611
  rules['.bw-badge'] = {
1374
- 'display': 'inline-block', 'padding': '.4em .75em', 'font-size': '.875em',
1612
+ 'display': 'inline-block', 'padding': '0.375rem 0.625rem', 'font-size': '0.875rem',
1375
1613
  'font-weight': '600', 'line-height': '1.3', 'text-align': 'center',
1376
1614
  'white-space': 'nowrap', 'vertical-align': 'baseline', 'border-radius': '.375rem'
1377
1615
  };
1378
1616
  rules['.bw-badge:empty'] = { 'display': 'none' };
1379
- rules['.bw-badge-sm'] = { 'font-size': '.75em', 'padding': '.25em .5em' };
1380
- rules['.bw-badge-lg'] = { 'font-size': '1em', 'padding': '.5em .9em' };
1617
+ rules['.bw-badge-sm'] = { 'font-size': '0.75rem', 'padding': '0.25rem 0.5rem' };
1618
+ rules['.bw-badge-lg'] = { 'font-size': '1rem', 'padding': '0.5rem 0.875rem' };
1381
1619
  rules['.bw-badge-pill'] = { 'border-radius': '50rem' };
1382
1620
 
1383
1621
  // Progress (structural)
@@ -1385,7 +1623,7 @@
1385
1623
  rules['.bw-progress-bar'] = {
1386
1624
  'display': 'flex', 'flex-direction': 'column', 'justify-content': 'center',
1387
1625
  'overflow': 'hidden', 'text-align': 'center', 'white-space': 'nowrap',
1388
- 'transition': 'width .6s ease', 'font-weight': '600'
1626
+ 'transition': 'width 0.3s ease-out', 'font-weight': '600'
1389
1627
  };
1390
1628
  rules['.bw-progress-bar-striped'] = {
1391
1629
  'background-image': 'linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)',
@@ -1402,7 +1640,7 @@
1402
1640
  'display': 'block', 'padding': '0.625rem 1rem', 'font-size': '0.875rem',
1403
1641
  'font-weight': '500', 'text-decoration': 'none', 'cursor': 'pointer',
1404
1642
  'border': 'none', 'background': 'transparent',
1405
- 'transition': 'color 0.15s, border-color 0.15s', 'font-family': 'inherit'
1643
+ 'transition': 'color 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out', 'font-family': 'inherit'
1406
1644
  };
1407
1645
  rules['.bw-nav-tabs .bw-nav-link'] = { 'border': 'none', 'border-bottom': '2px solid transparent', 'border-radius': '0', 'background-color': 'transparent' };
1408
1646
  rules['.bw-nav-pills .bw-nav-link'] = { 'border-radius': '6px' };
@@ -1420,7 +1658,8 @@
1420
1658
  rules['.bw-list-group-item:last-child'] = { 'border-bottom-right-radius': 'inherit', 'border-bottom-left-radius': 'inherit' };
1421
1659
  rules['.bw-list-group-item + .bw-list-group-item'] = { 'border-top-width': '0' };
1422
1660
  rules['.bw-list-group-item.disabled'] = { 'pointer-events': 'none' };
1423
- rules['a.bw-list-group-item'] = { 'cursor': 'pointer' };
1661
+ rules['a.bw-list-group-item'] = { 'cursor': 'pointer', 'transition': 'background-color 0.15s ease-out, color 0.15s ease-out' };
1662
+ rules['a.bw-list-group-item:focus-visible, .bw-list-group-item:focus-visible'] = { 'z-index': '2', 'outline': '2px solid currentColor', 'outline-offset': '-2px' };
1424
1663
  rules['.bw-list-group-flush'] = { 'border-radius': '0' };
1425
1664
  rules['.bw-list-group-flush > .bw-list-group-item'] = { 'border-width': '0 0 1px', 'border-radius': '0' };
1426
1665
  rules['.bw-list-group-flush > .bw-list-group-item:last-child'] = { 'border-bottom-width': '0' };
@@ -1431,16 +1670,19 @@
1431
1670
  rules['.bw-page-link'] = {
1432
1671
  'position': 'relative', 'display': 'block', 'padding': '0.375rem 0.75rem',
1433
1672
  'margin-left': '-1px', 'line-height': '1.25', 'text-decoration': 'none',
1434
- 'transition': 'color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out'
1673
+ 'transition': 'color 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out'
1435
1674
  };
1436
1675
  rules['.bw-page-item:first-child .bw-page-link'] = { 'margin-left': '0', 'border-top-left-radius': '0.375rem', 'border-bottom-left-radius': '0.375rem' };
1437
1676
  rules['.bw-page-item:last-child .bw-page-link'] = { 'border-top-right-radius': '0.375rem', 'border-bottom-right-radius': '0.375rem' };
1677
+ rules['.bw-page-link:focus-visible'] = { 'z-index': '3', 'outline': '2px solid currentColor', 'outline-offset': '-2px' };
1438
1678
 
1439
1679
  // Breadcrumb (structural)
1440
1680
  rules['.bw-breadcrumb'] = { 'display': 'flex', 'flex-wrap': 'wrap', 'padding': '0 0', 'margin-bottom': '1rem', 'list-style': 'none' };
1441
1681
  rules['.bw-breadcrumb-item'] = { 'display': 'flex' };
1442
1682
  rules['.bw-breadcrumb-item + .bw-breadcrumb-item'] = { 'padding-left': '0.5rem' };
1443
1683
  rules['.bw-breadcrumb-item + .bw-breadcrumb-item::before'] = { 'float': 'left', 'padding-right': '0.5rem', 'content': '"/"' };
1684
+ rules['.bw-breadcrumb-item a'] = { 'text-decoration': 'none', 'transition': 'color 0.15s ease-out' };
1685
+ rules['.bw-breadcrumb-item.active'] = { 'font-weight': '500' };
1444
1686
 
1445
1687
  // Hero (structural)
1446
1688
  rules['.bw-hero'] = { 'position': 'relative', 'overflow': 'hidden' };
@@ -1543,12 +1785,12 @@
1543
1785
  'position': 'relative', 'display': 'flex', 'align-items': 'center', 'width': '100%',
1544
1786
  'padding': '1rem 1.25rem', 'font-size': '1rem', 'font-weight': '500', 'text-align': 'left',
1545
1787
  'background-color': 'transparent', 'border': '0', 'overflow-anchor': 'none', 'cursor': 'pointer',
1546
- 'font-family': 'inherit', 'transition': 'color 0.15s ease-in-out, background-color 0.15s ease-in-out'
1788
+ 'font-family': 'inherit', 'transition': 'color 0.15s ease-out, background-color 0.15s ease-out'
1547
1789
  };
1548
1790
  rules['.bw-accordion-button::after'] = {
1549
1791
  'flex-shrink': '0', 'width': '1.25rem', 'height': '1.25rem', 'margin-left': 'auto',
1550
1792
  'content': '""', 'background-repeat': 'no-repeat', 'background-size': '1.25rem',
1551
- 'transition': 'transform 0.2s ease-in-out'
1793
+ 'transition': 'transform 0.2s ease-out'
1552
1794
  };
1553
1795
  rules['.bw-accordion-button:not(.bw-collapsed)::after'] = { 'transform': 'rotate(-180deg)' };
1554
1796
  rules['.bw-accordion-collapse'] = { 'max-height': '0', 'overflow': 'hidden', 'transition': 'max-height 0.3s ease' };
@@ -1557,10 +1799,13 @@
1557
1799
 
1558
1800
  // Modal (structural)
1559
1801
  rules['.bw-modal'] = {
1560
- 'display': 'none', 'position': 'fixed', 'top': '0', 'left': '0', 'width': '100%', 'height': '100%',
1561
- 'z-index': '1050', 'overflow-x': 'hidden', 'overflow-y': 'auto', 'opacity': '0', 'transition': 'opacity 0.15s linear'
1802
+ 'display': 'flex', 'align-items': 'center', 'justify-content': 'center',
1803
+ 'position': 'fixed', 'top': '0', 'left': '0', 'width': '100%', 'height': '100%',
1804
+ 'z-index': '1050', 'overflow-x': 'hidden', 'overflow-y': 'auto',
1805
+ 'opacity': '0', 'visibility': 'hidden', 'pointer-events': 'none',
1806
+ 'transition': 'opacity 0.2s ease, visibility 0.2s ease'
1562
1807
  };
1563
- rules['.bw-modal.bw-modal-show'] = { 'display': 'flex', 'align-items': 'center', 'justify-content': 'center', 'opacity': '1' };
1808
+ rules['.bw-modal.bw-modal-show'] = { 'opacity': '1', 'visibility': 'visible', 'pointer-events': 'auto' };
1564
1809
  rules['.bw-modal-dialog'] = {
1565
1810
  'position': 'relative', 'width': '100%', 'max-width': '500px', 'margin': '1.75rem auto',
1566
1811
  'pointer-events': 'none', 'transform': 'translateY(-20px)', 'transition': 'transform 0.2s ease-out'
@@ -1580,7 +1825,7 @@
1580
1825
 
1581
1826
  // Carousel (structural)
1582
1827
  rules['.bw-carousel'] = { 'position': 'relative', 'overflow': 'hidden', 'border-radius': '8px' };
1583
- rules['.bw-carousel-track'] = { 'display': 'flex', 'transition': 'transform 0.4s ease', 'height': '100%' };
1828
+ rules['.bw-carousel-track'] = { 'display': 'flex', 'transition': 'transform 0.3s ease-out', 'height': '100%' };
1584
1829
  rules['.bw-carousel-slide'] = { 'min-width': '100%', 'flex-shrink': '0', 'overflow': 'hidden', 'position': 'relative', 'display': 'flex', 'align-items': 'center', 'justify-content': 'center' };
1585
1830
  rules['.bw-carousel-slide img'] = { 'width': '100%', 'height': '100%', 'object-fit': 'cover' };
1586
1831
  rules['.bw-carousel-caption'] = { 'position': 'absolute', 'bottom': '0', 'left': '0', 'right': '0', 'padding': '0.75rem 1rem' };
@@ -1624,11 +1869,14 @@
1624
1869
  'border-bottom': '0', 'border-left': '0.3em solid transparent'
1625
1870
  };
1626
1871
  rules['.bw-dropdown-menu'] = {
1627
- 'position': 'absolute', 'top': '100%', 'left': '0', 'z-index': '1000', 'display': 'none',
1872
+ 'position': 'absolute', 'top': '100%', 'left': '0', 'z-index': '1000', 'display': 'block',
1628
1873
  'min-width': '10rem', 'padding': '0.5rem 0', 'margin': '0.125rem 0 0',
1629
- 'background-clip': 'padding-box', 'border-radius': '6px'
1874
+ 'background-clip': 'padding-box', 'border-radius': '6px',
1875
+ 'opacity': '0', 'visibility': 'hidden', 'transform': 'translateY(-4px)',
1876
+ 'pointer-events': 'none',
1877
+ 'transition': 'opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease'
1630
1878
  };
1631
- rules['.bw-dropdown-menu.bw-dropdown-show'] = { 'display': 'block' };
1879
+ rules['.bw-dropdown-menu.bw-dropdown-show'] = { 'opacity': '1', 'visibility': 'visible', 'transform': 'translateY(0)', 'pointer-events': 'auto' };
1632
1880
  rules['.bw-dropdown-menu-end'] = { 'left': 'auto', 'right': '0' };
1633
1881
  rules['.bw-dropdown-item'] = {
1634
1882
  'display': 'block', 'width': '100%', 'padding': '0.375rem 1rem', 'clear': 'both',
@@ -1636,6 +1884,7 @@
1636
1884
  'background-color': 'transparent', 'border': '0', 'font-size': '0.9375rem',
1637
1885
  'transition': 'background-color 0.15s, color 0.15s'
1638
1886
  };
1887
+ rules['.bw-dropdown-item:focus-visible'] = { 'outline': '2px solid currentColor', 'outline-offset': '-2px' };
1639
1888
  rules['.bw-dropdown-divider'] = { 'height': '0', 'margin': '0.5rem 0', 'overflow': 'hidden', 'opacity': '1' };
1640
1889
 
1641
1890
  // Switch (structural)
@@ -1643,7 +1892,7 @@
1643
1892
  rules['.bw-form-switch .bw-switch-input'] = {
1644
1893
  'width': '2em', 'height': '1.125em', 'margin-left': '-2.5em', 'border-radius': '2em',
1645
1894
  'appearance': 'none', 'background-position': 'left center', 'background-repeat': 'no-repeat',
1646
- 'background-size': 'contain', 'transition': 'background-position 0.15s ease-in-out, background-color 0.15s ease-in-out',
1895
+ 'background-size': 'contain', 'transition': 'background-position 0.15s ease-out, background-color 0.15s ease-out',
1647
1896
  'cursor': 'pointer'
1648
1897
  };
1649
1898
  rules['.bw-form-switch .bw-switch-input:checked'] = { 'background-position': 'right center' };
@@ -1668,6 +1917,123 @@
1668
1917
  rules['.bw-avatar-lg'] = { 'width': '4rem', 'height': '4rem', 'font-size': '1.25rem' };
1669
1918
  rules['.bw-avatar-xl'] = { 'width': '5rem', 'height': '5rem', 'font-size': '1.5rem' };
1670
1919
 
1920
+ // Stat card (structural)
1921
+ rules['.bw-stat-card'] = {
1922
+ 'border-radius': '8px', 'padding': '1.25rem',
1923
+ 'border-left': '4px solid transparent',
1924
+ 'transition': 'box-shadow 0.15s ease-out, transform 0.15s ease-out'
1925
+ };
1926
+ rules['.bw-stat-card:hover'] = { 'transform': 'translateY(-1px)' };
1927
+ rules['.bw-stat-icon'] = { 'font-size': '1.5rem', 'margin-bottom': '0.5rem' };
1928
+ rules['.bw-stat-value'] = { 'font-size': '2rem', 'font-weight': '700', 'line-height': '1.2' };
1929
+ rules['.bw-stat-label'] = { 'font-size': '0.875rem', 'margin-top': '0.25rem' };
1930
+ rules['.bw-stat-change'] = { 'font-size': '0.875rem', 'font-weight': '500', 'margin-top': '0.5rem' };
1931
+
1932
+ // Tooltip (structural)
1933
+ rules['.bw-tooltip-wrapper'] = { 'position': 'relative', 'display': 'inline-block' };
1934
+ rules['.bw-tooltip'] = {
1935
+ 'position': 'absolute', 'z-index': '999',
1936
+ 'padding': '0.375rem 0.75rem', 'border-radius': '4px', 'font-size': '0.875rem',
1937
+ 'white-space': 'nowrap', 'pointer-events': 'none',
1938
+ 'opacity': '0', 'visibility': 'hidden',
1939
+ 'transition': 'opacity 0.15s ease, visibility 0.15s ease, transform 0.15s ease'
1940
+ };
1941
+ rules['.bw-tooltip.bw-tooltip-show'] = { 'opacity': '1', 'visibility': 'visible' };
1942
+ rules['.bw-tooltip-top'] = { 'bottom': '100%', 'left': '50%', 'transform': 'translateX(-50%) translateY(-4px)', 'margin-bottom': '4px' };
1943
+ rules['.bw-tooltip-top.bw-tooltip-show'] = { 'transform': 'translateX(-50%) translateY(0)' };
1944
+ rules['.bw-tooltip-bottom'] = { 'top': '100%', 'left': '50%', 'transform': 'translateX(-50%) translateY(4px)', 'margin-top': '4px' };
1945
+ rules['.bw-tooltip-bottom.bw-tooltip-show'] = { 'transform': 'translateX(-50%) translateY(0)' };
1946
+ rules['.bw-tooltip-left'] = { 'right': '100%', 'top': '50%', 'transform': 'translateY(-50%) translateX(-4px)', 'margin-right': '4px' };
1947
+ rules['.bw-tooltip-left.bw-tooltip-show'] = { 'transform': 'translateY(-50%) translateX(0)' };
1948
+ rules['.bw-tooltip-right'] = { 'left': '100%', 'top': '50%', 'transform': 'translateY(-50%) translateX(4px)', 'margin-left': '4px' };
1949
+ rules['.bw-tooltip-right.bw-tooltip-show'] = { 'transform': 'translateY(-50%) translateX(0)' };
1950
+
1951
+ // Search input (structural)
1952
+ rules['.bw-search-input'] = { 'position': 'relative', 'display': 'flex', 'align-items': 'center' };
1953
+ rules['.bw-search-input .bw-search-field'] = { 'padding-right': '2.5rem' };
1954
+ rules['.bw-search-clear'] = {
1955
+ 'position': 'absolute', 'right': '0.5rem',
1956
+ 'display': 'flex', 'align-items': 'center', 'justify-content': 'center',
1957
+ 'width': '1.5rem', 'height': '1.5rem',
1958
+ 'border': 'none', 'background': 'none',
1959
+ 'font-size': '1.25rem', 'cursor': 'pointer', 'padding': '0',
1960
+ 'border-radius': '50%', 'transition': 'color 0.15s ease-out'
1961
+ };
1962
+
1963
+ // Range slider (structural)
1964
+ rules['.bw-range-wrapper'] = { 'margin-bottom': '1rem' };
1965
+ rules['.bw-range-label'] = { 'display': 'flex', 'justify-content': 'space-between', 'align-items': 'center', 'margin-bottom': '0.5rem', 'font-size': '0.875rem', 'font-weight': '500' };
1966
+ rules['.bw-range-value'] = { 'font-weight': '600' };
1967
+ rules['.bw-range'] = { 'width': '100%', 'height': '0.5rem', 'padding': '0', 'appearance': 'none', 'border': 'none', 'border-radius': '0.25rem', 'cursor': 'pointer', 'outline': 'none' };
1968
+ rules['.bw-range:disabled'] = { 'opacity': '0.5', 'cursor': 'not-allowed' };
1969
+
1970
+ // Media object (structural)
1971
+ rules['.bw-media'] = { 'display': 'flex', 'align-items': 'flex-start', 'gap': '1rem' };
1972
+ rules['.bw-media-reverse'] = { 'flex-direction': 'row-reverse' };
1973
+ rules['.bw-media-img'] = { 'border-radius': '50%', 'object-fit': 'cover', 'flex-shrink': '0' };
1974
+ rules['.bw-media-body'] = { 'flex': '1', 'min-width': '0' };
1975
+ rules['.bw-media-title'] = { 'margin': '0 0 0.25rem 0', 'font-size': '1rem', 'font-weight': '600', 'line-height': '1.3' };
1976
+
1977
+ // File upload (structural)
1978
+ rules['.bw-file-upload'] = {
1979
+ 'display': 'flex', 'flex-direction': 'column', 'align-items': 'center', 'justify-content': 'center',
1980
+ 'padding': '2rem', 'border': '2px dashed transparent', 'border-radius': '8px',
1981
+ 'cursor': 'pointer', 'text-align': 'center', 'position': 'relative',
1982
+ 'transition': 'border-color 0.15s ease-out, background-color 0.15s ease-out'
1983
+ };
1984
+ rules['.bw-file-upload-icon'] = { 'font-size': '2rem', 'margin-bottom': '0.5rem' };
1985
+ rules['.bw-file-upload-text'] = { 'font-size': '0.875rem' };
1986
+ rules['.bw-file-upload-input'] = {
1987
+ 'position': 'absolute', 'width': '1px', 'height': '1px', 'padding': '0',
1988
+ 'margin': '-1px', 'overflow': 'hidden', 'clip': 'rect(0,0,0,0)', 'border': '0'
1989
+ };
1990
+
1991
+ // Timeline (structural)
1992
+ rules['.bw-timeline'] = { 'position': 'relative', 'padding-left': '2rem' };
1993
+ rules['.bw-timeline-item'] = { 'position': 'relative', 'padding-bottom': '1.5rem' };
1994
+ rules['.bw-timeline-item:last-child'] = { 'padding-bottom': '0' };
1995
+ rules['.bw-timeline-marker'] = { 'position': 'absolute', 'left': '-1.75rem', 'top': '0.25rem', 'width': '0.75rem', 'height': '0.75rem', 'border-radius': '50%' };
1996
+ rules['.bw-timeline-content'] = { 'padding-left': '0.5rem' };
1997
+ rules['.bw-timeline-date'] = { 'font-size': '0.75rem', 'margin-bottom': '0.25rem', 'font-weight': '500' };
1998
+ rules['.bw-timeline-title'] = { 'font-size': '1rem', 'font-weight': '600', 'margin': '0 0 0.25rem 0', 'line-height': '1.3' };
1999
+ rules['.bw-timeline-text'] = { 'font-size': '0.875rem', 'margin': '0', 'line-height': '1.5' };
2000
+
2001
+ // Stepper (structural)
2002
+ rules['.bw-stepper'] = { 'display': 'flex', 'gap': '0' };
2003
+ rules['.bw-step'] = { 'flex': '1', 'display': 'flex', 'flex-direction': 'column', 'align-items': 'center', 'text-align': 'center', 'position': 'relative' };
2004
+ rules['.bw-step-indicator'] = { 'width': '2rem', 'height': '2rem', 'border-radius': '50%', 'display': 'flex', 'align-items': 'center', 'justify-content': 'center', 'font-size': '0.875rem', 'font-weight': '600', 'position': 'relative', 'z-index': '1', 'transition': 'background-color 0.2s ease-out, color 0.2s ease-out' };
2005
+ rules['.bw-step-body'] = { 'margin-top': '0.5rem' };
2006
+ rules['.bw-step-label'] = { 'font-size': '0.875rem', 'font-weight': '500' };
2007
+ rules['.bw-step-description'] = { 'font-size': '0.75rem', 'margin-top': '0.125rem' };
2008
+
2009
+ // Chip input (structural)
2010
+ rules['.bw-chip-input'] = { 'display': 'flex', 'flex-wrap': 'wrap', 'align-items': 'center', 'gap': '0.375rem', 'padding': '0.375rem 0.5rem', 'border-radius': '6px', 'min-height': '2.5rem', 'cursor': 'text', 'transition': 'border-color 0.15s ease-out, box-shadow 0.15s ease-out' };
2011
+ rules['.bw-chip'] = { 'display': 'inline-flex', 'align-items': 'center', 'gap': '0.25rem', 'padding': '0.125rem 0.5rem', 'border-radius': '1rem', 'font-size': '0.8125rem', 'line-height': '1.5', 'white-space': 'nowrap' };
2012
+ rules['.bw-chip-remove'] = { 'display': 'inline-flex', 'align-items': 'center', 'justify-content': 'center', 'width': '1rem', 'height': '1rem', 'border': 'none', 'background': 'none', 'font-size': '0.875rem', 'cursor': 'pointer', 'padding': '0', 'border-radius': '50%', 'transition': 'color 0.15s ease-out, background-color 0.15s ease-out' };
2013
+ rules['.bw-chip-field'] = { 'flex': '1', 'min-width': '80px', 'border': 'none', 'outline': 'none', 'font-size': '0.875rem', 'padding': '0.125rem 0', 'background': 'transparent' };
2014
+
2015
+ // Popover (structural)
2016
+ rules['.bw-popover-wrapper'] = { 'position': 'relative', 'display': 'inline-block' };
2017
+ rules['.bw-popover-trigger'] = { 'cursor': 'pointer' };
2018
+ rules['.bw-popover'] = {
2019
+ 'position': 'absolute', 'z-index': '1000',
2020
+ 'min-width': '200px', 'max-width': '320px',
2021
+ 'border-radius': '8px',
2022
+ 'pointer-events': 'none', 'opacity': '0', 'visibility': 'hidden',
2023
+ 'transition': 'opacity 0.15s ease, visibility 0.15s ease, transform 0.15s ease'
2024
+ };
2025
+ rules['.bw-popover.bw-popover-show'] = { 'opacity': '1', 'visibility': 'visible', 'pointer-events': 'auto' };
2026
+ rules['.bw-popover-header'] = { 'padding': '0.625rem 0.875rem', 'font-weight': '600', 'font-size': '0.9375rem' };
2027
+ rules['.bw-popover-body'] = { 'padding': '0.75rem 0.875rem', 'font-size': '0.875rem', 'line-height': '1.5' };
2028
+ rules['.bw-popover-top'] = { 'bottom': '100%', 'left': '50%', 'transform': 'translateX(-50%) translateY(-8px)', 'margin-bottom': '8px' };
2029
+ rules['.bw-popover-top.bw-popover-show'] = { 'transform': 'translateX(-50%) translateY(0)' };
2030
+ rules['.bw-popover-bottom'] = { 'top': '100%', 'left': '50%', 'transform': 'translateX(-50%) translateY(8px)', 'margin-top': '8px' };
2031
+ rules['.bw-popover-bottom.bw-popover-show'] = { 'transform': 'translateX(-50%) translateY(0)' };
2032
+ rules['.bw-popover-left'] = { 'right': '100%', 'top': '50%', 'transform': 'translateY(-50%) translateX(-8px)', 'margin-right': '8px' };
2033
+ rules['.bw-popover-left.bw-popover-show'] = { 'transform': 'translateY(-50%) translateX(0)' };
2034
+ rules['.bw-popover-right'] = { 'left': '100%', 'top': '50%', 'transform': 'translateY(-50%) translateX(8px)', 'margin-left': '8px' };
2035
+ rules['.bw-popover-right.bw-popover-show'] = { 'transform': 'translateY(-50%) translateX(0)' };
2036
+
1671
2037
  // Bar chart (structural)
1672
2038
  rules['.bw-bar-chart-container'] = {
1673
2039
  'padding': '1rem', 'border': '1px solid transparent', 'border-radius': '8px'
@@ -1681,7 +2047,7 @@
1681
2047
  };
1682
2048
  rules['.bw-bar'] = {
1683
2049
  'width': '100%', 'border-radius': '3px 3px 0 0',
1684
- 'transition': 'height 0.5s ease', 'min-height': '4px'
2050
+ 'transition': 'height 0.3s ease-out', 'min-height': '4px'
1685
2051
  };
1686
2052
  rules['.bw-bar:hover'] = { 'opacity': '0.85' };
1687
2053
  rules['.bw-bar-value'] = {
@@ -1810,6 +2176,16 @@
1810
2176
  // Responsive grid
1811
2177
  Object.assign(rules, defaultStyles.responsive);
1812
2178
 
2179
+ // Accessibility: reduce motion for users who prefer it
2180
+ rules['@media (prefers-reduced-motion: reduce)'] = {
2181
+ '*, *::before, *::after': {
2182
+ 'animation-duration': '0.01ms !important',
2183
+ 'animation-iteration-count': '1 !important',
2184
+ 'transition-duration': '0.01ms !important',
2185
+ 'scroll-behavior': 'auto !important'
2186
+ }
2187
+ };
2188
+
1813
2189
  return addUnderscoreAliases(rules);
1814
2190
  }
1815
2191
 
@@ -1818,9 +2194,25 @@
1818
2194
  // =========================================================================
1819
2195
 
1820
2196
  /**
1821
- * Add underscore aliases for all bw- selectors
2197
+ * Add underscore aliases for all `.bw-` selectors.
2198
+ *
2199
+ * CSS CLASS NAMING CONVENTION:
2200
+ *
2201
+ * Canonical form: `.bw-btn`, `.bw-card`, `.bw-table-hover` (hyphens)
2202
+ * Underscore alias: `.bw_btn`, `.bw_card`, `.bw_table_hover` (underscores)
2203
+ *
2204
+ * Both forms are valid in HTML and produce identical results. The hyphen
2205
+ * form is canonical (used in docs, generated CSS, component output).
2206
+ * Underscore aliases exist because:
2207
+ * 1. TACO attribute keys use underscores (`bw_id`, `bw_meta`) — no
2208
+ * quoting needed in JS object literals
2209
+ * 2. Some users prefer underscores for consistency with JS identifiers
2210
+ *
2211
+ * Use `bw.normalizeClass()` to convert underscore classes to canonical
2212
+ * hyphen form at runtime if needed.
2213
+ *
1822
2214
  * @param {Object} rules - CSS rules object
1823
- * @returns {Object} - Rules with underscore aliases added
2215
+ * @returns {Object} Rules with underscore aliases added (both forms work)
1824
2216
  */
1825
2217
  function addUnderscoreAliases(rules) {
1826
2218
  const result = {};
@@ -1837,6 +2229,27 @@
1837
2229
  // =========================================================================
1838
2230
  // Theme tokens (backwards compatible)
1839
2231
  // =========================================================================
2232
+ //
2233
+ // DESIGN NOTE — Why no CSS custom properties (CSS variables)?
2234
+ //
2235
+ // Bitwrench targets IE11 as Tier 1 (see dev/bw2x-compatibility.md).
2236
+ // CSS custom properties (var(--color-primary)) are not supported in IE11.
2237
+ //
2238
+ // Instead, bitwrench uses class-scoped CSS generation:
2239
+ // 1. `defaultStyles.*` provides hardcoded cosmetic defaults
2240
+ // 2. `generateTheme(name, config)` generates a complete set of
2241
+ // class-scoped CSS rules from 3 seed colors (primary, secondary,
2242
+ // tertiary) — all components are restyled with the new palette
2243
+ // 3. `generateAlternateCSS()` produces the alternate (dark/light)
2244
+ // variant scoped under `.bw-theme-alt`
2245
+ //
2246
+ // This achieves full theme customization without CSS variables:
2247
+ // bw.generateTheme('ocean', { primary: '#006666', secondary: '#cc6633' })
2248
+ // → generates .ocean .bw-btn-primary { background: #006666; } etc.
2249
+ //
2250
+ // When IE11 support is dropped, CSS custom properties can be added as
2251
+ // an optimization (one rule with var() instead of many scoped rules).
2252
+ // The generateTheme() API stays the same — only the output format changes.
1840
2253
 
1841
2254
  let theme = {
1842
2255
  colors: {
@@ -1844,8 +2257,8 @@
1844
2257
  secondary: '#6c757d',
1845
2258
  success: '#198754',
1846
2259
  danger: '#dc3545',
1847
- warning: '#ffc107',
1848
- info: '#0dcaf0',
2260
+ warning: '#b38600',
2261
+ info: '#0891b2',
1849
2262
  light: '#f8f9fa',
1850
2263
  dark: '#212529',
1851
2264
  white: '#fff',
@@ -1881,214 +2294,63 @@
1881
2294
  '5xl': '3rem'
1882
2295
  }
1883
2296
  },
1884
- darkMode: false
1885
2297
  };
1886
2298
 
1887
2299
  /**
1888
- * Generate theme-aware dark mode CSS from a palette.
1889
- * Derives dark variants from the palette colors instead of using hardcoded values.
2300
+ * Generate alternate-palette CSS scoped under `.bw-theme-alt`.
2301
+ * Uses the same `generateThemedCSS()` pipeline as the primary palette
2302
+ * both sides go through identical code paths.
1890
2303
  *
1891
- * @param {Object} palette - From derivePalette()
1892
- * @returns {Object} CSS rules object for dark mode
1893
- */
1894
- function generateDarkModeCSS(palette) {
1895
- var darkBg = adjustLightness(palette.primary.base, -15);
1896
- var darkBgHsl = hexToHsl(darkBg);
1897
- // Make it very dark (lightness 8-12%)
1898
- var bodyBg = hslToHex([darkBgHsl[0], Math.min(darkBgHsl[1], 30), 10]);
1899
- var surfaceBg = hslToHex([darkBgHsl[0], Math.min(darkBgHsl[1], 25), 15]);
1900
- var textColor = adjustLightness(palette.light.base, 5);
1901
- var borderColor = hslToHex([darkBgHsl[0], Math.min(darkBgHsl[1], 15), 30]);
1902
-
1903
- return {
1904
- ':root.bw-dark': {
1905
- '--bw-body-color': textColor,
1906
- '--bw-body-bg': bodyBg
1907
- },
1908
- '.bw-dark body, :root.bw-dark body': {
1909
- 'color': textColor,
1910
- 'background-color': bodyBg
1911
- },
1912
- '.bw-dark .bw-card': {
1913
- 'background-color': surfaceBg,
1914
- 'border-color': borderColor,
1915
- 'color': textColor
1916
- },
1917
- '.bw-dark .bw-card-header': {
1918
- 'background-color': bodyBg,
1919
- 'border-bottom-color': borderColor,
1920
- 'color': textColor
1921
- },
1922
- '.bw-dark .bw-card-footer': {
1923
- 'background-color': bodyBg,
1924
- 'border-top-color': borderColor,
1925
- 'color': textColor
1926
- },
1927
- '.bw-dark .bw-card-title': {
1928
- 'color': textColor
1929
- },
1930
- '.bw-dark .bw-navbar': {
1931
- 'background-color': surfaceBg,
1932
- 'border-bottom-color': borderColor
1933
- },
1934
- '.bw-dark .bw-navbar-brand': {
1935
- 'color': textColor
1936
- },
1937
- '.bw-dark .bw-navbar-nav .bw-nav-link': {
1938
- 'color': adjustLightness(textColor, -15)
1939
- },
1940
- '.bw-dark .bw-navbar-nav .bw-nav-link:hover': {
1941
- 'color': textColor
1942
- },
1943
- '.bw-dark .bw-form-control': {
1944
- 'background-color': surfaceBg,
1945
- 'border-color': borderColor,
1946
- 'color': textColor
1947
- },
1948
- '.bw-dark .bw-form-label': {
1949
- 'color': textColor
1950
- },
1951
- '.bw-dark .bw-form-text': {
1952
- 'color': adjustLightness(textColor, -20)
1953
- },
1954
- '.bw-dark .bw-table': {
1955
- 'color': textColor
1956
- },
1957
- '.bw-dark .bw-table > :not(caption) > * > *': {
1958
- 'border-bottom-color': borderColor
1959
- },
1960
- '.bw-dark .bw-table > thead > tr > *': {
1961
- 'background-color': bodyBg,
1962
- 'color': adjustLightness(textColor, -10),
1963
- 'border-bottom-color': borderColor
1964
- },
1965
- '.bw-dark .bw-table-striped > tbody > tr:nth-of-type(odd) > *': {
1966
- 'background-color': 'rgba(255, 255, 255, 0.05)'
1967
- },
1968
- '.bw-dark .bw-alert': {
1969
- 'border-color': borderColor
1970
- },
1971
- '.bw-dark .bw-list-group-item': {
1972
- 'background-color': surfaceBg,
1973
- 'border-color': borderColor,
1974
- 'color': textColor
1975
- },
1976
- '.bw-dark .bw-badge': {
1977
- 'color': textColor
1978
- },
1979
- '.bw-dark .bw-nav-tabs': {
1980
- 'border-bottom-color': borderColor
1981
- },
1982
- '.bw-dark .bw-nav-link': {
1983
- 'color': adjustLightness(textColor, -15)
1984
- },
1985
- '.bw-dark .bw-nav-tabs .bw-nav-link:hover': {
1986
- 'color': textColor,
1987
- 'border-bottom-color': borderColor
1988
- },
1989
- '.bw-dark .bw-pagination .bw-page-link': {
1990
- 'background-color': surfaceBg,
1991
- 'border-color': borderColor,
1992
- 'color': textColor
1993
- },
1994
- '.bw-dark .bw-breadcrumb-item + .bw-breadcrumb-item::before': {
1995
- 'color': adjustLightness(textColor, -20)
1996
- },
1997
- '.bw-dark .bw-breadcrumb-item.active': {
1998
- 'color': adjustLightness(textColor, -10)
1999
- },
2000
- '.bw-dark .bw-hero-light': {
2001
- 'background': surfaceBg,
2002
- 'color': textColor
2003
- },
2004
- '.bw-dark .bw-progress': {
2005
- 'background-color': surfaceBg
2006
- },
2007
- '.bw-dark .bw-section-subtitle': {
2008
- 'color': adjustLightness(textColor, -15)
2009
- },
2010
- '.bw-dark .bw-close': {
2011
- 'color': textColor
2012
- },
2013
- '.bw-dark .bw-accordion-item': {
2014
- 'background-color': surfaceBg,
2015
- 'border-color': borderColor
2016
- },
2017
- '.bw-dark .bw-accordion-button': {
2018
- 'color': textColor
2019
- },
2020
- '.bw-dark .bw-accordion-button:not(.bw-collapsed)': {
2021
- 'color': '#7dd3e0',
2022
- 'background-color': 'rgba(125, 211, 224, 0.1)'
2023
- },
2024
- '.bw-dark .bw-accordion-button:hover': {
2025
- 'background-color': bodyBg
2026
- },
2027
- '.bw-dark .bw-accordion-button:not(.bw-collapsed):hover': {
2028
- 'background-color': 'rgba(125, 211, 224, 0.15)'
2029
- },
2030
- '.bw-dark .bw-accordion-button:focus-visible': {
2031
- 'box-shadow': '0 0 0 0.2rem rgba(125, 211, 224, 0.3)'
2032
- },
2033
- '.bw-dark .bw-accordion-body': {
2034
- 'border-top-color': borderColor
2035
- },
2036
- '.bw-dark .bw-carousel': {
2037
- 'background-color': bodyBg
2038
- },
2039
- '.bw-dark .bw-carousel-control': {
2040
- 'background-color': 'rgba(255,255,255,0.15)'
2041
- },
2042
- '.bw-dark .bw-carousel-control:hover': {
2043
- 'background-color': 'rgba(255,255,255,0.25)'
2044
- },
2045
- '.bw-dark .bw-modal-content': {
2046
- 'background-color': surfaceBg,
2047
- 'border-color': borderColor
2048
- },
2049
- '.bw-dark .bw-modal-header': {
2050
- 'border-bottom-color': borderColor
2051
- },
2052
- '.bw-dark .bw-modal-footer': {
2053
- 'border-top-color': borderColor
2054
- },
2055
- '.bw-dark .bw-modal-title': {
2056
- 'color': textColor
2057
- },
2058
- '.bw-dark .bw-toast': {
2059
- 'background-color': surfaceBg,
2060
- 'border-color': borderColor
2061
- },
2062
- '.bw-dark .bw-toast-header': {
2063
- 'border-bottom-color': borderColor,
2064
- 'color': textColor
2065
- },
2066
- '.bw-dark .bw-dropdown-menu': {
2067
- 'background-color': surfaceBg,
2068
- 'border-color': borderColor
2069
- },
2070
- '.bw-dark .bw-dropdown-item': {
2071
- 'color': textColor
2072
- },
2073
- '.bw-dark .bw-dropdown-item:hover': {
2074
- 'background-color': bodyBg
2075
- },
2076
- '.bw-dark .bw-dropdown-divider': {
2077
- 'border-top-color': borderColor
2078
- },
2079
- '.bw-dark .bw-skeleton': {
2080
- 'background': 'linear-gradient(90deg, ' + borderColor + ' 25%, ' + surfaceBg + ' 37%, ' + borderColor + ' 63%)'
2081
- },
2082
- '.bw-dark h1, .bw-dark h2, .bw-dark h3, .bw-dark h4, .bw-dark h5, .bw-dark h6': {
2083
- 'color': textColor
2084
- },
2085
- '@media (prefers-color-scheme: dark)': {
2086
- ':root.bw-auto-dark body': {
2087
- 'color': textColor,
2088
- 'background-color': bodyBg
2304
+ * @param {string} name - Theme scope name (e.g. 'ocean'). '' for global.
2305
+ * @param {Object} altPalette - From derivePalette(deriveAlternateConfig(...))
2306
+ * @param {Object} layout - From resolveLayout()
2307
+ * @returns {Object} CSS rules object scoped under .bw-theme-alt (+ optional .name)
2308
+ */
2309
+ function generateAlternateCSS(name, altPalette, layout) {
2310
+ // Generate themed CSS using the same pipeline as primary
2311
+ var rawRules = generateThemedCSS('', altPalette, layout);
2312
+
2313
+ // Re-scope every selector under .bw-theme-alt (+ optional theme name)
2314
+ var altPrefix = name ? '.' + name + '.bw-theme-alt' : '.bw-theme-alt';
2315
+ var altRules = {};
2316
+
2317
+ for (var sel in rawRules) {
2318
+ if (!rawRules.hasOwnProperty(sel)) continue;
2319
+
2320
+ if (sel.charAt(0) === '@') {
2321
+ // @media / @keyframes — recurse into the block
2322
+ var innerBlock = rawRules[sel];
2323
+ var altInner = {};
2324
+ for (var innerSel in innerBlock) {
2325
+ if (!innerBlock.hasOwnProperty(innerSel)) continue;
2326
+ altInner[altPrefix + ' ' + innerSel] = innerBlock[innerSel];
2327
+ }
2328
+ altRules[sel] = altInner;
2329
+ } else {
2330
+ // Regular selector — prefix with alt scope
2331
+ // Handle comma-separated selectors
2332
+ var parts = sel.split(',');
2333
+ var scopedParts = [];
2334
+ for (var i = 0; i < parts.length; i++) {
2335
+ var s = parts[i].trim();
2336
+ // 'body' selector gets special treatment: .bw-theme-alt body
2337
+ if (s === 'body' || s.indexOf('body') === 0) {
2338
+ scopedParts.push(altPrefix + ' ' + s);
2339
+ } else {
2340
+ scopedParts.push(altPrefix + ' ' + s);
2341
+ }
2089
2342
  }
2343
+ altRules[scopedParts.join(', ')] = rawRules[sel];
2090
2344
  }
2345
+ }
2346
+
2347
+ // Add body-level overrides for the alternate surface
2348
+ altRules[altPrefix + ' body, :root' + altPrefix + ' body'] = {
2349
+ 'color': altPalette.dark.base,
2350
+ 'background-color': altPalette.light.base
2091
2351
  };
2352
+
2353
+ return altRules;
2092
2354
  }
2093
2355
 
2094
2356
  function deepMerge(target, source) {
@@ -2285,8 +2547,11 @@
2285
2547
  * variant: "success",
2286
2548
  * onclick: () => console.log("saved")
2287
2549
  * });
2550
+ * // String shorthand:
2551
+ * const ok = makeButton("OK");
2288
2552
  */
2289
2553
  function makeButton(props = {}) {
2554
+ if (typeof props === 'string') props = { text: props };
2290
2555
  const {
2291
2556
  text,
2292
2557
  variant = 'primary',
@@ -2591,6 +2856,7 @@
2591
2856
  class: `bw-nav-link ${index === actualActiveIndex ? 'active' : ''}`,
2592
2857
  type: 'button',
2593
2858
  role: 'tab',
2859
+ tabindex: index === actualActiveIndex ? '0' : '-1',
2594
2860
  'aria-selected': index === actualActiveIndex ? 'true' : 'false',
2595
2861
  'data-tab-index': index,
2596
2862
  onclick: (e) => {
@@ -2601,11 +2867,13 @@
2601
2867
  allTabs.forEach(t => {
2602
2868
  t.classList.remove('active');
2603
2869
  t.setAttribute('aria-selected', 'false');
2870
+ t.setAttribute('tabindex', '-1');
2604
2871
  });
2605
2872
  allPanes.forEach(p => p.classList.remove('active'));
2606
2873
 
2607
2874
  e.target.classList.add('active');
2608
2875
  e.target.setAttribute('aria-selected', 'true');
2876
+ e.target.setAttribute('tabindex', '0');
2609
2877
  const targetIndex = parseInt(e.target.getAttribute('data-tab-index'));
2610
2878
  allPanes[targetIndex].classList.add('active');
2611
2879
  }
@@ -2629,7 +2897,39 @@
2629
2897
  ],
2630
2898
  o: {
2631
2899
  type: 'tabs',
2632
- state: { activeIndex: actualActiveIndex }
2900
+ state: { activeIndex: actualActiveIndex },
2901
+ mounted: function(el) {
2902
+ var tablist = el.querySelector('[role="tablist"]');
2903
+ if (!tablist) return;
2904
+ tablist.addEventListener('keydown', function(e) {
2905
+ var tabButtons = tablist.querySelectorAll('[role="tab"]');
2906
+ var currentIndex = -1;
2907
+ for (var i = 0; i < tabButtons.length; i++) {
2908
+ if (tabButtons[i] === e.target) { currentIndex = i; break; }
2909
+ }
2910
+ if (currentIndex === -1) return;
2911
+
2912
+ var newIndex = -1;
2913
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
2914
+ e.preventDefault();
2915
+ newIndex = currentIndex > 0 ? currentIndex - 1 : tabButtons.length - 1;
2916
+ } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
2917
+ e.preventDefault();
2918
+ newIndex = currentIndex < tabButtons.length - 1 ? currentIndex + 1 : 0;
2919
+ } else if (e.key === 'Home') {
2920
+ e.preventDefault();
2921
+ newIndex = 0;
2922
+ } else if (e.key === 'End') {
2923
+ e.preventDefault();
2924
+ newIndex = tabButtons.length - 1;
2925
+ }
2926
+
2927
+ if (newIndex >= 0) {
2928
+ tabButtons[newIndex].focus();
2929
+ tabButtons[newIndex].click();
2930
+ }
2931
+ });
2932
+ }
2633
2933
  }
2634
2934
  };
2635
2935
  }
@@ -2650,8 +2950,11 @@
2650
2950
  * variant: "success",
2651
2951
  * dismissible: true
2652
2952
  * });
2953
+ * // String shorthand:
2954
+ * const msg = makeAlert("Something happened");
2653
2955
  */
2654
2956
  function makeAlert(props = {}) {
2957
+ if (typeof props === 'string') props = { content: props };
2655
2958
  const {
2656
2959
  content,
2657
2960
  variant = 'info',
@@ -2698,8 +3001,11 @@
2698
3001
  * @example
2699
3002
  * const badge = makeBadge({ text: "New", variant: "danger", pill: true });
2700
3003
  * const small = makeBadge({ text: "3", variant: "info", size: "sm" });
3004
+ * // String shorthand:
3005
+ * const tag = makeBadge("New");
2701
3006
  */
2702
3007
  function makeBadge(props = {}) {
3008
+ if (typeof props === 'string') props = { text: props };
2703
3009
  const {
2704
3010
  text,
2705
3011
  variant = 'primary',
@@ -2936,13 +3242,16 @@
2936
3242
  }
2937
3243
 
2938
3244
  /**
2939
- * Create a form group with label, input, and optional help text
3245
+ * Create a form group with label, input, optional help text and validation feedback
2940
3246
  *
2941
3247
  * @param {Object} [props] - Form group configuration
2942
3248
  * @param {string} [props.label] - Label text
2943
3249
  * @param {Object} [props.input] - Input TACO object (from makeInput, makeSelect, etc.)
2944
3250
  * @param {string} [props.help] - Help text displayed below the input
2945
3251
  * @param {string} [props.id] - Input ID (links label to input via for/id)
3252
+ * @param {string} [props.validation] - Validation state ("valid" or "invalid")
3253
+ * @param {string} [props.feedback] - Validation feedback text shown below input
3254
+ * @param {boolean} [props.required=false] - Show required indicator (*) on label
2946
3255
  * @returns {Object} TACO object representing a form group
2947
3256
  * @category Component Builders
2948
3257
  * @example
@@ -2950,11 +3259,22 @@
2950
3259
  * label: "Email",
2951
3260
  * id: "email",
2952
3261
  * input: makeInput({ type: "email", id: "email", placeholder: "you@example.com" }),
2953
- * help: "We'll never share your email."
3262
+ * validation: "invalid",
3263
+ * feedback: "Please enter a valid email address."
2954
3264
  * });
2955
3265
  */
2956
3266
  function makeFormGroup(props = {}) {
2957
- const { label, input, help, id } = props;
3267
+ var { label, input, help, id, validation, feedback, required } = props;
3268
+
3269
+ // Shallow-clone input TACO to add validation class without mutating original
3270
+ var styledInput = input;
3271
+ if (validation && input && input.a) {
3272
+ styledInput = { t: input.t, a: Object.assign({}, input.a), c: input.c, o: input.o };
3273
+ var validClass = validation === 'valid' ? 'bw-is-valid' : validation === 'invalid' ? 'bw-is-invalid' : '';
3274
+ if (validClass) {
3275
+ styledInput.a.class = ((styledInput.a.class || '') + ' ' + validClass).trim();
3276
+ }
3277
+ }
2958
3278
 
2959
3279
  return {
2960
3280
  t: 'div',
@@ -2963,9 +3283,14 @@
2963
3283
  label && {
2964
3284
  t: 'label',
2965
3285
  a: { for: id, class: 'bw-form-label' },
2966
- c: label
3286
+ c: required ? [label, { t: 'span', a: { class: 'bw-text-danger', style: 'margin-left: 0.25rem' }, c: '*' }] : label
3287
+ },
3288
+ styledInput,
3289
+ feedback && validation && {
3290
+ t: 'div',
3291
+ a: { class: validation === 'valid' ? 'bw-valid-feedback' : 'bw-invalid-feedback' },
3292
+ c: feedback
2967
3293
  },
2968
- input,
2969
3294
  help && {
2970
3295
  t: 'small',
2971
3296
  a: { class: 'bw-form-text bw-text-muted' },
@@ -3930,17 +4255,15 @@
3930
4255
  t: 'button',
3931
4256
  a: {
3932
4257
  class: 'bw-copy-btn bw-code-copy-btn',
3933
- onclick: (e) => {
3934
- navigator.clipboard.writeText(code).then(() => {
3935
- const btn = e.target;
3936
- const originalText = btn.textContent;
4258
+ onclick: function(e) {
4259
+ navigator.clipboard.writeText(code).then(function() {
4260
+ var btn = e.target;
4261
+ var originalText = btn.textContent;
3937
4262
  btn.textContent = 'Copied!';
3938
- btn.style.background = '#006666';
3939
- btn.style.color = '#fff';
3940
- setTimeout(() => {
4263
+ btn.classList.add('bw-code-copy-btn-copied');
4264
+ setTimeout(function() {
3941
4265
  btn.textContent = originalText;
3942
- btn.style.background = 'rgba(255,255,255,0.12)';
3943
- btn.style.color = '#aaa';
4266
+ btn.classList.remove('bw-code-copy-btn-copied');
3944
4267
  }, 2000);
3945
4268
  });
3946
4269
  }
@@ -4232,29 +4555,47 @@
4232
4555
  var isOpen = collapse.classList.contains('bw-collapse-show');
4233
4556
 
4234
4557
  if (!multiOpen) {
4235
- // Close all siblings
4236
- var allCollapses = accordionEl.querySelectorAll('.bw-accordion-collapse');
4237
- var allButtons = accordionEl.querySelectorAll('.bw-accordion-button');
4238
- for (var j = 0; j < allCollapses.length; j++) {
4239
- allCollapses[j].classList.remove('bw-collapse-show');
4240
- allCollapses[j].style.maxHeight = null;
4241
- }
4242
- for (var k = 0; k < allButtons.length; k++) {
4243
- allButtons[k].classList.add('bw-collapsed');
4244
- allButtons[k].setAttribute('aria-expanded', 'false');
4558
+ // Animate-close all other open siblings
4559
+ var allItems = accordionEl.querySelectorAll('.bw-accordion-item');
4560
+ for (var j = 0; j < allItems.length; j++) {
4561
+ if (allItems[j] === accordionItem) continue;
4562
+ var sibCollapse = allItems[j].querySelector('.bw-accordion-collapse');
4563
+ var sibBtn = allItems[j].querySelector('.bw-accordion-button');
4564
+ if (sibCollapse.classList.contains('bw-collapse-show')) {
4565
+ sibCollapse.style.maxHeight = sibCollapse.scrollHeight + 'px';
4566
+ sibCollapse.offsetHeight; // force reflow
4567
+ sibCollapse.style.maxHeight = '0px';
4568
+ sibCollapse.classList.remove('bw-collapse-show');
4569
+ sibBtn.classList.add('bw-collapsed');
4570
+ sibBtn.setAttribute('aria-expanded', 'false');
4571
+ }
4245
4572
  }
4246
4573
  }
4247
4574
 
4248
4575
  if (isOpen) {
4576
+ // Animate close
4577
+ collapse.style.maxHeight = collapse.scrollHeight + 'px';
4578
+ collapse.offsetHeight; // force reflow
4579
+ collapse.style.maxHeight = '0px';
4249
4580
  collapse.classList.remove('bw-collapse-show');
4250
- collapse.style.maxHeight = null;
4251
4581
  btn.classList.add('bw-collapsed');
4252
4582
  btn.setAttribute('aria-expanded', 'false');
4253
4583
  } else {
4584
+ // Animate open
4254
4585
  collapse.classList.add('bw-collapse-show');
4586
+ collapse.style.maxHeight = '0px';
4587
+ collapse.offsetHeight; // force reflow
4255
4588
  collapse.style.maxHeight = collapse.scrollHeight + 'px';
4256
4589
  btn.classList.remove('bw-collapsed');
4257
4590
  btn.setAttribute('aria-expanded', 'true');
4591
+ // After transition, allow dynamic content sizing
4592
+ var onEnd = function(ev) {
4593
+ if (ev.propertyName === 'max-height' && collapse.classList.contains('bw-collapse-show')) {
4594
+ collapse.style.maxHeight = 'none';
4595
+ }
4596
+ collapse.removeEventListener('transitionend', onEnd);
4597
+ };
4598
+ collapse.addEventListener('transitionend', onEnd);
4258
4599
  }
4259
4600
  }
4260
4601
  },
@@ -4271,7 +4612,7 @@
4271
4612
  },
4272
4613
  o: item.open ? {
4273
4614
  mounted: function(el) {
4274
- el.style.maxHeight = el.scrollHeight + 'px';
4615
+ el.style.maxHeight = 'none';
4275
4616
  }
4276
4617
  } : undefined
4277
4618
  }
@@ -4981,104 +5322,997 @@
4981
5322
  a: {
4982
5323
  class: ('bw-carousel ' + className).trim(),
4983
5324
  style: 'height: ' + height,
5325
+ tabindex: '0',
5326
+ 'aria-roledescription': 'carousel',
4984
5327
  'data-carousel-index': startIndex
4985
5328
  },
4986
5329
  c: children,
4987
5330
  o: {
4988
5331
  type: 'carousel',
4989
5332
  state: { activeIndex: startIndex, autoPlay: autoPlay, interval: interval },
4990
- mounted: autoPlay ? function(el) {
4991
- var intervalId = setInterval(function() {
5333
+ mounted: function(el) {
5334
+ // Keyboard navigation
5335
+ el.addEventListener('keydown', function(e) {
4992
5336
  var idx = parseInt(el.getAttribute('data-carousel-index') || '0');
4993
- goToSlide(el, idx + 1);
4994
- }, interval);
4995
- el._bw_carouselInterval = intervalId;
4996
- } : undefined,
4997
- unmount: autoPlay ? function(el) {
5337
+ if (e.key === 'ArrowLeft') {
5338
+ e.preventDefault();
5339
+ goToSlide(el, idx - 1);
5340
+ } else if (e.key === 'ArrowRight') {
5341
+ e.preventDefault();
5342
+ goToSlide(el, idx + 1);
5343
+ }
5344
+ });
5345
+ // Auto-play
5346
+ if (autoPlay) {
5347
+ var intervalId = setInterval(function() {
5348
+ var idx = parseInt(el.getAttribute('data-carousel-index') || '0');
5349
+ goToSlide(el, idx + 1);
5350
+ }, interval);
5351
+ el._bw_carouselInterval = intervalId;
5352
+ // Pause on hover/focus for usability
5353
+ el.addEventListener('mouseenter', function() {
5354
+ if (el._bw_carouselInterval) clearInterval(el._bw_carouselInterval);
5355
+ });
5356
+ el.addEventListener('mouseleave', function() {
5357
+ el._bw_carouselInterval = setInterval(function() {
5358
+ var idx = parseInt(el.getAttribute('data-carousel-index') || '0');
5359
+ goToSlide(el, idx + 1);
5360
+ }, interval);
5361
+ });
5362
+ }
5363
+ },
5364
+ unmount: function(el) {
4998
5365
  if (el._bw_carouselInterval) {
4999
5366
  clearInterval(el._bw_carouselInterval);
5000
5367
  }
5001
- } : undefined
5368
+ }
5002
5369
  }
5003
5370
  };
5004
5371
  }
5005
5372
 
5006
- const componentHandles = {
5007
- card: CardHandle,
5008
- table: TableHandle,
5009
- navbar: NavbarHandle,
5010
- tabs: TabsHandle,
5011
- modal: ModalHandle
5012
- };
5013
-
5014
- var components = /*#__PURE__*/Object.freeze({
5015
- __proto__: null,
5016
- CardHandle: CardHandle,
5017
- ModalHandle: ModalHandle,
5018
- NavbarHandle: NavbarHandle,
5019
- TableHandle: TableHandle,
5020
- TabsHandle: TabsHandle,
5021
- componentHandles: componentHandles,
5022
- makeAccordion: makeAccordion,
5023
- makeAlert: makeAlert,
5024
- makeAvatar: makeAvatar,
5025
- makeBadge: makeBadge,
5026
- makeBreadcrumb: makeBreadcrumb,
5027
- makeButton: makeButton,
5028
- makeButtonGroup: makeButtonGroup,
5029
- makeCTA: makeCTA,
5030
- makeCard: makeCard,
5031
- makeCarousel: makeCarousel,
5032
- makeCheckbox: makeCheckbox,
5033
- makeCodeDemo: makeCodeDemo,
5034
- makeCol: makeCol,
5035
- makeContainer: makeContainer,
5036
- makeDropdown: makeDropdown,
5037
- makeFeatureGrid: makeFeatureGrid,
5038
- makeForm: makeForm,
5039
- makeFormGroup: makeFormGroup,
5040
- makeHero: makeHero,
5041
- makeInput: makeInput,
5042
- makeListGroup: makeListGroup,
5043
- makeModal: makeModal,
5044
- makeNav: makeNav,
5045
- makeNavbar: makeNavbar,
5046
- makePagination: makePagination,
5047
- makeProgress: makeProgress,
5048
- makeRadio: makeRadio,
5049
- makeRow: makeRow,
5050
- makeSection: makeSection,
5051
- makeSelect: makeSelect,
5052
- makeSkeleton: makeSkeleton,
5053
- makeSpinner: makeSpinner,
5054
- makeStack: makeStack,
5055
- makeSwitch: makeSwitch,
5056
- makeTabs: makeTabs,
5057
- makeTextarea: makeTextarea,
5058
- makeToast: makeToast
5059
- });
5373
+ // =========================================================================
5374
+ // Phase 4: Dashboard & Data Display
5375
+ // =========================================================================
5060
5376
 
5061
5377
  /**
5062
- * Bitwrench v2 Core
5063
- * Zero-dependency UI library using JavaScript objects
5064
- * Works in browsers (IE11+) and Node.js
5065
- *
5066
- * @license BSD-2-Clause
5067
- * @author M A Chatterjee <deftio [at] deftio [dot] com>
5378
+ * Create a stat card for dashboard metrics display
5379
+ *
5380
+ * Shows a large value with a label and optional change indicator.
5381
+ * Designed for dashboard grid layouts with left-border accent.
5382
+ *
5383
+ * @param {Object|string} [props] - Stat card configuration (string shorthand sets label)
5384
+ * @param {string|number} [props.value=0] - The main stat value to display
5385
+ * @param {string} [props.label] - Descriptive label below the value
5386
+ * @param {number} [props.change] - Percentage change indicator (positive = green arrow, negative = red)
5387
+ * @param {string} [props.format] - Value format ("number", "currency", "percent")
5388
+ * @param {string} [props.prefix] - Custom prefix (e.g. "$")
5389
+ * @param {string} [props.suffix] - Custom suffix (e.g. "%")
5390
+ * @param {string} [props.icon] - Icon content (emoji or text) shown above value
5391
+ * @param {string} [props.variant] - Left-border color variant ("primary", "success", "danger", etc.)
5392
+ * @param {string} [props.className] - Additional CSS classes
5393
+ * @param {Object} [props.style] - Inline style object
5394
+ * @returns {Object} TACO object representing a stat card
5395
+ * @category Component Builders
5396
+ * @example
5397
+ * const stat = makeStatCard({
5398
+ * value: 2345,
5399
+ * label: 'Active Users',
5400
+ * change: 5.3,
5401
+ * format: 'number',
5402
+ * variant: 'primary'
5403
+ * });
5068
5404
  */
5405
+ function makeStatCard(props = {}) {
5406
+ if (typeof props === 'string') props = { label: props };
5407
+ var {
5408
+ value = 0,
5409
+ label,
5410
+ change,
5411
+ format,
5412
+ prefix,
5413
+ suffix,
5414
+ icon,
5415
+ variant,
5416
+ className = '',
5417
+ style
5418
+ } = props;
5069
5419
 
5420
+ function formatValue(val, fmt) {
5421
+ if (prefix || suffix) return (prefix || '') + val + (suffix || '');
5422
+ switch (fmt) {
5423
+ case 'currency': return '$' + Number(val).toLocaleString();
5424
+ case 'percent': return val + '%';
5425
+ case 'number': return Number(val).toLocaleString();
5426
+ default: return '' + val;
5427
+ }
5428
+ }
5070
5429
 
5071
- // Environment-aware module loader for optional Node.js built-ins (fs).
5072
- // Strategy: try require() first (CJS/UMD), fall back to import() (ESM).
5073
- // import() is wrapped in Function() to avoid parse errors in ES5/IE11 environments.
5430
+ var classes = [
5431
+ 'bw-stat-card',
5432
+ variant ? 'bw-stat-card-' + variant : '',
5433
+ className
5434
+ ].filter(Boolean).join(' ').trim();
5074
5435
 
5075
- // Core bitwrench namespace
5076
- const bw = {
5077
- // Version info from generated file
5078
- version: VERSION_INFO.version,
5079
- versionInfo: VERSION_INFO,
5080
-
5081
- /**
5436
+ var children = [];
5437
+
5438
+ if (icon) {
5439
+ children.push({
5440
+ t: 'div',
5441
+ a: { class: 'bw-stat-icon' },
5442
+ c: icon
5443
+ });
5444
+ }
5445
+
5446
+ children.push({
5447
+ t: 'div',
5448
+ a: { class: 'bw-stat-value' },
5449
+ c: formatValue(value, format)
5450
+ });
5451
+
5452
+ if (label) {
5453
+ children.push({
5454
+ t: 'div',
5455
+ a: { class: 'bw-stat-label' },
5456
+ c: label
5457
+ });
5458
+ }
5459
+
5460
+ if (change !== undefined && change !== null) {
5461
+ children.push({
5462
+ t: 'div',
5463
+ a: {
5464
+ class: 'bw-stat-change ' + (change >= 0 ? 'bw-stat-change-up' : 'bw-stat-change-down')
5465
+ },
5466
+ c: (change >= 0 ? '\u2191 +' : '\u2193 ') + change + '%'
5467
+ });
5468
+ }
5469
+
5470
+ return {
5471
+ t: 'div',
5472
+ a: { class: classes, style: style },
5473
+ c: children,
5474
+ o: { type: 'stat-card' }
5475
+ };
5476
+ }
5477
+
5478
+ // =========================================================================
5479
+ // Phase 5: Overlays & Popovers
5480
+ // =========================================================================
5481
+
5482
+ /**
5483
+ * Create a tooltip wrapper around trigger content
5484
+ *
5485
+ * Wraps the trigger element in a container that shows tooltip text
5486
+ * on hover and focus. Pure CSS-driven show/hide with JS lifecycle
5487
+ * for event binding.
5488
+ *
5489
+ * @param {Object} [props] - Tooltip configuration
5490
+ * @param {string|Object|Array} [props.content] - Trigger content (what the user hovers/focuses)
5491
+ * @param {string} [props.text=""] - Tooltip text to display
5492
+ * @param {string} [props.placement="top"] - Tooltip placement ("top", "bottom", "left", "right")
5493
+ * @param {string} [props.className] - Additional CSS classes
5494
+ * @returns {Object} TACO object representing a tooltip wrapper
5495
+ * @category Component Builders
5496
+ * @example
5497
+ * const tip = makeTooltip({
5498
+ * content: makeButton({ text: 'Hover me' }),
5499
+ * text: 'This is a tooltip!',
5500
+ * placement: 'top'
5501
+ * });
5502
+ */
5503
+ function makeTooltip(props = {}) {
5504
+ var {
5505
+ content,
5506
+ text = '',
5507
+ placement = 'top',
5508
+ className = ''
5509
+ } = props;
5510
+
5511
+ return {
5512
+ t: 'span',
5513
+ a: { class: ('bw-tooltip-wrapper ' + className).trim() },
5514
+ c: [
5515
+ content,
5516
+ {
5517
+ t: 'span',
5518
+ a: {
5519
+ class: 'bw-tooltip bw-tooltip-' + placement,
5520
+ role: 'tooltip'
5521
+ },
5522
+ c: text
5523
+ }
5524
+ ],
5525
+ o: {
5526
+ type: 'tooltip',
5527
+ mounted: function(el) {
5528
+ var tip = el.querySelector('.bw-tooltip');
5529
+ el.addEventListener('mouseenter', function() {
5530
+ tip.classList.add('bw-tooltip-show');
5531
+ });
5532
+ el.addEventListener('mouseleave', function() {
5533
+ tip.classList.remove('bw-tooltip-show');
5534
+ });
5535
+ el.addEventListener('focusin', function() {
5536
+ tip.classList.add('bw-tooltip-show');
5537
+ });
5538
+ el.addEventListener('focusout', function() {
5539
+ tip.classList.remove('bw-tooltip-show');
5540
+ });
5541
+ }
5542
+ }
5543
+ };
5544
+ }
5545
+
5546
+ /**
5547
+ * Create a popover wrapper around trigger content
5548
+ *
5549
+ * Like a tooltip but richer — supports title + body content and is
5550
+ * triggered by click rather than hover. Dismisses on click outside.
5551
+ *
5552
+ * @param {Object} [props] - Popover configuration
5553
+ * @param {string|Object|Array} [props.trigger] - Trigger content (what the user clicks)
5554
+ * @param {string} [props.title] - Popover header title
5555
+ * @param {string|Object|Array} [props.content] - Popover body content
5556
+ * @param {string} [props.placement="top"] - Placement ("top", "bottom", "left", "right")
5557
+ * @param {string} [props.className] - Additional CSS classes
5558
+ * @returns {Object} TACO object representing a popover wrapper
5559
+ * @category Component Builders
5560
+ * @example
5561
+ * const pop = makePopover({
5562
+ * trigger: makeButton({ text: 'Click me' }),
5563
+ * title: 'Popover Title',
5564
+ * content: 'Some helpful information here.',
5565
+ * placement: 'bottom'
5566
+ * });
5567
+ */
5568
+ function makePopover(props = {}) {
5569
+ var {
5570
+ trigger,
5571
+ title,
5572
+ content,
5573
+ placement = 'top',
5574
+ className = ''
5575
+ } = props;
5576
+
5577
+ var popoverContent = [
5578
+ title && {
5579
+ t: 'div',
5580
+ a: { class: 'bw-popover-header' },
5581
+ c: title
5582
+ },
5583
+ content && {
5584
+ t: 'div',
5585
+ a: { class: 'bw-popover-body' },
5586
+ c: content
5587
+ }
5588
+ ].filter(Boolean);
5589
+
5590
+ return {
5591
+ t: 'span',
5592
+ a: { class: ('bw-popover-wrapper ' + className).trim() },
5593
+ c: [
5594
+ {
5595
+ t: 'span',
5596
+ a: {
5597
+ class: 'bw-popover-trigger',
5598
+ onclick: function(e) {
5599
+ var wrapper = e.target.closest('.bw-popover-wrapper');
5600
+ var pop = wrapper.querySelector('.bw-popover');
5601
+ pop.classList.toggle('bw-popover-show');
5602
+ }
5603
+ },
5604
+ c: trigger
5605
+ },
5606
+ {
5607
+ t: 'div',
5608
+ a: {
5609
+ class: 'bw-popover bw-popover-' + placement
5610
+ },
5611
+ c: popoverContent
5612
+ }
5613
+ ],
5614
+ o: {
5615
+ type: 'popover',
5616
+ mounted: function(el) {
5617
+ // Click outside to close
5618
+ var outsideHandler = function(e) {
5619
+ if (!el.contains(e.target)) {
5620
+ var pop = el.querySelector('.bw-popover');
5621
+ if (pop) pop.classList.remove('bw-popover-show');
5622
+ }
5623
+ };
5624
+ document.addEventListener('click', outsideHandler);
5625
+ el._bw_outsideHandler = outsideHandler;
5626
+ },
5627
+ unmount: function(el) {
5628
+ if (el._bw_outsideHandler) {
5629
+ document.removeEventListener('click', el._bw_outsideHandler);
5630
+ }
5631
+ }
5632
+ }
5633
+ };
5634
+ }
5635
+
5636
+ // =========================================================================
5637
+ // Phase 6: Form Enhancements & Layout
5638
+ // =========================================================================
5639
+
5640
+ /**
5641
+ * Create a search input with clear button
5642
+ *
5643
+ * Wraps a text input with a clear (×) button that appears when
5644
+ * the field has content. Calls onSearch on Enter key.
5645
+ *
5646
+ * @param {Object} [props] - Search input configuration
5647
+ * @param {string} [props.placeholder="Search..."] - Placeholder text
5648
+ * @param {string} [props.value] - Initial value
5649
+ * @param {Function} [props.onSearch] - Callback when Enter is pressed, receives value
5650
+ * @param {Function} [props.onInput] - Callback on each keystroke, receives value
5651
+ * @param {string} [props.id] - Element ID
5652
+ * @param {string} [props.name] - Input name attribute
5653
+ * @param {string} [props.className] - Additional CSS classes
5654
+ * @returns {Object} TACO object representing a search input
5655
+ * @category Component Builders
5656
+ * @example
5657
+ * const search = makeSearchInput({
5658
+ * placeholder: 'Search users...',
5659
+ * onSearch: (val) => filterUsers(val)
5660
+ * });
5661
+ */
5662
+ function makeSearchInput(props = {}) {
5663
+ if (typeof props === 'string') props = { placeholder: props };
5664
+ var {
5665
+ placeholder = 'Search...',
5666
+ value,
5667
+ onSearch,
5668
+ onInput,
5669
+ id,
5670
+ name,
5671
+ className = ''
5672
+ } = props;
5673
+
5674
+ return {
5675
+ t: 'div',
5676
+ a: { class: ('bw-search-input ' + className).trim() },
5677
+ c: [
5678
+ {
5679
+ t: 'input',
5680
+ a: {
5681
+ type: 'search',
5682
+ class: 'bw-form-control bw-search-field',
5683
+ placeholder: placeholder,
5684
+ value: value,
5685
+ id: id,
5686
+ name: name,
5687
+ onkeydown: function(e) {
5688
+ if (e.key === 'Enter' && onSearch) {
5689
+ e.preventDefault();
5690
+ onSearch(e.target.value);
5691
+ }
5692
+ },
5693
+ oninput: function(e) {
5694
+ var wrapper = e.target.closest('.bw-search-input');
5695
+ var clearBtn = wrapper.querySelector('.bw-search-clear');
5696
+ if (clearBtn) {
5697
+ clearBtn.style.display = e.target.value ? 'flex' : 'none';
5698
+ }
5699
+ if (onInput) onInput(e.target.value);
5700
+ }
5701
+ }
5702
+ },
5703
+ {
5704
+ t: 'button',
5705
+ a: {
5706
+ type: 'button',
5707
+ class: 'bw-search-clear',
5708
+ 'aria-label': 'Clear search',
5709
+ style: value ? undefined : 'display: none',
5710
+ onclick: function(e) {
5711
+ var wrapper = e.target.closest('.bw-search-input');
5712
+ var input = wrapper.querySelector('.bw-search-field');
5713
+ input.value = '';
5714
+ e.target.style.display = 'none';
5715
+ input.focus();
5716
+ if (onInput) onInput('');
5717
+ if (onSearch) onSearch('');
5718
+ }
5719
+ },
5720
+ c: '\u00D7'
5721
+ }
5722
+ ],
5723
+ o: { type: 'search-input' }
5724
+ };
5725
+ }
5726
+
5727
+ /**
5728
+ * Create a styled range slider input
5729
+ *
5730
+ * @param {Object} [props] - Range configuration
5731
+ * @param {number} [props.min=0] - Minimum value
5732
+ * @param {number} [props.max=100] - Maximum value
5733
+ * @param {number} [props.step=1] - Step increment
5734
+ * @param {number} [props.value=50] - Current value
5735
+ * @param {string} [props.label] - Label text
5736
+ * @param {boolean} [props.showValue=false] - Show current value display
5737
+ * @param {string} [props.id] - Element ID
5738
+ * @param {string} [props.name] - Input name attribute
5739
+ * @param {boolean} [props.disabled=false] - Whether the slider is disabled
5740
+ * @param {string} [props.className] - Additional CSS classes
5741
+ * @returns {Object} TACO object representing a range input
5742
+ * @category Component Builders
5743
+ * @example
5744
+ * const slider = makeRange({
5745
+ * min: 0, max: 100, value: 50,
5746
+ * label: 'Volume',
5747
+ * showValue: true,
5748
+ * oninput: (e) => setVolume(e.target.value)
5749
+ * });
5750
+ */
5751
+ function makeRange(props = {}) {
5752
+ var {
5753
+ min = 0,
5754
+ max = 100,
5755
+ step = 1,
5756
+ value = 50,
5757
+ label,
5758
+ showValue = false,
5759
+ id,
5760
+ name,
5761
+ disabled = false,
5762
+ className = '',
5763
+ ...eventHandlers
5764
+ } = props;
5765
+
5766
+ var children = [];
5767
+
5768
+ if (label || showValue) {
5769
+ var labelContent = [];
5770
+ if (label) {
5771
+ labelContent.push({
5772
+ t: 'span',
5773
+ c: label
5774
+ });
5775
+ }
5776
+ if (showValue) {
5777
+ labelContent.push({
5778
+ t: 'span',
5779
+ a: { class: 'bw-range-value' },
5780
+ c: '' + value
5781
+ });
5782
+ }
5783
+ children.push({
5784
+ t: 'div',
5785
+ a: { class: 'bw-range-label' },
5786
+ c: labelContent
5787
+ });
5788
+ }
5789
+
5790
+ // Wrap oninput to update value display
5791
+ var userOnInput = eventHandlers.oninput;
5792
+ if (showValue) {
5793
+ eventHandlers.oninput = function(e) {
5794
+ var wrapper = e.target.closest('.bw-range-wrapper');
5795
+ var valDisplay = wrapper.querySelector('.bw-range-value');
5796
+ if (valDisplay) valDisplay.textContent = e.target.value;
5797
+ if (userOnInput) userOnInput(e);
5798
+ };
5799
+ }
5800
+
5801
+ children.push({
5802
+ t: 'input',
5803
+ a: {
5804
+ type: 'range',
5805
+ class: 'bw-range',
5806
+ min: min,
5807
+ max: max,
5808
+ step: step,
5809
+ value: value,
5810
+ id: id,
5811
+ name: name,
5812
+ disabled: disabled,
5813
+ ...eventHandlers
5814
+ }
5815
+ });
5816
+
5817
+ return {
5818
+ t: 'div',
5819
+ a: { class: ('bw-range-wrapper ' + className).trim() },
5820
+ c: children,
5821
+ o: { type: 'range' }
5822
+ };
5823
+ }
5824
+
5825
+ /**
5826
+ * Create a media object layout (image + text side-by-side)
5827
+ *
5828
+ * Classic media object pattern: image/icon on one side, text content
5829
+ * on the other, using flexbox. Supports reversed layout.
5830
+ *
5831
+ * @param {Object} [props] - Media object configuration
5832
+ * @param {string} [props.src] - Image source URL
5833
+ * @param {string} [props.alt=""] - Image alt text
5834
+ * @param {string} [props.title] - Title text
5835
+ * @param {string|Object|Array} [props.content] - Body content
5836
+ * @param {boolean} [props.reverse=false] - Put image on the right
5837
+ * @param {string} [props.imageSize="3rem"] - Image width/height
5838
+ * @param {string} [props.className] - Additional CSS classes
5839
+ * @returns {Object} TACO object representing a media object
5840
+ * @category Component Builders
5841
+ * @example
5842
+ * const media = makeMediaObject({
5843
+ * src: '/avatar.jpg',
5844
+ * title: 'Jane Doe',
5845
+ * content: 'Posted a comment 5 minutes ago.'
5846
+ * });
5847
+ */
5848
+ function makeMediaObject(props = {}) {
5849
+ var {
5850
+ src,
5851
+ alt = '',
5852
+ title,
5853
+ content,
5854
+ reverse = false,
5855
+ imageSize = '3rem',
5856
+ className = ''
5857
+ } = props;
5858
+
5859
+ var imgEl = src ? {
5860
+ t: 'img',
5861
+ a: {
5862
+ class: 'bw-media-img',
5863
+ src: src,
5864
+ alt: alt,
5865
+ style: 'width:' + imageSize + ';height:' + imageSize
5866
+ }
5867
+ } : null;
5868
+
5869
+ var bodyEl = {
5870
+ t: 'div',
5871
+ a: { class: 'bw-media-body' },
5872
+ c: [
5873
+ title && { t: 'h5', a: { class: 'bw-media-title' }, c: title },
5874
+ content
5875
+ ].filter(Boolean)
5876
+ };
5877
+
5878
+ return {
5879
+ t: 'div',
5880
+ a: { class: ('bw-media ' + (reverse ? 'bw-media-reverse ' : '') + className).trim() },
5881
+ c: reverse
5882
+ ? [bodyEl, imgEl].filter(Boolean)
5883
+ : [imgEl, bodyEl].filter(Boolean),
5884
+ o: { type: 'media-object' }
5885
+ };
5886
+ }
5887
+
5888
+ /**
5889
+ * Create a file upload zone with drag-and-drop support
5890
+ *
5891
+ * Styled drop zone with file input. Supports drag-and-drop visuals
5892
+ * and multiple file selection.
5893
+ *
5894
+ * @param {Object} [props] - File upload configuration
5895
+ * @param {string} [props.accept] - Accepted file types (e.g. "image/*", ".pdf,.doc")
5896
+ * @param {boolean} [props.multiple=false] - Allow multiple file selection
5897
+ * @param {Function} [props.onFiles] - Callback when files are selected, receives FileList
5898
+ * @param {string} [props.text="Drop files here or click to browse"] - Zone label text
5899
+ * @param {string} [props.id] - Element ID
5900
+ * @param {string} [props.className] - Additional CSS classes
5901
+ * @returns {Object} TACO object representing a file upload zone
5902
+ * @category Component Builders
5903
+ * @example
5904
+ * const upload = makeFileUpload({
5905
+ * accept: 'image/*',
5906
+ * multiple: true,
5907
+ * onFiles: (files) => uploadFiles(files)
5908
+ * });
5909
+ */
5910
+ function makeFileUpload(props = {}) {
5911
+ var {
5912
+ accept,
5913
+ multiple = false,
5914
+ onFiles,
5915
+ text = 'Drop files here or click to browse',
5916
+ id,
5917
+ className = ''
5918
+ } = props;
5919
+
5920
+ return {
5921
+ t: 'div',
5922
+ a: {
5923
+ class: ('bw-file-upload ' + className).trim(),
5924
+ tabindex: '0',
5925
+ role: 'button',
5926
+ 'aria-label': text
5927
+ },
5928
+ c: [
5929
+ { t: 'div', a: { class: 'bw-file-upload-icon' }, c: '\uD83D\uDCC1' },
5930
+ { t: 'div', a: { class: 'bw-file-upload-text' }, c: text },
5931
+ {
5932
+ t: 'input',
5933
+ a: {
5934
+ type: 'file',
5935
+ class: 'bw-file-upload-input',
5936
+ accept: accept,
5937
+ multiple: multiple,
5938
+ id: id,
5939
+ onchange: function(e) {
5940
+ if (onFiles && e.target.files.length) onFiles(e.target.files);
5941
+ }
5942
+ }
5943
+ }
5944
+ ],
5945
+ o: {
5946
+ type: 'file-upload',
5947
+ mounted: function(el) {
5948
+ var input = el.querySelector('.bw-file-upload-input');
5949
+
5950
+ // Click zone to trigger file input
5951
+ el.addEventListener('click', function(e) {
5952
+ if (e.target !== input) input.click();
5953
+ });
5954
+
5955
+ // Keyboard activation
5956
+ el.addEventListener('keydown', function(e) {
5957
+ if (e.key === 'Enter' || e.key === ' ') {
5958
+ e.preventDefault();
5959
+ input.click();
5960
+ }
5961
+ });
5962
+
5963
+ // Drag-and-drop visuals
5964
+ el.addEventListener('dragover', function(e) {
5965
+ e.preventDefault();
5966
+ el.classList.add('bw-file-upload-active');
5967
+ });
5968
+ el.addEventListener('dragleave', function() {
5969
+ el.classList.remove('bw-file-upload-active');
5970
+ });
5971
+ el.addEventListener('drop', function(e) {
5972
+ e.preventDefault();
5973
+ el.classList.remove('bw-file-upload-active');
5974
+ if (onFiles && e.dataTransfer.files.length) onFiles(e.dataTransfer.files);
5975
+ });
5976
+ }
5977
+ }
5978
+ };
5979
+ }
5980
+
5981
+ // =========================================================================
5982
+ // Phase 7: Data Display & Workflow
5983
+ // =========================================================================
5984
+
5985
+ /**
5986
+ * Create a vertical timeline for chronological event display
5987
+ *
5988
+ * Renders events as a vertical line with markers and content cards.
5989
+ * Each item can have a colored variant marker.
5990
+ *
5991
+ * @param {Object} [props] - Timeline configuration
5992
+ * @param {Array<Object>} [props.items=[]] - Timeline events
5993
+ * @param {string} [props.items[].title] - Event title
5994
+ * @param {string|Object|Array} [props.items[].content] - Event description content
5995
+ * @param {string} [props.items[].date] - Date or time label
5996
+ * @param {string} [props.items[].variant="primary"] - Marker color variant
5997
+ * @param {string} [props.className] - Additional CSS classes
5998
+ * @returns {Object} TACO object representing a timeline
5999
+ * @category Component Builders
6000
+ * @example
6001
+ * const timeline = makeTimeline({
6002
+ * items: [
6003
+ * { title: 'Project Started', date: 'Jan 2026', variant: 'primary' },
6004
+ * { title: 'Beta Release', date: 'Mar 2026', content: 'v2.0 beta shipped' },
6005
+ * { title: 'Stable Release', date: 'Jun 2026', variant: 'success' }
6006
+ * ]
6007
+ * });
6008
+ */
6009
+ function makeTimeline(props = {}) {
6010
+ var {
6011
+ items = [],
6012
+ className = ''
6013
+ } = props;
6014
+
6015
+ return {
6016
+ t: 'div',
6017
+ a: { class: ('bw-timeline ' + className).trim() },
6018
+ c: items.map(function(item) {
6019
+ return {
6020
+ t: 'div',
6021
+ a: { class: 'bw-timeline-item' },
6022
+ c: [
6023
+ {
6024
+ t: 'div',
6025
+ a: { class: 'bw-timeline-marker bw-timeline-marker-' + (item.variant || 'primary') }
6026
+ },
6027
+ {
6028
+ t: 'div',
6029
+ a: { class: 'bw-timeline-content' },
6030
+ c: [
6031
+ item.date && {
6032
+ t: 'div',
6033
+ a: { class: 'bw-timeline-date' },
6034
+ c: item.date
6035
+ },
6036
+ item.title && {
6037
+ t: 'h5',
6038
+ a: { class: 'bw-timeline-title' },
6039
+ c: item.title
6040
+ },
6041
+ item.content && (typeof item.content === 'string'
6042
+ ? { t: 'p', a: { class: 'bw-timeline-text' }, c: item.content }
6043
+ : item.content)
6044
+ ].filter(Boolean)
6045
+ }
6046
+ ]
6047
+ };
6048
+ }),
6049
+ o: { type: 'timeline' }
6050
+ };
6051
+ }
6052
+
6053
+ /**
6054
+ * Create a multi-step wizard/progress indicator
6055
+ *
6056
+ * Displays numbered steps with active and completed states.
6057
+ * Steps before currentStep are marked completed, the currentStep
6058
+ * is active, and subsequent steps are pending.
6059
+ *
6060
+ * @param {Object} [props] - Stepper configuration
6061
+ * @param {Array<Object>} [props.steps=[]] - Step definitions
6062
+ * @param {string} [props.steps[].label] - Step label text
6063
+ * @param {string} [props.steps[].description] - Optional step description
6064
+ * @param {number} [props.currentStep=0] - Zero-based index of the active step
6065
+ * @param {string} [props.className] - Additional CSS classes
6066
+ * @returns {Object} TACO object representing a stepper
6067
+ * @category Component Builders
6068
+ * @example
6069
+ * const stepper = makeStepper({
6070
+ * currentStep: 1,
6071
+ * steps: [
6072
+ * { label: 'Account', description: 'Create account' },
6073
+ * { label: 'Profile', description: 'Set up profile' },
6074
+ * { label: 'Confirm', description: 'Review & submit' }
6075
+ * ]
6076
+ * });
6077
+ */
6078
+ function makeStepper(props = {}) {
6079
+ var {
6080
+ steps = [],
6081
+ currentStep = 0,
6082
+ className = ''
6083
+ } = props;
6084
+
6085
+ return {
6086
+ t: 'div',
6087
+ a: { class: ('bw-stepper ' + className).trim(), role: 'list' },
6088
+ c: steps.map(function(step, index) {
6089
+ var state = index < currentStep ? 'completed' : index === currentStep ? 'active' : 'pending';
6090
+ return {
6091
+ t: 'div',
6092
+ a: {
6093
+ class: 'bw-step bw-step-' + state,
6094
+ role: 'listitem',
6095
+ 'aria-current': state === 'active' ? 'step' : undefined
6096
+ },
6097
+ c: [
6098
+ {
6099
+ t: 'div',
6100
+ a: { class: 'bw-step-indicator' },
6101
+ c: state === 'completed' ? '\u2713' : '' + (index + 1)
6102
+ },
6103
+ {
6104
+ t: 'div',
6105
+ a: { class: 'bw-step-body' },
6106
+ c: [
6107
+ { t: 'div', a: { class: 'bw-step-label' }, c: step.label },
6108
+ step.description && { t: 'div', a: { class: 'bw-step-description' }, c: step.description }
6109
+ ].filter(Boolean)
6110
+ }
6111
+ ]
6112
+ };
6113
+ }),
6114
+ o: { type: 'stepper' }
6115
+ };
6116
+ }
6117
+
6118
+ /**
6119
+ * Create a chip/tag input for managing a list of items
6120
+ *
6121
+ * Displays existing chips with remove buttons and an input field
6122
+ * for adding new ones. Chips are added on Enter and removed on
6123
+ * clicking the × button.
6124
+ *
6125
+ * @param {Object} [props] - Chip input configuration
6126
+ * @param {Array<string>} [props.chips=[]] - Initial chip values
6127
+ * @param {string} [props.placeholder="Add..."] - Input placeholder text
6128
+ * @param {Function} [props.onAdd] - Callback when a chip is added, receives value
6129
+ * @param {Function} [props.onRemove] - Callback when a chip is removed, receives value
6130
+ * @param {string} [props.className] - Additional CSS classes
6131
+ * @returns {Object} TACO object representing a chip input
6132
+ * @category Component Builders
6133
+ * @example
6134
+ * const tags = makeChipInput({
6135
+ * chips: ['JavaScript', 'CSS'],
6136
+ * placeholder: 'Add tag...',
6137
+ * onAdd: (val) => addTag(val),
6138
+ * onRemove: (val) => removeTag(val)
6139
+ * });
6140
+ */
6141
+ function makeChipInput(props = {}) {
6142
+ var {
6143
+ chips = [],
6144
+ placeholder = 'Add...',
6145
+ onAdd,
6146
+ onRemove,
6147
+ className = ''
6148
+ } = props;
6149
+
6150
+ function makeChipEl(text) {
6151
+ return {
6152
+ t: 'span',
6153
+ a: { class: 'bw-chip', 'data-chip-value': text },
6154
+ c: [
6155
+ text,
6156
+ {
6157
+ t: 'button',
6158
+ a: {
6159
+ type: 'button',
6160
+ class: 'bw-chip-remove',
6161
+ 'aria-label': 'Remove ' + text,
6162
+ onclick: function(e) {
6163
+ var chip = e.target.closest('.bw-chip');
6164
+ var val = chip.getAttribute('data-chip-value');
6165
+ chip.parentNode.removeChild(chip);
6166
+ if (onRemove) onRemove(val);
6167
+ }
6168
+ },
6169
+ c: '\u00D7'
6170
+ }
6171
+ ]
6172
+ };
6173
+ }
6174
+
6175
+ return {
6176
+ t: 'div',
6177
+ a: { class: ('bw-chip-input ' + className).trim() },
6178
+ c: [
6179
+ ...chips.map(makeChipEl),
6180
+ {
6181
+ t: 'input',
6182
+ a: {
6183
+ type: 'text',
6184
+ class: 'bw-chip-field',
6185
+ placeholder: placeholder,
6186
+ onkeydown: function(e) {
6187
+ if (e.key === 'Enter' && e.target.value.trim()) {
6188
+ e.preventDefault();
6189
+ var val = e.target.value.trim();
6190
+ var wrapper = e.target.closest('.bw-chip-input');
6191
+ // Insert chip before the input
6192
+ var chipEl = document.createElement('span');
6193
+ chipEl.className = 'bw-chip';
6194
+ chipEl.setAttribute('data-chip-value', val);
6195
+ chipEl.innerHTML = '';
6196
+ chipEl.textContent = val;
6197
+ var removeBtn = document.createElement('button');
6198
+ removeBtn.type = 'button';
6199
+ removeBtn.className = 'bw-chip-remove';
6200
+ removeBtn.setAttribute('aria-label', 'Remove ' + val);
6201
+ removeBtn.textContent = '\u00D7';
6202
+ removeBtn.onclick = function() {
6203
+ chipEl.parentNode.removeChild(chipEl);
6204
+ if (onRemove) onRemove(val);
6205
+ };
6206
+ chipEl.appendChild(removeBtn);
6207
+ wrapper.insertBefore(chipEl, e.target);
6208
+ e.target.value = '';
6209
+ if (onAdd) onAdd(val);
6210
+ }
6211
+ // Backspace on empty input removes last chip
6212
+ if (e.key === 'Backspace' && !e.target.value) {
6213
+ var wrapper = e.target.closest('.bw-chip-input');
6214
+ var chipEls = wrapper.querySelectorAll('.bw-chip');
6215
+ if (chipEls.length) {
6216
+ var last = chipEls[chipEls.length - 1];
6217
+ var removedVal = last.getAttribute('data-chip-value');
6218
+ last.parentNode.removeChild(last);
6219
+ if (onRemove) onRemove(removedVal);
6220
+ }
6221
+ }
6222
+ }
6223
+ }
6224
+ }
6225
+ ],
6226
+ o: { type: 'chip-input' }
6227
+ };
6228
+ }
6229
+
6230
+ const componentHandles = {
6231
+ card: CardHandle,
6232
+ table: TableHandle,
6233
+ navbar: NavbarHandle,
6234
+ tabs: TabsHandle,
6235
+ modal: ModalHandle
6236
+ };
6237
+
6238
+ var components = /*#__PURE__*/Object.freeze({
6239
+ __proto__: null,
6240
+ CardHandle: CardHandle,
6241
+ ModalHandle: ModalHandle,
6242
+ NavbarHandle: NavbarHandle,
6243
+ TableHandle: TableHandle,
6244
+ TabsHandle: TabsHandle,
6245
+ componentHandles: componentHandles,
6246
+ makeAccordion: makeAccordion,
6247
+ makeAlert: makeAlert,
6248
+ makeAvatar: makeAvatar,
6249
+ makeBadge: makeBadge,
6250
+ makeBreadcrumb: makeBreadcrumb,
6251
+ makeButton: makeButton,
6252
+ makeButtonGroup: makeButtonGroup,
6253
+ makeCTA: makeCTA,
6254
+ makeCard: makeCard,
6255
+ makeCarousel: makeCarousel,
6256
+ makeCheckbox: makeCheckbox,
6257
+ makeChipInput: makeChipInput,
6258
+ makeCodeDemo: makeCodeDemo,
6259
+ makeCol: makeCol,
6260
+ makeContainer: makeContainer,
6261
+ makeDropdown: makeDropdown,
6262
+ makeFeatureGrid: makeFeatureGrid,
6263
+ makeFileUpload: makeFileUpload,
6264
+ makeForm: makeForm,
6265
+ makeFormGroup: makeFormGroup,
6266
+ makeHero: makeHero,
6267
+ makeInput: makeInput,
6268
+ makeListGroup: makeListGroup,
6269
+ makeMediaObject: makeMediaObject,
6270
+ makeModal: makeModal,
6271
+ makeNav: makeNav,
6272
+ makeNavbar: makeNavbar,
6273
+ makePagination: makePagination,
6274
+ makePopover: makePopover,
6275
+ makeProgress: makeProgress,
6276
+ makeRadio: makeRadio,
6277
+ makeRange: makeRange,
6278
+ makeRow: makeRow,
6279
+ makeSearchInput: makeSearchInput,
6280
+ makeSection: makeSection,
6281
+ makeSelect: makeSelect,
6282
+ makeSkeleton: makeSkeleton,
6283
+ makeSpinner: makeSpinner,
6284
+ makeStack: makeStack,
6285
+ makeStatCard: makeStatCard,
6286
+ makeStepper: makeStepper,
6287
+ makeSwitch: makeSwitch,
6288
+ makeTabs: makeTabs,
6289
+ makeTextarea: makeTextarea,
6290
+ makeTimeline: makeTimeline,
6291
+ makeToast: makeToast,
6292
+ makeTooltip: makeTooltip
6293
+ });
6294
+
6295
+ /**
6296
+ * Bitwrench v2 Core
6297
+ * Zero-dependency UI library using JavaScript objects
6298
+ * Works in browsers (IE11+) and Node.js
6299
+ *
6300
+ * @license BSD-2-Clause
6301
+ * @author M A Chatterjee <deftio [at] deftio [dot] com>
6302
+ */
6303
+
6304
+
6305
+ // Environment-aware module loader for optional Node.js built-ins (fs).
6306
+ // Strategy: try require() first (CJS/UMD), fall back to import() (ESM).
6307
+ // import() is wrapped in Function() to avoid parse errors in ES5/IE11 environments.
6308
+
6309
+ // Core bitwrench namespace
6310
+ const bw = {
6311
+ // Version info from generated file
6312
+ version: VERSION_INFO.version,
6313
+ versionInfo: VERSION_INFO,
6314
+
6315
+ /**
5082
6316
  * Get version metadata object (v1-compatible callable API).
5083
6317
  *
5084
6318
  * Returns a copy of the build-time version info including version string,
@@ -6676,8 +7910,10 @@
6676
7910
  /**
6677
7911
  * Generate responsive CSS with media query breakpoints.
6678
7912
  *
6679
- * Produces a CSS string with `@media` rules for sm (640px), md (768px),
6680
- * lg (1024px), and xl (1280px) breakpoints. Pass the result to `bw.injectCSS()`.
7913
+ * Produces a CSS string with `@media (min-width)` rules for standard
7914
+ * breakpoints. These match the grid system and theme.breakpoints:
7915
+ * sm: 576px, md: 768px, lg: 992px, xl: 1200px
7916
+ * Pass the result to `bw.injectCSS()`.
6681
7917
  *
6682
7918
  * @param {string} selector - CSS selector
6683
7919
  * @param {Object} breakpoints - Object with keys: base, sm, md, lg, xl
@@ -6694,7 +7930,7 @@
6694
7930
  * bw.injectCSS(css);
6695
7931
  */
6696
7932
  bw.responsive = function(selector, breakpoints) {
6697
- var sizes = { sm: '640px', md: '768px', lg: '1024px', xl: '1280px' };
7933
+ var sizes = { sm: '576px', md: '768px', lg: '992px', xl: '1200px' };
6698
7934
  var parts = [];
6699
7935
  Object.keys(breakpoints).forEach(function(key) {
6700
7936
  var rules = {};
@@ -6828,7 +8064,8 @@
6828
8064
  * @returns {Element|null} Style element if in browser, null in Node.js
6829
8065
  * @category CSS & Styling
6830
8066
  * @see bw.setTheme
6831
- * @see bw.toggleDarkMode
8067
+ * @see bw.applyTheme
8068
+ * @see bw.toggleTheme
6832
8069
  * @example
6833
8070
  * bw.loadDefaultStyles(); // inject all default CSS
6834
8071
  */
@@ -6895,53 +8132,6 @@
6895
8132
  return bw.getTheme();
6896
8133
  };
6897
8134
 
6898
- /**
6899
- * Toggle dark mode on/off.
6900
- *
6901
- * Adds/removes the `bw-dark` class on `<html>` and injects dark mode CSS
6902
- * overrides. Pass `true`/`false` to force a mode, or omit to toggle.
6903
- *
6904
- * @param {boolean} [force] - Force dark (true) or light (false). Omit to toggle.
6905
- * @returns {boolean} Whether dark mode is now active
6906
- * @category CSS & Styling
6907
- * @see bw.setTheme
6908
- * @example
6909
- * bw.toggleDarkMode(); // toggle
6910
- * bw.toggleDarkMode(true); // force dark
6911
- * bw.toggleDarkMode(false); // force light
6912
- */
6913
- bw.toggleDarkMode = function(force) {
6914
- const isDark = force !== undefined ? force : !theme.darkMode;
6915
- theme.darkMode = isDark;
6916
-
6917
- if (bw._isBrowser) {
6918
- const root = document.documentElement;
6919
- if (isDark) {
6920
- root.classList.add('bw-dark');
6921
- // Generate palette-aware dark mode CSS, or fall back to default
6922
- var palette = bw._activePalette || derivePalette(DEFAULT_PALETTE_CONFIG);
6923
- var darkRules = generateDarkModeCSS(palette);
6924
- var darkCSS = bw.css(darkRules);
6925
-
6926
- // Remove existing dark styles to allow regeneration
6927
- var existing = document.getElementById('bw-dark-styles');
6928
- if (existing) existing.remove();
6929
-
6930
- var styleEl = document.createElement('style');
6931
- styleEl.id = 'bw-dark-styles';
6932
- styleEl.textContent = darkCSS;
6933
- document.head.appendChild(styleEl);
6934
- } else {
6935
- root.classList.remove('bw-dark');
6936
- // Remove dark mode styles when switching to light
6937
- var darkEl = document.getElementById('bw-dark-styles');
6938
- if (darkEl) darkEl.remove();
6939
- }
6940
- }
6941
-
6942
- return isDark;
6943
- };
6944
-
6945
8135
  /**
6946
8136
  * Generate a complete, scoped theme from seed colors.
6947
8137
  *
@@ -6964,13 +8154,19 @@
6964
8154
  * @param {string} [config.spacing='normal'] - 'compact' | 'normal' | 'spacious'
6965
8155
  * @param {string} [config.radius='md'] - 'none' | 'sm' | 'md' | 'lg' | 'pill'
6966
8156
  * @param {number} [config.fontSize=1.0] - Base font size scale factor
8157
+ * @param {string|number} [config.typeRatio='normal'] - 'tight' | 'normal' | 'relaxed' | 'dramatic' or a number
8158
+ * @param {string} [config.elevation='md'] - 'flat' | 'sm' | 'md' | 'lg'
8159
+ * @param {string} [config.motion='standard'] - 'reduced' | 'standard' | 'expressive'
8160
+ * @param {number} [config.harmonize=0.20] - 0-1, semantic color hue shift toward primary
6967
8161
  * @param {boolean} [config.inject=true] - Inject into DOM (browser only)
6968
- * @returns {Object} { css, palette, name }
8162
+ * @returns {Object} { css, palette, name, isLightPrimary, alternate: { css, palette } }
6969
8163
  * @category CSS & Styling
8164
+ * @see bw.applyTheme
8165
+ * @see bw.toggleTheme
6970
8166
  * @see bw.loadDefaultStyles
6971
8167
  * @example
6972
- * // Generate and inject an ocean theme
6973
- * bw.generateTheme('ocean', {
8168
+ * // Generate and inject an ocean theme (primary + alternate)
8169
+ * var theme = bw.generateTheme('ocean', {
6974
8170
  * primary: '#0077b6',
6975
8171
  * secondary: '#90e0ef',
6976
8172
  * tertiary: '#00b4d8'
@@ -6979,14 +8175,16 @@
6979
8175
  * // Apply to a container
6980
8176
  * document.getElementById('app').classList.add('ocean');
6981
8177
  *
8178
+ * // Toggle to alternate palette
8179
+ * bw.toggleTheme();
8180
+ *
6982
8181
  * // Generate CSS for static export (Node.js)
6983
8182
  * var result = bw.generateTheme('sunset', {
6984
8183
  * primary: '#e76f51',
6985
8184
  * secondary: '#264653',
6986
- * tertiary: '#e9c46a',
6987
8185
  * inject: false
6988
8186
  * });
6989
- * fs.writeFileSync('sunset.css', result.css);
8187
+ * fs.writeFileSync('sunset.css', result.css + result.alternate.css);
6990
8188
  */
6991
8189
  bw.generateTheme = function(name, config) {
6992
8190
  if (!config || !config.primary || !config.secondary) {
@@ -6997,29 +8195,37 @@
6997
8195
  var fullConfig = Object.assign({}, DEFAULT_PALETTE_CONFIG, config);
6998
8196
  if (!config.tertiary) fullConfig.tertiary = fullConfig.primary;
6999
8197
 
7000
- // Derive palette
8198
+ // Derive primary palette
7001
8199
  var palette = derivePalette(fullConfig);
7002
8200
 
7003
- // Store active palette for dark mode
7004
- bw._activePalette = palette;
7005
-
7006
8201
  // Resolve layout
7007
8202
  var layout = resolveLayout(fullConfig);
7008
8203
 
7009
- // Generate themed CSS rules
8204
+ // Generate primary themed CSS rules
7010
8205
  var themedRules = generateThemedCSS(name, palette, layout);
7011
-
7012
- // Add underscore aliases
7013
8206
  var aliasedRules = addUnderscoreAliases(themedRules);
7014
-
7015
- // Convert to CSS string
7016
8207
  var cssStr = bw.css(aliasedRules);
7017
8208
 
7018
- // Inject into DOM if requested and in browser
8209
+ // Derive alternate palette (luminance-inverted)
8210
+ var altConfig = deriveAlternateConfig(fullConfig);
8211
+ var altPalette = derivePalette(altConfig);
8212
+
8213
+ // Generate alternate CSS scoped under .bw-theme-alt
8214
+ var altRules = generateAlternateCSS(name, altPalette, layout);
8215
+ var aliasedAltRules = addUnderscoreAliases(altRules);
8216
+ var altCssStr = bw.css(aliasedAltRules);
8217
+
8218
+ // Determine if primary is light-flavored
8219
+ var lightPrimary = isLightPalette(fullConfig);
8220
+
8221
+ // Inject both CSS sets into DOM if requested
7019
8222
  var shouldInject = config.inject !== false;
7020
8223
  if (shouldInject && bw._isBrowser) {
7021
8224
  var styleId = name ? 'bw-theme-' + name : 'bw-theme-default';
7022
8225
  bw.injectCSS(cssStr, { id: styleId, append: false });
8226
+
8227
+ var altStyleId = name ? 'bw-theme-' + name + '-alt' : 'bw-theme-default-alt';
8228
+ bw.injectCSS(altCssStr, { id: altStyleId, append: false });
7023
8229
  }
7024
8230
 
7025
8231
  // Update bw.u color entries to reflect the palette
@@ -7030,7 +8236,72 @@
7030
8236
  bw.u.textWhite = { color: '#ffffff' };
7031
8237
  }
7032
8238
 
7033
- return { css: cssStr, palette: palette, name: name };
8239
+ // Store active theme state
8240
+ var result = {
8241
+ css: cssStr,
8242
+ palette: palette,
8243
+ name: name,
8244
+ isLightPrimary: lightPrimary,
8245
+ alternate: {
8246
+ css: altCssStr,
8247
+ palette: altPalette
8248
+ }
8249
+ };
8250
+ bw._activeTheme = result;
8251
+ bw._activeThemeMode = 'primary';
8252
+
8253
+ return result;
8254
+ };
8255
+
8256
+ /**
8257
+ * Apply a theme mode. Switches between primary and alternate palettes
8258
+ * by adding/removing the `bw-theme-alt` class on `<html>`.
8259
+ *
8260
+ * @param {string} mode - 'primary' | 'alternate' | 'light' | 'dark'
8261
+ * @returns {string} Active mode: 'primary' or 'alternate'
8262
+ * @category CSS & Styling
8263
+ * @see bw.generateTheme
8264
+ * @see bw.toggleTheme
8265
+ * @example
8266
+ * bw.applyTheme('alternate'); // switch to alternate palette
8267
+ * bw.applyTheme('dark'); // switch to whichever palette is darker
8268
+ * bw.applyTheme('primary'); // switch back to primary palette
8269
+ */
8270
+ bw.applyTheme = function(mode) {
8271
+ if (!bw._isBrowser) return mode || 'primary';
8272
+ var root = document.documentElement;
8273
+ var isLight = bw._activeTheme ? bw._activeTheme.isLightPrimary : true;
8274
+
8275
+ var wantAlt;
8276
+ if (mode === 'primary') wantAlt = false;
8277
+ else if (mode === 'alternate') wantAlt = true;
8278
+ else if (mode === 'light') wantAlt = !isLight;
8279
+ else if (mode === 'dark') wantAlt = isLight;
8280
+ else wantAlt = false;
8281
+
8282
+ if (wantAlt) {
8283
+ root.classList.add('bw-theme-alt');
8284
+ } else {
8285
+ root.classList.remove('bw-theme-alt');
8286
+ }
8287
+
8288
+ bw._activeThemeMode = wantAlt ? 'alternate' : 'primary';
8289
+ return bw._activeThemeMode;
8290
+ };
8291
+
8292
+ /**
8293
+ * Toggle between primary and alternate theme palettes.
8294
+ *
8295
+ * @returns {string} Active mode after toggle: 'primary' or 'alternate'
8296
+ * @category CSS & Styling
8297
+ * @see bw.applyTheme
8298
+ * @see bw.generateTheme
8299
+ * @example
8300
+ * bw.toggleTheme(); // flip between primary and alternate
8301
+ */
8302
+ bw.toggleTheme = function() {
8303
+ var current = bw._activeThemeMode || 'primary';
8304
+ return bw.applyTheme(current === 'primary' ? 'alternate' : 'primary');
7034
8305
  };
7035
8306
 
7036
8307
  // Expose color utility functions on bw namespace
@@ -7042,10 +8313,18 @@
7042
8313
  bw.textOnColor = textOnColor;
7043
8314
  bw.deriveShades = deriveShades;
7044
8315
  bw.derivePalette = derivePalette;
8316
+ bw.harmonize = harmonize;
8317
+ bw.deriveAlternateSeed = deriveAlternateSeed;
8318
+ bw.deriveAlternateConfig = deriveAlternateConfig;
8319
+ bw.isLightPalette = isLightPalette;
7045
8320
 
7046
8321
  // Expose layout and theme presets
7047
8322
  bw.SPACING_PRESETS = SPACING_PRESETS;
7048
8323
  bw.RADIUS_PRESETS = RADIUS_PRESETS;
8324
+ bw.TYPE_RATIO_PRESETS = TYPE_RATIO_PRESETS;
8325
+ bw.ELEVATION_PRESETS = ELEVATION_PRESETS;
8326
+ bw.MOTION_PRESETS = MOTION_PRESETS;
8327
+ bw.generateTypeScale = generateTypeScale;
7049
8328
  bw.DEFAULT_PALETTE_CONFIG = DEFAULT_PALETTE_CONFIG;
7050
8329
  bw.THEME_PRESETS = THEME_PRESETS;
7051
8330
 
@@ -8087,9 +9366,13 @@
8087
9366
  /**
8088
9367
  * Create a sortable TACO table from an array of row objects.
8089
9368
  *
9369
+ * Returns a bare `<table>` TACO — no wrapper, title, or responsive scroll.
9370
+ * Use this when you need full control over table placement, or when embedding
9371
+ * the table inside your own layout. For a ready-to-use table with title,
9372
+ * responsive wrapper, and defaults (striped + hover), use `bw.makeDataTable()`.
9373
+ *
8090
9374
  * Auto-detects columns from data keys if not specified. Supports click-to-sort
8091
- * headers with ascending/descending indicators. Returns a TACO object —
8092
- * render with `bw.DOM()` or `bw.html()`.
9375
+ * headers with ascending/descending indicators.
8093
9376
  *
8094
9377
  * @param {Object} config - Table configuration
8095
9378
  * @param {Array<Object>} config.data - Array of row objects to display
@@ -8389,10 +9672,12 @@
8389
9672
  };
8390
9673
 
8391
9674
  /**
8392
- * Create a responsive data table with title and optional wrapper
9675
+ * Create a ready-to-use data table with title and responsive wrapper.
8393
9676
  *
8394
- * Wraps bw.makeTable() output in a responsive container div.
8395
- * Adds an optional title heading above the table.
9677
+ * Convenience wrapper around `bw.makeTable()` that adds a title heading,
9678
+ * responsive horizontal scroll container, and defaults to striped + hover.
9679
+ * Use this for the common case; use `bw.makeTable()` when you need a bare
9680
+ * table element with no wrapper.
8396
9681
  *
8397
9682
  * @param {Object} config - Table configuration
8398
9683
  * @param {string} [config.title] - Table title heading