@loworbitstudio/visor-theme-engine 0.13.0 → 0.15.0

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
- import { g as GeneratedPrimitives, m as SemanticTokens, R as ResolvedThemeConfig } from '../types-CSO2avFQ.js';
1
+ import { g as GeneratedPrimitives, m as SemanticTokens, R as ResolvedThemeConfig } from '../types-zug1_eLX.js';
2
2
 
3
3
  /**
4
4
  * Adapter types for the Visor theme engine.
@@ -3,7 +3,9 @@ import {
3
3
  MATERIAL_TEXT_SLOTS,
4
4
  SELECTIVE_SHADE_STEPS,
5
5
  aliasFamily,
6
+ block,
6
7
  buildVisorFontUrl,
8
+ collectBrandPassthrough,
7
9
  fontStack,
8
10
  generateDarkCss,
9
11
  generateHairlineDecls,
@@ -18,7 +20,53 @@ import {
18
20
  resolveThemeBrand,
19
21
  resolveThemeFonts,
20
22
  sectionComment
21
- } from "../chunk-KFTTL3XP.js";
23
+ } from "../chunk-DQ256PSE.js";
24
+
25
+ // src/adapters/brand-passthrough.ts
26
+ var SENTINEL_COLOR = "#ff00ff";
27
+ function isDevBuild() {
28
+ return process.env.NODE_ENV !== "production";
29
+ }
30
+ function isUnresolved(value) {
31
+ return typeof value !== "string" || value.trim().length === 0;
32
+ }
33
+ function declFor(key, value) {
34
+ if (isUnresolved(value)) {
35
+ if (isDevBuild()) {
36
+ return `--${key}: ${SENTINEL_COLOR}; /* [visor-brand] UNRESOLVED pass-through value */`;
37
+ }
38
+ return `--${key}: ${value};`;
39
+ }
40
+ return `--${key}: ${value};`;
41
+ }
42
+ function indentBlock(selector, decls) {
43
+ if (decls.length === 0) return "";
44
+ return [selector + " {", ...decls.map((d) => ` ${d}`), "}"].join("\n");
45
+ }
46
+ function generateBrandPassthroughCss(passthrough, selectors) {
47
+ const lightKeys = Object.keys(passthrough.light);
48
+ const darkKeys = Object.keys(passthrough.dark);
49
+ if (lightKeys.length === 0 && darkKeys.length === 0) return "";
50
+ const blocks = [];
51
+ if (isDevBuild()) {
52
+ const names = [.../* @__PURE__ */ new Set([...lightKeys, ...darkKeys])].map((k) => `--${k}`).join(", ");
53
+ const count = lightKeys.length + darkKeys.length;
54
+ blocks.push(`/* [visor-brand] ${count} passthrough: ${names} */`);
55
+ }
56
+ if (lightKeys.length > 0) {
57
+ const decls = lightKeys.map((k) => declFor(k, passthrough.light[k]));
58
+ blocks.push(indentBlock(selectors.light, decls));
59
+ }
60
+ if (darkKeys.length > 0) {
61
+ const decls = darkKeys.map((k) => declFor(k, passthrough.dark[k]));
62
+ blocks.push(indentBlock(selectors.dark, decls));
63
+ const prefersInner = indentBlock(selectors.prefers, decls).split("\n").map((l) => ` ${l}`).join("\n");
64
+ blocks.push(`@media (prefers-color-scheme: dark) {
65
+ ${prefersInner}
66
+ }`);
67
+ }
68
+ return blocks.filter(Boolean).join("\n\n");
69
+ }
22
70
 
23
71
  // src/adapters/layers.ts
24
72
  var LAYER_ORDER = "@layer visor-primitives, visor-semantic, visor-brand, visor-adaptive, visor-bridge;";
@@ -102,6 +150,62 @@ function nextjsAdapter(input, options) {
102
150
  );
103
151
  lines.push(wrapInLayer("visor-primitives", primitivesBody));
104
152
  lines.push("");
153
+ const passthrough = collectBrandPassthrough(input.tokens, input.config.overrides);
154
+ const darkSelectors = scopePrefix ? [`${scopePrefix}.dark`, `${scopePrefix}.theme-dark`, `${scopePrefix}[data-theme="dark"]`] : [".dark", ".theme-dark", '[data-theme="dark"]'];
155
+ const passthroughCss = generateBrandPassthroughCss(passthrough, {
156
+ light: scopePrefix ?? ":root",
157
+ dark: darkSelectors.join(",\n"),
158
+ prefers: scopePrefix ? `${scopePrefix}:not(.light):not(.theme-light):not([data-theme="light"])` : ':root:not(.light):not(.theme-light):not([data-theme="light"])'
159
+ });
160
+ if (passthroughCss) {
161
+ lines.push(wrapInLayer("visor-brand", passthroughCss));
162
+ lines.push("");
163
+ }
164
+ const hostSelector = scopePrefix ?? ":root";
165
+ const lightModeSelector = `html:not(.dark) ${scopePrefix ?? ""}`.trim();
166
+ const darkModeSelector = `html.dark ${scopePrefix ?? ""}`.trim();
167
+ const prefersSelector = scopePrefix ? `${scopePrefix}:not(.light):not(.theme-light):not([data-theme="light"])` : ':root:not(.light):not(.theme-light):not([data-theme="light"])';
168
+ const semanticLines = [];
169
+ semanticLines.push("/* \u2500\u2500 Layer: Semantic aliases (VI-453) \u2500\u2500 */");
170
+ semanticLines.push(sectionComment("Discrete: Text size aliases (--text-N)"));
171
+ semanticLines.push(block(hostSelector, generateTextScaleAliasDecls()));
172
+ semanticLines.push("");
173
+ semanticLines.push(sectionComment("Discrete: Space aliases (--space-N)"));
174
+ semanticLines.push(block(hostSelector, generateSpaceAliasDecls(input.config)));
175
+ semanticLines.push("");
176
+ semanticLines.push(sectionComment("Intent aliases (light)"));
177
+ semanticLines.push(block(lightModeSelector, generateIntentDecls(input.tokens, "light")));
178
+ semanticLines.push("");
179
+ semanticLines.push(sectionComment("Hairline aliases (light)"));
180
+ semanticLines.push(block(lightModeSelector, generateHairlineDecls(input.tokens, "light")));
181
+ semanticLines.push("");
182
+ semanticLines.push(sectionComment("Intent aliases (dark) \u2014 manual toggle"));
183
+ semanticLines.push(block(darkModeSelector, generateIntentDecls(input.tokens, "dark")));
184
+ semanticLines.push("");
185
+ semanticLines.push(sectionComment("Hairline aliases (dark) \u2014 manual toggle"));
186
+ semanticLines.push(block(darkModeSelector, generateHairlineDecls(input.tokens, "dark")));
187
+ semanticLines.push("");
188
+ semanticLines.push(sectionComment("Intent aliases (dark) \u2014 prefers-color-scheme"));
189
+ {
190
+ const inner = block(prefersSelector, generateIntentDecls(input.tokens, "dark"));
191
+ semanticLines.push(`@media (prefers-color-scheme: dark) {
192
+ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
193
+ }`);
194
+ }
195
+ semanticLines.push("");
196
+ semanticLines.push(sectionComment("Hairline aliases (dark) \u2014 prefers-color-scheme"));
197
+ {
198
+ const inner = block(prefersSelector, generateHairlineDecls(input.tokens, "dark"));
199
+ semanticLines.push(`@media (prefers-color-scheme: dark) {
200
+ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
201
+ }`);
202
+ }
203
+ semanticLines.push("");
204
+ const semanticLayer = wrapInLayer("visor-semantic", semanticLines.join("\n").trim());
205
+ if (semanticLayer) {
206
+ lines.push(semanticLayer);
207
+ lines.push("");
208
+ }
105
209
  const lightBody = stripHeader(generateLightCss(input.tokens, { scopePrefix }));
106
210
  const darkBody = stripHeader(generateDarkCss(input.tokens, { scopePrefix }));
107
211
  lines.push(
@@ -357,7 +461,15 @@ function generateTypographyDecls(config, aliases) {
357
461
  for (const [name, value] of Object.entries(lineHeights)) {
358
462
  decls.push(`--line-height-${name}: ${value};`);
359
463
  }
360
- decls.push("--letter-spacing-normal: 0.05em;");
464
+ const ls = config.typography["letter-spacing"];
465
+ decls.push(`--letter-spacing-xl: ${ls.xl};`);
466
+ decls.push(`--letter-spacing-lg: ${ls.lg};`);
467
+ decls.push(`--letter-spacing-md: ${ls.md};`);
468
+ decls.push(`--letter-spacing-sm: ${ls.sm};`);
469
+ decls.push(`--letter-spacing-xs: ${ls.xs};`);
470
+ decls.push(`--letter-spacing-tight: ${ls.tight};`);
471
+ decls.push(`--letter-spacing-normal: ${ls.md};`);
472
+ decls.push(`--letter-spacing-wide: ${ls.lg};`);
361
473
  return decls;
362
474
  }
363
475
  function generateShadowDecls(config) {
@@ -377,6 +489,7 @@ function generateMotionDecls(config) {
377
489
  "--motion-duration-300: 300ms;",
378
490
  `--motion-duration-500: ${config.motion["duration-slow"]};`,
379
491
  "--motion-duration-800: 800ms;",
492
+ "--motion-duration-1500: 1500ms;",
380
493
  "--motion-easing-linear: linear;",
381
494
  "--motion-easing-ease-in: cubic-bezier(0.4, 0, 1, 1);",
382
495
  "--motion-easing-ease-out: cubic-bezier(0, 0, 0.2, 1);",
@@ -431,7 +544,7 @@ function generateFumadocsBridgeDecls(tokens, mode) {
431
544
  }
432
545
  return decls;
433
546
  }
434
- function block(selector, decls) {
547
+ function block2(selector, decls) {
435
548
  return [`${selector} {`, ...decls.map((d) => ` ${d}`), "}"].join("\n");
436
549
  }
437
550
  function sectionComment2(label) {
@@ -493,29 +606,29 @@ function docsAdapter(input, options) {
493
606
  "font-family: var(--font-sans);"
494
607
  ];
495
608
  lines.push(sectionComment2("Primitive: Colors"));
496
- lines.push(block(scopeClass, [
609
+ lines.push(block2(scopeClass, [
497
610
  ...sharedDecls,
498
611
  "",
499
612
  ...generateColorDecls(input.primitives)
500
613
  ]));
501
614
  lines.push("");
502
615
  lines.push(sectionComment2("Primitive: Spacing"));
503
- lines.push(block(scopeClass, generateSpacingDecls(input.config)));
616
+ lines.push(block2(scopeClass, generateSpacingDecls(input.config)));
504
617
  lines.push("");
505
618
  lines.push(sectionComment2("Primitive: Border Radius"));
506
- lines.push(block(scopeClass, generateRadiusDecls(input.config)));
619
+ lines.push(block2(scopeClass, generateRadiusDecls(input.config)));
507
620
  lines.push("");
508
621
  lines.push(sectionComment2("Primitive: Typography"));
509
- lines.push(block(scopeClass, generateTypographyDecls(input.config, aliasedFamilies)));
622
+ lines.push(block2(scopeClass, generateTypographyDecls(input.config, aliasedFamilies)));
510
623
  lines.push("");
511
624
  lines.push(sectionComment2("Primitive: Shadows"));
512
- lines.push(block(scopeClass, generateShadowDecls(input.config)));
625
+ lines.push(block2(scopeClass, generateShadowDecls(input.config)));
513
626
  lines.push("");
514
627
  lines.push(sectionComment2("Primitive: Motion"));
515
- lines.push(block(scopeClass, generateMotionDecls(input.config)));
628
+ lines.push(block2(scopeClass, generateMotionDecls(input.config)));
516
629
  lines.push("");
517
630
  lines.push(sectionComment2("Primitive: Miscellaneous"));
518
- lines.push(block(scopeClass, generateMiscDecls()));
631
+ lines.push(block2(scopeClass, generateMiscDecls()));
519
632
  lines.push("");
520
633
  lines.push("\n/* \u2500\u2500 Section 2: Dark mode overrides \u2500\u2500 */");
521
634
  const darkDecls = generateSemanticDecls2(input.tokens, "dark");
@@ -539,7 +652,7 @@ function docsAdapter(input, options) {
539
652
  }
540
653
  if (darkPrimitiveOverrides.length > 0) {
541
654
  lines.push(sectionComment2("Primitive overrides (dark) \u2014 dark brand color anchors at shade 500"));
542
- lines.push(block(`.dark ${scopeClass}`, darkPrimitiveOverrides));
655
+ lines.push(block2(`.dark ${scopeClass}`, darkPrimitiveOverrides));
543
656
  lines.push("");
544
657
  }
545
658
  const categories = ["Text", "Surface", "Border", "Interactive"];
@@ -551,7 +664,7 @@ function docsAdapter(input, options) {
551
664
  ];
552
665
  for (let i = 0; i < categories.length; i++) {
553
666
  lines.push(sectionComment2(`Adaptive: ${categories[i]} (dark) \u2014 manual toggle`));
554
- lines.push(block(`.dark ${scopeClass}`, categoryDecls[i]));
667
+ lines.push(block2(`.dark ${scopeClass}`, categoryDecls[i]));
555
668
  lines.push("");
556
669
  }
557
670
  const pcsCategories = [
@@ -562,7 +675,7 @@ function docsAdapter(input, options) {
562
675
  ];
563
676
  for (const cat of pcsCategories) {
564
677
  lines.push(sectionComment2(`Adaptive: ${cat.label} (dark) \u2014 prefers-color-scheme`));
565
- const inner = block(`${scopeClass}:not(.light)`, cat.entries);
678
+ const inner = block2(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, cat.entries);
566
679
  lines.push(`@media (prefers-color-scheme: dark) {
567
680
  ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
568
681
  }`);
@@ -570,7 +683,7 @@ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
570
683
  }
571
684
  if (darkPrimitiveOverrides.length > 0) {
572
685
  lines.push(sectionComment2("Primitive overrides (dark) \u2014 prefers-color-scheme"));
573
- const inner = block(`${scopeClass}:not(.light)`, darkPrimitiveOverrides);
686
+ const inner = block2(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, darkPrimitiveOverrides);
574
687
  lines.push(`@media (prefers-color-scheme: dark) {
575
688
  ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
576
689
  }`);
@@ -585,38 +698,38 @@ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
585
698
  ];
586
699
  for (const cat of lightCategoryDecls) {
587
700
  lines.push(sectionComment2(`Adaptive: ${cat.label} (light)`));
588
- lines.push(block(`html:not(.dark) ${scopeClass}`, cat.entries));
701
+ lines.push(block2(`html:not(.dark) ${scopeClass}`, cat.entries));
589
702
  lines.push("");
590
703
  }
591
704
  lines.push(sectionComment2("Fumadocs bridge: dark"));
592
- lines.push(block(`.dark ${scopeClass}`, generateFumadocsBridgeDecls(input.tokens, "dark")));
705
+ lines.push(block2(`.dark ${scopeClass}`, generateFumadocsBridgeDecls(input.tokens, "dark")));
593
706
  lines.push("");
594
707
  lines.push(sectionComment2("Fumadocs bridge: light"));
595
- lines.push(block(`html:not(.dark) ${scopeClass}`, generateFumadocsBridgeDecls(input.tokens, "light")));
708
+ lines.push(block2(`html:not(.dark) ${scopeClass}`, generateFumadocsBridgeDecls(input.tokens, "light")));
596
709
  lines.push("");
597
710
  const semanticLines = [];
598
711
  semanticLines.push("\n/* \u2500\u2500 Layer: Semantic aliases (VI-451) \u2500\u2500 */");
599
712
  semanticLines.push(sectionComment2("Discrete: Text size aliases (--text-N)"));
600
- semanticLines.push(block(scopeClass, generateTextScaleAliasDecls()));
713
+ semanticLines.push(block2(scopeClass, generateTextScaleAliasDecls()));
601
714
  semanticLines.push("");
602
715
  semanticLines.push(sectionComment2("Discrete: Space aliases (--space-N)"));
603
- semanticLines.push(block(scopeClass, generateSpaceAliasDecls(input.config)));
716
+ semanticLines.push(block2(scopeClass, generateSpaceAliasDecls(input.config)));
604
717
  semanticLines.push("");
605
718
  semanticLines.push(sectionComment2("Intent aliases (light)"));
606
- semanticLines.push(block(`html:not(.dark) ${scopeClass}`, generateIntentDecls(input.tokens, "light")));
719
+ semanticLines.push(block2(`html:not(.dark) ${scopeClass}`, generateIntentDecls(input.tokens, "light")));
607
720
  semanticLines.push("");
608
721
  semanticLines.push(sectionComment2("Hairline aliases (light)"));
609
- semanticLines.push(block(`html:not(.dark) ${scopeClass}`, generateHairlineDecls(input.tokens, "light")));
722
+ semanticLines.push(block2(`html:not(.dark) ${scopeClass}`, generateHairlineDecls(input.tokens, "light")));
610
723
  semanticLines.push("");
611
724
  semanticLines.push(sectionComment2("Intent aliases (dark) \u2014 manual toggle"));
612
- semanticLines.push(block(`.dark ${scopeClass}`, generateIntentDecls(input.tokens, "dark")));
725
+ semanticLines.push(block2(`.dark ${scopeClass}`, generateIntentDecls(input.tokens, "dark")));
613
726
  semanticLines.push("");
614
727
  semanticLines.push(sectionComment2("Hairline aliases (dark) \u2014 manual toggle"));
615
- semanticLines.push(block(`.dark ${scopeClass}`, generateHairlineDecls(input.tokens, "dark")));
728
+ semanticLines.push(block2(`.dark ${scopeClass}`, generateHairlineDecls(input.tokens, "dark")));
616
729
  semanticLines.push("");
617
730
  semanticLines.push(sectionComment2("Intent aliases (dark) \u2014 prefers-color-scheme"));
618
731
  {
619
- const inner = block(`${scopeClass}:not(.light)`, generateIntentDecls(input.tokens, "dark"));
732
+ const inner = block2(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, generateIntentDecls(input.tokens, "dark"));
620
733
  semanticLines.push(`@media (prefers-color-scheme: dark) {
621
734
  ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
622
735
  }`);
@@ -624,16 +737,25 @@ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
624
737
  semanticLines.push("");
625
738
  semanticLines.push(sectionComment2("Hairline aliases (dark) \u2014 prefers-color-scheme"));
626
739
  {
627
- const inner = block(`${scopeClass}:not(.light)`, generateHairlineDecls(input.tokens, "dark"));
740
+ const inner = block2(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, generateHairlineDecls(input.tokens, "dark"));
628
741
  semanticLines.push(`@media (prefers-color-scheme: dark) {
629
742
  ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
630
743
  }`);
631
744
  }
632
745
  semanticLines.push("");
633
746
  const brandResult = resolveThemeBrand(input.config.brand, { scope: scopeClass });
747
+ const passthroughCss = generateBrandPassthroughCss(
748
+ collectBrandPassthrough(input.tokens, input.config.overrides),
749
+ {
750
+ light: `html:not(.dark) ${scopeClass}`,
751
+ dark: `.dark ${scopeClass}`,
752
+ prefers: `${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`
753
+ }
754
+ );
634
755
  const adaptiveLayer = wrapInLayer("visor-adaptive", lines.join("\n").trim());
635
756
  const semanticLayer = wrapInLayer("visor-semantic", semanticLines.join("\n").trim());
636
- const brandLayer = wrapInLayer("visor-brand", brandResult.css);
757
+ const brandLayerBody = [brandResult.css, passthroughCss].filter(Boolean).join("\n\n");
758
+ const brandLayer = wrapInLayer("visor-brand", brandLayerBody);
637
759
  const head = fontLines.length > 0 ? fontLines.join("\n") + "\n" : "";
638
760
  const layerBlocks = [semanticLayer, brandLayer, adaptiveLayer].filter(Boolean);
639
761
  return head + LAYER_ORDER + "\n\n" + layerBlocks.join("\n\n") + "\n";
@@ -1347,6 +1347,91 @@ function generateShadeScale(color, role) {
1347
1347
  return scale;
1348
1348
  }
1349
1349
 
1350
+ // src/overrides.ts
1351
+ var TOKEN_CATEGORIES = [
1352
+ { prefix: "text-", key: "text" },
1353
+ { prefix: "surface-", key: "surface" },
1354
+ { prefix: "border-", key: "border" },
1355
+ { prefix: "interactive-", key: "interactive" },
1356
+ { prefix: "hairline-", key: "hairline" }
1357
+ ];
1358
+ function findToken(key, tokens) {
1359
+ if (key === "hairline" && "default" in tokens.hairline) {
1360
+ return { group: tokens.hairline, name: "default" };
1361
+ }
1362
+ for (const { prefix, key: groupKey } of TOKEN_CATEGORIES) {
1363
+ if (key.startsWith(prefix)) {
1364
+ const name = key.slice(prefix.length);
1365
+ if (name in tokens[groupKey]) {
1366
+ return { group: tokens[groupKey], name };
1367
+ }
1368
+ }
1369
+ }
1370
+ if (key in tokens.intent) {
1371
+ return { group: tokens.intent, name: key };
1372
+ }
1373
+ return null;
1374
+ }
1375
+ function isRecognizedOverrideKey(key, tokens) {
1376
+ return findToken(key, tokens) !== null;
1377
+ }
1378
+ function collectBrandPassthrough(tokens, overrides) {
1379
+ const passthrough = { light: {}, dark: {} };
1380
+ if (!overrides) return passthrough;
1381
+ for (const mode of ["light", "dark"]) {
1382
+ const modeOverrides = overrides[mode];
1383
+ if (!modeOverrides) continue;
1384
+ for (const [key, value] of Object.entries(modeOverrides)) {
1385
+ if (!isRecognizedOverrideKey(key, tokens)) {
1386
+ passthrough[mode][key] = value;
1387
+ }
1388
+ }
1389
+ }
1390
+ return passthrough;
1391
+ }
1392
+ function hasBrandPassthrough(passthrough) {
1393
+ return Object.keys(passthrough.light).length > 0 || Object.keys(passthrough.dark).length > 0;
1394
+ }
1395
+ function applyOverrides(tokens, overrides) {
1396
+ if (!overrides) return tokens;
1397
+ const result = {
1398
+ text: { ...tokens.text },
1399
+ surface: { ...tokens.surface },
1400
+ border: { ...tokens.border },
1401
+ interactive: { ...tokens.interactive },
1402
+ intent: { ...tokens.intent },
1403
+ hairline: { ...tokens.hairline }
1404
+ };
1405
+ for (const group of ["text", "surface", "border", "interactive", "intent", "hairline"]) {
1406
+ for (const [name, value] of Object.entries(result[group])) {
1407
+ result[group][name] = { ...value };
1408
+ }
1409
+ }
1410
+ if (overrides.light) {
1411
+ for (const [key, value] of Object.entries(overrides.light)) {
1412
+ const match = findToken(key, result);
1413
+ if (match) {
1414
+ match.group[match.name] = {
1415
+ ...match.group[match.name],
1416
+ light: value
1417
+ };
1418
+ }
1419
+ }
1420
+ }
1421
+ if (overrides.dark) {
1422
+ for (const [key, value] of Object.entries(overrides.dark)) {
1423
+ const match = findToken(key, result);
1424
+ if (match) {
1425
+ match.group[match.name] = {
1426
+ ...match.group[match.name],
1427
+ dark: value
1428
+ };
1429
+ }
1430
+ }
1431
+ }
1432
+ return result;
1433
+ }
1434
+
1350
1435
  // src/fonts/theme-alias.ts
1351
1436
  var EMPTY_ALIASES = /* @__PURE__ */ new Map();
1352
1437
  function aliasFamily(family, themeSlug) {
@@ -1491,6 +1576,7 @@ function generateMotionPrimitives(config) {
1491
1576
  decls.push("--motion-duration-300: 300ms;");
1492
1577
  decls.push(`--motion-duration-500: ${config.motion["duration-slow"]};`);
1493
1578
  decls.push("--motion-duration-800: 800ms;");
1579
+ decls.push("--motion-duration-1500: 1500ms;");
1494
1580
  decls.push("--motion-easing-linear: linear;");
1495
1581
  decls.push("--motion-easing-ease-in: cubic-bezier(0.4, 0, 1, 1);");
1496
1582
  decls.push("--motion-easing-ease-out: cubic-bezier(0, 0, 0.2, 1);");
@@ -1808,6 +1894,7 @@ export {
1808
1894
  hexToOklch,
1809
1895
  clampToSrgb,
1810
1896
  oklchToHex,
1897
+ getLuminance,
1811
1898
  getContrastRatio,
1812
1899
  parseHex,
1813
1900
  parseRgba,
@@ -1822,10 +1909,14 @@ export {
1822
1909
  SELECTIVE_SHADE_STEPS,
1823
1910
  TAILWIND_GRAY,
1824
1911
  generateShadeScale,
1912
+ collectBrandPassthrough,
1913
+ hasBrandPassthrough,
1914
+ applyOverrides,
1825
1915
  aliasFamily,
1826
1916
  fontStack,
1827
1917
  header,
1828
1918
  sectionComment,
1919
+ block2 as block,
1829
1920
  generatePrimitivesCss,
1830
1921
  generateSemanticCss,
1831
1922
  generateTextScaleAliasDecls,
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { F as FontResolveOptions, a as FontResolution, V as VisorTypography, b as FontDisplayStrategy, T as ThemeFontResult, G as GoogleFontEntry, c as VisorBrand, B as BrandSlot, d as BrandSource, e as BrandResolution, f as ThemeBrandResult, R as ResolvedThemeConfig, g as GeneratedPrimitives, h as ThemeOutput, i as ThemeData, j as VisorThemeConfig, k as FullShadeScale, C as ColorRole, S as SelectiveShadeScale, l as RGB, P as ParsedColor, O as OKLCH, m as SemanticTokens, n as ShadeStep } from './types-CSO2avFQ.js';
2
- export { o as BRAND_VARIANTS, p as BrandVariant, q as ColorFormat, r as FontSource, s as RGBA, t as SemanticTokenValue } from './types-CSO2avFQ.js';
1
+ import { F as FontResolveOptions, a as FontResolution, V as VisorTypography, b as FontDisplayStrategy, T as ThemeFontResult, G as GoogleFontEntry, c as VisorBrand, B as BrandSlot, d as BrandSource, e as BrandResolution, f as ThemeBrandResult, R as ResolvedThemeConfig, g as GeneratedPrimitives, h as ThemeOutput, i as ThemeData, j as VisorThemeConfig, k as FullShadeScale, C as ColorRole, S as SelectiveShadeScale, l as RGB, P as ParsedColor, O as OKLCH, m as SemanticTokens, n as ShadeStep } from './types-zug1_eLX.js';
2
+ export { o as BRAND_VARIANTS, p as BrandVariant, q as ColorFormat, r as FontSource, s as RGBA, t as SemanticTokenValue } from './types-zug1_eLX.js';
3
3
 
4
4
  /**
5
5
  * Font resolver — maps font family names to loadable font resources.
@@ -315,6 +315,18 @@ var properties = {
315
315
  type: "string",
316
316
  description: "Theme group for the docs site theme switcher (e.g. 'Visor', 'Client', 'Low Orbit'). Used by `visor theme sync`. Defaults to folder-based grouping when omitted."
317
317
  },
318
+ label: {
319
+ type: "string",
320
+ description: "Human-readable display name for the theme (e.g. 'Blacklight Pro'). Overrides the name-derived label in the docs theme switcher. Optional."
321
+ },
322
+ "default-mode": {
323
+ type: "string",
324
+ "enum": [
325
+ "light",
326
+ "dark"
327
+ ],
328
+ description: "Default color mode when activating this theme. When set, the docs site forces this mode on theme switch (unless the user has a stored mode preference). Optional."
329
+ },
318
330
  colors: {
319
331
  type: "object",
320
332
  description: "Color definitions for light mode. Only primary is required — all others have sensible defaults.",
@@ -502,19 +514,44 @@ var properties = {
502
514
  type: "number",
503
515
  description: "Type scale multiplier applied to the font-size ramp. Default: 1."
504
516
  },
517
+ "text-on-light": {
518
+ type: "string",
519
+ description: "VI-375: text color placed on a LIGHT interactive background. Auto-picked for interactive *-text tokens whose paired *-bg has relative luminance > 0.5. Defaults to #FFFFFF (which deliberately still fails AA on a bright bg so the contrast validator flags it); set a readable near-black (e.g. #1E1F21) to clear the warning. Per-token overrides (overrides.{mode}.interactive-*-text) still win."
520
+ },
521
+ "text-on-dark": {
522
+ type: "string",
523
+ description: "VI-375: text color placed on a DARK interactive background. Auto-picked for interactive *-text tokens whose paired *-bg has relative luminance <= 0.5. Defaults to #FFFFFF. Per-token overrides still win."
524
+ },
505
525
  "letter-spacing": {
506
526
  type: "object",
507
- description: "Letter spacing scale.",
527
+ description: "6-tier letter-spacing ramp (xl | lg | md | sm | xs | tight). Each value is a CSS length (e.g. \"0.1em\", \"0.44rem\", \"-0.01em\"). The legacy triad keys normal and wide remain valid for back-compat and map onto the ramp (normal->md, wide->lg, tight->tight).",
508
528
  additionalProperties: false,
509
529
  properties: {
530
+ xl: {
531
+ type: "string"
532
+ },
533
+ lg: {
534
+ type: "string"
535
+ },
536
+ md: {
537
+ type: "string"
538
+ },
539
+ sm: {
540
+ type: "string"
541
+ },
542
+ xs: {
543
+ type: "string"
544
+ },
510
545
  tight: {
511
546
  type: "string"
512
547
  },
513
548
  normal: {
514
- type: "string"
549
+ type: "string",
550
+ description: "Deprecated legacy alias — maps to md."
515
551
  },
516
552
  wide: {
517
- type: "string"
553
+ type: "string",
554
+ description: "Deprecated legacy alias — maps to lg."
518
555
  }
519
556
  }
520
557
  },
@@ -946,6 +983,21 @@ interface ThemeValidationResult {
946
983
  errors: ValidationIssue[];
947
984
  warnings: ValidationIssue[];
948
985
  }
986
+ /**
987
+ * Options for the validate() function.
988
+ */
989
+ interface ValidateOptions {
990
+ /**
991
+ * When true, promote DARK_LIGHT_PARITY warnings and the
992
+ * "colors.neutral present without colors-dark.neutral" check
993
+ * from warning to error. Use this in CI to enforce the
994
+ * "always both modes" authoring convention.
995
+ *
996
+ * Opt-in today; flip to the default after all convergent
997
+ * themes add their dark neutral (see VI-495 docs).
998
+ */
999
+ strictDark?: boolean;
1000
+ }
949
1001
  /**
950
1002
  * Validate a theme config comprehensively.
951
1003
  *
@@ -953,9 +1005,10 @@ interface ThemeValidationResult {
953
1005
  * Results are JSON-serializable for CLI `--json` output.
954
1006
  *
955
1007
  * @param config - A parsed theme config object (from YAML or programmatic)
1008
+ * @param options - Optional validator flags (e.g. strictDark)
956
1009
  * @returns ThemeValidationResult with errors[], warnings[], and valid boolean
957
1010
  */
958
- declare function validate(config: unknown): ThemeValidationResult;
1011
+ declare function validate(config: unknown, options?: ValidateOptions): ThemeValidationResult;
959
1012
 
960
1013
  /**
961
1014
  * Shade Scale Generation
@@ -1062,6 +1115,30 @@ declare function assignSemanticTokens(lightPrimitives: GeneratedPrimitives, dark
1062
1115
  * replacing derived token values with user-specified values.
1063
1116
  */
1064
1117
 
1118
+ /** Pass-through brand tokens collected per mode (VI-493). */
1119
+ interface BrandPassthrough {
1120
+ light: Record<string, string>;
1121
+ dark: Record<string, string>;
1122
+ }
1123
+ /**
1124
+ * Collect unrecognized override keys into a brand-passthrough map (VI-493).
1125
+ *
1126
+ * Any `overrides.{light,dark}` key that does NOT map to a recognized semantic,
1127
+ * intent, or hairline token is captured here verbatim (key + value). These were
1128
+ * previously DROPPED silently by `applyOverrides`; the adapters now emit them as
1129
+ * bare `--<key>` custom properties inside `@layer visor-brand`, ending the
1130
+ * dual-source-of-truth between `.visor.yaml` and hand-maintained `:root` blocks.
1131
+ *
1132
+ * Recognized tokens are excluded — they continue to flow through the normal
1133
+ * semantic pipeline. Pass-through tokens are legitimately mode-asymmetric (a key
1134
+ * may appear in `light` only, `dark` only, or both); no both-modes rule applies.
1135
+ */
1136
+ declare function collectBrandPassthrough(tokens: SemanticTokens, overrides?: {
1137
+ light?: Record<string, string>;
1138
+ dark?: Record<string, string>;
1139
+ }): BrandPassthrough;
1140
+ /** True when the passthrough map carries at least one token in either mode. */
1141
+ declare function hasBrandPassthrough(passthrough: BrandPassthrough): boolean;
1065
1142
  /**
1066
1143
  * Apply override values to semantic tokens.
1067
1144
  * Returns a new SemanticTokens with overrides applied (does not mutate input).
@@ -1221,4 +1298,4 @@ declare function cleanFontValue(val: string): string;
1221
1298
  */
1222
1299
  declare function extractFromCSS(files: CSSFile[], name?: string): ExtractionResult;
1223
1300
 
1224
- export { BrandResolution, BrandSlot, BrandSource, type CSSFile, ColorRole, type Confidence, DEFAULT_VISOR_BRAND, type ExtractedToken, type ExtractionResult, FONT_WEIGHT_ALIASES, type FontCoverageError, type FontCoverageResult, FontDisplayStrategy, type FontFaceDeclaration, FontResolution, FontResolveOptions, FullShadeScale, GeneratedPrimitives, GoogleFontEntry, OKLCH, ParsedColor, RGB, ResolvedThemeConfig, SEMANTIC_MAP, SelectiveShadeScale, SemanticTokens, ShadeStep, TAILWIND_GRAY, ThemeBrandResult, ThemeData, ThemeFontResult, ThemeOutput, type ThemeValidationResult, VISOR_BRANDS_CDN, VISOR_DEFAULT_BRAND_PATH, VISOR_FONTS_CDN, type ValidationIssue, type ValidationSeverity, VisorBrand, VisorThemeConfig, VisorTypography, applyOverrides, assignSemanticTokens, buildVisorBrandUrl, buildVisorFontUrl, clampToSrgb, cleanFontValue, compositeOverBackground, exportTheme, extractFromCSS, formatFontCoverageError, generateDarkCss, generateFullBundleCss, generateLightCss, generatePreloadLinks, generatePrimitives, generatePrimitivesCss, generateSemanticCss, generateShadeScale, generateStylesheetLinks, generateTheme, generateThemeData, generateThemeDataFromConfig, generateThemeFromConfig, getContrastRatio, googleFontsCatalog, hexToOklch, hexToRgb, isValidColor, isValidHex, isVisorThemeConfig, lookupFontWeightAlias, lookupGoogleFont, normalizeHex, oklchToHex, parseCSSDeclarations, parseColor, parseConfig, parseFontFaceDeclarations, parseHex, parseHsla, parseOklch, parseRgba, resolveBrandSlot, resolveBrandSource, resolveConfig, resolveFont, resolveThemeBrand, resolveThemeFonts, rgbToHex, serializeColor, validate, validateConfig, validateFontCoverage, visorTheme_schema as visorThemeSchema };
1301
+ export { type BrandPassthrough, BrandResolution, BrandSlot, BrandSource, type CSSFile, ColorRole, type Confidence, DEFAULT_VISOR_BRAND, type ExtractedToken, type ExtractionResult, FONT_WEIGHT_ALIASES, type FontCoverageError, type FontCoverageResult, FontDisplayStrategy, type FontFaceDeclaration, FontResolution, FontResolveOptions, FullShadeScale, GeneratedPrimitives, GoogleFontEntry, OKLCH, ParsedColor, RGB, ResolvedThemeConfig, SEMANTIC_MAP, SelectiveShadeScale, SemanticTokens, ShadeStep, TAILWIND_GRAY, ThemeBrandResult, ThemeData, ThemeFontResult, ThemeOutput, type ThemeValidationResult, VISOR_BRANDS_CDN, VISOR_DEFAULT_BRAND_PATH, VISOR_FONTS_CDN, type ValidateOptions, type ValidationIssue, type ValidationSeverity, VisorBrand, VisorThemeConfig, VisorTypography, applyOverrides, assignSemanticTokens, buildVisorBrandUrl, buildVisorFontUrl, clampToSrgb, cleanFontValue, collectBrandPassthrough, compositeOverBackground, exportTheme, extractFromCSS, formatFontCoverageError, generateDarkCss, generateFullBundleCss, generateLightCss, generatePreloadLinks, generatePrimitives, generatePrimitivesCss, generateSemanticCss, generateShadeScale, generateStylesheetLinks, generateTheme, generateThemeData, generateThemeDataFromConfig, generateThemeFromConfig, getContrastRatio, googleFontsCatalog, hasBrandPassthrough, hexToOklch, hexToRgb, isValidColor, isValidHex, isVisorThemeConfig, lookupFontWeightAlias, lookupGoogleFont, normalizeHex, oklchToHex, parseCSSDeclarations, parseColor, parseConfig, parseFontFaceDeclarations, parseHex, parseHsla, parseOklch, parseRgba, resolveBrandSlot, resolveBrandSource, resolveConfig, resolveFont, resolveThemeBrand, resolveThemeFonts, rgbToHex, serializeColor, validate, validateConfig, validateFontCoverage, visorTheme_schema as visorThemeSchema };
package/dist/index.js CHANGED
@@ -7,9 +7,11 @@ import {
7
7
  VISOR_BRANDS_CDN,
8
8
  VISOR_DEFAULT_BRAND_PATH,
9
9
  VISOR_FONTS_CDN,
10
+ applyOverrides,
10
11
  buildVisorBrandUrl,
11
12
  buildVisorFontUrl,
12
13
  clampToSrgb,
14
+ collectBrandPassthrough,
13
15
  compositeOverBackground,
14
16
  generateDarkCss,
15
17
  generateFullBundleCss,
@@ -20,7 +22,9 @@ import {
20
22
  generateShadeScale,
21
23
  generateStylesheetLinks,
22
24
  getContrastRatio,
25
+ getLuminance,
23
26
  googleFontsCatalog,
27
+ hasBrandPassthrough,
24
28
  hexToOklch,
25
29
  hexToRgb,
26
30
  isValidColor,
@@ -42,7 +46,7 @@ import {
42
46
  rgbToHex,
43
47
  rgbToOklch,
44
48
  serializeColor
45
- } from "./chunk-KFTTL3XP.js";
49
+ } from "./chunk-DQ256PSE.js";
46
50
 
47
51
  // src/fonts/validate-coverage.ts
48
52
  var FONT_VAR_RE = /--font-(heading|display|body|sans|mono)\s*:\s*([^;]+);/g;
@@ -224,6 +228,15 @@ var visor_theme_schema_default = {
224
228
  type: "string",
225
229
  description: "Theme group for the docs site theme switcher (e.g. 'Visor', 'Client', 'Low Orbit'). Used by `visor theme sync`. Defaults to folder-based grouping when omitted."
226
230
  },
231
+ label: {
232
+ type: "string",
233
+ description: "Human-readable display name for the theme (e.g. 'Blacklight Pro'). Overrides the name-derived label in the docs theme switcher. Optional."
234
+ },
235
+ "default-mode": {
236
+ type: "string",
237
+ enum: ["light", "dark"],
238
+ description: "Default color mode when activating this theme. When set, the docs site forces this mode on theme switch (unless the user has a stored mode preference). Optional."
239
+ },
227
240
  colors: {
228
241
  type: "object",
229
242
  description: "Color definitions for light mode. Only primary is required \u2014 all others have sensible defaults.",
@@ -379,14 +392,27 @@ var visor_theme_schema_default = {
379
392
  type: "number",
380
393
  description: "Type scale multiplier applied to the font-size ramp. Default: 1."
381
394
  },
395
+ "text-on-light": {
396
+ type: "string",
397
+ description: "VI-375: text color placed on a LIGHT interactive background. Auto-picked for interactive *-text tokens whose paired *-bg has relative luminance > 0.5. Defaults to #FFFFFF (which deliberately still fails AA on a bright bg so the contrast validator flags it); set a readable near-black (e.g. #1E1F21) to clear the warning. Per-token overrides (overrides.{mode}.interactive-*-text) still win."
398
+ },
399
+ "text-on-dark": {
400
+ type: "string",
401
+ description: "VI-375: text color placed on a DARK interactive background. Auto-picked for interactive *-text tokens whose paired *-bg has relative luminance <= 0.5. Defaults to #FFFFFF. Per-token overrides still win."
402
+ },
382
403
  "letter-spacing": {
383
404
  type: "object",
384
- description: "Letter spacing scale.",
405
+ description: '6-tier letter-spacing ramp (xl | lg | md | sm | xs | tight). Each value is a CSS length (e.g. "0.1em", "0.44rem", "-0.01em"). The legacy triad keys normal and wide remain valid for back-compat and map onto the ramp (normal->md, wide->lg, tight->tight).',
385
406
  additionalProperties: false,
386
407
  properties: {
408
+ xl: { type: "string" },
409
+ lg: { type: "string" },
410
+ md: { type: "string" },
411
+ sm: { type: "string" },
412
+ xs: { type: "string" },
387
413
  tight: { type: "string" },
388
- normal: { type: "string" },
389
- wide: { type: "string" }
414
+ normal: { type: "string", description: "Deprecated legacy alias \u2014 maps to md." },
415
+ wide: { type: "string", description: "Deprecated legacy alias \u2014 maps to lg." }
390
416
  }
391
417
  },
392
418
  "cdn-overrides": {
@@ -683,12 +709,25 @@ var KNOWN_TYPOGRAPHY_KEYS = /* @__PURE__ */ new Set([
683
709
  "letter-spacing",
684
710
  "scale",
685
711
  "slots",
686
- "cdn-overrides"
712
+ "cdn-overrides",
713
+ // VI-375: default text colors for interactive backgrounds (auto-picked by
714
+ // paired `*-bg` luminance; theme-overridable).
715
+ "text-on-light",
716
+ "text-on-dark"
687
717
  ]);
688
718
  var KNOWN_CDN_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["visor-fonts"]);
689
719
  var KNOWN_TYPOGRAPHY_FONT_KEYS = /* @__PURE__ */ new Set(["family", "weight", "weights", "source", "org"]);
690
720
  var KNOWN_TYPOGRAPHY_MONO_KEYS = /* @__PURE__ */ new Set(["family", "weight", "weights", "source", "org"]);
691
- var KNOWN_LETTER_SPACING_KEYS = /* @__PURE__ */ new Set(["tight", "normal", "wide"]);
721
+ var KNOWN_LETTER_SPACING_KEYS = /* @__PURE__ */ new Set([
722
+ "xl",
723
+ "lg",
724
+ "md",
725
+ "sm",
726
+ "xs",
727
+ "tight",
728
+ "normal",
729
+ "wide"
730
+ ]);
692
731
  var KNOWN_SLOT_NAMES = new Set(MATERIAL_TEXT_SLOTS);
693
732
  var KNOWN_SLOT_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["size", "weight", "letter-spacing"]);
694
733
  var KNOWN_SPACING_KEYS = /* @__PURE__ */ new Set(["base"]);
@@ -768,6 +807,12 @@ function checkUnknownKeys(obj, errors) {
768
807
  }
769
808
  }
770
809
  }
810
+ for (const key of ["text-on-light", "text-on-dark"]) {
811
+ const value = typo[key];
812
+ if (value !== void 0 && (typeof value !== "string" || !isValidColor(value))) {
813
+ errors.push(`'typography.${key}' must be a valid CSS color (hex, rgba, hsla, or oklch)`);
814
+ }
815
+ }
771
816
  if (typeof typo["cdn-overrides"] === "object" && typo["cdn-overrides"] !== null) {
772
817
  for (const key of Object.keys(typo["cdn-overrides"])) {
773
818
  if (!KNOWN_CDN_OVERRIDE_KEYS.has(key)) {
@@ -1091,9 +1136,29 @@ var DEFAULTS = {
1091
1136
  },
1092
1137
  typography: {
1093
1138
  scale: 1,
1139
+ // VI-375: default text colors for interactive backgrounds. Auto-picked for
1140
+ // `*-text` tokens by the paired `*-bg` luminance. The defaults preserve the
1141
+ // historical white-on-button behavior (both default to white), so a theme
1142
+ // that only overrides a bright button bg STILL fails AA and raises a
1143
+ // WCAG_CONTRAST warning — the operator must opt into a readable on-light
1144
+ // color (e.g. ENTR's `text-on-light: "#1E1F21"`) to clear it. This keeps the
1145
+ // fix loud rather than silently flipping every theme's button text.
1146
+ "text-on-light": "#FFFFFF",
1147
+ "text-on-dark": "#FFFFFF",
1094
1148
  heading: { family: DEFAULT_FONT_SANS, weight: 600 },
1095
1149
  body: { family: DEFAULT_FONT_SANS, weight: 400 },
1096
- mono: { family: DEFAULT_FONT_MONO }
1150
+ mono: { family: DEFAULT_FONT_MONO },
1151
+ // VI-447: default 6-tier letter-spacing ramp. em-based so it scales with
1152
+ // type size; `md` is anchored at the historical `--letter-spacing-normal`
1153
+ // value (0.05em) so existing themes emit byte-identical output.
1154
+ "letter-spacing": {
1155
+ xl: "0.16em",
1156
+ lg: "0.1em",
1157
+ md: "0.05em",
1158
+ sm: "0.025em",
1159
+ xs: "0.01em",
1160
+ tight: "-0.01em"
1161
+ }
1097
1162
  },
1098
1163
  spacing: { base: 4 },
1099
1164
  radius: { sm: 2, md: 4, lg: 8, xl: 12, pill: 9999 },
@@ -1134,6 +1199,17 @@ function resolveBrand(brand) {
1134
1199
  ...brand.custom && { custom: brand.custom }
1135
1200
  };
1136
1201
  }
1202
+ function resolveLetterSpacing(ls) {
1203
+ const d = DEFAULTS.typography["letter-spacing"];
1204
+ return {
1205
+ xl: ls?.xl ?? d.xl,
1206
+ lg: ls?.lg ?? ls?.wide ?? d.lg,
1207
+ md: ls?.md ?? ls?.normal ?? d.md,
1208
+ sm: ls?.sm ?? d.sm,
1209
+ xs: ls?.xs ?? d.xs,
1210
+ tight: ls?.tight ?? d.tight
1211
+ };
1212
+ }
1137
1213
  function resolveConfig(config) {
1138
1214
  const colors = config.colors;
1139
1215
  const originalColors = {};
@@ -1165,6 +1241,7 @@ function resolveConfig(config) {
1165
1241
  return {
1166
1242
  name: config.name,
1167
1243
  ...config.label !== void 0 && { label: config.label },
1244
+ ...config["default-mode"] !== void 0 && { "default-mode": config["default-mode"] },
1168
1245
  version: 1,
1169
1246
  colors: {
1170
1247
  primary: colors.primary,
@@ -1181,6 +1258,8 @@ function resolveConfig(config) {
1181
1258
  "colors-dark": config["colors-dark"],
1182
1259
  typography: {
1183
1260
  scale: config.typography?.scale ?? DEFAULTS.typography.scale,
1261
+ "text-on-light": config.typography?.["text-on-light"] ?? DEFAULTS.typography["text-on-light"],
1262
+ "text-on-dark": config.typography?.["text-on-dark"] ?? DEFAULTS.typography["text-on-dark"],
1184
1263
  heading: {
1185
1264
  family: config.typography?.heading?.family ?? DEFAULTS.typography.heading.family,
1186
1265
  weight: config.typography?.heading?.weight ?? DEFAULTS.typography.heading.weight,
@@ -1212,7 +1291,8 @@ function resolveConfig(config) {
1212
1291
  ...config.typography?.["cdn-overrides"] && {
1213
1292
  "cdn-overrides": config.typography["cdn-overrides"]
1214
1293
  },
1215
- slots: config.typography?.slots ?? {}
1294
+ slots: config.typography?.slots ?? {},
1295
+ "letter-spacing": resolveLetterSpacing(config.typography?.["letter-spacing"])
1216
1296
  },
1217
1297
  brand: resolveBrand(config.brand),
1218
1298
  spacing: {
@@ -1261,6 +1341,10 @@ var CONFIG_BACKGROUND = "__CONFIG_BACKGROUND__";
1261
1341
  var CONFIG_SURFACE = "__CONFIG_SURFACE__";
1262
1342
  var CONFIG_DARK_BACKGROUND = "__CONFIG_DARK_BACKGROUND__";
1263
1343
  var CONFIG_DARK_SURFACE = "__CONFIG_DARK_SURFACE__";
1344
+ var DERIVE_ON_BG_PREFIX = "__DERIVE_ON_BG__";
1345
+ function deriveOnBg(pairedBgToken) {
1346
+ return `${DERIVE_ON_BG_PREFIX}${pairedBgToken}`;
1347
+ }
1264
1348
  var SEMANTIC_TEXT_MAP = {
1265
1349
  primary: {
1266
1350
  light: { role: "neutral", shade: 900 },
@@ -1516,9 +1600,13 @@ var SEMANTIC_INTERACTIVE_MAP = {
1516
1600
  light: { role: "primary", shade: 800 },
1517
1601
  dark: { role: "primary", shade: 300 }
1518
1602
  },
1603
+ // VI-375: was a hardcoded #ffffff constant in both modes — a blind spot that
1604
+ // let a bright brand bg (ENTR dark mint) render white text at ~1.6:1. Now
1605
+ // derives from the paired `primary-bg` luminance: light bg → text-on-light,
1606
+ // dark bg → text-on-dark. Per-token override still wins.
1519
1607
  "primary-text": {
1520
- light: { constant: "#ffffff" },
1521
- dark: { constant: "#ffffff" }
1608
+ light: { constant: deriveOnBg("primary-bg") },
1609
+ dark: { constant: deriveOnBg("primary-bg") }
1522
1610
  },
1523
1611
  // VI-478: brand-derived alpha-overlay helpers (BL-193). `soft`/`glow` are
1524
1612
  // alpha overlays that track the theme's primary via color-mix (distinct from
@@ -1567,9 +1655,11 @@ var SEMANTIC_INTERACTIVE_MAP = {
1567
1655
  light: { role: "error", shade: 700 },
1568
1656
  dark: { role: "error", shade: 600 }
1569
1657
  },
1658
+ // VI-375: derive from the paired `destructive-bg` luminance (was hardcoded
1659
+ // #ffffff). Per-token override still wins.
1570
1660
  "destructive-text": {
1571
- light: { constant: "#ffffff" },
1572
- dark: { constant: "#ffffff" }
1661
+ light: { constant: deriveOnBg("destructive-bg") },
1662
+ dark: { constant: deriveOnBg("destructive-bg") }
1573
1663
  },
1574
1664
  // Ghost action
1575
1665
  "ghost-bg": {
@@ -1586,11 +1676,14 @@ var SEMANTIC_INTENT_MAP = {
1586
1676
  light: { role: "primary", shade: 500 },
1587
1677
  dark: { role: "primary", shade: 500 }
1588
1678
  },
1589
- // Text color paired with --primary backgrounds. Default white; themes whose
1590
- // primary fails AA on white pin to a graphite via overrides (entr does this).
1679
+ // Single-source alias of --interactive-primary-text. Default white (same value
1680
+ // as the interactive group); themes that need a different value (e.g. entr)
1681
+ // override via overrides.{light,dark}["primary-text"] which replaces this alias
1682
+ // with the explicit override value. Hand-authored static CSS (blackout-theme.css,
1683
+ // neutral-theme.css) should consume --primary-text via this alias path.
1591
1684
  "primary-text": {
1592
- light: { constant: "#ffffff" },
1593
- dark: { constant: "#ffffff" }
1685
+ light: { constant: "var(--interactive-primary-text)" },
1686
+ dark: { constant: "var(--interactive-primary-text)" }
1594
1687
  },
1595
1688
  accent: {
1596
1689
  light: { role: "accent", shade: 500 },
@@ -1624,6 +1717,16 @@ var SEMANTIC_HAIRLINE_MAP = {
1624
1717
  dark: { constant: "rgba(255, 255, 255, 0.10)" }
1625
1718
  }
1626
1719
  };
1720
+ var INTERACTIVE_TEXT_BG_PAIRS = (() => {
1721
+ const pairs = {};
1722
+ for (const [name, mapping] of Object.entries(SEMANTIC_INTERACTIVE_MAP)) {
1723
+ const light = mapping.light;
1724
+ if ("constant" in light && light.constant.startsWith(DERIVE_ON_BG_PREFIX)) {
1725
+ pairs[name] = light.constant.slice(DERIVE_ON_BG_PREFIX.length);
1726
+ }
1727
+ }
1728
+ return pairs;
1729
+ })();
1627
1730
  var SEMANTIC_MAP = {
1628
1731
  text: SEMANTIC_TEXT_MAP,
1629
1732
  surface: SEMANTIC_SURFACE_MAP,
@@ -1661,6 +1764,66 @@ function resolveRef(ref, primitives, config) {
1661
1764
  return ref.constant;
1662
1765
  }
1663
1766
  }
1767
+ function pickTextForBg(bgValue, config) {
1768
+ const parsed = parseColor(bgValue);
1769
+ const luminance = parsed ? getLuminance(...parsed.rgb) : 0;
1770
+ return luminance > 0.5 ? config.typography["text-on-light"] : config.typography["text-on-dark"];
1771
+ }
1772
+ function deriveInteractiveTextColors(interactive, config, opts = {}) {
1773
+ const skip = opts.skipTokens ?? /* @__PURE__ */ new Set();
1774
+ const modes = opts.modes ?? ["light", "dark"];
1775
+ for (const [textToken, bgToken] of Object.entries(INTERACTIVE_TEXT_BG_PAIRS)) {
1776
+ if (skip.has(textToken)) continue;
1777
+ const textValue = interactive[textToken];
1778
+ const bgValue = interactive[bgToken];
1779
+ if (!textValue || !bgValue) continue;
1780
+ const next = { ...textValue };
1781
+ for (const mode of modes) {
1782
+ next[mode] = pickTextForBg(bgValue[mode], config);
1783
+ }
1784
+ interactive[textToken] = next;
1785
+ }
1786
+ }
1787
+ function reapplyInteractiveTextDerivation(tokens, config, overrides) {
1788
+ if (!overrides) return tokens;
1789
+ const skip = /* @__PURE__ */ new Set();
1790
+ const bgOverriddenModes = /* @__PURE__ */ new Map();
1791
+ for (const mode of ["light", "dark"]) {
1792
+ const modeOverrides = overrides[mode];
1793
+ if (!modeOverrides) continue;
1794
+ for (const key of Object.keys(modeOverrides)) {
1795
+ if (!key.startsWith("interactive-")) continue;
1796
+ const tokenName = key.slice("interactive-".length);
1797
+ if (tokenName in INTERACTIVE_TEXT_BG_PAIRS) {
1798
+ skip.add(tokenName);
1799
+ }
1800
+ for (const [textToken, bgToken] of Object.entries(INTERACTIVE_TEXT_BG_PAIRS)) {
1801
+ if (tokenName === bgToken) {
1802
+ if (!bgOverriddenModes.has(textToken)) {
1803
+ bgOverriddenModes.set(textToken, /* @__PURE__ */ new Set());
1804
+ }
1805
+ bgOverriddenModes.get(textToken).add(mode);
1806
+ }
1807
+ }
1808
+ }
1809
+ }
1810
+ if (bgOverriddenModes.size === 0) return tokens;
1811
+ const interactive = {};
1812
+ for (const [name, value] of Object.entries(tokens.interactive)) {
1813
+ interactive[name] = { ...value };
1814
+ }
1815
+ for (const [textToken, modes] of bgOverriddenModes) {
1816
+ if (skip.has(textToken)) continue;
1817
+ deriveInteractiveTextColors(interactive, config, {
1818
+ // Re-derive only this token by skipping the others.
1819
+ skipTokens: new Set(
1820
+ Object.keys(INTERACTIVE_TEXT_BG_PAIRS).filter((t) => t !== textToken)
1821
+ ),
1822
+ modes: [...modes]
1823
+ });
1824
+ }
1825
+ return { ...tokens, interactive };
1826
+ }
1664
1827
  function resolveMapping(mapping, lightPrimitives, darkPrimitives, config) {
1665
1828
  return {
1666
1829
  light: resolveRef(mapping.light, lightPrimitives, config),
@@ -1686,6 +1849,7 @@ function assignSemanticTokens(lightPrimitives, darkPrimitives, config) {
1686
1849
  for (const [name, mapping] of Object.entries(SEMANTIC_MAP.interactive)) {
1687
1850
  interactive[name] = resolveMapping(mapping, lightPrimitives, darkPrimitives, config);
1688
1851
  }
1852
+ deriveInteractiveTextColors(interactive, config);
1689
1853
  for (const [name, mapping] of Object.entries(SEMANTIC_MAP.intent)) {
1690
1854
  intent[name] = resolveMapping(mapping, lightPrimitives, darkPrimitives, config);
1691
1855
  }
@@ -1695,71 +1859,6 @@ function assignSemanticTokens(lightPrimitives, darkPrimitives, config) {
1695
1859
  return { text, surface, border, interactive, intent, hairline };
1696
1860
  }
1697
1861
 
1698
- // src/overrides.ts
1699
- var TOKEN_CATEGORIES = [
1700
- { prefix: "text-", key: "text" },
1701
- { prefix: "surface-", key: "surface" },
1702
- { prefix: "border-", key: "border" },
1703
- { prefix: "interactive-", key: "interactive" },
1704
- { prefix: "hairline-", key: "hairline" }
1705
- ];
1706
- function findToken(key, tokens) {
1707
- if (key === "hairline" && "default" in tokens.hairline) {
1708
- return { group: tokens.hairline, name: "default" };
1709
- }
1710
- for (const { prefix, key: groupKey } of TOKEN_CATEGORIES) {
1711
- if (key.startsWith(prefix)) {
1712
- const name = key.slice(prefix.length);
1713
- if (name in tokens[groupKey]) {
1714
- return { group: tokens[groupKey], name };
1715
- }
1716
- }
1717
- }
1718
- if (key in tokens.intent) {
1719
- return { group: tokens.intent, name: key };
1720
- }
1721
- return null;
1722
- }
1723
- function applyOverrides(tokens, overrides) {
1724
- if (!overrides) return tokens;
1725
- const result = {
1726
- text: { ...tokens.text },
1727
- surface: { ...tokens.surface },
1728
- border: { ...tokens.border },
1729
- interactive: { ...tokens.interactive },
1730
- intent: { ...tokens.intent },
1731
- hairline: { ...tokens.hairline }
1732
- };
1733
- for (const group of ["text", "surface", "border", "interactive", "intent", "hairline"]) {
1734
- for (const [name, value] of Object.entries(result[group])) {
1735
- result[group][name] = { ...value };
1736
- }
1737
- }
1738
- if (overrides.light) {
1739
- for (const [key, value] of Object.entries(overrides.light)) {
1740
- const match = findToken(key, result);
1741
- if (match) {
1742
- match.group[match.name] = {
1743
- ...match.group[match.name],
1744
- light: value
1745
- };
1746
- }
1747
- }
1748
- }
1749
- if (overrides.dark) {
1750
- for (const [key, value] of Object.entries(overrides.dark)) {
1751
- const match = findToken(key, result);
1752
- if (match) {
1753
- match.group[match.name] = {
1754
- ...match.group[match.name],
1755
- dark: value
1756
- };
1757
- }
1758
- }
1759
- }
1760
- return result;
1761
- }
1762
-
1763
1862
  // src/pipeline.ts
1764
1863
  function generatePrimitives(config) {
1765
1864
  return {
@@ -1819,6 +1918,7 @@ ${validation.errors.map((e) => ` - ${e}`).join("\n")}`
1819
1918
  const darkPrimitives = generateDarkPrimitives(resolved, primitives);
1820
1919
  let tokens = assignSemanticTokens(primitives, darkPrimitives, resolved);
1821
1920
  tokens = applyOverrides(tokens, resolved.overrides);
1921
+ tokens = reapplyInteractiveTextDerivation(tokens, resolved, resolved.overrides);
1822
1922
  const output = {
1823
1923
  primitivesCss: generatePrimitivesCss(primitives, resolved),
1824
1924
  semanticCss: generateSemanticCss(tokens),
@@ -2242,7 +2342,7 @@ function checkTypeScaleCoherence(config, issues) {
2242
2342
  function checkLetterSpacing(config, issues) {
2243
2343
  const ls = config.typography?.["letter-spacing"];
2244
2344
  if (!ls) return;
2245
- for (const key of ["tight", "normal", "wide"]) {
2345
+ for (const key of ["xl", "lg", "md", "sm", "xs", "tight", "normal", "wide"]) {
2246
2346
  const value = ls[key];
2247
2347
  if (value !== void 0) {
2248
2348
  if (typeof value !== "string" || !CSS_LENGTH_RE.test(value.trim())) {
@@ -2361,7 +2461,7 @@ function checkOverrides(config, issues) {
2361
2461
  issue(
2362
2462
  "warning",
2363
2463
  "UNKNOWN_OVERRIDE_KEY",
2364
- `'overrides.${mode}.${key}' does not match any known semantic token. Valid tokens include: text-primary, surface-page, border-default, interactive-primary-bg, etc.`,
2464
+ `'overrides.${mode}.${key}' does not match any known semantic token; it will be emitted as a bare '--${key}' custom property in @layer visor-brand (brand pass-through). If you meant to override a semantic token, valid tokens include: text-primary, surface-page, border-default, interactive-primary-bg, etc.`,
2365
2465
  `overrides.${mode}.${key}`
2366
2466
  )
2367
2467
  );
@@ -2455,12 +2555,27 @@ function colorToRgb(color) {
2455
2555
  }
2456
2556
  var STANDARD_TEXT_TOKENS = ["primary", "secondary", "tertiary"];
2457
2557
  var STATUS_TEXT_TOKENS = ["error", "warning", "success", "info"];
2558
+ var INTERACTIVE_PAIRS_TO_VALIDATE = (() => {
2559
+ const pairs = {};
2560
+ for (const name of Object.keys(SEMANTIC_INTERACTIVE_MAP)) {
2561
+ if (!name.endsWith("-text")) continue;
2562
+ const bgToken = `${name.slice(0, -"-text".length)}-bg`;
2563
+ if (bgToken in SEMANTIC_INTERACTIVE_MAP) {
2564
+ pairs[name] = bgToken;
2565
+ }
2566
+ }
2567
+ return pairs;
2568
+ })();
2458
2569
  function checkContrastWarnings(config, issues) {
2459
2570
  const resolved = resolveConfig(config);
2460
2571
  const lightPrimitives = generatePrimitives(resolved);
2461
2572
  const darkPrimitives = generateDarkPrimitives(resolved, lightPrimitives);
2462
- const tokens = applyOverrides(
2463
- assignSemanticTokens(lightPrimitives, darkPrimitives, resolved),
2573
+ const tokens = reapplyInteractiveTextDerivation(
2574
+ applyOverrides(
2575
+ assignSemanticTokens(lightPrimitives, darkPrimitives, resolved),
2576
+ resolved.overrides
2577
+ ),
2578
+ resolved,
2464
2579
  resolved.overrides
2465
2580
  );
2466
2581
  const lightBg = resolved.colors.background;
@@ -2523,7 +2638,10 @@ function checkContrastWarnings(config, issues) {
2523
2638
  );
2524
2639
  }
2525
2640
  }
2526
- const primaryOnBg = getContrastRatio(primary, lightBg, lightBgRgb);
2641
+ const primaryBgToken = tokens.interactive["primary-bg"];
2642
+ const lightPrimaryBg = primaryBgToken?.light ?? primary;
2643
+ const darkPrimaryBg = primaryBgToken?.dark ?? darkPrimary;
2644
+ const primaryOnBg = getContrastRatio(lightPrimaryBg, lightBg, lightBgRgb);
2527
2645
  if (primaryOnBg < CONTRAST_INTERACTIVE_AA) {
2528
2646
  issues.push(
2529
2647
  issue(
@@ -2534,7 +2652,7 @@ function checkContrastWarnings(config, issues) {
2534
2652
  )
2535
2653
  );
2536
2654
  }
2537
- const primaryOnSurface = getContrastRatio(primary, lightSurface, lightSurfaceRgb);
2655
+ const primaryOnSurface = getContrastRatio(lightPrimaryBg, lightSurface, lightSurfaceRgb);
2538
2656
  if (primaryOnSurface < CONTRAST_INTERACTIVE_AA) {
2539
2657
  issues.push(
2540
2658
  issue(
@@ -2545,7 +2663,7 @@ function checkContrastWarnings(config, issues) {
2545
2663
  )
2546
2664
  );
2547
2665
  }
2548
- const darkPrimaryOnBg = getContrastRatio(darkPrimary, darkBg, darkBgRgb);
2666
+ const darkPrimaryOnBg = getContrastRatio(darkPrimaryBg, darkBg, darkBgRgb);
2549
2667
  if (darkPrimaryOnBg < CONTRAST_INTERACTIVE_AA) {
2550
2668
  issues.push(
2551
2669
  issue(
@@ -2556,7 +2674,7 @@ function checkContrastWarnings(config, issues) {
2556
2674
  )
2557
2675
  );
2558
2676
  }
2559
- const darkPrimaryOnSurface = getContrastRatio(darkPrimary, darkSurface, darkSurfaceRgb);
2677
+ const darkPrimaryOnSurface = getContrastRatio(darkPrimaryBg, darkSurface, darkSurfaceRgb);
2560
2678
  if (darkPrimaryOnSurface < CONTRAST_INTERACTIVE_AA) {
2561
2679
  issues.push(
2562
2680
  issue(
@@ -2567,6 +2685,33 @@ function checkContrastWarnings(config, issues) {
2567
2685
  )
2568
2686
  );
2569
2687
  }
2688
+ for (const [textToken, bgToken] of Object.entries(INTERACTIVE_PAIRS_TO_VALIDATE)) {
2689
+ const text = tokens.interactive[textToken];
2690
+ const bg = tokens.interactive[bgToken];
2691
+ if (!text || !bg) continue;
2692
+ const lightRatio = getContrastRatio(text.light, bg.light, colorToRgb(bg.light));
2693
+ if (lightRatio < CONTRAST_TEXT_AA) {
2694
+ issues.push(
2695
+ issue(
2696
+ "warning",
2697
+ "WCAG_CONTRAST",
2698
+ `Light mode: interactive-${textToken} on interactive-${bgToken} has contrast ratio ${lightRatio.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
2699
+ `interactive.${textToken}`
2700
+ )
2701
+ );
2702
+ }
2703
+ const darkRatio = getContrastRatio(text.dark, bg.dark, colorToRgb(bg.dark));
2704
+ if (darkRatio < CONTRAST_TEXT_AA) {
2705
+ issues.push(
2706
+ issue(
2707
+ "warning",
2708
+ "WCAG_CONTRAST",
2709
+ `Dark mode: interactive-${textToken} on interactive-${bgToken} has contrast ratio ${darkRatio.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
2710
+ `interactive.${textToken}`
2711
+ )
2712
+ );
2713
+ }
2714
+ }
2570
2715
  }
2571
2716
  function checkColorSimilarity(config, issues) {
2572
2717
  const resolved = resolveConfig(config);
@@ -2642,14 +2787,15 @@ function checkRadiusScale(config, issues) {
2642
2787
  );
2643
2788
  }
2644
2789
  }
2645
- function checkDarkLightParity(config, issues) {
2790
+ function checkDarkLightParity(config, issues, opts) {
2646
2791
  if (!config.colors) return;
2647
2792
  const colorKeys = Object.keys(config.colors).filter((k) => k !== "primary");
2648
2793
  const hasDarkSection = config["colors-dark"] !== void 0;
2649
2794
  if (colorKeys.length > 0 && !hasDarkSection) {
2795
+ const severity = opts.strictDark ? "error" : "warning";
2650
2796
  issues.push(
2651
2797
  issue(
2652
- "warning",
2798
+ severity,
2653
2799
  "DARK_LIGHT_PARITY",
2654
2800
  "Custom colors are set but no colors-dark section exists. Dark mode will use generated defaults which may not match your brand.",
2655
2801
  "colors-dark"
@@ -2663,9 +2809,10 @@ function checkDarkLightParity(config, issues) {
2663
2809
  for (const key of lightKeys) {
2664
2810
  if (key === "primary") continue;
2665
2811
  if (!darkKeys.has(key)) {
2812
+ const severity = opts.strictDark ? "error" : "warning";
2666
2813
  issues.push(
2667
2814
  issue(
2668
- "warning",
2815
+ severity,
2669
2816
  "DARK_LIGHT_PARITY",
2670
2817
  `Color "${key}" is set in colors but missing from colors-dark. Dark mode will use a generated default.`,
2671
2818
  "colors-dark"
@@ -2687,7 +2834,8 @@ function checkDarkLightParity(config, issues) {
2687
2834
  }
2688
2835
  }
2689
2836
  }
2690
- function validate(config) {
2837
+ function validate(config, options) {
2838
+ const opts = options || {};
2691
2839
  const errors = [];
2692
2840
  const warnings = [];
2693
2841
  const structurallyValid = checkStructuralIntegrity(config, errors);
@@ -2719,7 +2867,11 @@ function validate(config) {
2719
2867
  checkColorSimilarity(typedConfig, warnings);
2720
2868
  checkMissingGlowShadow(typedConfig, warnings);
2721
2869
  checkRadiusScale(typedConfig, warnings);
2722
- checkDarkLightParity(typedConfig, warnings);
2870
+ const parityIssues = [];
2871
+ checkDarkLightParity(typedConfig, parityIssues, opts);
2872
+ for (const iss of parityIssues) {
2873
+ (iss.severity === "error" ? errors : warnings).push(iss);
2874
+ }
2723
2875
  }
2724
2876
  return {
2725
2877
  valid: errors.length === 0,
@@ -3229,6 +3381,7 @@ export {
3229
3381
  buildVisorFontUrl,
3230
3382
  clampToSrgb,
3231
3383
  cleanFontValue,
3384
+ collectBrandPassthrough,
3232
3385
  compositeOverBackground,
3233
3386
  exportTheme,
3234
3387
  extractFromCSS,
@@ -3248,6 +3401,7 @@ export {
3248
3401
  generateThemeFromConfig,
3249
3402
  getContrastRatio,
3250
3403
  googleFontsCatalog,
3404
+ hasBrandPassthrough,
3251
3405
  hexToOklch,
3252
3406
  hexToRgb,
3253
3407
  isValidColor,
@@ -294,6 +294,22 @@ interface VisorThemeConfig {
294
294
  };
295
295
  typography?: {
296
296
  scale?: number;
297
+ /**
298
+ * VI-375: text color placed on a LIGHT interactive background. Auto-picked
299
+ * for interactive `*-text` tokens whose paired `*-bg` resolves to a light
300
+ * color (relative luminance > 0.5). Defaults to `#FFFFFF` (preserving the
301
+ * historical white-on-button default, which deliberately still fails AA on
302
+ * a bright bg so the contrast validator flags it). Set a readable near-black
303
+ * (e.g. ENTR's `#1E1F21`) to clear the warning.
304
+ */
305
+ "text-on-light"?: string;
306
+ /**
307
+ * VI-375: text color placed on a DARK interactive background. Auto-picked
308
+ * for interactive `*-text` tokens whose paired `*-bg` resolves to a dark
309
+ * color (relative luminance <= 0.5). Theme-overridable. Defaults to
310
+ * `#FFFFFF`.
311
+ */
312
+ "text-on-dark"?: string;
297
313
  heading?: {
298
314
  family?: string;
299
315
  weight?: number;
@@ -322,9 +338,22 @@ interface VisorThemeConfig {
322
338
  source?: FontSource;
323
339
  org?: string;
324
340
  };
341
+ /**
342
+ * VI-447: 6-tier letter-spacing ramp (`xl | lg | md | sm | xs | tight`).
343
+ * Each value is a CSS length (e.g. `"0.1em"`, `"0.44rem"`, `"-0.01em"`).
344
+ * The legacy triad keys (`normal`, `wide`) stay valid for back-compat and
345
+ * map onto the ramp at resolve time (`normal`→md, `wide`→lg, `tight`→tight).
346
+ */
325
347
  "letter-spacing"?: {
348
+ xl?: string;
349
+ lg?: string;
350
+ md?: string;
351
+ sm?: string;
352
+ xs?: string;
326
353
  tight?: string;
354
+ /** @deprecated legacy alias — maps to `md`. */
327
355
  normal?: string;
356
+ /** @deprecated legacy alias — maps to `lg`. */
328
357
  wide?: string;
329
358
  };
330
359
  /**
@@ -412,6 +441,8 @@ interface ResolvedThemeConfig {
412
441
  name: string;
413
442
  /** Optional display label override forwarded from VisorThemeConfig.label. */
414
443
  label?: string;
444
+ /** Default color mode forwarded from VisorThemeConfig["default-mode"]. */
445
+ "default-mode"?: "dark" | "light";
415
446
  version: 1;
416
447
  colors: {
417
448
  primary: string;
@@ -427,6 +458,16 @@ interface ResolvedThemeConfig {
427
458
  "colors-dark"?: VisorThemeConfig["colors-dark"];
428
459
  typography: {
429
460
  scale: number;
461
+ /**
462
+ * VI-375: resolved default text color for LIGHT interactive backgrounds.
463
+ * Always present (defaults to `#1E1F21`); theme-overridable.
464
+ */
465
+ "text-on-light": string;
466
+ /**
467
+ * VI-375: resolved default text color for DARK interactive backgrounds.
468
+ * Always present (defaults to `#FFFFFF`); theme-overridable.
469
+ */
470
+ "text-on-dark": string;
430
471
  heading: {
431
472
  family: string;
432
473
  weight: number;
@@ -469,6 +510,19 @@ interface ResolvedThemeConfig {
469
510
  * consumes these; other adapters may ignore them.
470
511
  */
471
512
  slots: Partial<Record<MaterialTextSlot, TextSlotOverride>>;
513
+ /**
514
+ * VI-447: resolved 6-tier letter-spacing ramp. Always present — legacy
515
+ * triad input is folded onto the ramp and missing tiers fall back to the
516
+ * Visor default ramp, so every tier carries a CSS length.
517
+ */
518
+ "letter-spacing": {
519
+ xl: string;
520
+ lg: string;
521
+ md: string;
522
+ sm: string;
523
+ xs: string;
524
+ tight: string;
525
+ };
472
526
  };
473
527
  /**
474
528
  * Resolved brand block (VI-470). Always present — falls back to the Visor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loworbitstudio/visor-theme-engine",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Theme engine for the Visor design system — shade generation, token mapping, font resolution, and import/export for .visor.yaml themes.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,6 +20,15 @@
20
20
  "type": "string",
21
21
  "description": "Theme group for the docs site theme switcher (e.g. 'Visor', 'Client', 'Low Orbit'). Used by `visor theme sync`. Defaults to folder-based grouping when omitted."
22
22
  },
23
+ "label": {
24
+ "type": "string",
25
+ "description": "Human-readable display name for the theme (e.g. 'Blacklight Pro'). Overrides the name-derived label in the docs theme switcher. Optional."
26
+ },
27
+ "default-mode": {
28
+ "type": "string",
29
+ "enum": ["light", "dark"],
30
+ "description": "Default color mode when activating this theme. When set, the docs site forces this mode on theme switch (unless the user has a stored mode preference). Optional."
31
+ },
23
32
  "colors": {
24
33
  "type": "object",
25
34
  "description": "Color definitions for light mode. Only primary is required — all others have sensible defaults.",
@@ -175,14 +184,27 @@
175
184
  "type": "number",
176
185
  "description": "Type scale multiplier applied to the font-size ramp. Default: 1."
177
186
  },
187
+ "text-on-light": {
188
+ "type": "string",
189
+ "description": "VI-375: text color placed on a LIGHT interactive background. Auto-picked for interactive *-text tokens whose paired *-bg has relative luminance > 0.5. Defaults to #FFFFFF (which deliberately still fails AA on a bright bg so the contrast validator flags it); set a readable near-black (e.g. #1E1F21) to clear the warning. Per-token overrides (overrides.{mode}.interactive-*-text) still win."
190
+ },
191
+ "text-on-dark": {
192
+ "type": "string",
193
+ "description": "VI-375: text color placed on a DARK interactive background. Auto-picked for interactive *-text tokens whose paired *-bg has relative luminance <= 0.5. Defaults to #FFFFFF. Per-token overrides still win."
194
+ },
178
195
  "letter-spacing": {
179
196
  "type": "object",
180
- "description": "Letter spacing scale.",
197
+ "description": "6-tier letter-spacing ramp (xl | lg | md | sm | xs | tight). Each value is a CSS length (e.g. \"0.1em\", \"0.44rem\", \"-0.01em\"). The legacy triad keys normal and wide remain valid for back-compat and map onto the ramp (normal->md, wide->lg, tight->tight).",
181
198
  "additionalProperties": false,
182
199
  "properties": {
200
+ "xl": { "type": "string" },
201
+ "lg": { "type": "string" },
202
+ "md": { "type": "string" },
203
+ "sm": { "type": "string" },
204
+ "xs": { "type": "string" },
183
205
  "tight": { "type": "string" },
184
- "normal": { "type": "string" },
185
- "wide": { "type": "string" }
206
+ "normal": { "type": "string", "description": "Deprecated legacy alias — maps to md." },
207
+ "wide": { "type": "string", "description": "Deprecated legacy alias — maps to lg." }
186
208
  }
187
209
  },
188
210
  "cdn-overrides": {