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-lean v2.0.13 | BSD-2-Clause | https://deftio.github.com/bitwrench/pages */
1
+ /*! bitwrench-lean v2.0.14 | BSD-2-Clause | https://deftio.github.com/bitwrench/pages */
2
2
  'use strict';
3
3
 
4
4
  /**
@@ -7,14 +7,14 @@
7
7
  */
8
8
 
9
9
  const VERSION_INFO = {
10
- version: '2.0.13',
10
+ version: '2.0.14',
11
11
  name: 'bitwrench',
12
12
  description: 'A library for javascript UI functions.',
13
13
  license: 'BSD-2-Clause',
14
14
  homepage: 'https://deftio.github.com/bitwrench/pages',
15
15
  repository: 'git+https://github.com/deftio/bitwrench.git',
16
16
  author: 'manu a. chatterjee <deftio@deftio.com> (https://deftio.com/)',
17
- buildDate: '2026-03-07T22:35:06.056Z'
17
+ buildDate: '2026-03-08T08:04:06.572Z'
18
18
  };
19
19
 
20
20
  /**
@@ -272,6 +272,29 @@ function textOnColor(hex) {
272
272
  return relativeLuminance(hex) > 0.179 ? '#000' : '#fff';
273
273
  }
274
274
 
275
+ /**
276
+ * Shift a color's hue toward a target hue by a given amount.
277
+ * Uses shortest-arc interpolation on the hue wheel.
278
+ * @param {string} sourceHex - Color to shift
279
+ * @param {string} targetHex - Color whose hue to shift toward
280
+ * @param {number} [amount=0.20] - 0 = no shift, 1 = full shift to target hue
281
+ * @returns {string} Harmonized hex color
282
+ */
283
+ function harmonize(sourceHex, targetHex, amount) {
284
+ if (amount === undefined) amount = 0.20;
285
+ if (amount === 0) return sourceHex;
286
+ var srcHsl = hexToHsl(sourceHex);
287
+ var tgtHsl = hexToHsl(targetHex);
288
+
289
+ // Shortest-arc hue interpolation
290
+ var diff = tgtHsl[0] - srcHsl[0];
291
+ if (diff > 180) diff -= 360;
292
+ if (diff < -180) diff += 360;
293
+
294
+ var newHue = (srcHsl[0] + diff * amount + 360) % 360;
295
+ return hslToHex([newHue, srcHsl[1], srcHsl[2]]);
296
+ }
297
+
275
298
  /**
276
299
  * Derive a full shade palette for a single semantic color.
277
300
  * @param {string} hex - Base color hex
@@ -291,31 +314,128 @@ function deriveShades(hex) {
291
314
  };
292
315
  }
293
316
 
317
+ /**
318
+ * Derive the alternate (luminance-inverted) version of a single seed color.
319
+ * Preserves hue, mirrors lightness, adjusts saturation for readability.
320
+ * @param {string} hex - Seed hex color
321
+ * @returns {string} Alternate hex color
322
+ */
323
+ function deriveAlternateSeed(hex) {
324
+ var hsl = hexToHsl(hex);
325
+ var h = hsl[0], s = hsl[1], l = hsl[2];
326
+ var altL, altS;
327
+
328
+ if (l > 50) {
329
+ // Light color → make dark. Map 50-100 → 30-10 range
330
+ altL = clip(100 - l - 10, 8, 40);
331
+ // Reduce saturation slightly — vivid colors at low lightness look garish
332
+ altS = clip(s * 0.85, 0, 100);
333
+ } else {
334
+ // Dark color → make light. Map 0-50 → 65-92 range
335
+ altL = clip(100 - l + 10, 60, 92);
336
+ // Slightly increase saturation for light variant
337
+ altS = clip(s * 1.1, 0, 100);
338
+ }
339
+
340
+ return hslToHex([h, altS, altL]);
341
+ }
342
+
343
+ /**
344
+ * Determine whether a palette config is "light-flavored" based on
345
+ * the average luminance of its seed colors.
346
+ * @param {Object} config - Theme config with primary, secondary hex colors
347
+ * @returns {boolean} true if the seeds are predominantly light
348
+ */
349
+ function isLightPalette(config) {
350
+ var lum = relativeLuminance(config.primary);
351
+ if (config.secondary) lum = (lum + relativeLuminance(config.secondary)) / 2;
352
+ if (config.tertiary) lum = (lum * 2 + relativeLuminance(config.tertiary)) / 3;
353
+ return lum > 0.179;
354
+ }
355
+
356
+ /**
357
+ * Derive a complete alternate config from a primary theme config.
358
+ * Each seed color is luminance-inverted; semantic colors are adjusted for
359
+ * the new luminance context.
360
+ * @param {Object} config - Primary theme config
361
+ * @returns {Object} Alternate theme config (same shape, inverted lightness)
362
+ */
363
+ function deriveAlternateConfig(config) {
364
+ var alt = {};
365
+ // Invert the user's seed colors
366
+ alt.primary = deriveAlternateSeed(config.primary);
367
+ alt.secondary = deriveAlternateSeed(config.secondary);
368
+ alt.tertiary = config.tertiary ? deriveAlternateSeed(config.tertiary) : alt.primary;
369
+
370
+ // Derive alternate surface colors from primary hue
371
+ var priHsl = hexToHsl(config.primary);
372
+ var h = priHsl[0];
373
+ var isLight = isLightPalette(config);
374
+
375
+ if (isLight) {
376
+ // Primary is light → alternate needs dark surfaces
377
+ alt.light = hslToHex([h, Math.min(priHsl[1], 15), 15]);
378
+ alt.dark = hslToHex([h, 5, 88]);
379
+ } else {
380
+ // Primary is dark → alternate needs light surfaces
381
+ alt.light = hslToHex([h, Math.min(priHsl[1], 10), 96]);
382
+ alt.dark = hslToHex([h, 10, 18]);
383
+ }
384
+
385
+ // Semantic colors: harmonize toward primary, then invert for alternate
386
+ var amt = config.harmonize !== undefined ? config.harmonize : 0.20;
387
+ var semanticDefaults = {
388
+ success: '#198754', danger: '#dc3545',
389
+ warning: '#f0ad4e', info: '#17a2b8'
390
+ };
391
+ var semantics = ['success', 'danger', 'warning', 'info'];
392
+ for (var i = 0; i < semantics.length; i++) {
393
+ var key = semantics[i];
394
+ var seed = config[key] || semanticDefaults[key];
395
+ var harmonized = harmonize(seed, config.primary, amt);
396
+ alt[key] = deriveAlternateSeed(harmonized);
397
+ }
398
+
399
+ // Semantic colors are already harmonized+inverted — don't re-harmonize in derivePalette
400
+ alt.harmonize = 0;
401
+
402
+ return alt;
403
+ }
404
+
294
405
  /**
295
406
  * Derive complete palette from a theme config object.
407
+ * Semantic colors are harmonized toward the primary hue (configurable).
408
+ * Light/dark surface colors are tinted with the primary hue.
296
409
  * @param {Object} config - Theme config with primary, secondary, tertiary, etc.
297
- * @returns {Object} Full palette with shades for all 8 semantic colors + tertiary
410
+ * @param {number} [config.harmonize=0.20] - Hue shift amount for semantic colors (0-1)
411
+ * @returns {Object} Full palette with shades for all 9 semantic colors
298
412
  */
299
413
  function derivePalette(config) {
300
- var defaults = {
301
- success: '#198754',
302
- danger: '#dc3545',
303
- warning: '#ffc107',
304
- info: '#0dcaf0',
305
- light: '#f8f9fa',
306
- dark: '#212529'
307
- };
414
+ var amt = config.harmonize !== undefined ? config.harmonize : 0.20;
415
+ var pri = config.primary;
416
+ var priHsl = hexToHsl(pri);
417
+ var h = priHsl[0];
418
+
419
+ // Semantic defaults — harmonized toward primary hue
420
+ var successBase = harmonize(config.success || '#198754', pri, amt);
421
+ var dangerBase = harmonize(config.danger || '#dc3545', pri, amt);
422
+ var warningBase = harmonize(config.warning || '#f0ad4e', pri, amt);
423
+ var infoBase = harmonize(config.info || '#17a2b8', pri, amt);
424
+
425
+ // Light/dark: derive from primary hue with low saturation (if not user-supplied)
426
+ var lightBase = config.light || hslToHex([h, 8, 97]);
427
+ var darkBase = config.dark || hslToHex([h, 10, 13]);
308
428
 
309
429
  var palette = {
310
- primary: deriveShades(config.primary),
430
+ primary: deriveShades(config.primary),
311
431
  secondary: deriveShades(config.secondary),
312
- tertiary: deriveShades(config.tertiary),
313
- success: deriveShades(config.success || defaults.success),
314
- danger: deriveShades(config.danger || defaults.danger),
315
- warning: deriveShades(config.warning || defaults.warning),
316
- info: deriveShades(config.info || defaults.info),
317
- light: deriveShades(config.light || defaults.light),
318
- dark: deriveShades(config.dark || defaults.dark)
432
+ tertiary: deriveShades(config.tertiary),
433
+ success: deriveShades(successBase),
434
+ danger: deriveShades(dangerBase),
435
+ warning: deriveShades(warningBase),
436
+ info: deriveShades(infoBase),
437
+ light: deriveShades(lightBase),
438
+ dark: deriveShades(darkBase)
319
439
  };
320
440
 
321
441
  return palette;
@@ -364,6 +484,88 @@ var RADIUS_PRESETS = {
364
484
  pill: { btn: '50rem', card: '1rem', badge: '50rem', alert: '1rem', input: '50rem' }
365
485
  };
366
486
 
487
+ // ---- Typography scale presets ----
488
+
489
+ var TYPE_RATIO_PRESETS = {
490
+ tight: 1.125,
491
+ normal: 1.200,
492
+ relaxed: 1.250,
493
+ dramatic: 1.333
494
+ };
495
+
496
+ /**
497
+ * Generate a modular type scale from a base size and ratio.
498
+ * @param {number} base - Base font size in px (default 16)
499
+ * @param {number} ratio - Scale ratio (default 1.200)
500
+ * @returns {Object} { xs, sm, base, lg, xl, '2xl', '3xl', '4xl' } in px
501
+ */
502
+ function generateTypeScale(base, ratio) {
503
+ if (!base) base = 16;
504
+ if (!ratio) ratio = 1.200;
505
+ return {
506
+ xs: Math.round(base / (ratio * ratio)),
507
+ sm: Math.round(base / ratio),
508
+ base: base,
509
+ lg: Math.round(base * ratio),
510
+ xl: Math.round(base * ratio * ratio),
511
+ '2xl': Math.round(base * Math.pow(ratio, 3)),
512
+ '3xl': Math.round(base * Math.pow(ratio, 4)),
513
+ '4xl': Math.round(base * Math.pow(ratio, 5))
514
+ };
515
+ }
516
+
517
+ // ---- Elevation (shadow depth) presets ----
518
+
519
+ var ELEVATION_PRESETS = {
520
+ flat: {
521
+ sm: 'none',
522
+ md: 'none',
523
+ lg: 'none',
524
+ xl: 'none'
525
+ },
526
+ sm: {
527
+ sm: '0 1px 2px rgba(0,0,0,0.05)',
528
+ md: '0 1px 3px rgba(0,0,0,0.08)',
529
+ lg: '0 2px 6px rgba(0,0,0,0.10)',
530
+ xl: '0 4px 12px rgba(0,0,0,0.12)'
531
+ },
532
+ md: {
533
+ sm: '0 1px 3px rgba(0,0,0,0.08)',
534
+ md: '0 2px 6px rgba(0,0,0,0.12)',
535
+ lg: '0 4px 12px rgba(0,0,0,0.16)',
536
+ xl: '0 8px 24px rgba(0,0,0,0.20)'
537
+ },
538
+ lg: {
539
+ sm: '0 2px 4px rgba(0,0,0,0.10)',
540
+ md: '0 4px 12px rgba(0,0,0,0.16)',
541
+ lg: '0 8px 24px rgba(0,0,0,0.22)',
542
+ xl: '0 16px 48px rgba(0,0,0,0.28)'
543
+ }
544
+ };
545
+
546
+ // ---- Motion (transition) presets ----
547
+
548
+ var MOTION_PRESETS = {
549
+ reduced: {
550
+ fast: '0ms',
551
+ normal: '0ms',
552
+ slow: '0ms',
553
+ easing: 'linear'
554
+ },
555
+ standard: {
556
+ fast: '100ms',
557
+ normal: '200ms',
558
+ slow: '300ms',
559
+ easing: 'ease-out'
560
+ },
561
+ expressive: {
562
+ fast: '150ms',
563
+ normal: '300ms',
564
+ slow: '500ms',
565
+ easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)'
566
+ }
567
+ };
568
+
367
569
  /**
368
570
  * Default palette config — matches existing hardcoded colors
369
571
  */
@@ -373,8 +575,8 @@ var DEFAULT_PALETTE_CONFIG = {
373
575
  tertiary: '#006666',
374
576
  success: '#198754',
375
577
  danger: '#dc3545',
376
- warning: '#ffc107',
377
- info: '#0dcaf0',
578
+ warning: '#b38600',
579
+ info: '#0891b2',
378
580
  light: '#f8f9fa',
379
581
  dark: '#212529'
380
582
  };
@@ -399,18 +601,32 @@ var THEME_PRESETS = {
399
601
  };
400
602
 
401
603
  /**
402
- * Resolve layout config to spacing + radius objects
403
- * @param {Object} config - { spacing, radius, fontSize }
404
- * @returns {Object} { spacing, radius, fontSize }
604
+ * Resolve layout config to spacing, radius, typeScale, elevation, and motion objects.
605
+ * @param {Object} config - { spacing, radius, fontSize, typeRatio, elevation, motion }
606
+ * @returns {Object} { spacing, radius, fontSize, typeScale, elevation, motion }
405
607
  */
406
608
  function resolveLayout(config) {
407
609
  var sp = (config && config.spacing) || 'normal';
408
610
  var rd = (config && config.radius) || 'md';
409
611
  var fs = (config && config.fontSize) || 1.0;
612
+
613
+ // typeRatio: accept preset name or number
614
+ var tr = (config && config.typeRatio) || 'normal';
615
+ var ratioNum = typeof tr === 'string' ? (TYPE_RATIO_PRESETS[tr] || TYPE_RATIO_PRESETS.normal) : tr;
616
+
617
+ // elevation: accept preset name or object
618
+ var el = (config && config.elevation) || 'md';
619
+
620
+ // motion: accept preset name or object
621
+ var mo = (config && config.motion) || 'standard';
622
+
410
623
  return {
411
624
  spacing: typeof sp === 'string' ? (SPACING_PRESETS[sp] || SPACING_PRESETS.normal) : sp,
412
625
  radius: typeof rd === 'string' ? (RADIUS_PRESETS[rd] || RADIUS_PRESETS.md) : rd,
413
- fontSize: fs
626
+ fontSize: fs,
627
+ typeScale: generateTypeScale(16, ratioNum),
628
+ elevation: typeof el === 'string' ? (ELEVATION_PRESETS[el] || ELEVATION_PRESETS.md) : el,
629
+ motion: typeof mo === 'string' ? (MOTION_PRESETS[mo] || MOTION_PRESETS.standard) : mo
414
630
  };
415
631
  }
416
632
 
@@ -434,12 +650,13 @@ function scopeSelector(name, sel) {
434
650
  // Themed CSS generators
435
651
  // =========================================================================
436
652
 
437
- function generateTypographyThemed(scope, palette) {
653
+ function generateTypographyThemed(scope, palette, layout) {
654
+ var mot = layout.motion;
438
655
  var rules = {};
439
656
  rules[scopeSelector(scope, 'a')] = {
440
657
  'color': palette.primary.base,
441
658
  'text-decoration': 'none',
442
- 'transition': 'color 0.15s'
659
+ 'transition': 'color ' + mot.fast + ' ' + mot.easing
443
660
  };
444
661
  rules[scopeSelector(scope, 'a:hover')] = {
445
662
  'color': palette.primary.hover,
@@ -459,7 +676,8 @@ function generateButtons(scope, palette, layout) {
459
676
  'border-radius': rd.btn
460
677
  };
461
678
  rules[scopeSelector(scope, '.bw-btn:focus-visible')] = {
462
- 'outline': '0',
679
+ 'outline': '2px solid currentColor',
680
+ 'outline-offset': '2px',
463
681
  'box-shadow': '0 0 0 3px ' + palette.primary.focus
464
682
  };
465
683
 
@@ -549,14 +767,15 @@ function generateCards(scope, palette, layout) {
549
767
  var sp = layout.spacing;
550
768
  var rd = layout.radius;
551
769
 
770
+ var elev = layout.elevation;
552
771
  rules[scopeSelector(scope, '.bw-card')] = {
553
772
  'background-color': '#fff',
554
773
  'border': '1px solid ' + palette.light.border,
555
774
  'border-radius': rd.card,
556
- 'box-shadow': '0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04)'
775
+ 'box-shadow': elev.sm
557
776
  };
558
777
  rules[scopeSelector(scope, '.bw-card:hover')] = {
559
- 'box-shadow': '0 4px 12px rgba(0,0,0,.1), 0 2px 4px rgba(0,0,0,.06)'
778
+ 'box-shadow': elev.md
560
779
  };
561
780
  rules[scopeSelector(scope, '.bw-card-body')] = {
562
781
  'padding': sp.card
@@ -603,6 +822,8 @@ function generateForms(scope, palette, layout) {
603
822
  };
604
823
  rules[scopeSelector(scope, '.bw-form-control:focus')] = {
605
824
  'border-color': palette.primary.border,
825
+ 'outline': '2px solid ' + palette.primary.base,
826
+ 'outline-offset': '-1px',
606
827
  'box-shadow': '0 0 0 0.25rem ' + palette.primary.focus
607
828
  };
608
829
  rules[scopeSelector(scope, '.bw-form-control::placeholder')] = {
@@ -760,7 +981,8 @@ function generatePagination(scope, palette) {
760
981
  'border-color': palette.light.border
761
982
  };
762
983
  rules[scopeSelector(scope, '.bw-page-link:focus')] = {
763
- 'box-shadow': '0 0 0 0.25rem ' + palette.primary.focus
984
+ 'outline': '2px solid ' + palette.primary.base,
985
+ 'outline-offset': '-2px'
764
986
  };
765
987
  rules[scopeSelector(scope, '.bw-page-item.bw-active .bw-page-link')] = {
766
988
  'color': palette.primary.textOn,
@@ -919,12 +1141,12 @@ function generateCarouselThemed(scope, palette) {
919
1141
  return rules;
920
1142
  }
921
1143
 
922
- function generateModalThemed(scope, palette) {
1144
+ function generateModalThemed(scope, palette, layout) {
923
1145
  var rules = {};
924
1146
  rules[scopeSelector(scope, '.bw-modal-content')] = {
925
1147
  'background-color': '#fff',
926
1148
  'border-color': palette.light.border,
927
- 'box-shadow': '0 0.5rem 1rem rgba(0,0,0,0.15)'
1149
+ 'box-shadow': layout.elevation.lg
928
1150
  };
929
1151
  rules[scopeSelector(scope, '.bw-modal-header')] = {
930
1152
  'border-bottom-color': palette.light.border
@@ -938,12 +1160,12 @@ function generateModalThemed(scope, palette) {
938
1160
  return rules;
939
1161
  }
940
1162
 
941
- function generateToastThemed(scope, palette) {
1163
+ function generateToastThemed(scope, palette, layout) {
942
1164
  var rules = {};
943
1165
  rules[scopeSelector(scope, '.bw-toast')] = {
944
1166
  'background-color': '#fff',
945
1167
  'border-color': 'rgba(0,0,0,0.1)',
946
- 'box-shadow': '0 0.5rem 1rem rgba(0,0,0,0.15)'
1168
+ 'box-shadow': layout.elevation.lg
947
1169
  };
948
1170
  rules[scopeSelector(scope, '.bw-toast-header')] = {
949
1171
  'border-bottom-color': 'rgba(0,0,0,0.05)'
@@ -957,12 +1179,12 @@ function generateToastThemed(scope, palette) {
957
1179
  return rules;
958
1180
  }
959
1181
 
960
- function generateDropdownThemed(scope, palette) {
1182
+ function generateDropdownThemed(scope, palette, layout) {
961
1183
  var rules = {};
962
1184
  rules[scopeSelector(scope, '.bw-dropdown-menu')] = {
963
1185
  'background-color': '#fff',
964
1186
  'border-color': palette.light.border,
965
- 'box-shadow': '0 0.5rem 1rem rgba(0,0,0,0.15)'
1187
+ 'box-shadow': layout.elevation.md
966
1188
  };
967
1189
  rules[scopeSelector(scope, '.bw-dropdown-item')] = {
968
1190
  'color': palette.dark.base
@@ -1028,7 +1250,7 @@ function generateAvatarThemed(scope, palette) {
1028
1250
  function generateThemedCSS(scopeName, palette, layout) {
1029
1251
  return Object.assign({},
1030
1252
  generateResetThemed(scopeName, palette),
1031
- generateTypographyThemed(scopeName, palette),
1253
+ generateTypographyThemed(scopeName, palette, layout),
1032
1254
  generateButtons(scopeName, palette, layout),
1033
1255
  generateAlerts(scopeName, palette, layout),
1034
1256
  generateBadges(scopeName, palette),
@@ -1047,9 +1269,9 @@ function generateThemedCSS(scopeName, palette, layout) {
1047
1269
  generateSectionsThemed(scopeName, palette),
1048
1270
  generateAccordionThemed(scopeName, palette),
1049
1271
  generateCarouselThemed(scopeName, palette),
1050
- generateModalThemed(scopeName, palette),
1051
- generateToastThemed(scopeName, palette),
1052
- generateDropdownThemed(scopeName, palette),
1272
+ generateModalThemed(scopeName, palette, layout),
1273
+ generateToastThemed(scopeName, palette, layout),
1274
+ generateDropdownThemed(scopeName, palette, layout),
1053
1275
  generateSwitchThemed(scopeName, palette),
1054
1276
  generateSkeletonThemed(scopeName, palette),
1055
1277
  generateAvatarThemed(scopeName, palette),
@@ -1202,11 +1424,23 @@ const defaultStyles = {
1202
1424
  // =========================================================================
1203
1425
 
1204
1426
  /**
1205
- * Structural styles contain only layout, sizing, spacing, and behavior
1206
- * properties. No colors, backgrounds, shadows, or border-colors.
1207
- * These never change with themes.
1427
+ * Structural styles layout, sizing, spacing, positioning, and behavior.
1208
1428
  *
1209
- * @returns {Object} CSS rules object
1429
+ * POLICY: No colors, backgrounds, shadows, or border-colors in this function.
1430
+ * All cosmetic values belong in `defaultStyles.*` sections (unthemed defaults)
1431
+ * or in `generateThemedCSS()` (theme-driven colors).
1432
+ *
1433
+ * Exception: `.bw-progress-bar-striped` uses rgba(255,255,255,.15) for the
1434
+ * stripe pattern overlay. This is theme-neutral — a semi-transparent white
1435
+ * gradient that creates visible stripes on any background color.
1436
+ *
1437
+ * Architecture:
1438
+ * getStructuralStyles() → layout-only rules (never change with themes)
1439
+ * defaultStyles.* → cosmetic defaults (colors, shadows, borders)
1440
+ * generateThemedCSS() → palette-driven cosmetics from seed colors
1441
+ * generateAlternateCSS() → alternate palette (luminance-inverted)
1442
+ *
1443
+ * @returns {Object} CSS rules object (layout-only, theme-independent)
1210
1444
  */
1211
1445
  function getStructuralStyles() {
1212
1446
  var rules = {};
@@ -1257,12 +1491,12 @@ function getStructuralStyles() {
1257
1491
  'text-decoration': 'none', 'vertical-align': 'middle', 'cursor': 'pointer',
1258
1492
  'user-select': 'none', 'border': '1px solid transparent',
1259
1493
  'padding': '0.5rem 1.125rem', 'font-size': '0.875rem', 'font-family': 'inherit',
1260
- 'border-radius': '6px', 'transition': 'all 0.15s cubic-bezier(0.4, 0, 0.2, 1)',
1494
+ 'border-radius': '6px', 'transition': 'all 0.15s ease-out',
1261
1495
  'gap': '0.5rem'
1262
1496
  };
1263
1497
  rules['.bw-btn:hover'] = { 'text-decoration': 'none', 'transform': 'translateY(-1px)' };
1264
1498
  rules['.bw-btn:active'] = { 'transform': 'translateY(0)' };
1265
- rules['.bw-btn:focus-visible'] = { 'outline': '0' };
1499
+ rules['.bw-btn:focus-visible'] = { 'outline': '2px solid currentColor', 'outline-offset': '2px' };
1266
1500
  rules['.bw-btn:disabled'] = { 'opacity': '0.5', 'cursor': 'not-allowed', 'pointer-events': 'none' };
1267
1501
  rules['.bw-btn-lg'] = { 'padding': '0.625rem 1.5rem', 'font-size': '1rem', 'border-radius': '8px' };
1268
1502
  rules['.bw-btn-sm'] = { 'padding': '0.25rem 0.75rem', 'font-size': '0.8125rem', 'border-radius': '5px' };
@@ -1272,7 +1506,7 @@ function getStructuralStyles() {
1272
1506
  'position': 'relative', 'display': 'flex', 'flex-direction': 'column',
1273
1507
  'min-width': '0', 'height': '100%', 'word-wrap': 'break-word',
1274
1508
  'background-clip': 'border-box', 'border': '1px solid transparent',
1275
- '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)',
1509
+ 'border-radius': '8px', 'transition': 'box-shadow 0.2s ease-out, transform 0.2s ease-out',
1276
1510
  'margin-bottom': '1.5rem', 'overflow': 'hidden'
1277
1511
  };
1278
1512
  rules['.bw-card-body'] = { 'flex': '1 1 auto', 'padding': '1.25rem 1.5rem' };
@@ -1281,7 +1515,7 @@ function getStructuralStyles() {
1281
1515
  rules['.bw-card-text'] = { 'margin-bottom': '0', 'font-size': '0.9375rem', 'line-height': '1.6' };
1282
1516
  rules['.bw-card-header'] = { 'padding': '0.875rem 1.5rem', 'margin-bottom': '0', 'font-weight': '600', 'font-size': '0.875rem' };
1283
1517
  rules['.bw-card-footer'] = { 'padding': '0.75rem 1.5rem', 'font-size': '0.875rem' };
1284
- rules['.bw-card-hoverable'] = { 'transition': 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)' };
1518
+ rules['.bw-card-hoverable'] = { 'transition': 'all 0.3s ease-out' };
1285
1519
  rules['.bw-card-img-top'] = { 'width': '100%', 'border-top-left-radius': '7px', 'border-top-right-radius': '7px' };
1286
1520
  rules['.bw-card-img-bottom'] = { 'width': '100%', 'border-bottom-left-radius': '7px', 'border-bottom-right-radius': '7px' };
1287
1521
  rules['.bw-card-img-left'] = { 'width': '40%', 'object-fit': 'cover' };
@@ -1294,10 +1528,10 @@ function getStructuralStyles() {
1294
1528
  'font-size': '0.9375rem', 'font-weight': '400', 'line-height': '1.5',
1295
1529
  'background-clip': 'padding-box', 'appearance': 'none',
1296
1530
  'border': '1px solid transparent', 'border-radius': '6px',
1297
- 'transition': 'border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out',
1531
+ 'transition': 'border-color 0.15s ease-out, box-shadow 0.15s ease-out',
1298
1532
  'font-family': 'inherit'
1299
1533
  };
1300
- rules['.bw-form-control:focus'] = { 'outline': '0' };
1534
+ rules['.bw-form-control:focus'] = { 'outline': '2px solid currentColor', 'outline-offset': '-1px' };
1301
1535
  rules['.bw-form-control::placeholder'] = { 'opacity': '1' };
1302
1536
  rules['.bw-form-label'] = { 'display': 'block', 'margin-bottom': '0.375rem', 'font-size': '0.875rem', 'font-weight': '600' };
1303
1537
  rules['.bw-form-group'] = { 'margin-bottom': '1.25rem' };
@@ -1309,6 +1543,10 @@ function getStructuralStyles() {
1309
1543
  };
1310
1544
  rules['textarea.bw-form-control'] = { 'min-height': '5rem', 'resize': 'vertical' };
1311
1545
 
1546
+ // Form validation (structural)
1547
+ rules['.bw-valid-feedback'] = { 'display': 'block', 'font-size': '0.875rem', 'margin-top': '0.25rem' };
1548
+ rules['.bw-invalid-feedback'] = { 'display': 'block', 'font-size': '0.875rem', 'margin-top': '0.25rem' };
1549
+
1312
1550
  // Form checks (structural)
1313
1551
  Object.assign(rules, {
1314
1552
  '.bw-form-check': { 'display': 'flex', 'align-items': 'center', 'gap': '0.5rem', 'min-height': '1.5rem', 'margin-bottom': '0.25rem' },
@@ -1367,13 +1605,13 @@ function getStructuralStyles() {
1367
1605
 
1368
1606
  // Badges (structural)
1369
1607
  rules['.bw-badge'] = {
1370
- 'display': 'inline-block', 'padding': '.4em .75em', 'font-size': '.875em',
1608
+ 'display': 'inline-block', 'padding': '0.375rem 0.625rem', 'font-size': '0.875rem',
1371
1609
  'font-weight': '600', 'line-height': '1.3', 'text-align': 'center',
1372
1610
  'white-space': 'nowrap', 'vertical-align': 'baseline', 'border-radius': '.375rem'
1373
1611
  };
1374
1612
  rules['.bw-badge:empty'] = { 'display': 'none' };
1375
- rules['.bw-badge-sm'] = { 'font-size': '.75em', 'padding': '.25em .5em' };
1376
- rules['.bw-badge-lg'] = { 'font-size': '1em', 'padding': '.5em .9em' };
1613
+ rules['.bw-badge-sm'] = { 'font-size': '0.75rem', 'padding': '0.25rem 0.5rem' };
1614
+ rules['.bw-badge-lg'] = { 'font-size': '1rem', 'padding': '0.5rem 0.875rem' };
1377
1615
  rules['.bw-badge-pill'] = { 'border-radius': '50rem' };
1378
1616
 
1379
1617
  // Progress (structural)
@@ -1381,7 +1619,7 @@ function getStructuralStyles() {
1381
1619
  rules['.bw-progress-bar'] = {
1382
1620
  'display': 'flex', 'flex-direction': 'column', 'justify-content': 'center',
1383
1621
  'overflow': 'hidden', 'text-align': 'center', 'white-space': 'nowrap',
1384
- 'transition': 'width .6s ease', 'font-weight': '600'
1622
+ 'transition': 'width 0.3s ease-out', 'font-weight': '600'
1385
1623
  };
1386
1624
  rules['.bw-progress-bar-striped'] = {
1387
1625
  '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)',
@@ -1398,7 +1636,7 @@ function getStructuralStyles() {
1398
1636
  'display': 'block', 'padding': '0.625rem 1rem', 'font-size': '0.875rem',
1399
1637
  'font-weight': '500', 'text-decoration': 'none', 'cursor': 'pointer',
1400
1638
  'border': 'none', 'background': 'transparent',
1401
- 'transition': 'color 0.15s, border-color 0.15s', 'font-family': 'inherit'
1639
+ 'transition': 'color 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out', 'font-family': 'inherit'
1402
1640
  };
1403
1641
  rules['.bw-nav-tabs .bw-nav-link'] = { 'border': 'none', 'border-bottom': '2px solid transparent', 'border-radius': '0', 'background-color': 'transparent' };
1404
1642
  rules['.bw-nav-pills .bw-nav-link'] = { 'border-radius': '6px' };
@@ -1416,7 +1654,8 @@ function getStructuralStyles() {
1416
1654
  rules['.bw-list-group-item:last-child'] = { 'border-bottom-right-radius': 'inherit', 'border-bottom-left-radius': 'inherit' };
1417
1655
  rules['.bw-list-group-item + .bw-list-group-item'] = { 'border-top-width': '0' };
1418
1656
  rules['.bw-list-group-item.disabled'] = { 'pointer-events': 'none' };
1419
- rules['a.bw-list-group-item'] = { 'cursor': 'pointer' };
1657
+ rules['a.bw-list-group-item'] = { 'cursor': 'pointer', 'transition': 'background-color 0.15s ease-out, color 0.15s ease-out' };
1658
+ rules['a.bw-list-group-item:focus-visible, .bw-list-group-item:focus-visible'] = { 'z-index': '2', 'outline': '2px solid currentColor', 'outline-offset': '-2px' };
1420
1659
  rules['.bw-list-group-flush'] = { 'border-radius': '0' };
1421
1660
  rules['.bw-list-group-flush > .bw-list-group-item'] = { 'border-width': '0 0 1px', 'border-radius': '0' };
1422
1661
  rules['.bw-list-group-flush > .bw-list-group-item:last-child'] = { 'border-bottom-width': '0' };
@@ -1427,16 +1666,19 @@ function getStructuralStyles() {
1427
1666
  rules['.bw-page-link'] = {
1428
1667
  'position': 'relative', 'display': 'block', 'padding': '0.375rem 0.75rem',
1429
1668
  'margin-left': '-1px', 'line-height': '1.25', 'text-decoration': 'none',
1430
- 'transition': 'color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out'
1669
+ 'transition': 'color 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out'
1431
1670
  };
1432
1671
  rules['.bw-page-item:first-child .bw-page-link'] = { 'margin-left': '0', 'border-top-left-radius': '0.375rem', 'border-bottom-left-radius': '0.375rem' };
1433
1672
  rules['.bw-page-item:last-child .bw-page-link'] = { 'border-top-right-radius': '0.375rem', 'border-bottom-right-radius': '0.375rem' };
1673
+ rules['.bw-page-link:focus-visible'] = { 'z-index': '3', 'outline': '2px solid currentColor', 'outline-offset': '-2px' };
1434
1674
 
1435
1675
  // Breadcrumb (structural)
1436
1676
  rules['.bw-breadcrumb'] = { 'display': 'flex', 'flex-wrap': 'wrap', 'padding': '0 0', 'margin-bottom': '1rem', 'list-style': 'none' };
1437
1677
  rules['.bw-breadcrumb-item'] = { 'display': 'flex' };
1438
1678
  rules['.bw-breadcrumb-item + .bw-breadcrumb-item'] = { 'padding-left': '0.5rem' };
1439
1679
  rules['.bw-breadcrumb-item + .bw-breadcrumb-item::before'] = { 'float': 'left', 'padding-right': '0.5rem', 'content': '"/"' };
1680
+ rules['.bw-breadcrumb-item a'] = { 'text-decoration': 'none', 'transition': 'color 0.15s ease-out' };
1681
+ rules['.bw-breadcrumb-item.active'] = { 'font-weight': '500' };
1440
1682
 
1441
1683
  // Hero (structural)
1442
1684
  rules['.bw-hero'] = { 'position': 'relative', 'overflow': 'hidden' };
@@ -1539,12 +1781,12 @@ function getStructuralStyles() {
1539
1781
  'position': 'relative', 'display': 'flex', 'align-items': 'center', 'width': '100%',
1540
1782
  'padding': '1rem 1.25rem', 'font-size': '1rem', 'font-weight': '500', 'text-align': 'left',
1541
1783
  'background-color': 'transparent', 'border': '0', 'overflow-anchor': 'none', 'cursor': 'pointer',
1542
- 'font-family': 'inherit', 'transition': 'color 0.15s ease-in-out, background-color 0.15s ease-in-out'
1784
+ 'font-family': 'inherit', 'transition': 'color 0.15s ease-out, background-color 0.15s ease-out'
1543
1785
  };
1544
1786
  rules['.bw-accordion-button::after'] = {
1545
1787
  'flex-shrink': '0', 'width': '1.25rem', 'height': '1.25rem', 'margin-left': 'auto',
1546
1788
  'content': '""', 'background-repeat': 'no-repeat', 'background-size': '1.25rem',
1547
- 'transition': 'transform 0.2s ease-in-out'
1789
+ 'transition': 'transform 0.2s ease-out'
1548
1790
  };
1549
1791
  rules['.bw-accordion-button:not(.bw-collapsed)::after'] = { 'transform': 'rotate(-180deg)' };
1550
1792
  rules['.bw-accordion-collapse'] = { 'max-height': '0', 'overflow': 'hidden', 'transition': 'max-height 0.3s ease' };
@@ -1553,10 +1795,13 @@ function getStructuralStyles() {
1553
1795
 
1554
1796
  // Modal (structural)
1555
1797
  rules['.bw-modal'] = {
1556
- 'display': 'none', 'position': 'fixed', 'top': '0', 'left': '0', 'width': '100%', 'height': '100%',
1557
- 'z-index': '1050', 'overflow-x': 'hidden', 'overflow-y': 'auto', 'opacity': '0', 'transition': 'opacity 0.15s linear'
1798
+ 'display': 'flex', 'align-items': 'center', 'justify-content': 'center',
1799
+ 'position': 'fixed', 'top': '0', 'left': '0', 'width': '100%', 'height': '100%',
1800
+ 'z-index': '1050', 'overflow-x': 'hidden', 'overflow-y': 'auto',
1801
+ 'opacity': '0', 'visibility': 'hidden', 'pointer-events': 'none',
1802
+ 'transition': 'opacity 0.2s ease, visibility 0.2s ease'
1558
1803
  };
1559
- rules['.bw-modal.bw-modal-show'] = { 'display': 'flex', 'align-items': 'center', 'justify-content': 'center', 'opacity': '1' };
1804
+ rules['.bw-modal.bw-modal-show'] = { 'opacity': '1', 'visibility': 'visible', 'pointer-events': 'auto' };
1560
1805
  rules['.bw-modal-dialog'] = {
1561
1806
  'position': 'relative', 'width': '100%', 'max-width': '500px', 'margin': '1.75rem auto',
1562
1807
  'pointer-events': 'none', 'transform': 'translateY(-20px)', 'transition': 'transform 0.2s ease-out'
@@ -1576,7 +1821,7 @@ function getStructuralStyles() {
1576
1821
 
1577
1822
  // Carousel (structural)
1578
1823
  rules['.bw-carousel'] = { 'position': 'relative', 'overflow': 'hidden', 'border-radius': '8px' };
1579
- rules['.bw-carousel-track'] = { 'display': 'flex', 'transition': 'transform 0.4s ease', 'height': '100%' };
1824
+ rules['.bw-carousel-track'] = { 'display': 'flex', 'transition': 'transform 0.3s ease-out', 'height': '100%' };
1580
1825
  rules['.bw-carousel-slide'] = { 'min-width': '100%', 'flex-shrink': '0', 'overflow': 'hidden', 'position': 'relative', 'display': 'flex', 'align-items': 'center', 'justify-content': 'center' };
1581
1826
  rules['.bw-carousel-slide img'] = { 'width': '100%', 'height': '100%', 'object-fit': 'cover' };
1582
1827
  rules['.bw-carousel-caption'] = { 'position': 'absolute', 'bottom': '0', 'left': '0', 'right': '0', 'padding': '0.75rem 1rem' };
@@ -1620,11 +1865,14 @@ function getStructuralStyles() {
1620
1865
  'border-bottom': '0', 'border-left': '0.3em solid transparent'
1621
1866
  };
1622
1867
  rules['.bw-dropdown-menu'] = {
1623
- 'position': 'absolute', 'top': '100%', 'left': '0', 'z-index': '1000', 'display': 'none',
1868
+ 'position': 'absolute', 'top': '100%', 'left': '0', 'z-index': '1000', 'display': 'block',
1624
1869
  'min-width': '10rem', 'padding': '0.5rem 0', 'margin': '0.125rem 0 0',
1625
- 'background-clip': 'padding-box', 'border-radius': '6px'
1870
+ 'background-clip': 'padding-box', 'border-radius': '6px',
1871
+ 'opacity': '0', 'visibility': 'hidden', 'transform': 'translateY(-4px)',
1872
+ 'pointer-events': 'none',
1873
+ 'transition': 'opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease'
1626
1874
  };
1627
- rules['.bw-dropdown-menu.bw-dropdown-show'] = { 'display': 'block' };
1875
+ rules['.bw-dropdown-menu.bw-dropdown-show'] = { 'opacity': '1', 'visibility': 'visible', 'transform': 'translateY(0)', 'pointer-events': 'auto' };
1628
1876
  rules['.bw-dropdown-menu-end'] = { 'left': 'auto', 'right': '0' };
1629
1877
  rules['.bw-dropdown-item'] = {
1630
1878
  'display': 'block', 'width': '100%', 'padding': '0.375rem 1rem', 'clear': 'both',
@@ -1632,6 +1880,7 @@ function getStructuralStyles() {
1632
1880
  'background-color': 'transparent', 'border': '0', 'font-size': '0.9375rem',
1633
1881
  'transition': 'background-color 0.15s, color 0.15s'
1634
1882
  };
1883
+ rules['.bw-dropdown-item:focus-visible'] = { 'outline': '2px solid currentColor', 'outline-offset': '-2px' };
1635
1884
  rules['.bw-dropdown-divider'] = { 'height': '0', 'margin': '0.5rem 0', 'overflow': 'hidden', 'opacity': '1' };
1636
1885
 
1637
1886
  // Switch (structural)
@@ -1639,7 +1888,7 @@ function getStructuralStyles() {
1639
1888
  rules['.bw-form-switch .bw-switch-input'] = {
1640
1889
  'width': '2em', 'height': '1.125em', 'margin-left': '-2.5em', 'border-radius': '2em',
1641
1890
  'appearance': 'none', 'background-position': 'left center', 'background-repeat': 'no-repeat',
1642
- 'background-size': 'contain', 'transition': 'background-position 0.15s ease-in-out, background-color 0.15s ease-in-out',
1891
+ 'background-size': 'contain', 'transition': 'background-position 0.15s ease-out, background-color 0.15s ease-out',
1643
1892
  'cursor': 'pointer'
1644
1893
  };
1645
1894
  rules['.bw-form-switch .bw-switch-input:checked'] = { 'background-position': 'right center' };
@@ -1664,6 +1913,123 @@ function getStructuralStyles() {
1664
1913
  rules['.bw-avatar-lg'] = { 'width': '4rem', 'height': '4rem', 'font-size': '1.25rem' };
1665
1914
  rules['.bw-avatar-xl'] = { 'width': '5rem', 'height': '5rem', 'font-size': '1.5rem' };
1666
1915
 
1916
+ // Stat card (structural)
1917
+ rules['.bw-stat-card'] = {
1918
+ 'border-radius': '8px', 'padding': '1.25rem',
1919
+ 'border-left': '4px solid transparent',
1920
+ 'transition': 'box-shadow 0.15s ease-out, transform 0.15s ease-out'
1921
+ };
1922
+ rules['.bw-stat-card:hover'] = { 'transform': 'translateY(-1px)' };
1923
+ rules['.bw-stat-icon'] = { 'font-size': '1.5rem', 'margin-bottom': '0.5rem' };
1924
+ rules['.bw-stat-value'] = { 'font-size': '2rem', 'font-weight': '700', 'line-height': '1.2' };
1925
+ rules['.bw-stat-label'] = { 'font-size': '0.875rem', 'margin-top': '0.25rem' };
1926
+ rules['.bw-stat-change'] = { 'font-size': '0.875rem', 'font-weight': '500', 'margin-top': '0.5rem' };
1927
+
1928
+ // Tooltip (structural)
1929
+ rules['.bw-tooltip-wrapper'] = { 'position': 'relative', 'display': 'inline-block' };
1930
+ rules['.bw-tooltip'] = {
1931
+ 'position': 'absolute', 'z-index': '999',
1932
+ 'padding': '0.375rem 0.75rem', 'border-radius': '4px', 'font-size': '0.875rem',
1933
+ 'white-space': 'nowrap', 'pointer-events': 'none',
1934
+ 'opacity': '0', 'visibility': 'hidden',
1935
+ 'transition': 'opacity 0.15s ease, visibility 0.15s ease, transform 0.15s ease'
1936
+ };
1937
+ rules['.bw-tooltip.bw-tooltip-show'] = { 'opacity': '1', 'visibility': 'visible' };
1938
+ rules['.bw-tooltip-top'] = { 'bottom': '100%', 'left': '50%', 'transform': 'translateX(-50%) translateY(-4px)', 'margin-bottom': '4px' };
1939
+ rules['.bw-tooltip-top.bw-tooltip-show'] = { 'transform': 'translateX(-50%) translateY(0)' };
1940
+ rules['.bw-tooltip-bottom'] = { 'top': '100%', 'left': '50%', 'transform': 'translateX(-50%) translateY(4px)', 'margin-top': '4px' };
1941
+ rules['.bw-tooltip-bottom.bw-tooltip-show'] = { 'transform': 'translateX(-50%) translateY(0)' };
1942
+ rules['.bw-tooltip-left'] = { 'right': '100%', 'top': '50%', 'transform': 'translateY(-50%) translateX(-4px)', 'margin-right': '4px' };
1943
+ rules['.bw-tooltip-left.bw-tooltip-show'] = { 'transform': 'translateY(-50%) translateX(0)' };
1944
+ rules['.bw-tooltip-right'] = { 'left': '100%', 'top': '50%', 'transform': 'translateY(-50%) translateX(4px)', 'margin-left': '4px' };
1945
+ rules['.bw-tooltip-right.bw-tooltip-show'] = { 'transform': 'translateY(-50%) translateX(0)' };
1946
+
1947
+ // Search input (structural)
1948
+ rules['.bw-search-input'] = { 'position': 'relative', 'display': 'flex', 'align-items': 'center' };
1949
+ rules['.bw-search-input .bw-search-field'] = { 'padding-right': '2.5rem' };
1950
+ rules['.bw-search-clear'] = {
1951
+ 'position': 'absolute', 'right': '0.5rem',
1952
+ 'display': 'flex', 'align-items': 'center', 'justify-content': 'center',
1953
+ 'width': '1.5rem', 'height': '1.5rem',
1954
+ 'border': 'none', 'background': 'none',
1955
+ 'font-size': '1.25rem', 'cursor': 'pointer', 'padding': '0',
1956
+ 'border-radius': '50%', 'transition': 'color 0.15s ease-out'
1957
+ };
1958
+
1959
+ // Range slider (structural)
1960
+ rules['.bw-range-wrapper'] = { 'margin-bottom': '1rem' };
1961
+ rules['.bw-range-label'] = { 'display': 'flex', 'justify-content': 'space-between', 'align-items': 'center', 'margin-bottom': '0.5rem', 'font-size': '0.875rem', 'font-weight': '500' };
1962
+ rules['.bw-range-value'] = { 'font-weight': '600' };
1963
+ rules['.bw-range'] = { 'width': '100%', 'height': '0.5rem', 'padding': '0', 'appearance': 'none', 'border': 'none', 'border-radius': '0.25rem', 'cursor': 'pointer', 'outline': 'none' };
1964
+ rules['.bw-range:disabled'] = { 'opacity': '0.5', 'cursor': 'not-allowed' };
1965
+
1966
+ // Media object (structural)
1967
+ rules['.bw-media'] = { 'display': 'flex', 'align-items': 'flex-start', 'gap': '1rem' };
1968
+ rules['.bw-media-reverse'] = { 'flex-direction': 'row-reverse' };
1969
+ rules['.bw-media-img'] = { 'border-radius': '50%', 'object-fit': 'cover', 'flex-shrink': '0' };
1970
+ rules['.bw-media-body'] = { 'flex': '1', 'min-width': '0' };
1971
+ rules['.bw-media-title'] = { 'margin': '0 0 0.25rem 0', 'font-size': '1rem', 'font-weight': '600', 'line-height': '1.3' };
1972
+
1973
+ // File upload (structural)
1974
+ rules['.bw-file-upload'] = {
1975
+ 'display': 'flex', 'flex-direction': 'column', 'align-items': 'center', 'justify-content': 'center',
1976
+ 'padding': '2rem', 'border': '2px dashed transparent', 'border-radius': '8px',
1977
+ 'cursor': 'pointer', 'text-align': 'center', 'position': 'relative',
1978
+ 'transition': 'border-color 0.15s ease-out, background-color 0.15s ease-out'
1979
+ };
1980
+ rules['.bw-file-upload-icon'] = { 'font-size': '2rem', 'margin-bottom': '0.5rem' };
1981
+ rules['.bw-file-upload-text'] = { 'font-size': '0.875rem' };
1982
+ rules['.bw-file-upload-input'] = {
1983
+ 'position': 'absolute', 'width': '1px', 'height': '1px', 'padding': '0',
1984
+ 'margin': '-1px', 'overflow': 'hidden', 'clip': 'rect(0,0,0,0)', 'border': '0'
1985
+ };
1986
+
1987
+ // Timeline (structural)
1988
+ rules['.bw-timeline'] = { 'position': 'relative', 'padding-left': '2rem' };
1989
+ rules['.bw-timeline-item'] = { 'position': 'relative', 'padding-bottom': '1.5rem' };
1990
+ rules['.bw-timeline-item:last-child'] = { 'padding-bottom': '0' };
1991
+ rules['.bw-timeline-marker'] = { 'position': 'absolute', 'left': '-1.75rem', 'top': '0.25rem', 'width': '0.75rem', 'height': '0.75rem', 'border-radius': '50%' };
1992
+ rules['.bw-timeline-content'] = { 'padding-left': '0.5rem' };
1993
+ rules['.bw-timeline-date'] = { 'font-size': '0.75rem', 'margin-bottom': '0.25rem', 'font-weight': '500' };
1994
+ rules['.bw-timeline-title'] = { 'font-size': '1rem', 'font-weight': '600', 'margin': '0 0 0.25rem 0', 'line-height': '1.3' };
1995
+ rules['.bw-timeline-text'] = { 'font-size': '0.875rem', 'margin': '0', 'line-height': '1.5' };
1996
+
1997
+ // Stepper (structural)
1998
+ rules['.bw-stepper'] = { 'display': 'flex', 'gap': '0' };
1999
+ rules['.bw-step'] = { 'flex': '1', 'display': 'flex', 'flex-direction': 'column', 'align-items': 'center', 'text-align': 'center', 'position': 'relative' };
2000
+ 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' };
2001
+ rules['.bw-step-body'] = { 'margin-top': '0.5rem' };
2002
+ rules['.bw-step-label'] = { 'font-size': '0.875rem', 'font-weight': '500' };
2003
+ rules['.bw-step-description'] = { 'font-size': '0.75rem', 'margin-top': '0.125rem' };
2004
+
2005
+ // Chip input (structural)
2006
+ 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' };
2007
+ 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' };
2008
+ 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' };
2009
+ rules['.bw-chip-field'] = { 'flex': '1', 'min-width': '80px', 'border': 'none', 'outline': 'none', 'font-size': '0.875rem', 'padding': '0.125rem 0', 'background': 'transparent' };
2010
+
2011
+ // Popover (structural)
2012
+ rules['.bw-popover-wrapper'] = { 'position': 'relative', 'display': 'inline-block' };
2013
+ rules['.bw-popover-trigger'] = { 'cursor': 'pointer' };
2014
+ rules['.bw-popover'] = {
2015
+ 'position': 'absolute', 'z-index': '1000',
2016
+ 'min-width': '200px', 'max-width': '320px',
2017
+ 'border-radius': '8px',
2018
+ 'pointer-events': 'none', 'opacity': '0', 'visibility': 'hidden',
2019
+ 'transition': 'opacity 0.15s ease, visibility 0.15s ease, transform 0.15s ease'
2020
+ };
2021
+ rules['.bw-popover.bw-popover-show'] = { 'opacity': '1', 'visibility': 'visible', 'pointer-events': 'auto' };
2022
+ rules['.bw-popover-header'] = { 'padding': '0.625rem 0.875rem', 'font-weight': '600', 'font-size': '0.9375rem' };
2023
+ rules['.bw-popover-body'] = { 'padding': '0.75rem 0.875rem', 'font-size': '0.875rem', 'line-height': '1.5' };
2024
+ rules['.bw-popover-top'] = { 'bottom': '100%', 'left': '50%', 'transform': 'translateX(-50%) translateY(-8px)', 'margin-bottom': '8px' };
2025
+ rules['.bw-popover-top.bw-popover-show'] = { 'transform': 'translateX(-50%) translateY(0)' };
2026
+ rules['.bw-popover-bottom'] = { 'top': '100%', 'left': '50%', 'transform': 'translateX(-50%) translateY(8px)', 'margin-top': '8px' };
2027
+ rules['.bw-popover-bottom.bw-popover-show'] = { 'transform': 'translateX(-50%) translateY(0)' };
2028
+ rules['.bw-popover-left'] = { 'right': '100%', 'top': '50%', 'transform': 'translateY(-50%) translateX(-8px)', 'margin-right': '8px' };
2029
+ rules['.bw-popover-left.bw-popover-show'] = { 'transform': 'translateY(-50%) translateX(0)' };
2030
+ rules['.bw-popover-right'] = { 'left': '100%', 'top': '50%', 'transform': 'translateY(-50%) translateX(8px)', 'margin-left': '8px' };
2031
+ rules['.bw-popover-right.bw-popover-show'] = { 'transform': 'translateY(-50%) translateX(0)' };
2032
+
1667
2033
  // Bar chart (structural)
1668
2034
  rules['.bw-bar-chart-container'] = {
1669
2035
  'padding': '1rem', 'border': '1px solid transparent', 'border-radius': '8px'
@@ -1677,7 +2043,7 @@ function getStructuralStyles() {
1677
2043
  };
1678
2044
  rules['.bw-bar'] = {
1679
2045
  'width': '100%', 'border-radius': '3px 3px 0 0',
1680
- 'transition': 'height 0.5s ease', 'min-height': '4px'
2046
+ 'transition': 'height 0.3s ease-out', 'min-height': '4px'
1681
2047
  };
1682
2048
  rules['.bw-bar:hover'] = { 'opacity': '0.85' };
1683
2049
  rules['.bw-bar-value'] = {
@@ -1806,6 +2172,16 @@ function getStructuralStyles() {
1806
2172
  // Responsive grid
1807
2173
  Object.assign(rules, defaultStyles.responsive);
1808
2174
 
2175
+ // Accessibility: reduce motion for users who prefer it
2176
+ rules['@media (prefers-reduced-motion: reduce)'] = {
2177
+ '*, *::before, *::after': {
2178
+ 'animation-duration': '0.01ms !important',
2179
+ 'animation-iteration-count': '1 !important',
2180
+ 'transition-duration': '0.01ms !important',
2181
+ 'scroll-behavior': 'auto !important'
2182
+ }
2183
+ };
2184
+
1809
2185
  return addUnderscoreAliases(rules);
1810
2186
  }
1811
2187
 
@@ -1814,9 +2190,25 @@ function getStructuralStyles() {
1814
2190
  // =========================================================================
1815
2191
 
1816
2192
  /**
1817
- * Add underscore aliases for all bw- selectors
2193
+ * Add underscore aliases for all `.bw-` selectors.
2194
+ *
2195
+ * CSS CLASS NAMING CONVENTION:
2196
+ *
2197
+ * Canonical form: `.bw-btn`, `.bw-card`, `.bw-table-hover` (hyphens)
2198
+ * Underscore alias: `.bw_btn`, `.bw_card`, `.bw_table_hover` (underscores)
2199
+ *
2200
+ * Both forms are valid in HTML and produce identical results. The hyphen
2201
+ * form is canonical (used in docs, generated CSS, component output).
2202
+ * Underscore aliases exist because:
2203
+ * 1. TACO attribute keys use underscores (`bw_id`, `bw_meta`) — no
2204
+ * quoting needed in JS object literals
2205
+ * 2. Some users prefer underscores for consistency with JS identifiers
2206
+ *
2207
+ * Use `bw.normalizeClass()` to convert underscore classes to canonical
2208
+ * hyphen form at runtime if needed.
2209
+ *
1818
2210
  * @param {Object} rules - CSS rules object
1819
- * @returns {Object} - Rules with underscore aliases added
2211
+ * @returns {Object} Rules with underscore aliases added (both forms work)
1820
2212
  */
1821
2213
  function addUnderscoreAliases(rules) {
1822
2214
  const result = {};
@@ -1833,6 +2225,27 @@ function addUnderscoreAliases(rules) {
1833
2225
  // =========================================================================
1834
2226
  // Theme tokens (backwards compatible)
1835
2227
  // =========================================================================
2228
+ //
2229
+ // DESIGN NOTE — Why no CSS custom properties (CSS variables)?
2230
+ //
2231
+ // Bitwrench targets IE11 as Tier 1 (see dev/bw2x-compatibility.md).
2232
+ // CSS custom properties (var(--color-primary)) are not supported in IE11.
2233
+ //
2234
+ // Instead, bitwrench uses class-scoped CSS generation:
2235
+ // 1. `defaultStyles.*` provides hardcoded cosmetic defaults
2236
+ // 2. `generateTheme(name, config)` generates a complete set of
2237
+ // class-scoped CSS rules from 3 seed colors (primary, secondary,
2238
+ // tertiary) — all components are restyled with the new palette
2239
+ // 3. `generateAlternateCSS()` produces the alternate (dark/light)
2240
+ // variant scoped under `.bw-theme-alt`
2241
+ //
2242
+ // This achieves full theme customization without CSS variables:
2243
+ // bw.generateTheme('ocean', { primary: '#006666', secondary: '#cc6633' })
2244
+ // → generates .ocean .bw-btn-primary { background: #006666; } etc.
2245
+ //
2246
+ // When IE11 support is dropped, CSS custom properties can be added as
2247
+ // an optimization (one rule with var() instead of many scoped rules).
2248
+ // The generateTheme() API stays the same — only the output format changes.
1836
2249
 
1837
2250
  let theme = {
1838
2251
  colors: {
@@ -1840,8 +2253,8 @@ let theme = {
1840
2253
  secondary: '#6c757d',
1841
2254
  success: '#198754',
1842
2255
  danger: '#dc3545',
1843
- warning: '#ffc107',
1844
- info: '#0dcaf0',
2256
+ warning: '#b38600',
2257
+ info: '#0891b2',
1845
2258
  light: '#f8f9fa',
1846
2259
  dark: '#212529',
1847
2260
  white: '#fff',
@@ -1877,214 +2290,63 @@ let theme = {
1877
2290
  '5xl': '3rem'
1878
2291
  }
1879
2292
  },
1880
- darkMode: false
1881
2293
  };
1882
2294
 
1883
2295
  /**
1884
- * Generate theme-aware dark mode CSS from a palette.
1885
- * Derives dark variants from the palette colors instead of using hardcoded values.
2296
+ * Generate alternate-palette CSS scoped under `.bw-theme-alt`.
2297
+ * Uses the same `generateThemedCSS()` pipeline as the primary palette
2298
+ * both sides go through identical code paths.
1886
2299
  *
1887
- * @param {Object} palette - From derivePalette()
1888
- * @returns {Object} CSS rules object for dark mode
2300
+ * @param {string} name - Theme scope name (e.g. 'ocean'). '' for global.
2301
+ * @param {Object} altPalette - From derivePalette(deriveAlternateConfig(...))
2302
+ * @param {Object} layout - From resolveLayout()
2303
+ * @returns {Object} CSS rules object scoped under .bw-theme-alt (+ optional .name)
1889
2304
  */
1890
- function generateDarkModeCSS(palette) {
1891
- var darkBg = adjustLightness(palette.primary.base, -15);
1892
- var darkBgHsl = hexToHsl(darkBg);
1893
- // Make it very dark (lightness 8-12%)
1894
- var bodyBg = hslToHex([darkBgHsl[0], Math.min(darkBgHsl[1], 30), 10]);
1895
- var surfaceBg = hslToHex([darkBgHsl[0], Math.min(darkBgHsl[1], 25), 15]);
1896
- var textColor = adjustLightness(palette.light.base, 5);
1897
- var borderColor = hslToHex([darkBgHsl[0], Math.min(darkBgHsl[1], 15), 30]);
1898
-
1899
- return {
1900
- ':root.bw-dark': {
1901
- '--bw-body-color': textColor,
1902
- '--bw-body-bg': bodyBg
1903
- },
1904
- '.bw-dark body, :root.bw-dark body': {
1905
- 'color': textColor,
1906
- 'background-color': bodyBg
1907
- },
1908
- '.bw-dark .bw-card': {
1909
- 'background-color': surfaceBg,
1910
- 'border-color': borderColor,
1911
- 'color': textColor
1912
- },
1913
- '.bw-dark .bw-card-header': {
1914
- 'background-color': bodyBg,
1915
- 'border-bottom-color': borderColor,
1916
- 'color': textColor
1917
- },
1918
- '.bw-dark .bw-card-footer': {
1919
- 'background-color': bodyBg,
1920
- 'border-top-color': borderColor,
1921
- 'color': textColor
1922
- },
1923
- '.bw-dark .bw-card-title': {
1924
- 'color': textColor
1925
- },
1926
- '.bw-dark .bw-navbar': {
1927
- 'background-color': surfaceBg,
1928
- 'border-bottom-color': borderColor
1929
- },
1930
- '.bw-dark .bw-navbar-brand': {
1931
- 'color': textColor
1932
- },
1933
- '.bw-dark .bw-navbar-nav .bw-nav-link': {
1934
- 'color': adjustLightness(textColor, -15)
1935
- },
1936
- '.bw-dark .bw-navbar-nav .bw-nav-link:hover': {
1937
- 'color': textColor
1938
- },
1939
- '.bw-dark .bw-form-control': {
1940
- 'background-color': surfaceBg,
1941
- 'border-color': borderColor,
1942
- 'color': textColor
1943
- },
1944
- '.bw-dark .bw-form-label': {
1945
- 'color': textColor
1946
- },
1947
- '.bw-dark .bw-form-text': {
1948
- 'color': adjustLightness(textColor, -20)
1949
- },
1950
- '.bw-dark .bw-table': {
1951
- 'color': textColor
1952
- },
1953
- '.bw-dark .bw-table > :not(caption) > * > *': {
1954
- 'border-bottom-color': borderColor
1955
- },
1956
- '.bw-dark .bw-table > thead > tr > *': {
1957
- 'background-color': bodyBg,
1958
- 'color': adjustLightness(textColor, -10),
1959
- 'border-bottom-color': borderColor
1960
- },
1961
- '.bw-dark .bw-table-striped > tbody > tr:nth-of-type(odd) > *': {
1962
- 'background-color': 'rgba(255, 255, 255, 0.05)'
1963
- },
1964
- '.bw-dark .bw-alert': {
1965
- 'border-color': borderColor
1966
- },
1967
- '.bw-dark .bw-list-group-item': {
1968
- 'background-color': surfaceBg,
1969
- 'border-color': borderColor,
1970
- 'color': textColor
1971
- },
1972
- '.bw-dark .bw-badge': {
1973
- 'color': textColor
1974
- },
1975
- '.bw-dark .bw-nav-tabs': {
1976
- 'border-bottom-color': borderColor
1977
- },
1978
- '.bw-dark .bw-nav-link': {
1979
- 'color': adjustLightness(textColor, -15)
1980
- },
1981
- '.bw-dark .bw-nav-tabs .bw-nav-link:hover': {
1982
- 'color': textColor,
1983
- 'border-bottom-color': borderColor
1984
- },
1985
- '.bw-dark .bw-pagination .bw-page-link': {
1986
- 'background-color': surfaceBg,
1987
- 'border-color': borderColor,
1988
- 'color': textColor
1989
- },
1990
- '.bw-dark .bw-breadcrumb-item + .bw-breadcrumb-item::before': {
1991
- 'color': adjustLightness(textColor, -20)
1992
- },
1993
- '.bw-dark .bw-breadcrumb-item.active': {
1994
- 'color': adjustLightness(textColor, -10)
1995
- },
1996
- '.bw-dark .bw-hero-light': {
1997
- 'background': surfaceBg,
1998
- 'color': textColor
1999
- },
2000
- '.bw-dark .bw-progress': {
2001
- 'background-color': surfaceBg
2002
- },
2003
- '.bw-dark .bw-section-subtitle': {
2004
- 'color': adjustLightness(textColor, -15)
2005
- },
2006
- '.bw-dark .bw-close': {
2007
- 'color': textColor
2008
- },
2009
- '.bw-dark .bw-accordion-item': {
2010
- 'background-color': surfaceBg,
2011
- 'border-color': borderColor
2012
- },
2013
- '.bw-dark .bw-accordion-button': {
2014
- 'color': textColor
2015
- },
2016
- '.bw-dark .bw-accordion-button:not(.bw-collapsed)': {
2017
- 'color': '#7dd3e0',
2018
- 'background-color': 'rgba(125, 211, 224, 0.1)'
2019
- },
2020
- '.bw-dark .bw-accordion-button:hover': {
2021
- 'background-color': bodyBg
2022
- },
2023
- '.bw-dark .bw-accordion-button:not(.bw-collapsed):hover': {
2024
- 'background-color': 'rgba(125, 211, 224, 0.15)'
2025
- },
2026
- '.bw-dark .bw-accordion-button:focus-visible': {
2027
- 'box-shadow': '0 0 0 0.2rem rgba(125, 211, 224, 0.3)'
2028
- },
2029
- '.bw-dark .bw-accordion-body': {
2030
- 'border-top-color': borderColor
2031
- },
2032
- '.bw-dark .bw-carousel': {
2033
- 'background-color': bodyBg
2034
- },
2035
- '.bw-dark .bw-carousel-control': {
2036
- 'background-color': 'rgba(255,255,255,0.15)'
2037
- },
2038
- '.bw-dark .bw-carousel-control:hover': {
2039
- 'background-color': 'rgba(255,255,255,0.25)'
2040
- },
2041
- '.bw-dark .bw-modal-content': {
2042
- 'background-color': surfaceBg,
2043
- 'border-color': borderColor
2044
- },
2045
- '.bw-dark .bw-modal-header': {
2046
- 'border-bottom-color': borderColor
2047
- },
2048
- '.bw-dark .bw-modal-footer': {
2049
- 'border-top-color': borderColor
2050
- },
2051
- '.bw-dark .bw-modal-title': {
2052
- 'color': textColor
2053
- },
2054
- '.bw-dark .bw-toast': {
2055
- 'background-color': surfaceBg,
2056
- 'border-color': borderColor
2057
- },
2058
- '.bw-dark .bw-toast-header': {
2059
- 'border-bottom-color': borderColor,
2060
- 'color': textColor
2061
- },
2062
- '.bw-dark .bw-dropdown-menu': {
2063
- 'background-color': surfaceBg,
2064
- 'border-color': borderColor
2065
- },
2066
- '.bw-dark .bw-dropdown-item': {
2067
- 'color': textColor
2068
- },
2069
- '.bw-dark .bw-dropdown-item:hover': {
2070
- 'background-color': bodyBg
2071
- },
2072
- '.bw-dark .bw-dropdown-divider': {
2073
- 'border-top-color': borderColor
2074
- },
2075
- '.bw-dark .bw-skeleton': {
2076
- 'background': 'linear-gradient(90deg, ' + borderColor + ' 25%, ' + surfaceBg + ' 37%, ' + borderColor + ' 63%)'
2077
- },
2078
- '.bw-dark h1, .bw-dark h2, .bw-dark h3, .bw-dark h4, .bw-dark h5, .bw-dark h6': {
2079
- 'color': textColor
2080
- },
2081
- '@media (prefers-color-scheme: dark)': {
2082
- ':root.bw-auto-dark body': {
2083
- 'color': textColor,
2084
- 'background-color': bodyBg
2305
+ function generateAlternateCSS(name, altPalette, layout) {
2306
+ // Generate themed CSS using the same pipeline as primary
2307
+ var rawRules = generateThemedCSS('', altPalette, layout);
2308
+
2309
+ // Re-scope every selector under .bw-theme-alt (+ optional theme name)
2310
+ var altPrefix = name ? '.' + name + '.bw-theme-alt' : '.bw-theme-alt';
2311
+ var altRules = {};
2312
+
2313
+ for (var sel in rawRules) {
2314
+ if (!rawRules.hasOwnProperty(sel)) continue;
2315
+
2316
+ if (sel.charAt(0) === '@') {
2317
+ // @media / @keyframes — recurse into the block
2318
+ var innerBlock = rawRules[sel];
2319
+ var altInner = {};
2320
+ for (var innerSel in innerBlock) {
2321
+ if (!innerBlock.hasOwnProperty(innerSel)) continue;
2322
+ altInner[altPrefix + ' ' + innerSel] = innerBlock[innerSel];
2085
2323
  }
2324
+ altRules[sel] = altInner;
2325
+ } else {
2326
+ // Regular selector — prefix with alt scope
2327
+ // Handle comma-separated selectors
2328
+ var parts = sel.split(',');
2329
+ var scopedParts = [];
2330
+ for (var i = 0; i < parts.length; i++) {
2331
+ var s = parts[i].trim();
2332
+ // 'body' selector gets special treatment: .bw-theme-alt body
2333
+ if (s === 'body' || s.indexOf('body') === 0) {
2334
+ scopedParts.push(altPrefix + ' ' + s);
2335
+ } else {
2336
+ scopedParts.push(altPrefix + ' ' + s);
2337
+ }
2338
+ }
2339
+ altRules[scopedParts.join(', ')] = rawRules[sel];
2086
2340
  }
2341
+ }
2342
+
2343
+ // Add body-level overrides for the alternate surface
2344
+ altRules[altPrefix + ' body, :root' + altPrefix + ' body'] = {
2345
+ 'color': altPalette.dark.base,
2346
+ 'background-color': altPalette.light.base
2087
2347
  };
2348
+
2349
+ return altRules;
2088
2350
  }
2089
2351
 
2090
2352
  function deepMerge(target, source) {
@@ -3732,8 +3994,10 @@ bw.u = {
3732
3994
  /**
3733
3995
  * Generate responsive CSS with media query breakpoints.
3734
3996
  *
3735
- * Produces a CSS string with `@media` rules for sm (640px), md (768px),
3736
- * lg (1024px), and xl (1280px) breakpoints. Pass the result to `bw.injectCSS()`.
3997
+ * Produces a CSS string with `@media (min-width)` rules for standard
3998
+ * breakpoints. These match the grid system and theme.breakpoints:
3999
+ * sm: 576px, md: 768px, lg: 992px, xl: 1200px
4000
+ * Pass the result to `bw.injectCSS()`.
3737
4001
  *
3738
4002
  * @param {string} selector - CSS selector
3739
4003
  * @param {Object} breakpoints - Object with keys: base, sm, md, lg, xl
@@ -3750,7 +4014,7 @@ bw.u = {
3750
4014
  * bw.injectCSS(css);
3751
4015
  */
3752
4016
  bw.responsive = function(selector, breakpoints) {
3753
- var sizes = { sm: '640px', md: '768px', lg: '1024px', xl: '1280px' };
4017
+ var sizes = { sm: '576px', md: '768px', lg: '992px', xl: '1200px' };
3754
4018
  var parts = [];
3755
4019
  Object.keys(breakpoints).forEach(function(key) {
3756
4020
  var rules = {};
@@ -3884,7 +4148,8 @@ if (bw._isBrowser) {
3884
4148
  * @returns {Element|null} Style element if in browser, null in Node.js
3885
4149
  * @category CSS & Styling
3886
4150
  * @see bw.setTheme
3887
- * @see bw.toggleDarkMode
4151
+ * @see bw.applyTheme
4152
+ * @see bw.toggleTheme
3888
4153
  * @example
3889
4154
  * bw.loadDefaultStyles(); // inject all default CSS
3890
4155
  */
@@ -3951,53 +4216,6 @@ bw.setTheme = function(overrides, options = {}) {
3951
4216
  return bw.getTheme();
3952
4217
  };
3953
4218
 
3954
- /**
3955
- * Toggle dark mode on/off.
3956
- *
3957
- * Adds/removes the `bw-dark` class on `<html>` and injects dark mode CSS
3958
- * overrides. Pass `true`/`false` to force a mode, or omit to toggle.
3959
- *
3960
- * @param {boolean} [force] - Force dark (true) or light (false). Omit to toggle.
3961
- * @returns {boolean} Whether dark mode is now active
3962
- * @category CSS & Styling
3963
- * @see bw.setTheme
3964
- * @example
3965
- * bw.toggleDarkMode(); // toggle
3966
- * bw.toggleDarkMode(true); // force dark
3967
- * bw.toggleDarkMode(false); // force light
3968
- */
3969
- bw.toggleDarkMode = function(force) {
3970
- const isDark = force !== undefined ? force : !theme.darkMode;
3971
- theme.darkMode = isDark;
3972
-
3973
- if (bw._isBrowser) {
3974
- const root = document.documentElement;
3975
- if (isDark) {
3976
- root.classList.add('bw-dark');
3977
- // Generate palette-aware dark mode CSS, or fall back to default
3978
- var palette = bw._activePalette || derivePalette(DEFAULT_PALETTE_CONFIG);
3979
- var darkRules = generateDarkModeCSS(palette);
3980
- var darkCSS = bw.css(darkRules);
3981
-
3982
- // Remove existing dark styles to allow regeneration
3983
- var existing = document.getElementById('bw-dark-styles');
3984
- if (existing) existing.remove();
3985
-
3986
- var styleEl = document.createElement('style');
3987
- styleEl.id = 'bw-dark-styles';
3988
- styleEl.textContent = darkCSS;
3989
- document.head.appendChild(styleEl);
3990
- } else {
3991
- root.classList.remove('bw-dark');
3992
- // Remove dark mode styles when switching to light
3993
- var darkEl = document.getElementById('bw-dark-styles');
3994
- if (darkEl) darkEl.remove();
3995
- }
3996
- }
3997
-
3998
- return isDark;
3999
- };
4000
-
4001
4219
  /**
4002
4220
  * Generate a complete, scoped theme from seed colors.
4003
4221
  *
@@ -4020,13 +4238,19 @@ bw.toggleDarkMode = function(force) {
4020
4238
  * @param {string} [config.spacing='normal'] - 'compact' | 'normal' | 'spacious'
4021
4239
  * @param {string} [config.radius='md'] - 'none' | 'sm' | 'md' | 'lg' | 'pill'
4022
4240
  * @param {number} [config.fontSize=1.0] - Base font size scale factor
4241
+ * @param {string|number} [config.typeRatio='normal'] - 'tight' | 'normal' | 'relaxed' | 'dramatic' or a number
4242
+ * @param {string} [config.elevation='md'] - 'flat' | 'sm' | 'md' | 'lg'
4243
+ * @param {string} [config.motion='standard'] - 'reduced' | 'standard' | 'expressive'
4244
+ * @param {number} [config.harmonize=0.20] - 0-1, semantic color hue shift toward primary
4023
4245
  * @param {boolean} [config.inject=true] - Inject into DOM (browser only)
4024
- * @returns {Object} { css, palette, name }
4246
+ * @returns {Object} { css, palette, name, isLightPrimary, alternate: { css, palette } }
4025
4247
  * @category CSS & Styling
4248
+ * @see bw.applyTheme
4249
+ * @see bw.toggleTheme
4026
4250
  * @see bw.loadDefaultStyles
4027
4251
  * @example
4028
- * // Generate and inject an ocean theme
4029
- * bw.generateTheme('ocean', {
4252
+ * // Generate and inject an ocean theme (primary + alternate)
4253
+ * var theme = bw.generateTheme('ocean', {
4030
4254
  * primary: '#0077b6',
4031
4255
  * secondary: '#90e0ef',
4032
4256
  * tertiary: '#00b4d8'
@@ -4035,14 +4259,16 @@ bw.toggleDarkMode = function(force) {
4035
4259
  * // Apply to a container
4036
4260
  * document.getElementById('app').classList.add('ocean');
4037
4261
  *
4262
+ * // Toggle to alternate palette
4263
+ * bw.toggleTheme();
4264
+ *
4038
4265
  * // Generate CSS for static export (Node.js)
4039
4266
  * var result = bw.generateTheme('sunset', {
4040
4267
  * primary: '#e76f51',
4041
4268
  * secondary: '#264653',
4042
- * tertiary: '#e9c46a',
4043
4269
  * inject: false
4044
4270
  * });
4045
- * fs.writeFileSync('sunset.css', result.css);
4271
+ * fs.writeFileSync('sunset.css', result.css + result.alternate.css);
4046
4272
  */
4047
4273
  bw.generateTheme = function(name, config) {
4048
4274
  if (!config || !config.primary || !config.secondary) {
@@ -4053,29 +4279,37 @@ bw.generateTheme = function(name, config) {
4053
4279
  var fullConfig = Object.assign({}, DEFAULT_PALETTE_CONFIG, config);
4054
4280
  if (!config.tertiary) fullConfig.tertiary = fullConfig.primary;
4055
4281
 
4056
- // Derive palette
4282
+ // Derive primary palette
4057
4283
  var palette = derivePalette(fullConfig);
4058
4284
 
4059
- // Store active palette for dark mode
4060
- bw._activePalette = palette;
4061
-
4062
4285
  // Resolve layout
4063
4286
  var layout = resolveLayout(fullConfig);
4064
4287
 
4065
- // Generate themed CSS rules
4288
+ // Generate primary themed CSS rules
4066
4289
  var themedRules = generateThemedCSS(name, palette, layout);
4067
-
4068
- // Add underscore aliases
4069
4290
  var aliasedRules = addUnderscoreAliases(themedRules);
4070
-
4071
- // Convert to CSS string
4072
4291
  var cssStr = bw.css(aliasedRules);
4073
4292
 
4074
- // Inject into DOM if requested and in browser
4293
+ // Derive alternate palette (luminance-inverted)
4294
+ var altConfig = deriveAlternateConfig(fullConfig);
4295
+ var altPalette = derivePalette(altConfig);
4296
+
4297
+ // Generate alternate CSS scoped under .bw-theme-alt
4298
+ var altRules = generateAlternateCSS(name, altPalette, layout);
4299
+ var aliasedAltRules = addUnderscoreAliases(altRules);
4300
+ var altCssStr = bw.css(aliasedAltRules);
4301
+
4302
+ // Determine if primary is light-flavored
4303
+ var lightPrimary = isLightPalette(fullConfig);
4304
+
4305
+ // Inject both CSS sets into DOM if requested
4075
4306
  var shouldInject = config.inject !== false;
4076
4307
  if (shouldInject && bw._isBrowser) {
4077
4308
  var styleId = name ? 'bw-theme-' + name : 'bw-theme-default';
4078
4309
  bw.injectCSS(cssStr, { id: styleId, append: false });
4310
+
4311
+ var altStyleId = name ? 'bw-theme-' + name + '-alt' : 'bw-theme-default-alt';
4312
+ bw.injectCSS(altCssStr, { id: altStyleId, append: false });
4079
4313
  }
4080
4314
 
4081
4315
  // Update bw.u color entries to reflect the palette
@@ -4086,7 +4320,72 @@ bw.generateTheme = function(name, config) {
4086
4320
  bw.u.textWhite = { color: '#ffffff' };
4087
4321
  }
4088
4322
 
4089
- return { css: cssStr, palette: palette, name: name };
4323
+ // Store active theme state
4324
+ var result = {
4325
+ css: cssStr,
4326
+ palette: palette,
4327
+ name: name,
4328
+ isLightPrimary: lightPrimary,
4329
+ alternate: {
4330
+ css: altCssStr,
4331
+ palette: altPalette
4332
+ }
4333
+ };
4334
+ bw._activeTheme = result;
4335
+ bw._activeThemeMode = 'primary';
4336
+
4337
+ return result;
4338
+ };
4339
+
4340
+ /**
4341
+ * Apply a theme mode. Switches between primary and alternate palettes
4342
+ * by adding/removing the `bw-theme-alt` class on `<html>`.
4343
+ *
4344
+ * @param {string} mode - 'primary' | 'alternate' | 'light' | 'dark'
4345
+ * @returns {string} Active mode: 'primary' or 'alternate'
4346
+ * @category CSS & Styling
4347
+ * @see bw.generateTheme
4348
+ * @see bw.toggleTheme
4349
+ * @example
4350
+ * bw.applyTheme('alternate'); // switch to alternate palette
4351
+ * bw.applyTheme('dark'); // switch to whichever palette is darker
4352
+ * bw.applyTheme('primary'); // switch back to primary palette
4353
+ */
4354
+ bw.applyTheme = function(mode) {
4355
+ if (!bw._isBrowser) return mode || 'primary';
4356
+ var root = document.documentElement;
4357
+ var isLight = bw._activeTheme ? bw._activeTheme.isLightPrimary : true;
4358
+
4359
+ var wantAlt;
4360
+ if (mode === 'primary') wantAlt = false;
4361
+ else if (mode === 'alternate') wantAlt = true;
4362
+ else if (mode === 'light') wantAlt = !isLight;
4363
+ else if (mode === 'dark') wantAlt = isLight;
4364
+ else wantAlt = false;
4365
+
4366
+ if (wantAlt) {
4367
+ root.classList.add('bw-theme-alt');
4368
+ } else {
4369
+ root.classList.remove('bw-theme-alt');
4370
+ }
4371
+
4372
+ bw._activeThemeMode = wantAlt ? 'alternate' : 'primary';
4373
+ return bw._activeThemeMode;
4374
+ };
4375
+
4376
+ /**
4377
+ * Toggle between primary and alternate theme palettes.
4378
+ *
4379
+ * @returns {string} Active mode after toggle: 'primary' or 'alternate'
4380
+ * @category CSS & Styling
4381
+ * @see bw.applyTheme
4382
+ * @see bw.generateTheme
4383
+ * @example
4384
+ * bw.toggleTheme(); // flip between primary and alternate
4385
+ */
4386
+ bw.toggleTheme = function() {
4387
+ var current = bw._activeThemeMode || 'primary';
4388
+ return bw.applyTheme(current === 'primary' ? 'alternate' : 'primary');
4090
4389
  };
4091
4390
 
4092
4391
  // Expose color utility functions on bw namespace
@@ -4098,10 +4397,18 @@ bw.relativeLuminance = relativeLuminance;
4098
4397
  bw.textOnColor = textOnColor;
4099
4398
  bw.deriveShades = deriveShades;
4100
4399
  bw.derivePalette = derivePalette;
4400
+ bw.harmonize = harmonize;
4401
+ bw.deriveAlternateSeed = deriveAlternateSeed;
4402
+ bw.deriveAlternateConfig = deriveAlternateConfig;
4403
+ bw.isLightPalette = isLightPalette;
4101
4404
 
4102
4405
  // Expose layout and theme presets
4103
4406
  bw.SPACING_PRESETS = SPACING_PRESETS;
4104
4407
  bw.RADIUS_PRESETS = RADIUS_PRESETS;
4408
+ bw.TYPE_RATIO_PRESETS = TYPE_RATIO_PRESETS;
4409
+ bw.ELEVATION_PRESETS = ELEVATION_PRESETS;
4410
+ bw.MOTION_PRESETS = MOTION_PRESETS;
4411
+ bw.generateTypeScale = generateTypeScale;
4105
4412
  bw.DEFAULT_PALETTE_CONFIG = DEFAULT_PALETTE_CONFIG;
4106
4413
  bw.THEME_PRESETS = THEME_PRESETS;
4107
4414
 
@@ -5143,9 +5450,13 @@ bw.copyToClipboard = function(text) {
5143
5450
  /**
5144
5451
  * Create a sortable TACO table from an array of row objects.
5145
5452
  *
5453
+ * Returns a bare `<table>` TACO — no wrapper, title, or responsive scroll.
5454
+ * Use this when you need full control over table placement, or when embedding
5455
+ * the table inside your own layout. For a ready-to-use table with title,
5456
+ * responsive wrapper, and defaults (striped + hover), use `bw.makeDataTable()`.
5457
+ *
5146
5458
  * Auto-detects columns from data keys if not specified. Supports click-to-sort
5147
- * headers with ascending/descending indicators. Returns a TACO object —
5148
- * render with `bw.DOM()` or `bw.html()`.
5459
+ * headers with ascending/descending indicators.
5149
5460
  *
5150
5461
  * @param {Object} config - Table configuration
5151
5462
  * @param {Array<Object>} config.data - Array of row objects to display
@@ -5445,10 +5756,12 @@ bw.makeBarChart = function(config) {
5445
5756
  };
5446
5757
 
5447
5758
  /**
5448
- * Create a responsive data table with title and optional wrapper
5759
+ * Create a ready-to-use data table with title and responsive wrapper.
5449
5760
  *
5450
- * Wraps bw.makeTable() output in a responsive container div.
5451
- * Adds an optional title heading above the table.
5761
+ * Convenience wrapper around `bw.makeTable()` that adds a title heading,
5762
+ * responsive horizontal scroll container, and defaults to striped + hover.
5763
+ * Use this for the common case; use `bw.makeTable()` when you need a bare
5764
+ * table element with no wrapper.
5452
5765
  *
5453
5766
  * @param {Object} config - Table configuration
5454
5767
  * @param {string} [config.title] - Table title heading