@loworbitstudio/visor-theme-engine 0.4.0 → 0.4.2

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.
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ MATERIAL_TEXT_SLOTS,
2
3
  TAILWIND_GRAY,
3
4
  VISOR_FONTS_CDN,
4
5
  buildVisorFontUrl,
@@ -31,7 +32,7 @@ import {
31
32
  rgbToHex,
32
33
  rgbToOklch,
33
34
  serializeColor
34
- } from "./chunk-NZF2MS4L.js";
35
+ } from "./chunk-SXT2KY6D.js";
35
36
 
36
37
  // src/pipeline.ts
37
38
  import { parse as parseYaml } from "yaml";
@@ -223,6 +224,29 @@ var visor_theme_schema_default = {
223
224
  normal: { type: "string" },
224
225
  wide: { type: "string" }
225
226
  }
227
+ },
228
+ slots: {
229
+ type: "object",
230
+ description: "Per-slot overrides for the generated Flutter Material TextTheme. Any subset of the 16 slots may be specified; omitted slots fall through to the Material 3 2024 defaults shipped in visor_core.",
231
+ additionalProperties: false,
232
+ properties: {
233
+ displayLarge: { $ref: "#/$defs/textSlotOverride" },
234
+ displayMedium: { $ref: "#/$defs/textSlotOverride" },
235
+ displaySmall: { $ref: "#/$defs/textSlotOverride" },
236
+ headlineLarge: { $ref: "#/$defs/textSlotOverride" },
237
+ headlineMedium: { $ref: "#/$defs/textSlotOverride" },
238
+ headlineSmall: { $ref: "#/$defs/textSlotOverride" },
239
+ titleLarge: { $ref: "#/$defs/textSlotOverride" },
240
+ titleMedium: { $ref: "#/$defs/textSlotOverride" },
241
+ titleSmall: { $ref: "#/$defs/textSlotOverride" },
242
+ bodyLarge: { $ref: "#/$defs/textSlotOverride" },
243
+ bodyMedium: { $ref: "#/$defs/textSlotOverride" },
244
+ bodySmall: { $ref: "#/$defs/textSlotOverride" },
245
+ labelLarge: { $ref: "#/$defs/textSlotOverride" },
246
+ labelMedium: { $ref: "#/$defs/textSlotOverride" },
247
+ labelSmall: { $ref: "#/$defs/textSlotOverride" },
248
+ labelXSmall: { $ref: "#/$defs/textSlotOverride" }
249
+ }
226
250
  }
227
251
  }
228
252
  },
@@ -262,6 +286,17 @@ var visor_theme_schema_default = {
262
286
  xl: { type: "string" }
263
287
  }
264
288
  },
289
+ strokeWidths: {
290
+ type: "object",
291
+ description: "Stroke-width scale in pixels \u2014 used for borders, outlines, dividers, progress-indicator strokes. Defaults: thin=1, regular=1.5, medium=2, thick=2.5.",
292
+ additionalProperties: false,
293
+ properties: {
294
+ thin: { type: "number", minimum: 0 },
295
+ regular: { type: "number", minimum: 0 },
296
+ medium: { type: "number", minimum: 0 },
297
+ thick: { type: "number", minimum: 0 }
298
+ }
299
+ },
265
300
  motion: {
266
301
  type: "object",
267
302
  description: "Motion/animation configuration.",
@@ -304,6 +339,18 @@ var visor_theme_schema_default = {
304
339
  additionalProperties: { type: "string" }
305
340
  }
306
341
  }
342
+ },
343
+ migrate: {
344
+ type: "object",
345
+ description: "Migration metadata consumed by `visor migrate` commands. Does not affect CSS generation or theme application.",
346
+ additionalProperties: false,
347
+ properties: {
348
+ "token-substitution": {
349
+ type: "object",
350
+ 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`.",
351
+ additionalProperties: { type: "string" }
352
+ }
353
+ }
307
354
  }
308
355
  },
309
356
  $defs: {
@@ -316,6 +363,28 @@ var visor_theme_schema_default = {
316
363
  { pattern: "^hsla?\\(" },
317
364
  { pattern: "^oklch\\(" }
318
365
  ]
366
+ },
367
+ textSlotOverride: {
368
+ type: "object",
369
+ description: "Per-slot override for one Material TextTheme entry.",
370
+ additionalProperties: false,
371
+ properties: {
372
+ size: {
373
+ type: "number",
374
+ exclusiveMinimum: 0,
375
+ description: "Font size in logical pixels (Flutter TextStyle.fontSize)."
376
+ },
377
+ weight: {
378
+ type: "integer",
379
+ minimum: 100,
380
+ maximum: 900,
381
+ description: "Font weight."
382
+ },
383
+ "letter-spacing": {
384
+ type: "number",
385
+ description: "Letter spacing in logical pixels (Flutter TextStyle.letterSpacing). Material defaults include negative values, e.g. -0.25 for displayLarge."
386
+ }
387
+ }
319
388
  }
320
389
  }
321
390
  };
@@ -333,6 +402,7 @@ var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
333
402
  "spacing",
334
403
  "radius",
335
404
  "shadows",
405
+ "strokeWidths",
336
406
  "motion",
337
407
  "overrides"
338
408
  ]);
@@ -353,14 +423,18 @@ var KNOWN_TYPOGRAPHY_KEYS = /* @__PURE__ */ new Set([
353
423
  "body",
354
424
  "mono",
355
425
  "letter-spacing",
356
- "scale"
426
+ "scale",
427
+ "slots"
357
428
  ]);
358
429
  var KNOWN_TYPOGRAPHY_FONT_KEYS = /* @__PURE__ */ new Set(["family", "weight", "weights", "source", "org"]);
359
430
  var KNOWN_TYPOGRAPHY_MONO_KEYS = /* @__PURE__ */ new Set(["family"]);
360
431
  var KNOWN_LETTER_SPACING_KEYS = /* @__PURE__ */ new Set(["tight", "normal", "wide"]);
432
+ var KNOWN_SLOT_NAMES = new Set(MATERIAL_TEXT_SLOTS);
433
+ var KNOWN_SLOT_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["size", "weight", "letter-spacing"]);
361
434
  var KNOWN_SPACING_KEYS = /* @__PURE__ */ new Set(["base"]);
362
435
  var KNOWN_RADIUS_KEYS = /* @__PURE__ */ new Set(["sm", "md", "lg", "xl", "pill"]);
363
436
  var KNOWN_SHADOW_KEYS = /* @__PURE__ */ new Set(["xs", "sm", "md", "lg", "xl"]);
437
+ var KNOWN_STROKE_WIDTH_KEYS = /* @__PURE__ */ new Set(["thin", "regular", "medium", "thick"]);
364
438
  var KNOWN_MOTION_KEYS = /* @__PURE__ */ new Set(["duration-fast", "duration-normal", "duration-slow", "easing"]);
365
439
  var KNOWN_OVERRIDES_KEYS = /* @__PURE__ */ new Set(["light", "dark"]);
366
440
  function checkUnknownKeys(obj, errors) {
@@ -425,6 +499,31 @@ function checkUnknownKeys(obj, errors) {
425
499
  }
426
500
  }
427
501
  }
502
+ if (typeof typo.slots === "object" && typo.slots !== null) {
503
+ const slots = typo.slots;
504
+ for (const slotName of Object.keys(slots)) {
505
+ if (!KNOWN_SLOT_NAMES.has(slotName)) {
506
+ errors.push(
507
+ `Unknown key 'typography.slots.${slotName}'. Valid keys: ${[...MATERIAL_TEXT_SLOTS].join(", ")}`
508
+ );
509
+ continue;
510
+ }
511
+ const override = slots[slotName];
512
+ if (typeof override !== "object" || override === null) {
513
+ errors.push(
514
+ `'typography.slots.${slotName}' must be an object with optional size/weight/letter-spacing fields`
515
+ );
516
+ continue;
517
+ }
518
+ for (const key of Object.keys(override)) {
519
+ if (!KNOWN_SLOT_OVERRIDE_KEYS.has(key)) {
520
+ errors.push(
521
+ `Unknown key 'typography.slots.${slotName}.${key}'. Valid keys: ${[...KNOWN_SLOT_OVERRIDE_KEYS].join(", ")}`
522
+ );
523
+ }
524
+ }
525
+ }
526
+ }
428
527
  }
429
528
  if (typeof obj.spacing === "object" && obj.spacing !== null) {
430
529
  for (const key of Object.keys(obj.spacing)) {
@@ -447,6 +546,13 @@ function checkUnknownKeys(obj, errors) {
447
546
  }
448
547
  }
449
548
  }
549
+ if (typeof obj.strokeWidths === "object" && obj.strokeWidths !== null) {
550
+ for (const key of Object.keys(obj.strokeWidths)) {
551
+ if (!KNOWN_STROKE_WIDTH_KEYS.has(key)) {
552
+ errors.push(`Unknown key 'strokeWidths.${key}'. Valid keys: ${[...KNOWN_STROKE_WIDTH_KEYS].join(", ")}`);
553
+ }
554
+ }
555
+ }
450
556
  if (typeof obj.motion === "object" && obj.motion !== null) {
451
557
  for (const key of Object.keys(obj.motion)) {
452
558
  if (!KNOWN_MOTION_KEYS.has(key)) {
@@ -547,6 +653,23 @@ function validateConfig(config) {
547
653
  }
548
654
  }
549
655
  }
656
+ if (typeof typo.slots === "object" && typo.slots !== null) {
657
+ const slots = typo.slots;
658
+ for (const slotName of Object.keys(slots)) {
659
+ const override = slots[slotName];
660
+ if (typeof override !== "object" || override === null) continue;
661
+ const o = override;
662
+ if (o.size !== void 0 && (typeof o.size !== "number" || o.size <= 0)) {
663
+ errors.push(`'typography.slots.${slotName}.size' must be a positive number (logical pixels)`);
664
+ }
665
+ if (o.weight !== void 0 && (typeof o.weight !== "number" || o.weight < 100 || o.weight > 900)) {
666
+ errors.push(`'typography.slots.${slotName}.weight' must be between 100 and 900`);
667
+ }
668
+ if (o["letter-spacing"] !== void 0 && typeof o["letter-spacing"] !== "number") {
669
+ errors.push(`'typography.slots.${slotName}.letter-spacing' must be a number (Flutter logical pixels)`);
670
+ }
671
+ }
672
+ }
550
673
  }
551
674
  if (obj.overrides !== void 0) {
552
675
  if (typeof obj.overrides !== "object" || obj.overrides === null) {
@@ -595,6 +718,12 @@ var DEFAULTS = {
595
718
  lg: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)",
596
719
  xl: "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)"
597
720
  },
721
+ strokeWidths: {
722
+ thin: 1,
723
+ regular: 1.5,
724
+ medium: 2,
725
+ thick: 2.5
726
+ },
598
727
  motion: {
599
728
  "duration-fast": "100ms",
600
729
  "duration-normal": "200ms",
@@ -671,7 +800,8 @@ function resolveConfig(config) {
671
800
  },
672
801
  mono: {
673
802
  family: config.typography?.mono?.family ?? DEFAULTS.typography.mono.family
674
- }
803
+ },
804
+ slots: config.typography?.slots ?? {}
675
805
  },
676
806
  spacing: {
677
807
  base: config.spacing?.base ?? DEFAULTS.spacing.base
@@ -690,6 +820,12 @@ function resolveConfig(config) {
690
820
  lg: config.shadows?.lg ?? DEFAULTS.shadows.lg,
691
821
  xl: config.shadows?.xl ?? DEFAULTS.shadows.xl
692
822
  },
823
+ strokeWidths: {
824
+ thin: config.strokeWidths?.thin ?? DEFAULTS.strokeWidths.thin,
825
+ regular: config.strokeWidths?.regular ?? DEFAULTS.strokeWidths.regular,
826
+ medium: config.strokeWidths?.medium ?? DEFAULTS.strokeWidths.medium,
827
+ thick: config.strokeWidths?.thick ?? DEFAULTS.strokeWidths.thick
828
+ },
693
829
  motion: {
694
830
  "duration-fast": config.motion?.["duration-fast"] ?? DEFAULTS.motion["duration-fast"],
695
831
  "duration-normal": config.motion?.["duration-normal"] ?? DEFAULTS.motion["duration-normal"],
@@ -715,13 +851,17 @@ var SEMANTIC_TEXT_MAP = {
715
851
  light: { role: "neutral", shade: 900 },
716
852
  dark: { role: "neutral", shade: 50 }
717
853
  },
854
+ // VI-346: rebalanced to fixed-L shades (700/300, 600/400) so AA contrast holds
855
+ // by default for any reasonable input neutral. Shade 500 is brand-anchored
856
+ // (variable L = input itself) and therefore forbidden for any text level that
857
+ // must clear AA. See docs/token-rules.md and SEMANTIC_TEXT_MAP rationale.
718
858
  secondary: {
719
- light: { role: "neutral", shade: 600 },
720
- dark: { role: "neutral", shade: 400 }
859
+ light: { role: "neutral", shade: 700 },
860
+ dark: { role: "neutral", shade: 300 }
721
861
  },
722
862
  tertiary: {
723
- light: { role: "neutral", shade: 400 },
724
- dark: { role: "neutral", shade: 500 }
863
+ light: { role: "neutral", shade: 600 },
864
+ dark: { role: "neutral", shade: 400 }
725
865
  },
726
866
  disabled: {
727
867
  light: { role: "neutral", shade: 300 },
@@ -769,6 +909,12 @@ var SEMANTIC_SURFACE_MAP = {
769
909
  light: { constant: CONFIG_SURFACE },
770
910
  dark: { constant: CONFIG_DARK_SURFACE }
771
911
  },
912
+ // Distinct from card: glass themes (Blackout, Modern Minimal dark) set surface-card translucent.
913
+ // Floating panels rendered over arbitrary page content must be opaque — override this token there.
914
+ popover: {
915
+ light: { constant: CONFIG_SURFACE },
916
+ dark: { constant: CONFIG_DARK_SURFACE }
917
+ },
772
918
  subtle: {
773
919
  light: { role: "neutral", shade: 50 },
774
920
  dark: { role: "neutral", shade: 800 }
@@ -797,6 +943,12 @@ var SEMANTIC_SURFACE_MAP = {
797
943
  light: { role: "neutral", shade: 50 },
798
944
  dark: { role: "neutral", shade: 800 }
799
945
  },
946
+ // Persistent selected-state surface (active nav item, currently-selected list row).
947
+ // Distinct from interactive-active (transient press) and from accent-subtle (broader brand surface).
948
+ selected: {
949
+ light: { role: "primary", shade: 100 },
950
+ dark: { role: "primary", shade: 800 }
951
+ },
800
952
  "accent-subtle": {
801
953
  light: { role: "primary", shade: 50 },
802
954
  dark: { role: "primary", shade: 900 }
@@ -840,6 +992,29 @@ var SEMANTIC_SURFACE_MAP = {
840
992
  "info-default": {
841
993
  light: { role: "info", shade: 500 },
842
994
  dark: { role: "info", shade: 500 }
995
+ },
996
+ // 5-tier ordinal elevation scale — deepest (0) to highest (4)
997
+ // Light mode: BO-10 near-white ramp (white → neutral-300).
998
+ // Dark mode: deep neutral ramp (neutral-950 → neutral-600).
999
+ "elev-0": {
1000
+ light: { constant: "#ffffff" },
1001
+ dark: { role: "neutral", shade: 950 }
1002
+ },
1003
+ "elev-1": {
1004
+ light: { role: "neutral", shade: 50 },
1005
+ dark: { role: "neutral", shade: 900 }
1006
+ },
1007
+ "elev-2": {
1008
+ light: { role: "neutral", shade: 100 },
1009
+ dark: { role: "neutral", shade: 800 }
1010
+ },
1011
+ "elev-3": {
1012
+ light: { role: "neutral", shade: 200 },
1013
+ dark: { role: "neutral", shade: 700 }
1014
+ },
1015
+ "elev-4": {
1016
+ light: { role: "neutral", shade: 300 },
1017
+ dark: { role: "neutral", shade: 600 }
843
1018
  }
844
1019
  };
845
1020
  var SEMANTIC_BORDER_MAP = {
@@ -977,28 +1152,28 @@ function resolveRef(ref, primitives, config) {
977
1152
  return ref.constant;
978
1153
  }
979
1154
  }
980
- function resolveMapping(mapping, primitives, config) {
1155
+ function resolveMapping(mapping, lightPrimitives, darkPrimitives, config) {
981
1156
  return {
982
- light: resolveRef(mapping.light, primitives, config),
983
- dark: resolveRef(mapping.dark, primitives, config)
1157
+ light: resolveRef(mapping.light, lightPrimitives, config),
1158
+ dark: resolveRef(mapping.dark, darkPrimitives, config)
984
1159
  };
985
1160
  }
986
- function assignSemanticTokens(primitives, config) {
1161
+ function assignSemanticTokens(lightPrimitives, darkPrimitives, config) {
987
1162
  const text = {};
988
1163
  const surface = {};
989
1164
  const border = {};
990
1165
  const interactive = {};
991
1166
  for (const [name, mapping] of Object.entries(SEMANTIC_MAP.text)) {
992
- text[name] = resolveMapping(mapping, primitives, config);
1167
+ text[name] = resolveMapping(mapping, lightPrimitives, darkPrimitives, config);
993
1168
  }
994
1169
  for (const [name, mapping] of Object.entries(SEMANTIC_MAP.surface)) {
995
- surface[name] = resolveMapping(mapping, primitives, config);
1170
+ surface[name] = resolveMapping(mapping, lightPrimitives, darkPrimitives, config);
996
1171
  }
997
1172
  for (const [name, mapping] of Object.entries(SEMANTIC_MAP.border)) {
998
- border[name] = resolveMapping(mapping, primitives, config);
1173
+ border[name] = resolveMapping(mapping, lightPrimitives, darkPrimitives, config);
999
1174
  }
1000
1175
  for (const [name, mapping] of Object.entries(SEMANTIC_MAP.interactive)) {
1001
- interactive[name] = resolveMapping(mapping, primitives, config);
1176
+ interactive[name] = resolveMapping(mapping, lightPrimitives, darkPrimitives, config);
1002
1177
  }
1003
1178
  return { text, surface, border, interactive };
1004
1179
  }
@@ -1071,6 +1246,18 @@ function generatePrimitives(config) {
1071
1246
  info: generateShadeScale(config.colors.info, "info")
1072
1247
  };
1073
1248
  }
1249
+ function generateDarkPrimitives(config, lightPrimitives) {
1250
+ const cd = config["colors-dark"];
1251
+ return {
1252
+ primary: cd?.primary ? generateShadeScale(cd.primary, "primary") : lightPrimitives.primary,
1253
+ accent: cd?.accent ? generateShadeScale(cd.accent, "accent") : lightPrimitives.accent,
1254
+ neutral: cd?.neutral ? config.colors.neutral === null ? TAILWIND_GRAY : generateShadeScale(cd.neutral, "neutral") : lightPrimitives.neutral,
1255
+ success: cd?.success ? generateShadeScale(cd.success, "success") : lightPrimitives.success,
1256
+ warning: cd?.warning ? generateShadeScale(cd.warning, "warning") : lightPrimitives.warning,
1257
+ error: cd?.error ? generateShadeScale(cd.error, "error") : lightPrimitives.error,
1258
+ info: cd?.info ? generateShadeScale(cd.info, "info") : lightPrimitives.info
1259
+ };
1260
+ }
1074
1261
  function parseConfig(yamlString) {
1075
1262
  const parsed = parseYaml(yamlString);
1076
1263
  const result = validateConfig(parsed);
@@ -1103,7 +1290,8 @@ ${validation.errors.map((e) => ` - ${e}`).join("\n")}`
1103
1290
  }
1104
1291
  const resolved = resolveConfig(config);
1105
1292
  const primitives = generatePrimitives(resolved);
1106
- let tokens = assignSemanticTokens(primitives, resolved);
1293
+ const darkPrimitives = generateDarkPrimitives(resolved, primitives);
1294
+ let tokens = assignSemanticTokens(primitives, darkPrimitives, resolved);
1107
1295
  tokens = applyOverrides(tokens, resolved.overrides);
1108
1296
  const output = {
1109
1297
  primitivesCss: generatePrimitivesCss(primitives, resolved),
@@ -1677,35 +1865,75 @@ function colorToRgb(color) {
1677
1865
  const parsed = parseColor(color);
1678
1866
  return parsed ? parsed.rgb : [0, 0, 0];
1679
1867
  }
1868
+ var STANDARD_TEXT_TOKENS = ["primary", "secondary", "tertiary"];
1869
+ var STATUS_TEXT_TOKENS = ["error", "warning", "success", "info"];
1680
1870
  function checkContrastWarnings(config, issues) {
1681
1871
  const resolved = resolveConfig(config);
1872
+ const lightPrimitives = generatePrimitives(resolved);
1873
+ const darkPrimitives = generateDarkPrimitives(resolved, lightPrimitives);
1874
+ const tokens = applyOverrides(
1875
+ assignSemanticTokens(lightPrimitives, darkPrimitives, resolved),
1876
+ resolved.overrides
1877
+ );
1682
1878
  const lightBg = resolved.colors.background;
1683
1879
  const lightSurface = resolved.colors.surface;
1684
1880
  const primary = resolved.colors.primary;
1685
1881
  const lightBgRgb = colorToRgb(lightBg);
1686
1882
  const lightSurfaceRgb = colorToRgb(lightSurface);
1687
- const textDark = "#111827";
1688
- const textOnBg = getContrastRatio(textDark, lightBg, lightBgRgb);
1689
- if (textOnBg < CONTRAST_TEXT_AA) {
1690
- issues.push(
1691
- issue(
1692
- "warning",
1693
- "WCAG_CONTRAST",
1694
- `Light mode: text-primary on background has contrast ratio ${textOnBg.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1695
- "colors.background"
1696
- )
1697
- );
1698
- }
1699
- const textOnSurface = getContrastRatio(textDark, lightSurface, lightSurfaceRgb);
1700
- if (textOnSurface < CONTRAST_TEXT_AA) {
1701
- issues.push(
1702
- issue(
1703
- "warning",
1704
- "WCAG_CONTRAST",
1705
- `Light mode: text-primary on surface has contrast ratio ${textOnSurface.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1706
- "colors.surface"
1707
- )
1708
- );
1883
+ const darkBg = resolved["colors-dark"]?.background ?? "#0a0a0a";
1884
+ const darkSurface = resolved["colors-dark"]?.surface ?? "#171717";
1885
+ const darkPrimary = resolved["colors-dark"]?.primary ?? primary;
1886
+ const darkBgRgb = colorToRgb(darkBg);
1887
+ const darkSurfaceRgb = colorToRgb(darkSurface);
1888
+ const textTokensToCheck = [...STANDARD_TEXT_TOKENS, ...STATUS_TEXT_TOKENS];
1889
+ for (const tokenName of textTokensToCheck) {
1890
+ const tokenValue = tokens.text[tokenName];
1891
+ if (!tokenValue) continue;
1892
+ const fullName = `text-${tokenName}`;
1893
+ const lightOnBg = getContrastRatio(tokenValue.light, lightBg, lightBgRgb);
1894
+ if (lightOnBg < CONTRAST_TEXT_AA) {
1895
+ issues.push(
1896
+ issue(
1897
+ "warning",
1898
+ "WCAG_CONTRAST",
1899
+ `Light mode: ${fullName} on background has contrast ratio ${lightOnBg.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1900
+ "colors.background"
1901
+ )
1902
+ );
1903
+ }
1904
+ const lightOnSurface = getContrastRatio(tokenValue.light, lightSurface, lightSurfaceRgb);
1905
+ if (lightOnSurface < CONTRAST_TEXT_AA) {
1906
+ issues.push(
1907
+ issue(
1908
+ "warning",
1909
+ "WCAG_CONTRAST",
1910
+ `Light mode: ${fullName} on surface has contrast ratio ${lightOnSurface.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1911
+ "colors.surface"
1912
+ )
1913
+ );
1914
+ }
1915
+ const darkOnBg = getContrastRatio(tokenValue.dark, darkBg, darkBgRgb);
1916
+ if (darkOnBg < CONTRAST_TEXT_AA) {
1917
+ issues.push(
1918
+ issue(
1919
+ "warning",
1920
+ "WCAG_CONTRAST",
1921
+ `Dark mode: ${fullName} on background has contrast ratio ${darkOnBg.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1922
+ "colors-dark.background"
1923
+ )
1924
+ );
1925
+ }
1926
+ const darkOnSurface = getContrastRatio(tokenValue.dark, darkSurface, darkSurfaceRgb);
1927
+ if (darkOnSurface < CONTRAST_TEXT_AA) {
1928
+ issues.push(
1929
+ issue(
1930
+ "warning",
1931
+ "WCAG_CONTRAST",
1932
+ `Dark mode: ${fullName} on surface has contrast ratio ${darkOnSurface.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1933
+ "colors-dark.surface"
1934
+ )
1935
+ );
1936
+ }
1709
1937
  }
1710
1938
  const primaryOnBg = getContrastRatio(primary, lightBg, lightBgRgb);
1711
1939
  if (primaryOnBg < CONTRAST_INTERACTIVE_AA) {
@@ -1729,34 +1957,6 @@ function checkContrastWarnings(config, issues) {
1729
1957
  )
1730
1958
  );
1731
1959
  }
1732
- const darkBg = resolved["colors-dark"]?.background ?? "#0a0a0a";
1733
- const darkSurface = resolved["colors-dark"]?.surface ?? "#171717";
1734
- const darkPrimary = resolved["colors-dark"]?.primary ?? primary;
1735
- const darkBgRgb = colorToRgb(darkBg);
1736
- const darkSurfaceRgb = colorToRgb(darkSurface);
1737
- const textLight = "#f9fafb";
1738
- const textOnDarkBg = getContrastRatio(textLight, darkBg, darkBgRgb);
1739
- if (textOnDarkBg < CONTRAST_TEXT_AA) {
1740
- issues.push(
1741
- issue(
1742
- "warning",
1743
- "WCAG_CONTRAST",
1744
- `Dark mode: text-primary on background has contrast ratio ${textOnDarkBg.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1745
- "colors-dark.background"
1746
- )
1747
- );
1748
- }
1749
- const textOnDarkSurface = getContrastRatio(textLight, darkSurface, darkSurfaceRgb);
1750
- if (textOnDarkSurface < CONTRAST_TEXT_AA) {
1751
- issues.push(
1752
- issue(
1753
- "warning",
1754
- "WCAG_CONTRAST",
1755
- `Dark mode: text-primary on surface has contrast ratio ${textOnDarkSurface.toFixed(2)}:1 (needs >= ${CONTRAST_TEXT_AA}:1)`,
1756
- "colors-dark.surface"
1757
- )
1758
- );
1759
- }
1760
1960
  const darkPrimaryOnBg = getContrastRatio(darkPrimary, darkBg, darkBgRgb);
1761
1961
  if (darkPrimaryOnBg < CONTRAST_INTERACTIVE_AA) {
1762
1962
  issues.push(
@@ -124,6 +124,23 @@ interface ParsedColor {
124
124
  format: ColorFormat;
125
125
  original: string;
126
126
  }
127
+ /**
128
+ * The 15 Material 3 type-scale slots plus Visor's `labelXSmall` extension.
129
+ *
130
+ * Per-slot size / weight / letter-spacing can be overridden in
131
+ * `typography.slots` to tune the generated Flutter `TextTheme` without
132
+ * touching the global font families.
133
+ */
134
+ type MaterialTextSlot = "displayLarge" | "displayMedium" | "displaySmall" | "headlineLarge" | "headlineMedium" | "headlineSmall" | "titleLarge" | "titleMedium" | "titleSmall" | "bodyLarge" | "bodyMedium" | "bodySmall" | "labelLarge" | "labelMedium" | "labelSmall" | "labelXSmall";
135
+ /** Per-slot override in `typography.slots.<slot>`. All fields optional. */
136
+ interface TextSlotOverride {
137
+ /** Font size in logical pixels (Flutter `TextStyle.fontSize`). */
138
+ size?: number;
139
+ /** Font weight (100–900, matching Flutter `FontWeight.w100..w900`). */
140
+ weight?: number;
141
+ /** Letter spacing in logical pixels (Flutter `TextStyle.letterSpacing`). */
142
+ "letter-spacing"?: number;
143
+ }
127
144
  interface VisorThemeConfig {
128
145
  name: string;
129
146
  version: 1;
@@ -189,6 +206,13 @@ interface VisorThemeConfig {
189
206
  normal?: string;
190
207
  wide?: string;
191
208
  };
209
+ /**
210
+ * Per-slot overrides for the generated Flutter `TextTheme`. Any subset
211
+ * of the 16 Material slots may be specified; omitted slots fall
212
+ * through to `VisorTextStylesData.defaults` (Material 3 2024 scale).
213
+ * Flutter-only — ignored by CSS/NextJS adapters.
214
+ */
215
+ slots?: Partial<Record<MaterialTextSlot, TextSlotOverride>>;
192
216
  };
193
217
  spacing?: {
194
218
  base?: number;
@@ -207,6 +231,12 @@ interface VisorThemeConfig {
207
231
  lg?: string;
208
232
  xl?: string;
209
233
  };
234
+ strokeWidths?: {
235
+ thin?: number;
236
+ regular?: number;
237
+ medium?: number;
238
+ thick?: number;
239
+ };
210
240
  motion?: {
211
241
  "duration-fast"?: string;
212
242
  "duration-normal"?: string;
@@ -217,6 +247,21 @@ interface VisorThemeConfig {
217
247
  light?: Record<string, string>;
218
248
  dark?: Record<string, string>;
219
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
+ };
220
265
  }
221
266
  /** Config with all defaults resolved */
222
267
  interface ResolvedThemeConfig {
@@ -260,6 +305,12 @@ interface ResolvedThemeConfig {
260
305
  mono: {
261
306
  family: string;
262
307
  };
308
+ /**
309
+ * Per-slot Material `TextTheme` overrides, passed through from the
310
+ * raw config. Empty object when none supplied. Flutter adapter
311
+ * consumes these; other adapters may ignore them.
312
+ */
313
+ slots: Partial<Record<MaterialTextSlot, TextSlotOverride>>;
263
314
  };
264
315
  spacing: {
265
316
  base: number;
@@ -278,6 +329,12 @@ interface ResolvedThemeConfig {
278
329
  lg: string;
279
330
  xl: string;
280
331
  };
332
+ strokeWidths: {
333
+ thin: number;
334
+ regular: number;
335
+ medium: number;
336
+ thick: number;
337
+ };
281
338
  motion: {
282
339
  "duration-fast": string;
283
340
  "duration-normal": string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loworbitstudio/visor-theme-engine",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",