@loworbitstudio/visor-theme-engine 0.14.0 → 0.15.1

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-BDRXkldG.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,6 +3,7 @@ import {
3
3
  MATERIAL_TEXT_SLOTS,
4
4
  SELECTIVE_SHADE_STEPS,
5
5
  aliasFamily,
6
+ block,
6
7
  buildVisorFontUrl,
7
8
  collectBrandPassthrough,
8
9
  fontStack,
@@ -19,7 +20,7 @@ import {
19
20
  resolveThemeBrand,
20
21
  resolveThemeFonts,
21
22
  sectionComment
22
- } from "../chunk-YDRQQIOB.js";
23
+ } from "../chunk-DQ256PSE.js";
23
24
 
24
25
  // src/adapters/brand-passthrough.ts
25
26
  var SENTINEL_COLOR = "#ff00ff";
@@ -160,6 +161,51 @@ function nextjsAdapter(input, options) {
160
161
  lines.push(wrapInLayer("visor-brand", passthroughCss));
161
162
  lines.push("");
162
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
+ }
163
209
  const lightBody = stripHeader(generateLightCss(input.tokens, { scopePrefix }));
164
210
  const darkBody = stripHeader(generateDarkCss(input.tokens, { scopePrefix }));
165
211
  lines.push(
@@ -415,7 +461,15 @@ function generateTypographyDecls(config, aliases) {
415
461
  for (const [name, value] of Object.entries(lineHeights)) {
416
462
  decls.push(`--line-height-${name}: ${value};`);
417
463
  }
418
- 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};`);
419
473
  return decls;
420
474
  }
421
475
  function generateShadowDecls(config) {
@@ -435,6 +489,7 @@ function generateMotionDecls(config) {
435
489
  "--motion-duration-300: 300ms;",
436
490
  `--motion-duration-500: ${config.motion["duration-slow"]};`,
437
491
  "--motion-duration-800: 800ms;",
492
+ "--motion-duration-1500: 1500ms;",
438
493
  "--motion-easing-linear: linear;",
439
494
  "--motion-easing-ease-in: cubic-bezier(0.4, 0, 1, 1);",
440
495
  "--motion-easing-ease-out: cubic-bezier(0, 0, 0.2, 1);",
@@ -489,7 +544,7 @@ function generateFumadocsBridgeDecls(tokens, mode) {
489
544
  }
490
545
  return decls;
491
546
  }
492
- function block(selector, decls) {
547
+ function block2(selector, decls) {
493
548
  return [`${selector} {`, ...decls.map((d) => ` ${d}`), "}"].join("\n");
494
549
  }
495
550
  function sectionComment2(label) {
@@ -551,29 +606,29 @@ function docsAdapter(input, options) {
551
606
  "font-family: var(--font-sans);"
552
607
  ];
553
608
  lines.push(sectionComment2("Primitive: Colors"));
554
- lines.push(block(scopeClass, [
609
+ lines.push(block2(scopeClass, [
555
610
  ...sharedDecls,
556
611
  "",
557
612
  ...generateColorDecls(input.primitives)
558
613
  ]));
559
614
  lines.push("");
560
615
  lines.push(sectionComment2("Primitive: Spacing"));
561
- lines.push(block(scopeClass, generateSpacingDecls(input.config)));
616
+ lines.push(block2(scopeClass, generateSpacingDecls(input.config)));
562
617
  lines.push("");
563
618
  lines.push(sectionComment2("Primitive: Border Radius"));
564
- lines.push(block(scopeClass, generateRadiusDecls(input.config)));
619
+ lines.push(block2(scopeClass, generateRadiusDecls(input.config)));
565
620
  lines.push("");
566
621
  lines.push(sectionComment2("Primitive: Typography"));
567
- lines.push(block(scopeClass, generateTypographyDecls(input.config, aliasedFamilies)));
622
+ lines.push(block2(scopeClass, generateTypographyDecls(input.config, aliasedFamilies)));
568
623
  lines.push("");
569
624
  lines.push(sectionComment2("Primitive: Shadows"));
570
- lines.push(block(scopeClass, generateShadowDecls(input.config)));
625
+ lines.push(block2(scopeClass, generateShadowDecls(input.config)));
571
626
  lines.push("");
572
627
  lines.push(sectionComment2("Primitive: Motion"));
573
- lines.push(block(scopeClass, generateMotionDecls(input.config)));
628
+ lines.push(block2(scopeClass, generateMotionDecls(input.config)));
574
629
  lines.push("");
575
630
  lines.push(sectionComment2("Primitive: Miscellaneous"));
576
- lines.push(block(scopeClass, generateMiscDecls()));
631
+ lines.push(block2(scopeClass, generateMiscDecls()));
577
632
  lines.push("");
578
633
  lines.push("\n/* \u2500\u2500 Section 2: Dark mode overrides \u2500\u2500 */");
579
634
  const darkDecls = generateSemanticDecls2(input.tokens, "dark");
@@ -597,7 +652,7 @@ function docsAdapter(input, options) {
597
652
  }
598
653
  if (darkPrimitiveOverrides.length > 0) {
599
654
  lines.push(sectionComment2("Primitive overrides (dark) \u2014 dark brand color anchors at shade 500"));
600
- lines.push(block(`.dark ${scopeClass}`, darkPrimitiveOverrides));
655
+ lines.push(block2(`.dark ${scopeClass}`, darkPrimitiveOverrides));
601
656
  lines.push("");
602
657
  }
603
658
  const categories = ["Text", "Surface", "Border", "Interactive"];
@@ -609,7 +664,7 @@ function docsAdapter(input, options) {
609
664
  ];
610
665
  for (let i = 0; i < categories.length; i++) {
611
666
  lines.push(sectionComment2(`Adaptive: ${categories[i]} (dark) \u2014 manual toggle`));
612
- lines.push(block(`.dark ${scopeClass}`, categoryDecls[i]));
667
+ lines.push(block2(`.dark ${scopeClass}`, categoryDecls[i]));
613
668
  lines.push("");
614
669
  }
615
670
  const pcsCategories = [
@@ -620,7 +675,7 @@ function docsAdapter(input, options) {
620
675
  ];
621
676
  for (const cat of pcsCategories) {
622
677
  lines.push(sectionComment2(`Adaptive: ${cat.label} (dark) \u2014 prefers-color-scheme`));
623
- const inner = block(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, cat.entries);
678
+ const inner = block2(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, cat.entries);
624
679
  lines.push(`@media (prefers-color-scheme: dark) {
625
680
  ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
626
681
  }`);
@@ -628,7 +683,7 @@ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
628
683
  }
629
684
  if (darkPrimitiveOverrides.length > 0) {
630
685
  lines.push(sectionComment2("Primitive overrides (dark) \u2014 prefers-color-scheme"));
631
- const inner = block(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, darkPrimitiveOverrides);
686
+ const inner = block2(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, darkPrimitiveOverrides);
632
687
  lines.push(`@media (prefers-color-scheme: dark) {
633
688
  ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
634
689
  }`);
@@ -643,38 +698,38 @@ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
643
698
  ];
644
699
  for (const cat of lightCategoryDecls) {
645
700
  lines.push(sectionComment2(`Adaptive: ${cat.label} (light)`));
646
- lines.push(block(`html:not(.dark) ${scopeClass}`, cat.entries));
701
+ lines.push(block2(`html:not(.dark) ${scopeClass}`, cat.entries));
647
702
  lines.push("");
648
703
  }
649
704
  lines.push(sectionComment2("Fumadocs bridge: dark"));
650
- lines.push(block(`.dark ${scopeClass}`, generateFumadocsBridgeDecls(input.tokens, "dark")));
705
+ lines.push(block2(`.dark ${scopeClass}`, generateFumadocsBridgeDecls(input.tokens, "dark")));
651
706
  lines.push("");
652
707
  lines.push(sectionComment2("Fumadocs bridge: light"));
653
- lines.push(block(`html:not(.dark) ${scopeClass}`, generateFumadocsBridgeDecls(input.tokens, "light")));
708
+ lines.push(block2(`html:not(.dark) ${scopeClass}`, generateFumadocsBridgeDecls(input.tokens, "light")));
654
709
  lines.push("");
655
710
  const semanticLines = [];
656
711
  semanticLines.push("\n/* \u2500\u2500 Layer: Semantic aliases (VI-451) \u2500\u2500 */");
657
712
  semanticLines.push(sectionComment2("Discrete: Text size aliases (--text-N)"));
658
- semanticLines.push(block(scopeClass, generateTextScaleAliasDecls()));
713
+ semanticLines.push(block2(scopeClass, generateTextScaleAliasDecls()));
659
714
  semanticLines.push("");
660
715
  semanticLines.push(sectionComment2("Discrete: Space aliases (--space-N)"));
661
- semanticLines.push(block(scopeClass, generateSpaceAliasDecls(input.config)));
716
+ semanticLines.push(block2(scopeClass, generateSpaceAliasDecls(input.config)));
662
717
  semanticLines.push("");
663
718
  semanticLines.push(sectionComment2("Intent aliases (light)"));
664
- semanticLines.push(block(`html:not(.dark) ${scopeClass}`, generateIntentDecls(input.tokens, "light")));
719
+ semanticLines.push(block2(`html:not(.dark) ${scopeClass}`, generateIntentDecls(input.tokens, "light")));
665
720
  semanticLines.push("");
666
721
  semanticLines.push(sectionComment2("Hairline aliases (light)"));
667
- semanticLines.push(block(`html:not(.dark) ${scopeClass}`, generateHairlineDecls(input.tokens, "light")));
722
+ semanticLines.push(block2(`html:not(.dark) ${scopeClass}`, generateHairlineDecls(input.tokens, "light")));
668
723
  semanticLines.push("");
669
724
  semanticLines.push(sectionComment2("Intent aliases (dark) \u2014 manual toggle"));
670
- semanticLines.push(block(`.dark ${scopeClass}`, generateIntentDecls(input.tokens, "dark")));
725
+ semanticLines.push(block2(`.dark ${scopeClass}`, generateIntentDecls(input.tokens, "dark")));
671
726
  semanticLines.push("");
672
727
  semanticLines.push(sectionComment2("Hairline aliases (dark) \u2014 manual toggle"));
673
- semanticLines.push(block(`.dark ${scopeClass}`, generateHairlineDecls(input.tokens, "dark")));
728
+ semanticLines.push(block2(`.dark ${scopeClass}`, generateHairlineDecls(input.tokens, "dark")));
674
729
  semanticLines.push("");
675
730
  semanticLines.push(sectionComment2("Intent aliases (dark) \u2014 prefers-color-scheme"));
676
731
  {
677
- const inner = block(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, generateIntentDecls(input.tokens, "dark"));
732
+ const inner = block2(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, generateIntentDecls(input.tokens, "dark"));
678
733
  semanticLines.push(`@media (prefers-color-scheme: dark) {
679
734
  ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
680
735
  }`);
@@ -682,7 +737,7 @@ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
682
737
  semanticLines.push("");
683
738
  semanticLines.push(sectionComment2("Hairline aliases (dark) \u2014 prefers-color-scheme"));
684
739
  {
685
- const inner = block(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, generateHairlineDecls(input.tokens, "dark"));
740
+ const inner = block2(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, generateHairlineDecls(input.tokens, "dark"));
686
741
  semanticLines.push(`@media (prefers-color-scheme: dark) {
687
742
  ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
688
743
  }`);
@@ -1576,6 +1576,7 @@ function generateMotionPrimitives(config) {
1576
1576
  decls.push("--motion-duration-300: 300ms;");
1577
1577
  decls.push(`--motion-duration-500: ${config.motion["duration-slow"]};`);
1578
1578
  decls.push("--motion-duration-800: 800ms;");
1579
+ decls.push("--motion-duration-1500: 1500ms;");
1579
1580
  decls.push("--motion-easing-linear: linear;");
1580
1581
  decls.push("--motion-easing-ease-in: cubic-bezier(0.4, 0, 1, 1);");
1581
1582
  decls.push("--motion-easing-ease-out: cubic-bezier(0, 0, 0.2, 1);");
@@ -1893,6 +1894,7 @@ export {
1893
1894
  hexToOklch,
1894
1895
  clampToSrgb,
1895
1896
  oklchToHex,
1897
+ getLuminance,
1896
1898
  getContrastRatio,
1897
1899
  parseHex,
1898
1900
  parseRgba,
@@ -1914,6 +1916,7 @@ export {
1914
1916
  fontStack,
1915
1917
  header,
1916
1918
  sectionComment,
1919
+ block2 as block,
1917
1920
  generatePrimitivesCss,
1918
1921
  generateSemanticCss,
1919
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-BDRXkldG.js';
2
- export { o as BRAND_VARIANTS, p as BrandVariant, q as ColorFormat, r as FontSource, s as RGBA, t as SemanticTokenValue } from './types-BDRXkldG.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.
@@ -418,7 +418,7 @@ var properties = {
418
418
  properties: {
419
419
  family: {
420
420
  type: "string",
421
- description: "Google Fonts family name or CSS font stack."
421
+ description: "Font family name — Google Fonts name, Visor Fonts name, or CSS font stack."
422
422
  },
423
423
  weight: {
424
424
  type: "integer",
@@ -433,7 +433,7 @@ var properties = {
433
433
  "visor-fonts",
434
434
  "local"
435
435
  ],
436
- description: "Font source. Defaults to Google Fonts lookup, then local."
436
+ description: "Font source. Default: \"google-fonts\". Use \"visor-fonts\" for CDN-hosted fonts, \"local\" for self-hosted fonts."
437
437
  },
438
438
  org: {
439
439
  type: "string",
@@ -448,7 +448,7 @@ var properties = {
448
448
  properties: {
449
449
  family: {
450
450
  type: "string",
451
- description: "Google Fonts family name or CSS font stack."
451
+ description: "Font family name — Google Fonts name, Visor Fonts name, or CSS font stack."
452
452
  },
453
453
  weight: {
454
454
  type: "integer",
@@ -463,7 +463,7 @@ var properties = {
463
463
  "visor-fonts",
464
464
  "local"
465
465
  ],
466
- description: "Font source. Defaults to Google Fonts lookup, then local."
466
+ description: "Font source. Default: \"google-fonts\". Use \"visor-fonts\" for CDN-hosted fonts, \"local\" for self-hosted fonts."
467
467
  },
468
468
  org: {
469
469
  type: "string",
@@ -477,7 +477,7 @@ var properties = {
477
477
  properties: {
478
478
  family: {
479
479
  type: "string",
480
- description: "Google Fonts family name or CSS font stack."
480
+ description: "Font family name — Google Fonts name, Visor Fonts name, or CSS font stack."
481
481
  },
482
482
  weight: {
483
483
  type: "integer",
@@ -492,7 +492,7 @@ var properties = {
492
492
  "visor-fonts",
493
493
  "local"
494
494
  ],
495
- description: "Font source. Defaults to Google Fonts lookup, then local."
495
+ description: "Font source. Default: \"google-fonts\". Use \"visor-fonts\" for CDN-hosted fonts, \"local\" for self-hosted fonts."
496
496
  },
497
497
  org: {
498
498
  type: "string",
@@ -514,19 +514,44 @@ var properties = {
514
514
  type: "number",
515
515
  description: "Type scale multiplier applied to the font-size ramp. Default: 1."
516
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
+ },
517
525
  "letter-spacing": {
518
526
  type: "object",
519
- 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).",
520
528
  additionalProperties: false,
521
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
+ },
522
545
  tight: {
523
546
  type: "string"
524
547
  },
525
548
  normal: {
526
- type: "string"
549
+ type: "string",
550
+ description: "Deprecated legacy alias — maps to md."
527
551
  },
528
552
  wide: {
529
- type: "string"
553
+ type: "string",
554
+ description: "Deprecated legacy alias — maps to lg."
530
555
  }
531
556
  }
532
557
  },
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  generateShadeScale,
23
23
  generateStylesheetLinks,
24
24
  getContrastRatio,
25
+ getLuminance,
25
26
  googleFontsCatalog,
26
27
  hasBrandPassthrough,
27
28
  hexToOklch,
@@ -45,7 +46,7 @@ import {
45
46
  rgbToHex,
46
47
  rgbToOklch,
47
48
  serializeColor
48
- } from "./chunk-YDRQQIOB.js";
49
+ } from "./chunk-DQ256PSE.js";
49
50
 
50
51
  // src/fonts/validate-coverage.ts
51
52
  var FONT_VAR_RE = /--font-(heading|display|body|sans|mono)\s*:\s*([^;]+);/g;
@@ -307,7 +308,7 @@ var visor_theme_schema_default = {
307
308
  properties: {
308
309
  family: {
309
310
  type: "string",
310
- description: "Google Fonts family name or CSS font stack."
311
+ description: "Font family name \u2014 Google Fonts name, Visor Fonts name, or CSS font stack."
311
312
  },
312
313
  weight: {
313
314
  type: "integer",
@@ -318,7 +319,7 @@ var visor_theme_schema_default = {
318
319
  source: {
319
320
  type: "string",
320
321
  enum: ["google-fonts", "visor-fonts", "local"],
321
- description: "Font source. Defaults to Google Fonts lookup, then local."
322
+ description: 'Font source. Default: "google-fonts". Use "visor-fonts" for CDN-hosted fonts, "local" for self-hosted fonts.'
322
323
  },
323
324
  org: {
324
325
  type: "string",
@@ -333,7 +334,7 @@ var visor_theme_schema_default = {
333
334
  properties: {
334
335
  family: {
335
336
  type: "string",
336
- description: "Google Fonts family name or CSS font stack."
337
+ description: "Font family name \u2014 Google Fonts name, Visor Fonts name, or CSS font stack."
337
338
  },
338
339
  weight: {
339
340
  type: "integer",
@@ -344,7 +345,7 @@ var visor_theme_schema_default = {
344
345
  source: {
345
346
  type: "string",
346
347
  enum: ["google-fonts", "visor-fonts", "local"],
347
- description: "Font source. Defaults to Google Fonts lookup, then local."
348
+ description: 'Font source. Default: "google-fonts". Use "visor-fonts" for CDN-hosted fonts, "local" for self-hosted fonts.'
348
349
  },
349
350
  org: {
350
351
  type: "string",
@@ -358,7 +359,7 @@ var visor_theme_schema_default = {
358
359
  properties: {
359
360
  family: {
360
361
  type: "string",
361
- description: "Google Fonts family name or CSS font stack."
362
+ description: "Font family name \u2014 Google Fonts name, Visor Fonts name, or CSS font stack."
362
363
  },
363
364
  weight: {
364
365
  type: "integer",
@@ -369,7 +370,7 @@ var visor_theme_schema_default = {
369
370
  source: {
370
371
  type: "string",
371
372
  enum: ["google-fonts", "visor-fonts", "local"],
372
- description: "Font source. Defaults to Google Fonts lookup, then local."
373
+ description: 'Font source. Default: "google-fonts". Use "visor-fonts" for CDN-hosted fonts, "local" for self-hosted fonts.'
373
374
  },
374
375
  org: {
375
376
  type: "string",
@@ -391,14 +392,27 @@ var visor_theme_schema_default = {
391
392
  type: "number",
392
393
  description: "Type scale multiplier applied to the font-size ramp. Default: 1."
393
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
+ },
394
403
  "letter-spacing": {
395
404
  type: "object",
396
- 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).',
397
406
  additionalProperties: false,
398
407
  properties: {
408
+ xl: { type: "string" },
409
+ lg: { type: "string" },
410
+ md: { type: "string" },
411
+ sm: { type: "string" },
412
+ xs: { type: "string" },
399
413
  tight: { type: "string" },
400
- normal: { type: "string" },
401
- 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." }
402
416
  }
403
417
  },
404
418
  "cdn-overrides": {
@@ -695,12 +709,25 @@ var KNOWN_TYPOGRAPHY_KEYS = /* @__PURE__ */ new Set([
695
709
  "letter-spacing",
696
710
  "scale",
697
711
  "slots",
698
- "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"
699
717
  ]);
700
718
  var KNOWN_CDN_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["visor-fonts"]);
701
719
  var KNOWN_TYPOGRAPHY_FONT_KEYS = /* @__PURE__ */ new Set(["family", "weight", "weights", "source", "org"]);
702
720
  var KNOWN_TYPOGRAPHY_MONO_KEYS = /* @__PURE__ */ new Set(["family", "weight", "weights", "source", "org"]);
703
- 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
+ ]);
704
731
  var KNOWN_SLOT_NAMES = new Set(MATERIAL_TEXT_SLOTS);
705
732
  var KNOWN_SLOT_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["size", "weight", "letter-spacing"]);
706
733
  var KNOWN_SPACING_KEYS = /* @__PURE__ */ new Set(["base"]);
@@ -780,6 +807,12 @@ function checkUnknownKeys(obj, errors) {
780
807
  }
781
808
  }
782
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
+ }
783
816
  if (typeof typo["cdn-overrides"] === "object" && typo["cdn-overrides"] !== null) {
784
817
  for (const key of Object.keys(typo["cdn-overrides"])) {
785
818
  if (!KNOWN_CDN_OVERRIDE_KEYS.has(key)) {
@@ -1103,9 +1136,29 @@ var DEFAULTS = {
1103
1136
  },
1104
1137
  typography: {
1105
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",
1106
1148
  heading: { family: DEFAULT_FONT_SANS, weight: 600 },
1107
1149
  body: { family: DEFAULT_FONT_SANS, weight: 400 },
1108
- 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
+ }
1109
1162
  },
1110
1163
  spacing: { base: 4 },
1111
1164
  radius: { sm: 2, md: 4, lg: 8, xl: 12, pill: 9999 },
@@ -1146,6 +1199,17 @@ function resolveBrand(brand) {
1146
1199
  ...brand.custom && { custom: brand.custom }
1147
1200
  };
1148
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
+ }
1149
1213
  function resolveConfig(config) {
1150
1214
  const colors = config.colors;
1151
1215
  const originalColors = {};
@@ -1194,6 +1258,8 @@ function resolveConfig(config) {
1194
1258
  "colors-dark": config["colors-dark"],
1195
1259
  typography: {
1196
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"],
1197
1263
  heading: {
1198
1264
  family: config.typography?.heading?.family ?? DEFAULTS.typography.heading.family,
1199
1265
  weight: config.typography?.heading?.weight ?? DEFAULTS.typography.heading.weight,
@@ -1225,7 +1291,8 @@ function resolveConfig(config) {
1225
1291
  ...config.typography?.["cdn-overrides"] && {
1226
1292
  "cdn-overrides": config.typography["cdn-overrides"]
1227
1293
  },
1228
- slots: config.typography?.slots ?? {}
1294
+ slots: config.typography?.slots ?? {},
1295
+ "letter-spacing": resolveLetterSpacing(config.typography?.["letter-spacing"])
1229
1296
  },
1230
1297
  brand: resolveBrand(config.brand),
1231
1298
  spacing: {
@@ -1274,6 +1341,10 @@ var CONFIG_BACKGROUND = "__CONFIG_BACKGROUND__";
1274
1341
  var CONFIG_SURFACE = "__CONFIG_SURFACE__";
1275
1342
  var CONFIG_DARK_BACKGROUND = "__CONFIG_DARK_BACKGROUND__";
1276
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
+ }
1277
1348
  var SEMANTIC_TEXT_MAP = {
1278
1349
  primary: {
1279
1350
  light: { role: "neutral", shade: 900 },
@@ -1529,9 +1600,13 @@ var SEMANTIC_INTERACTIVE_MAP = {
1529
1600
  light: { role: "primary", shade: 800 },
1530
1601
  dark: { role: "primary", shade: 300 }
1531
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.
1532
1607
  "primary-text": {
1533
- light: { constant: "#ffffff" },
1534
- dark: { constant: "#ffffff" }
1608
+ light: { constant: deriveOnBg("primary-bg") },
1609
+ dark: { constant: deriveOnBg("primary-bg") }
1535
1610
  },
1536
1611
  // VI-478: brand-derived alpha-overlay helpers (BL-193). `soft`/`glow` are
1537
1612
  // alpha overlays that track the theme's primary via color-mix (distinct from
@@ -1580,9 +1655,11 @@ var SEMANTIC_INTERACTIVE_MAP = {
1580
1655
  light: { role: "error", shade: 700 },
1581
1656
  dark: { role: "error", shade: 600 }
1582
1657
  },
1658
+ // VI-375: derive from the paired `destructive-bg` luminance (was hardcoded
1659
+ // #ffffff). Per-token override still wins.
1583
1660
  "destructive-text": {
1584
- light: { constant: "#ffffff" },
1585
- dark: { constant: "#ffffff" }
1661
+ light: { constant: deriveOnBg("destructive-bg") },
1662
+ dark: { constant: deriveOnBg("destructive-bg") }
1586
1663
  },
1587
1664
  // Ghost action
1588
1665
  "ghost-bg": {
@@ -1640,6 +1717,16 @@ var SEMANTIC_HAIRLINE_MAP = {
1640
1717
  dark: { constant: "rgba(255, 255, 255, 0.10)" }
1641
1718
  }
1642
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
+ })();
1643
1730
  var SEMANTIC_MAP = {
1644
1731
  text: SEMANTIC_TEXT_MAP,
1645
1732
  surface: SEMANTIC_SURFACE_MAP,
@@ -1677,6 +1764,66 @@ function resolveRef(ref, primitives, config) {
1677
1764
  return ref.constant;
1678
1765
  }
1679
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
+ }
1680
1827
  function resolveMapping(mapping, lightPrimitives, darkPrimitives, config) {
1681
1828
  return {
1682
1829
  light: resolveRef(mapping.light, lightPrimitives, config),
@@ -1702,6 +1849,7 @@ function assignSemanticTokens(lightPrimitives, darkPrimitives, config) {
1702
1849
  for (const [name, mapping] of Object.entries(SEMANTIC_MAP.interactive)) {
1703
1850
  interactive[name] = resolveMapping(mapping, lightPrimitives, darkPrimitives, config);
1704
1851
  }
1852
+ deriveInteractiveTextColors(interactive, config);
1705
1853
  for (const [name, mapping] of Object.entries(SEMANTIC_MAP.intent)) {
1706
1854
  intent[name] = resolveMapping(mapping, lightPrimitives, darkPrimitives, config);
1707
1855
  }
@@ -1770,6 +1918,7 @@ ${validation.errors.map((e) => ` - ${e}`).join("\n")}`
1770
1918
  const darkPrimitives = generateDarkPrimitives(resolved, primitives);
1771
1919
  let tokens = assignSemanticTokens(primitives, darkPrimitives, resolved);
1772
1920
  tokens = applyOverrides(tokens, resolved.overrides);
1921
+ tokens = reapplyInteractiveTextDerivation(tokens, resolved, resolved.overrides);
1773
1922
  const output = {
1774
1923
  primitivesCss: generatePrimitivesCss(primitives, resolved),
1775
1924
  semanticCss: generateSemanticCss(tokens),
@@ -2193,7 +2342,7 @@ function checkTypeScaleCoherence(config, issues) {
2193
2342
  function checkLetterSpacing(config, issues) {
2194
2343
  const ls = config.typography?.["letter-spacing"];
2195
2344
  if (!ls) return;
2196
- for (const key of ["tight", "normal", "wide"]) {
2345
+ for (const key of ["xl", "lg", "md", "sm", "xs", "tight", "normal", "wide"]) {
2197
2346
  const value = ls[key];
2198
2347
  if (value !== void 0) {
2199
2348
  if (typeof value !== "string" || !CSS_LENGTH_RE.test(value.trim())) {
@@ -2406,12 +2555,27 @@ function colorToRgb(color) {
2406
2555
  }
2407
2556
  var STANDARD_TEXT_TOKENS = ["primary", "secondary", "tertiary"];
2408
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
+ })();
2409
2569
  function checkContrastWarnings(config, issues) {
2410
2570
  const resolved = resolveConfig(config);
2411
2571
  const lightPrimitives = generatePrimitives(resolved);
2412
2572
  const darkPrimitives = generateDarkPrimitives(resolved, lightPrimitives);
2413
- const tokens = applyOverrides(
2414
- assignSemanticTokens(lightPrimitives, darkPrimitives, resolved),
2573
+ const tokens = reapplyInteractiveTextDerivation(
2574
+ applyOverrides(
2575
+ assignSemanticTokens(lightPrimitives, darkPrimitives, resolved),
2576
+ resolved.overrides
2577
+ ),
2578
+ resolved,
2415
2579
  resolved.overrides
2416
2580
  );
2417
2581
  const lightBg = resolved.colors.background;
@@ -2474,7 +2638,10 @@ function checkContrastWarnings(config, issues) {
2474
2638
  );
2475
2639
  }
2476
2640
  }
2477
- 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);
2478
2645
  if (primaryOnBg < CONTRAST_INTERACTIVE_AA) {
2479
2646
  issues.push(
2480
2647
  issue(
@@ -2485,7 +2652,7 @@ function checkContrastWarnings(config, issues) {
2485
2652
  )
2486
2653
  );
2487
2654
  }
2488
- const primaryOnSurface = getContrastRatio(primary, lightSurface, lightSurfaceRgb);
2655
+ const primaryOnSurface = getContrastRatio(lightPrimaryBg, lightSurface, lightSurfaceRgb);
2489
2656
  if (primaryOnSurface < CONTRAST_INTERACTIVE_AA) {
2490
2657
  issues.push(
2491
2658
  issue(
@@ -2496,7 +2663,7 @@ function checkContrastWarnings(config, issues) {
2496
2663
  )
2497
2664
  );
2498
2665
  }
2499
- const darkPrimaryOnBg = getContrastRatio(darkPrimary, darkBg, darkBgRgb);
2666
+ const darkPrimaryOnBg = getContrastRatio(darkPrimaryBg, darkBg, darkBgRgb);
2500
2667
  if (darkPrimaryOnBg < CONTRAST_INTERACTIVE_AA) {
2501
2668
  issues.push(
2502
2669
  issue(
@@ -2507,7 +2674,7 @@ function checkContrastWarnings(config, issues) {
2507
2674
  )
2508
2675
  );
2509
2676
  }
2510
- const darkPrimaryOnSurface = getContrastRatio(darkPrimary, darkSurface, darkSurfaceRgb);
2677
+ const darkPrimaryOnSurface = getContrastRatio(darkPrimaryBg, darkSurface, darkSurfaceRgb);
2511
2678
  if (darkPrimaryOnSurface < CONTRAST_INTERACTIVE_AA) {
2512
2679
  issues.push(
2513
2680
  issue(
@@ -2518,6 +2685,33 @@ function checkContrastWarnings(config, issues) {
2518
2685
  )
2519
2686
  );
2520
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
+ }
2521
2715
  }
2522
2716
  function checkColorSimilarity(config, issues) {
2523
2717
  const resolved = resolveConfig(config);
@@ -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
  /**
@@ -429,6 +458,16 @@ interface ResolvedThemeConfig {
429
458
  "colors-dark"?: VisorThemeConfig["colors-dark"];
430
459
  typography: {
431
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;
432
471
  heading: {
433
472
  family: string;
434
473
  weight: number;
@@ -471,6 +510,19 @@ interface ResolvedThemeConfig {
471
510
  * consumes these; other adapters may ignore them.
472
511
  */
473
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
+ };
474
526
  };
475
527
  /**
476
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.14.0",
3
+ "version": "0.15.1",
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",
@@ -100,7 +100,7 @@
100
100
  "properties": {
101
101
  "family": {
102
102
  "type": "string",
103
- "description": "Google Fonts family name or CSS font stack."
103
+ "description": "Font family name — Google Fonts name, Visor Fonts name, or CSS font stack."
104
104
  },
105
105
  "weight": {
106
106
  "type": "integer",
@@ -111,7 +111,7 @@
111
111
  "source": {
112
112
  "type": "string",
113
113
  "enum": ["google-fonts", "visor-fonts", "local"],
114
- "description": "Font source. Defaults to Google Fonts lookup, then local."
114
+ "description": "Font source. Default: \"google-fonts\". Use \"visor-fonts\" for CDN-hosted fonts, \"local\" for self-hosted fonts."
115
115
  },
116
116
  "org": {
117
117
  "type": "string",
@@ -126,7 +126,7 @@
126
126
  "properties": {
127
127
  "family": {
128
128
  "type": "string",
129
- "description": "Google Fonts family name or CSS font stack."
129
+ "description": "Font family name — Google Fonts name, Visor Fonts name, or CSS font stack."
130
130
  },
131
131
  "weight": {
132
132
  "type": "integer",
@@ -137,7 +137,7 @@
137
137
  "source": {
138
138
  "type": "string",
139
139
  "enum": ["google-fonts", "visor-fonts", "local"],
140
- "description": "Font source. Defaults to Google Fonts lookup, then local."
140
+ "description": "Font source. Default: \"google-fonts\". Use \"visor-fonts\" for CDN-hosted fonts, \"local\" for self-hosted fonts."
141
141
  },
142
142
  "org": {
143
143
  "type": "string",
@@ -151,7 +151,7 @@
151
151
  "properties": {
152
152
  "family": {
153
153
  "type": "string",
154
- "description": "Google Fonts family name or CSS font stack."
154
+ "description": "Font family name — Google Fonts name, Visor Fonts name, or CSS font stack."
155
155
  },
156
156
  "weight": {
157
157
  "type": "integer",
@@ -162,7 +162,7 @@
162
162
  "source": {
163
163
  "type": "string",
164
164
  "enum": ["google-fonts", "visor-fonts", "local"],
165
- "description": "Font source. Defaults to Google Fonts lookup, then local."
165
+ "description": "Font source. Default: \"google-fonts\". Use \"visor-fonts\" for CDN-hosted fonts, \"local\" for self-hosted fonts."
166
166
  },
167
167
  "org": {
168
168
  "type": "string",
@@ -184,14 +184,27 @@
184
184
  "type": "number",
185
185
  "description": "Type scale multiplier applied to the font-size ramp. Default: 1."
186
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
+ },
187
195
  "letter-spacing": {
188
196
  "type": "object",
189
- "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).",
190
198
  "additionalProperties": false,
191
199
  "properties": {
200
+ "xl": { "type": "string" },
201
+ "lg": { "type": "string" },
202
+ "md": { "type": "string" },
203
+ "sm": { "type": "string" },
204
+ "xs": { "type": "string" },
192
205
  "tight": { "type": "string" },
193
- "normal": { "type": "string" },
194
- "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." }
195
208
  }
196
209
  },
197
210
  "cdn-overrides": {