@loworbitstudio/visor-theme-engine 0.4.1 → 0.5.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 { c as GeneratedPrimitives, i as SemanticTokens, R as ResolvedThemeConfig } from '../types-gAlkt__C.js';
1
+ import { c as GeneratedPrimitives, i as SemanticTokens, R as ResolvedThemeConfig } from '../types-ljcTtODU.js';
2
2
 
3
3
  /**
4
4
  * Adapter types for the Visor theme engine.
@@ -139,8 +139,10 @@ declare function deckAdapter(input: AdapterInput, options?: DeckAdapterOptions):
139
139
  /**
140
140
  * Generate docs-site CSS for a theme.
141
141
  *
142
- * Output is class-scoped (.{slug}-theme) with no @layer wrapping,
143
- * matching the hand-written theme files in packages/docs/app/.
142
+ * Output is class-scoped (.{slug}-theme) and wrapped in @layer visor-adaptive
143
+ * so consumer overrides (unlayered) and visor-core's own visor-adaptive layer
144
+ * cascade correctly. Font @import / @font-face statements stay outside the
145
+ * layer block per CSS spec (VI-312).
144
146
  */
145
147
  declare function docsAdapter(input: AdapterInput, options?: DocsAdapterOptions): string;
146
148
 
@@ -177,9 +179,11 @@ declare function flutterAdapter(input: AdapterInput, options?: FlutterAdapterOpt
177
179
  /**
178
180
  * CSS @layer utilities for adapter output.
179
181
  *
180
- * Establishes a specificity ordering so theme overrides work
181
- * without !important. Layers are adapter-only the base
182
- * generateFullBundleCss output is not wrapped in layers.
182
+ * Establishes a specificity ordering so theme overrides work without
183
+ * !important. Both adapter output (here) and visor-core's emitted CSS
184
+ * (packages/tokens/src/generate/generate-css.ts) declare this same layer
185
+ * order — defense in depth, so whichever stylesheet loads first establishes
186
+ * the cascade.
183
187
  */
184
188
  /** Layer order declaration — must appear before any @layer blocks. */
185
189
  declare const LAYER_ORDER = "@layer visor-primitives, visor-semantic, visor-adaptive, visor-bridge;";
@@ -11,7 +11,7 @@ import {
11
11
  parseColor,
12
12
  resolveThemeFonts,
13
13
  sectionComment
14
- } from "../chunk-G4B57FB3.js";
14
+ } from "../chunk-U5FXQ5EC.js";
15
15
 
16
16
  // src/adapters/layers.ts
17
17
  var LAYER_ORDER = "@layer visor-primitives, visor-semantic, visor-adaptive, visor-bridge;";
@@ -418,6 +418,7 @@ function docsAdapter(input, options) {
418
418
  const slug = toKebabCase2(input.config.name);
419
419
  const scopeClass = `.${slug}-theme`;
420
420
  const includeFontImports = options?.includeFontImports ?? true;
421
+ const fontLines = [];
421
422
  const lines = [];
422
423
  if (includeFontImports && input.config.typography) {
423
424
  const fontResult = resolveThemeFonts(input.config.typography);
@@ -426,8 +427,8 @@ function docsAdapter(input, options) {
426
427
  for (const font of fontSlots) {
427
428
  if (font && font.source === "google-fonts" && font.cssUrl && !seenUrls.has(font.cssUrl)) {
428
429
  seenUrls.add(font.cssUrl);
429
- lines.push(`@import url("${font.cssUrl}");`);
430
- lines.push("");
430
+ fontLines.push(`@import url("${font.cssUrl}");`);
431
+ fontLines.push("");
431
432
  }
432
433
  }
433
434
  const scale = input.config.typography?.scale ?? 1;
@@ -437,17 +438,17 @@ function docsAdapter(input, options) {
437
438
  seenFamilies.add(font.family);
438
439
  for (const weight of font.weights) {
439
440
  const url = buildVisorFontUrl(font.org ?? "", font.family, weight);
440
- lines.push("@font-face {");
441
- lines.push(` font-family: "${font.family}";`);
442
- lines.push(` src: url("${url}") format("woff2");`);
443
- lines.push(` font-weight: ${weight};`);
444
- lines.push(` font-style: ${font.italic ? "italic" : "normal"};`);
445
- lines.push(` font-display: ${font.display};`);
441
+ fontLines.push("@font-face {");
442
+ fontLines.push(` font-family: "${font.family}";`);
443
+ fontLines.push(` src: url("${url}") format("woff2");`);
444
+ fontLines.push(` font-weight: ${weight};`);
445
+ fontLines.push(` font-style: ${font.italic ? "italic" : "normal"};`);
446
+ fontLines.push(` font-display: ${font.display};`);
446
447
  if (scale !== 1) {
447
- lines.push(` size-adjust: ${Math.round(scale * 100)}%;`);
448
+ fontLines.push(` size-adjust: ${Math.round(scale * 100)}%;`);
448
449
  }
449
- lines.push("}");
450
- lines.push("");
450
+ fontLines.push("}");
451
+ fontLines.push("");
451
452
  }
452
453
  }
453
454
  }
@@ -562,7 +563,9 @@ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
562
563
  lines.push(sectionComment2("Fumadocs bridge: light"));
563
564
  lines.push(block(`html:not(.dark) ${scopeClass}`, generateFumadocsBridgeDecls(input.tokens, "light")));
564
565
  lines.push("");
565
- return lines.join("\n") + "\n";
566
+ const layered = wrapInLayer("visor-adaptive", lines.join("\n").trim());
567
+ const head = fontLines.length > 0 ? fontLines.join("\n") + "\n" : "";
568
+ return head + LAYER_ORDER + "\n\n" + layered + "\n";
566
569
  }
567
570
 
568
571
  // src/flutter/color-to-dart.ts
@@ -116,6 +116,21 @@ function lookupGoogleFont(family) {
116
116
  return catalogMap.get(family.toLowerCase());
117
117
  }
118
118
 
119
+ // src/fonts/font-aliases.ts
120
+ var FONT_WEIGHT_ALIASES = {
121
+ "PP Model Mono": {
122
+ 400: "Book",
123
+ 800: "Super"
124
+ },
125
+ "PP Model Plastic": {
126
+ 400: "Book",
127
+ 800: "Super"
128
+ }
129
+ };
130
+ function lookupFontWeightAlias(family, weight) {
131
+ return FONT_WEIGHT_ALIASES[family]?.[weight] ?? null;
132
+ }
133
+
119
134
  // src/fonts/resolve.ts
120
135
  var DEFAULT_WEIGHTS = [400, 700];
121
136
  var DEFAULT_DISPLAY = "swap";
@@ -156,7 +171,7 @@ var WEIGHT_NAMES = {
156
171
  function buildVisorFontUrl(org, family, weight) {
157
172
  const slug = buildFamilySlug(family);
158
173
  const prefix = buildFamilyPrefix(family);
159
- const weightName = WEIGHT_NAMES[weight] ?? `W${weight}`;
174
+ const weightName = lookupFontWeightAlias(family, weight) ?? WEIGHT_NAMES[weight] ?? `W${weight}`;
160
175
  return `${VISOR_FONTS_CDN}/${org}/${slug}/${prefix}-${weightName}.woff2`;
161
176
  }
162
177
  function resolveFont(family, options = {}) {
@@ -964,8 +979,11 @@ var LIGHTNESS_TARGETS = {
964
979
  200: 0.87,
965
980
  300: 0.78,
966
981
  400: 0.65,
967
- 500: -1,
968
- // placeholder replaced by input L at anchor (brand color lives at 500)
982
+ // 500 is the reference midpoint (Tailwind gray-500's OKLCH L ≈ 0.55).
983
+ // At the anchor shade the input L is used directly; this value seeds the
984
+ // interpolation math in computeLightness() for inputs that don't land at
985
+ // the reference midpoint.
986
+ 500: 0.55,
969
987
  600: 0.45,
970
988
  700: 0.38,
971
989
  800: 0.3,
@@ -1012,20 +1030,20 @@ function computeLightness(step, inputL, anchorShade) {
1012
1030
  if (step === anchorShade) {
1013
1031
  return inputL;
1014
1032
  }
1015
- const anchorTarget = anchorShade === 600 ? inputL : LIGHTNESS_TARGETS[anchorShade];
1033
+ const anchorTarget = LIGHTNESS_TARGETS[anchorShade];
1016
1034
  const stepTarget = LIGHTNESS_TARGETS[step];
1017
1035
  if (Math.abs(anchorTarget - inputL) < 0.01) {
1018
1036
  return stepTarget;
1019
1037
  }
1020
1038
  if (step < anchorShade) {
1021
- const anchorDefaultL = anchorShade === 600 ? 0.45 : LIGHTNESS_TARGETS[anchorShade];
1039
+ const anchorDefaultL = LIGHTNESS_TARGETS[anchorShade];
1022
1040
  const rawRange = 0.97 - anchorDefaultL;
1023
1041
  const newRange = 0.97 - inputL;
1024
1042
  if (rawRange <= 0) return stepTarget;
1025
1043
  const t = (stepTarget - anchorDefaultL) / rawRange;
1026
1044
  return inputL + t * newRange;
1027
1045
  } else {
1028
- const anchorDefaultL = anchorShade === 600 ? 0.45 : LIGHTNESS_TARGETS[anchorShade];
1046
+ const anchorDefaultL = LIGHTNESS_TARGETS[anchorShade];
1029
1047
  const rawRange = anchorDefaultL - 0.14;
1030
1048
  const newRange = inputL - 0.14;
1031
1049
  if (rawRange <= 0) return stepTarget;
@@ -1043,7 +1061,7 @@ function generateShadeScale(color, role) {
1043
1061
  for (const step of steps) {
1044
1062
  const targetL = computeLightness(step, inputL, anchorShade);
1045
1063
  let targetC = inputC * CHROMA_MULTIPLIERS[step];
1046
- if (role === "neutral") {
1064
+ if (role === "neutral" && step !== anchorShade) {
1047
1065
  targetC = Math.min(targetC, maxNeutralChroma);
1048
1066
  }
1049
1067
  scale[step] = oklchToHex(targetL, targetC, inputH);
@@ -1389,6 +1407,8 @@ var MATERIAL_TEXT_SLOTS = [
1389
1407
  export {
1390
1408
  googleFontsCatalog,
1391
1409
  lookupGoogleFont,
1410
+ FONT_WEIGHT_ALIASES,
1411
+ lookupFontWeightAlias,
1392
1412
  VISOR_FONTS_CDN,
1393
1413
  buildVisorFontUrl,
1394
1414
  resolveFont,
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, R as ResolvedThemeConfig, c as GeneratedPrimitives, d as ThemeOutput, e as ThemeData, f as VisorThemeConfig, g as FullShadeScale, C as ColorRole, S as SelectiveShadeScale, h as RGB, P as ParsedColor, O as OKLCH, i as SemanticTokens, j as ShadeStep } from './types-gAlkt__C.js';
2
- export { k as ColorFormat, l as FontSource, m as RGBA, n as SemanticTokenValue } from './types-gAlkt__C.js';
1
+ import { F as FontResolveOptions, a as FontResolution, V as VisorTypography, b as FontDisplayStrategy, T as ThemeFontResult, G as GoogleFontEntry, R as ResolvedThemeConfig, c as GeneratedPrimitives, d as ThemeOutput, e as ThemeData, f as VisorThemeConfig, g as FullShadeScale, C as ColorRole, S as SelectiveShadeScale, h as RGB, P as ParsedColor, O as OKLCH, i as SemanticTokens, j as ShadeStep } from './types-ljcTtODU.js';
2
+ export { k as ColorFormat, l as FontSource, m as RGBA, n as SemanticTokenValue } from './types-ljcTtODU.js';
3
3
 
4
4
  /**
5
5
  * Font resolver — maps font family names to loadable font resources.
@@ -28,6 +28,22 @@ declare function buildVisorFontUrl(org: string, family: string, weight: number):
28
28
  */
29
29
  declare function resolveFont(family: string, options?: FontResolveOptions): FontResolution;
30
30
 
31
+ /**
32
+ * Font weight-name alias registry for the Visor Fonts CDN URL builder.
33
+ *
34
+ * Standard PostScript naming (Light/Regular/Medium/Bold/ExtraBold/Black)
35
+ * is handled by the WEIGHT_NAMES table in resolve.ts. Foundries that use
36
+ * non-standard names (e.g. Pangram Pangram's `Book` and `Super`) register
37
+ * per-family overrides here so theme authors can keep writing standard
38
+ * weight numbers in their .visor.yaml files.
39
+ *
40
+ * Family keys are exact-match (case-sensitive); weight keys are the numeric
41
+ * weight (300, 400, 500, …) as in WEIGHT_NAMES. The mapped string is the
42
+ * PostScript-style suffix that follows `{Family}-` in the bucket filename.
43
+ */
44
+ declare const FONT_WEIGHT_ALIASES: Record<string, Record<number, string>>;
45
+ declare function lookupFontWeightAlias(family: string, weight: number): string | null;
46
+
31
47
  /**
32
48
  * Preload hint generation for font loading performance.
33
49
  *
@@ -556,6 +572,20 @@ var properties = {
556
572
  }
557
573
  }
558
574
  }
575
+ },
576
+ migrate: {
577
+ type: "object",
578
+ description: "Migration metadata consumed by `visor migrate` commands. Does not affect CSS generation or theme application.",
579
+ additionalProperties: false,
580
+ properties: {
581
+ "token-substitution": {
582
+ type: "object",
583
+ description: "§3.1 V7-primitive → Visor-semantic substitution table. Maps V7 CSS custom property names (with -- prefix) to Visor semantic token names. Consumed by `visor migrate token-substitution`.",
584
+ additionalProperties: {
585
+ type: "string"
586
+ }
587
+ }
588
+ }
559
589
  }
560
590
  };
561
591
  var $defs = {
@@ -898,4 +928,4 @@ declare function cleanFontValue(val: string): string;
898
928
  */
899
929
  declare function extractFromCSS(files: CSSFile[], name?: string): ExtractionResult;
900
930
 
901
- export { type CSSFile, ColorRole, type Confidence, type ExtractedToken, type ExtractionResult, FontDisplayStrategy, type FontFaceDeclaration, FontResolution, FontResolveOptions, FullShadeScale, GeneratedPrimitives, GoogleFontEntry, OKLCH, ParsedColor, RGB, ResolvedThemeConfig, SEMANTIC_MAP, SelectiveShadeScale, SemanticTokens, ShadeStep, TAILWIND_GRAY, ThemeData, ThemeFontResult, ThemeOutput, type ThemeValidationResult, VISOR_FONTS_CDN, type ValidationIssue, type ValidationSeverity, VisorThemeConfig, VisorTypography, applyOverrides, assignSemanticTokens, buildVisorFontUrl, clampToSrgb, cleanFontValue, compositeOverBackground, exportTheme, extractFromCSS, generateDarkCss, generateFullBundleCss, generateLightCss, generatePreloadLinks, generatePrimitives, generatePrimitivesCss, generateSemanticCss, generateShadeScale, generateStylesheetLinks, generateTheme, generateThemeData, generateThemeDataFromConfig, generateThemeFromConfig, getContrastRatio, googleFontsCatalog, hexToOklch, hexToRgb, isValidColor, isValidHex, isVisorThemeConfig, lookupGoogleFont, normalizeHex, oklchToHex, parseCSSDeclarations, parseColor, parseConfig, parseFontFaceDeclarations, parseHex, parseHsla, parseOklch, parseRgba, resolveConfig, resolveFont, resolveThemeFonts, rgbToHex, serializeColor, validate, validateConfig, visorTheme_schema as visorThemeSchema };
931
+ export { type CSSFile, ColorRole, type Confidence, type ExtractedToken, type ExtractionResult, FONT_WEIGHT_ALIASES, FontDisplayStrategy, type FontFaceDeclaration, FontResolution, FontResolveOptions, FullShadeScale, GeneratedPrimitives, GoogleFontEntry, OKLCH, ParsedColor, RGB, ResolvedThemeConfig, SEMANTIC_MAP, SelectiveShadeScale, SemanticTokens, ShadeStep, TAILWIND_GRAY, ThemeData, ThemeFontResult, ThemeOutput, type ThemeValidationResult, VISOR_FONTS_CDN, type ValidationIssue, type ValidationSeverity, VisorThemeConfig, VisorTypography, applyOverrides, assignSemanticTokens, buildVisorFontUrl, clampToSrgb, cleanFontValue, compositeOverBackground, exportTheme, extractFromCSS, 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, resolveConfig, resolveFont, resolveThemeFonts, rgbToHex, serializeColor, validate, validateConfig, visorTheme_schema as visorThemeSchema };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ FONT_WEIGHT_ALIASES,
2
3
  MATERIAL_TEXT_SLOTS,
3
4
  TAILWIND_GRAY,
4
5
  VISOR_FONTS_CDN,
@@ -19,6 +20,7 @@ import {
19
20
  hexToRgb,
20
21
  isValidColor,
21
22
  isValidHex,
23
+ lookupFontWeightAlias,
22
24
  lookupGoogleFont,
23
25
  normalizeHex,
24
26
  oklchToHex,
@@ -32,7 +34,7 @@ import {
32
34
  rgbToHex,
33
35
  rgbToOklch,
34
36
  serializeColor
35
- } from "./chunk-G4B57FB3.js";
37
+ } from "./chunk-U5FXQ5EC.js";
36
38
 
37
39
  // src/pipeline.ts
38
40
  import { parse as parseYaml } from "yaml";
@@ -339,6 +341,18 @@ var visor_theme_schema_default = {
339
341
  additionalProperties: { type: "string" }
340
342
  }
341
343
  }
344
+ },
345
+ migrate: {
346
+ type: "object",
347
+ description: "Migration metadata consumed by `visor migrate` commands. Does not affect CSS generation or theme application.",
348
+ additionalProperties: false,
349
+ properties: {
350
+ "token-substitution": {
351
+ type: "object",
352
+ description: "\xA73.1 V7-primitive \u2192 Visor-semantic substitution table. Maps V7 CSS custom property names (with -- prefix) to Visor semantic token names. Consumed by `visor migrate token-substitution`.",
353
+ additionalProperties: { type: "string" }
354
+ }
355
+ }
342
356
  }
343
357
  },
344
358
  $defs: {
@@ -839,13 +853,17 @@ var SEMANTIC_TEXT_MAP = {
839
853
  light: { role: "neutral", shade: 900 },
840
854
  dark: { role: "neutral", shade: 50 }
841
855
  },
856
+ // VI-346: rebalanced to fixed-L shades (700/300, 600/400) so AA contrast holds
857
+ // by default for any reasonable input neutral. Shade 500 is brand-anchored
858
+ // (variable L = input itself) and therefore forbidden for any text level that
859
+ // must clear AA. See docs/token-rules.md and SEMANTIC_TEXT_MAP rationale.
842
860
  secondary: {
843
- light: { role: "neutral", shade: 600 },
844
- dark: { role: "neutral", shade: 400 }
861
+ light: { role: "neutral", shade: 700 },
862
+ dark: { role: "neutral", shade: 300 }
845
863
  },
846
864
  tertiary: {
847
- light: { role: "neutral", shade: 400 },
848
- dark: { role: "neutral", shade: 500 }
865
+ light: { role: "neutral", shade: 600 },
866
+ dark: { role: "neutral", shade: 400 }
849
867
  },
850
868
  disabled: {
851
869
  light: { role: "neutral", shade: 300 },
@@ -976,6 +994,29 @@ var SEMANTIC_SURFACE_MAP = {
976
994
  "info-default": {
977
995
  light: { role: "info", shade: 500 },
978
996
  dark: { role: "info", shade: 500 }
997
+ },
998
+ // 5-tier ordinal elevation scale — deepest (0) to highest (4)
999
+ // Light mode: BO-10 near-white ramp (white → neutral-300).
1000
+ // Dark mode: deep neutral ramp (neutral-950 → neutral-600).
1001
+ "elev-0": {
1002
+ light: { constant: "#ffffff" },
1003
+ dark: { role: "neutral", shade: 950 }
1004
+ },
1005
+ "elev-1": {
1006
+ light: { role: "neutral", shade: 50 },
1007
+ dark: { role: "neutral", shade: 900 }
1008
+ },
1009
+ "elev-2": {
1010
+ light: { role: "neutral", shade: 100 },
1011
+ dark: { role: "neutral", shade: 800 }
1012
+ },
1013
+ "elev-3": {
1014
+ light: { role: "neutral", shade: 200 },
1015
+ dark: { role: "neutral", shade: 700 }
1016
+ },
1017
+ "elev-4": {
1018
+ light: { role: "neutral", shade: 300 },
1019
+ dark: { role: "neutral", shade: 600 }
979
1020
  }
980
1021
  };
981
1022
  var SEMANTIC_BORDER_MAP = {
@@ -1826,35 +1867,75 @@ function colorToRgb(color) {
1826
1867
  const parsed = parseColor(color);
1827
1868
  return parsed ? parsed.rgb : [0, 0, 0];
1828
1869
  }
1870
+ var STANDARD_TEXT_TOKENS = ["primary", "secondary", "tertiary"];
1871
+ var STATUS_TEXT_TOKENS = ["error", "warning", "success", "info"];
1829
1872
  function checkContrastWarnings(config, issues) {
1830
1873
  const resolved = resolveConfig(config);
1874
+ const lightPrimitives = generatePrimitives(resolved);
1875
+ const darkPrimitives = generateDarkPrimitives(resolved, lightPrimitives);
1876
+ const tokens = applyOverrides(
1877
+ assignSemanticTokens(lightPrimitives, darkPrimitives, resolved),
1878
+ resolved.overrides
1879
+ );
1831
1880
  const lightBg = resolved.colors.background;
1832
1881
  const lightSurface = resolved.colors.surface;
1833
1882
  const primary = resolved.colors.primary;
1834
1883
  const lightBgRgb = colorToRgb(lightBg);
1835
1884
  const lightSurfaceRgb = colorToRgb(lightSurface);
1836
- const textDark = "#111827";
1837
- const textOnBg = getContrastRatio(textDark, lightBg, lightBgRgb);
1838
- if (textOnBg < CONTRAST_TEXT_AA) {
1839
- issues.push(
1840
- issue(
1841
- "warning",
1842
- "WCAG_CONTRAST",
1843
- `Light mode: text-primary on background has contrast ratio ${textOnBg.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1844
- "colors.background"
1845
- )
1846
- );
1847
- }
1848
- const textOnSurface = getContrastRatio(textDark, lightSurface, lightSurfaceRgb);
1849
- if (textOnSurface < CONTRAST_TEXT_AA) {
1850
- issues.push(
1851
- issue(
1852
- "warning",
1853
- "WCAG_CONTRAST",
1854
- `Light mode: text-primary on surface has contrast ratio ${textOnSurface.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1855
- "colors.surface"
1856
- )
1857
- );
1885
+ const darkBg = resolved["colors-dark"]?.background ?? "#0a0a0a";
1886
+ const darkSurface = resolved["colors-dark"]?.surface ?? "#171717";
1887
+ const darkPrimary = resolved["colors-dark"]?.primary ?? primary;
1888
+ const darkBgRgb = colorToRgb(darkBg);
1889
+ const darkSurfaceRgb = colorToRgb(darkSurface);
1890
+ const textTokensToCheck = [...STANDARD_TEXT_TOKENS, ...STATUS_TEXT_TOKENS];
1891
+ for (const tokenName of textTokensToCheck) {
1892
+ const tokenValue = tokens.text[tokenName];
1893
+ if (!tokenValue) continue;
1894
+ const fullName = `text-${tokenName}`;
1895
+ const lightOnBg = getContrastRatio(tokenValue.light, lightBg, lightBgRgb);
1896
+ if (lightOnBg < CONTRAST_TEXT_AA) {
1897
+ issues.push(
1898
+ issue(
1899
+ "warning",
1900
+ "WCAG_CONTRAST",
1901
+ `Light mode: ${fullName} on background has contrast ratio ${lightOnBg.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1902
+ "colors.background"
1903
+ )
1904
+ );
1905
+ }
1906
+ const lightOnSurface = getContrastRatio(tokenValue.light, lightSurface, lightSurfaceRgb);
1907
+ if (lightOnSurface < CONTRAST_TEXT_AA) {
1908
+ issues.push(
1909
+ issue(
1910
+ "warning",
1911
+ "WCAG_CONTRAST",
1912
+ `Light mode: ${fullName} on surface has contrast ratio ${lightOnSurface.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1913
+ "colors.surface"
1914
+ )
1915
+ );
1916
+ }
1917
+ const darkOnBg = getContrastRatio(tokenValue.dark, darkBg, darkBgRgb);
1918
+ if (darkOnBg < CONTRAST_TEXT_AA) {
1919
+ issues.push(
1920
+ issue(
1921
+ "warning",
1922
+ "WCAG_CONTRAST",
1923
+ `Dark mode: ${fullName} on background has contrast ratio ${darkOnBg.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1924
+ "colors-dark.background"
1925
+ )
1926
+ );
1927
+ }
1928
+ const darkOnSurface = getContrastRatio(tokenValue.dark, darkSurface, darkSurfaceRgb);
1929
+ if (darkOnSurface < CONTRAST_TEXT_AA) {
1930
+ issues.push(
1931
+ issue(
1932
+ "warning",
1933
+ "WCAG_CONTRAST",
1934
+ `Dark mode: ${fullName} on surface has contrast ratio ${darkOnSurface.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1935
+ "colors-dark.surface"
1936
+ )
1937
+ );
1938
+ }
1858
1939
  }
1859
1940
  const primaryOnBg = getContrastRatio(primary, lightBg, lightBgRgb);
1860
1941
  if (primaryOnBg < CONTRAST_INTERACTIVE_AA) {
@@ -1878,34 +1959,6 @@ function checkContrastWarnings(config, issues) {
1878
1959
  )
1879
1960
  );
1880
1961
  }
1881
- const darkBg = resolved["colors-dark"]?.background ?? "#0a0a0a";
1882
- const darkSurface = resolved["colors-dark"]?.surface ?? "#171717";
1883
- const darkPrimary = resolved["colors-dark"]?.primary ?? primary;
1884
- const darkBgRgb = colorToRgb(darkBg);
1885
- const darkSurfaceRgb = colorToRgb(darkSurface);
1886
- const textLight = "#f9fafb";
1887
- const textOnDarkBg = getContrastRatio(textLight, darkBg, darkBgRgb);
1888
- if (textOnDarkBg < CONTRAST_TEXT_AA) {
1889
- issues.push(
1890
- issue(
1891
- "warning",
1892
- "WCAG_CONTRAST",
1893
- `Dark mode: text-primary on background has contrast ratio ${textOnDarkBg.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1894
- "colors-dark.background"
1895
- )
1896
- );
1897
- }
1898
- const textOnDarkSurface = getContrastRatio(textLight, darkSurface, darkSurfaceRgb);
1899
- if (textOnDarkSurface < CONTRAST_TEXT_AA) {
1900
- issues.push(
1901
- issue(
1902
- "warning",
1903
- "WCAG_CONTRAST",
1904
- `Dark mode: text-primary on surface has contrast ratio ${textOnDarkSurface.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1905
- "colors-dark.surface"
1906
- )
1907
- );
1908
- }
1909
1962
  const darkPrimaryOnBg = getContrastRatio(darkPrimary, darkBg, darkBgRgb);
1910
1963
  if (darkPrimaryOnBg < CONTRAST_INTERACTIVE_AA) {
1911
1964
  issues.push(
@@ -2575,6 +2628,7 @@ function extractFromCSS(files, name = "extracted-theme") {
2575
2628
  return { config, tokens, unmapped, warnings };
2576
2629
  }
2577
2630
  export {
2631
+ FONT_WEIGHT_ALIASES,
2578
2632
  SEMANTIC_MAP,
2579
2633
  TAILWIND_GRAY,
2580
2634
  VISOR_FONTS_CDN,
@@ -2606,6 +2660,7 @@ export {
2606
2660
  isValidColor,
2607
2661
  isValidHex,
2608
2662
  isVisorThemeConfig,
2663
+ lookupFontWeightAlias,
2609
2664
  lookupGoogleFont,
2610
2665
  normalizeHex,
2611
2666
  oklchToHex,
@@ -247,6 +247,21 @@ interface VisorThemeConfig {
247
247
  light?: Record<string, string>;
248
248
  dark?: Record<string, string>;
249
249
  };
250
+ /**
251
+ * Migration metadata — optional, additive, consumed by `visor migrate` commands.
252
+ * Does not affect CSS generation or theme application.
253
+ */
254
+ migrate?: {
255
+ /**
256
+ * §3.1 V7-primitive → Visor-semantic substitution table.
257
+ * Maps V7 CSS custom property names (without `var()`) to Visor semantic
258
+ * token names. Consumed by `visor migrate token-substitution`.
259
+ *
260
+ * @example
261
+ * { "--panel": "--surface-card", "--text": "--text-primary" }
262
+ */
263
+ "token-substitution"?: Record<string, string>;
264
+ };
250
265
  }
251
266
  /** Config with all defaults resolved */
252
267
  interface ResolvedThemeConfig {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loworbitstudio/visor-theme-engine",
3
- "version": "0.4.1",
3
+ "version": "0.5.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",
@@ -299,6 +299,18 @@
299
299
  "additionalProperties": { "type": "string" }
300
300
  }
301
301
  }
302
+ },
303
+ "migrate": {
304
+ "type": "object",
305
+ "description": "Migration metadata consumed by `visor migrate` commands. Does not affect CSS generation or theme application.",
306
+ "additionalProperties": false,
307
+ "properties": {
308
+ "token-substitution": {
309
+ "type": "object",
310
+ "description": "§3.1 V7-primitive → Visor-semantic substitution table. Maps V7 CSS custom property names (with -- prefix) to Visor semantic token names. Consumed by `visor migrate token-substitution`.",
311
+ "additionalProperties": { "type": "string" }
312
+ }
313
+ }
302
314
  }
303
315
  },
304
316
  "$defs": {