@loworbitstudio/visor-theme-engine 0.1.0 → 0.4.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.
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-ZLXFCNYF.js";
35
+ } from "./chunk-G4B57FB3.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.",
@@ -316,6 +351,28 @@ var visor_theme_schema_default = {
316
351
  { pattern: "^hsla?\\(" },
317
352
  { pattern: "^oklch\\(" }
318
353
  ]
354
+ },
355
+ textSlotOverride: {
356
+ type: "object",
357
+ description: "Per-slot override for one Material TextTheme entry.",
358
+ additionalProperties: false,
359
+ properties: {
360
+ size: {
361
+ type: "number",
362
+ exclusiveMinimum: 0,
363
+ description: "Font size in logical pixels (Flutter TextStyle.fontSize)."
364
+ },
365
+ weight: {
366
+ type: "integer",
367
+ minimum: 100,
368
+ maximum: 900,
369
+ description: "Font weight."
370
+ },
371
+ "letter-spacing": {
372
+ type: "number",
373
+ description: "Letter spacing in logical pixels (Flutter TextStyle.letterSpacing). Material defaults include negative values, e.g. -0.25 for displayLarge."
374
+ }
375
+ }
319
376
  }
320
377
  }
321
378
  };
@@ -325,12 +382,15 @@ var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
325
382
  "name",
326
383
  "version",
327
384
  "group",
385
+ "label",
386
+ "default-mode",
328
387
  "colors",
329
388
  "colors-dark",
330
389
  "typography",
331
390
  "spacing",
332
391
  "radius",
333
392
  "shadows",
393
+ "strokeWidths",
334
394
  "motion",
335
395
  "overrides"
336
396
  ]);
@@ -351,14 +411,18 @@ var KNOWN_TYPOGRAPHY_KEYS = /* @__PURE__ */ new Set([
351
411
  "body",
352
412
  "mono",
353
413
  "letter-spacing",
354
- "scale"
414
+ "scale",
415
+ "slots"
355
416
  ]);
356
- var KNOWN_TYPOGRAPHY_FONT_KEYS = /* @__PURE__ */ new Set(["family", "weight", "source", "org"]);
417
+ var KNOWN_TYPOGRAPHY_FONT_KEYS = /* @__PURE__ */ new Set(["family", "weight", "weights", "source", "org"]);
357
418
  var KNOWN_TYPOGRAPHY_MONO_KEYS = /* @__PURE__ */ new Set(["family"]);
358
419
  var KNOWN_LETTER_SPACING_KEYS = /* @__PURE__ */ new Set(["tight", "normal", "wide"]);
420
+ var KNOWN_SLOT_NAMES = new Set(MATERIAL_TEXT_SLOTS);
421
+ var KNOWN_SLOT_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["size", "weight", "letter-spacing"]);
359
422
  var KNOWN_SPACING_KEYS = /* @__PURE__ */ new Set(["base"]);
360
423
  var KNOWN_RADIUS_KEYS = /* @__PURE__ */ new Set(["sm", "md", "lg", "xl", "pill"]);
361
424
  var KNOWN_SHADOW_KEYS = /* @__PURE__ */ new Set(["xs", "sm", "md", "lg", "xl"]);
425
+ var KNOWN_STROKE_WIDTH_KEYS = /* @__PURE__ */ new Set(["thin", "regular", "medium", "thick"]);
362
426
  var KNOWN_MOTION_KEYS = /* @__PURE__ */ new Set(["duration-fast", "duration-normal", "duration-slow", "easing"]);
363
427
  var KNOWN_OVERRIDES_KEYS = /* @__PURE__ */ new Set(["light", "dark"]);
364
428
  function checkUnknownKeys(obj, errors) {
@@ -423,6 +487,31 @@ function checkUnknownKeys(obj, errors) {
423
487
  }
424
488
  }
425
489
  }
490
+ if (typeof typo.slots === "object" && typo.slots !== null) {
491
+ const slots = typo.slots;
492
+ for (const slotName of Object.keys(slots)) {
493
+ if (!KNOWN_SLOT_NAMES.has(slotName)) {
494
+ errors.push(
495
+ `Unknown key 'typography.slots.${slotName}'. Valid keys: ${[...MATERIAL_TEXT_SLOTS].join(", ")}`
496
+ );
497
+ continue;
498
+ }
499
+ const override = slots[slotName];
500
+ if (typeof override !== "object" || override === null) {
501
+ errors.push(
502
+ `'typography.slots.${slotName}' must be an object with optional size/weight/letter-spacing fields`
503
+ );
504
+ continue;
505
+ }
506
+ for (const key of Object.keys(override)) {
507
+ if (!KNOWN_SLOT_OVERRIDE_KEYS.has(key)) {
508
+ errors.push(
509
+ `Unknown key 'typography.slots.${slotName}.${key}'. Valid keys: ${[...KNOWN_SLOT_OVERRIDE_KEYS].join(", ")}`
510
+ );
511
+ }
512
+ }
513
+ }
514
+ }
426
515
  }
427
516
  if (typeof obj.spacing === "object" && obj.spacing !== null) {
428
517
  for (const key of Object.keys(obj.spacing)) {
@@ -445,6 +534,13 @@ function checkUnknownKeys(obj, errors) {
445
534
  }
446
535
  }
447
536
  }
537
+ if (typeof obj.strokeWidths === "object" && obj.strokeWidths !== null) {
538
+ for (const key of Object.keys(obj.strokeWidths)) {
539
+ if (!KNOWN_STROKE_WIDTH_KEYS.has(key)) {
540
+ errors.push(`Unknown key 'strokeWidths.${key}'. Valid keys: ${[...KNOWN_STROKE_WIDTH_KEYS].join(", ")}`);
541
+ }
542
+ }
543
+ }
448
544
  if (typeof obj.motion === "object" && obj.motion !== null) {
449
545
  for (const key of Object.keys(obj.motion)) {
450
546
  if (!KNOWN_MOTION_KEYS.has(key)) {
@@ -473,6 +569,15 @@ function validateConfig(config) {
473
569
  if (obj.version !== 1) {
474
570
  errors.push("'version' must be 1");
475
571
  }
572
+ if (obj.label !== void 0 && typeof obj.label !== "string") {
573
+ errors.push("'label' must be a string (optional display name override)");
574
+ }
575
+ if (obj["default-mode"] !== void 0) {
576
+ const mode = obj["default-mode"];
577
+ if (mode !== "dark" && mode !== "light") {
578
+ errors.push("'default-mode' must be either 'dark' or 'light'");
579
+ }
580
+ }
476
581
  if (typeof obj.colors !== "object" || obj.colors === null) {
477
582
  errors.push("'colors' is required and must be an object");
478
583
  return { valid: false, errors };
@@ -530,6 +635,28 @@ function validateConfig(config) {
530
635
  if (font && font.source === "visor-fonts" && !font.org) {
531
636
  errors.push(`'typography.${slot}.org' is required when source is 'visor-fonts'`);
532
637
  }
638
+ if (font && font.weights !== void 0) {
639
+ if (!Array.isArray(font.weights) || !font.weights.every((w) => typeof w === "number" && w > 0)) {
640
+ errors.push(`'typography.${slot}.weights' must be an array of positive numbers (e.g., [300, 500])`);
641
+ }
642
+ }
643
+ }
644
+ if (typeof typo.slots === "object" && typo.slots !== null) {
645
+ const slots = typo.slots;
646
+ for (const slotName of Object.keys(slots)) {
647
+ const override = slots[slotName];
648
+ if (typeof override !== "object" || override === null) continue;
649
+ const o = override;
650
+ if (o.size !== void 0 && (typeof o.size !== "number" || o.size <= 0)) {
651
+ errors.push(`'typography.slots.${slotName}.size' must be a positive number (logical pixels)`);
652
+ }
653
+ if (o.weight !== void 0 && (typeof o.weight !== "number" || o.weight < 100 || o.weight > 900)) {
654
+ errors.push(`'typography.slots.${slotName}.weight' must be between 100 and 900`);
655
+ }
656
+ if (o["letter-spacing"] !== void 0 && typeof o["letter-spacing"] !== "number") {
657
+ errors.push(`'typography.slots.${slotName}.letter-spacing' must be a number (Flutter logical pixels)`);
658
+ }
659
+ }
533
660
  }
534
661
  }
535
662
  if (obj.overrides !== void 0) {
@@ -579,6 +706,12 @@ var DEFAULTS = {
579
706
  lg: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)",
580
707
  xl: "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)"
581
708
  },
709
+ strokeWidths: {
710
+ thin: 1,
711
+ regular: 1.5,
712
+ medium: 2,
713
+ thick: 2.5
714
+ },
582
715
  motion: {
583
716
  "duration-fast": "100ms",
584
717
  "duration-normal": "200ms",
@@ -636,23 +769,27 @@ function resolveConfig(config) {
636
769
  family: config.typography?.heading?.family ?? DEFAULTS.typography.heading.family,
637
770
  weight: config.typography?.heading?.weight ?? DEFAULTS.typography.heading.weight,
638
771
  ...config.typography?.heading?.source && { source: config.typography.heading.source },
639
- ...config.typography?.heading?.org && { org: config.typography.heading.org }
772
+ ...config.typography?.heading?.org && { org: config.typography.heading.org },
773
+ ...config.typography?.heading?.weights && { weights: config.typography.heading.weights }
640
774
  },
641
775
  display: {
642
776
  family: config.typography?.display?.family ?? config.typography?.heading?.family ?? DEFAULTS.typography.heading.family,
643
777
  weight: config.typography?.display?.weight ?? 400,
644
778
  ...config.typography?.display?.source && { source: config.typography.display.source },
645
- ...config.typography?.display?.org && { org: config.typography.display.org }
779
+ ...config.typography?.display?.org && { org: config.typography.display.org },
780
+ ...config.typography?.display?.weights && { weights: config.typography.display.weights }
646
781
  },
647
782
  body: {
648
783
  family: config.typography?.body?.family ?? DEFAULTS.typography.body.family,
649
784
  weight: config.typography?.body?.weight ?? DEFAULTS.typography.body.weight,
650
785
  ...config.typography?.body?.source && { source: config.typography.body.source },
651
- ...config.typography?.body?.org && { org: config.typography.body.org }
786
+ ...config.typography?.body?.org && { org: config.typography.body.org },
787
+ ...config.typography?.body?.weights && { weights: config.typography.body.weights }
652
788
  },
653
789
  mono: {
654
790
  family: config.typography?.mono?.family ?? DEFAULTS.typography.mono.family
655
- }
791
+ },
792
+ slots: config.typography?.slots ?? {}
656
793
  },
657
794
  spacing: {
658
795
  base: config.spacing?.base ?? DEFAULTS.spacing.base
@@ -671,6 +808,12 @@ function resolveConfig(config) {
671
808
  lg: config.shadows?.lg ?? DEFAULTS.shadows.lg,
672
809
  xl: config.shadows?.xl ?? DEFAULTS.shadows.xl
673
810
  },
811
+ strokeWidths: {
812
+ thin: config.strokeWidths?.thin ?? DEFAULTS.strokeWidths.thin,
813
+ regular: config.strokeWidths?.regular ?? DEFAULTS.strokeWidths.regular,
814
+ medium: config.strokeWidths?.medium ?? DEFAULTS.strokeWidths.medium,
815
+ thick: config.strokeWidths?.thick ?? DEFAULTS.strokeWidths.thick
816
+ },
674
817
  motion: {
675
818
  "duration-fast": config.motion?.["duration-fast"] ?? DEFAULTS.motion["duration-fast"],
676
819
  "duration-normal": config.motion?.["duration-normal"] ?? DEFAULTS.motion["duration-normal"],
@@ -750,6 +893,12 @@ var SEMANTIC_SURFACE_MAP = {
750
893
  light: { constant: CONFIG_SURFACE },
751
894
  dark: { constant: CONFIG_DARK_SURFACE }
752
895
  },
896
+ // Distinct from card: glass themes (Blackout, Modern Minimal dark) set surface-card translucent.
897
+ // Floating panels rendered over arbitrary page content must be opaque — override this token there.
898
+ popover: {
899
+ light: { constant: CONFIG_SURFACE },
900
+ dark: { constant: CONFIG_DARK_SURFACE }
901
+ },
753
902
  subtle: {
754
903
  light: { role: "neutral", shade: 50 },
755
904
  dark: { role: "neutral", shade: 800 }
@@ -778,6 +927,12 @@ var SEMANTIC_SURFACE_MAP = {
778
927
  light: { role: "neutral", shade: 50 },
779
928
  dark: { role: "neutral", shade: 800 }
780
929
  },
930
+ // Persistent selected-state surface (active nav item, currently-selected list row).
931
+ // Distinct from interactive-active (transient press) and from accent-subtle (broader brand surface).
932
+ selected: {
933
+ light: { role: "primary", shade: 100 },
934
+ dark: { role: "primary", shade: 800 }
935
+ },
781
936
  "accent-subtle": {
782
937
  light: { role: "primary", shade: 50 },
783
938
  dark: { role: "primary", shade: 900 }
@@ -958,28 +1113,28 @@ function resolveRef(ref, primitives, config) {
958
1113
  return ref.constant;
959
1114
  }
960
1115
  }
961
- function resolveMapping(mapping, primitives, config) {
1116
+ function resolveMapping(mapping, lightPrimitives, darkPrimitives, config) {
962
1117
  return {
963
- light: resolveRef(mapping.light, primitives, config),
964
- dark: resolveRef(mapping.dark, primitives, config)
1118
+ light: resolveRef(mapping.light, lightPrimitives, config),
1119
+ dark: resolveRef(mapping.dark, darkPrimitives, config)
965
1120
  };
966
1121
  }
967
- function assignSemanticTokens(primitives, config) {
1122
+ function assignSemanticTokens(lightPrimitives, darkPrimitives, config) {
968
1123
  const text = {};
969
1124
  const surface = {};
970
1125
  const border = {};
971
1126
  const interactive = {};
972
1127
  for (const [name, mapping] of Object.entries(SEMANTIC_MAP.text)) {
973
- text[name] = resolveMapping(mapping, primitives, config);
1128
+ text[name] = resolveMapping(mapping, lightPrimitives, darkPrimitives, config);
974
1129
  }
975
1130
  for (const [name, mapping] of Object.entries(SEMANTIC_MAP.surface)) {
976
- surface[name] = resolveMapping(mapping, primitives, config);
1131
+ surface[name] = resolveMapping(mapping, lightPrimitives, darkPrimitives, config);
977
1132
  }
978
1133
  for (const [name, mapping] of Object.entries(SEMANTIC_MAP.border)) {
979
- border[name] = resolveMapping(mapping, primitives, config);
1134
+ border[name] = resolveMapping(mapping, lightPrimitives, darkPrimitives, config);
980
1135
  }
981
1136
  for (const [name, mapping] of Object.entries(SEMANTIC_MAP.interactive)) {
982
- interactive[name] = resolveMapping(mapping, primitives, config);
1137
+ interactive[name] = resolveMapping(mapping, lightPrimitives, darkPrimitives, config);
983
1138
  }
984
1139
  return { text, surface, border, interactive };
985
1140
  }
@@ -1052,6 +1207,18 @@ function generatePrimitives(config) {
1052
1207
  info: generateShadeScale(config.colors.info, "info")
1053
1208
  };
1054
1209
  }
1210
+ function generateDarkPrimitives(config, lightPrimitives) {
1211
+ const cd = config["colors-dark"];
1212
+ return {
1213
+ primary: cd?.primary ? generateShadeScale(cd.primary, "primary") : lightPrimitives.primary,
1214
+ accent: cd?.accent ? generateShadeScale(cd.accent, "accent") : lightPrimitives.accent,
1215
+ neutral: cd?.neutral ? config.colors.neutral === null ? TAILWIND_GRAY : generateShadeScale(cd.neutral, "neutral") : lightPrimitives.neutral,
1216
+ success: cd?.success ? generateShadeScale(cd.success, "success") : lightPrimitives.success,
1217
+ warning: cd?.warning ? generateShadeScale(cd.warning, "warning") : lightPrimitives.warning,
1218
+ error: cd?.error ? generateShadeScale(cd.error, "error") : lightPrimitives.error,
1219
+ info: cd?.info ? generateShadeScale(cd.info, "info") : lightPrimitives.info
1220
+ };
1221
+ }
1055
1222
  function parseConfig(yamlString) {
1056
1223
  const parsed = parseYaml(yamlString);
1057
1224
  const result = validateConfig(parsed);
@@ -1084,7 +1251,8 @@ ${validation.errors.map((e) => ` - ${e}`).join("\n")}`
1084
1251
  }
1085
1252
  const resolved = resolveConfig(config);
1086
1253
  const primitives = generatePrimitives(resolved);
1087
- let tokens = assignSemanticTokens(primitives, resolved);
1254
+ const darkPrimitives = generateDarkPrimitives(resolved, primitives);
1255
+ let tokens = assignSemanticTokens(primitives, darkPrimitives, resolved);
1088
1256
  tokens = applyOverrides(tokens, resolved.overrides);
1089
1257
  const output = {
1090
1258
  primitivesCss: generatePrimitivesCss(primitives, resolved),
@@ -1835,6 +2003,51 @@ function checkRadiusScale(config, issues) {
1835
2003
  );
1836
2004
  }
1837
2005
  }
2006
+ function checkDarkLightParity(config, issues) {
2007
+ if (!config.colors) return;
2008
+ const colorKeys = Object.keys(config.colors).filter((k) => k !== "primary");
2009
+ const hasDarkSection = config["colors-dark"] !== void 0;
2010
+ if (colorKeys.length > 0 && !hasDarkSection) {
2011
+ issues.push(
2012
+ issue(
2013
+ "warning",
2014
+ "DARK_LIGHT_PARITY",
2015
+ "Custom colors are set but no colors-dark section exists. Dark mode will use generated defaults which may not match your brand.",
2016
+ "colors-dark"
2017
+ )
2018
+ );
2019
+ return;
2020
+ }
2021
+ if (colorKeys.length > 0 && hasDarkSection) {
2022
+ const lightKeys = new Set(Object.keys(config.colors));
2023
+ const darkKeys = new Set(Object.keys(config["colors-dark"]));
2024
+ for (const key of lightKeys) {
2025
+ if (key === "primary") continue;
2026
+ if (!darkKeys.has(key)) {
2027
+ issues.push(
2028
+ issue(
2029
+ "warning",
2030
+ "DARK_LIGHT_PARITY",
2031
+ `Color "${key}" is set in colors but missing from colors-dark. Dark mode will use a generated default.`,
2032
+ "colors-dark"
2033
+ )
2034
+ );
2035
+ }
2036
+ }
2037
+ for (const key of darkKeys) {
2038
+ if (!lightKeys.has(key)) {
2039
+ issues.push(
2040
+ issue(
2041
+ "warning",
2042
+ "DARK_LIGHT_PARITY",
2043
+ `Color "${key}" is set in colors-dark but missing from colors. Light mode will use a generated default.`,
2044
+ "colors"
2045
+ )
2046
+ );
2047
+ }
2048
+ }
2049
+ }
2050
+ }
1838
2051
  function validate(config) {
1839
2052
  const errors = [];
1840
2053
  const warnings = [];
@@ -1866,6 +2079,7 @@ function validate(config) {
1866
2079
  checkColorSimilarity(typedConfig, warnings);
1867
2080
  checkMissingGlowShadow(typedConfig, warnings);
1868
2081
  checkRadiusScale(typedConfig, warnings);
2082
+ checkDarkLightParity(typedConfig, warnings);
1869
2083
  }
1870
2084
  return {
1871
2085
  valid: errors.length === 0,
@@ -46,18 +46,24 @@ interface VisorTypography {
46
46
  heading?: {
47
47
  family: string;
48
48
  weight?: number;
49
+ /** Explicit list of font weights to load (overrides engine defaults) */
50
+ weights?: number[];
49
51
  source?: FontSource;
50
52
  org?: string;
51
53
  };
52
54
  display?: {
53
55
  family: string;
54
56
  weight?: number;
57
+ /** Explicit list of font weights to load (overrides engine defaults) */
58
+ weights?: number[];
55
59
  source?: FontSource;
56
60
  org?: string;
57
61
  };
58
62
  body?: {
59
63
  family: string;
60
64
  weight?: number;
65
+ /** Explicit list of font weights to load (overrides engine defaults) */
66
+ weights?: number[];
61
67
  source?: FontSource;
62
68
  org?: string;
63
69
  };
@@ -118,11 +124,32 @@ interface ParsedColor {
118
124
  format: ColorFormat;
119
125
  original: string;
120
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
+ }
121
144
  interface VisorThemeConfig {
122
145
  name: string;
123
146
  version: 1;
124
147
  /** Theme group for the docs site theme switcher (e.g. 'Visor', 'Client', 'Low Orbit'). Used by `visor theme sync`. */
125
148
  group?: string;
149
+ /** Optional display label override for the theme switcher (e.g. 'ENTR', 'SoleSpark'). Falls back to title-cased name. */
150
+ label?: string;
151
+ /** Default color mode to force when the theme is activated ('dark' or 'light'). If unset, user/system preference applies. */
152
+ "default-mode"?: "dark" | "light";
126
153
  colors: {
127
154
  primary: string;
128
155
  accent?: string;
@@ -150,18 +177,21 @@ interface VisorThemeConfig {
150
177
  heading?: {
151
178
  family?: string;
152
179
  weight?: number;
180
+ weights?: number[];
153
181
  source?: FontSource;
154
182
  org?: string;
155
183
  };
156
184
  display?: {
157
185
  family?: string;
158
186
  weight?: number;
187
+ weights?: number[];
159
188
  source?: FontSource;
160
189
  org?: string;
161
190
  };
162
191
  body?: {
163
192
  family?: string;
164
193
  weight?: number;
194
+ weights?: number[];
165
195
  source?: FontSource;
166
196
  org?: string;
167
197
  };
@@ -176,6 +206,13 @@ interface VisorThemeConfig {
176
206
  normal?: string;
177
207
  wide?: string;
178
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>>;
179
216
  };
180
217
  spacing?: {
181
218
  base?: number;
@@ -194,6 +231,12 @@ interface VisorThemeConfig {
194
231
  lg?: string;
195
232
  xl?: string;
196
233
  };
234
+ strokeWidths?: {
235
+ thin?: number;
236
+ regular?: number;
237
+ medium?: number;
238
+ thick?: number;
239
+ };
197
240
  motion?: {
198
241
  "duration-fast"?: string;
199
242
  "duration-normal"?: string;
@@ -226,24 +269,33 @@ interface ResolvedThemeConfig {
226
269
  heading: {
227
270
  family: string;
228
271
  weight: number;
272
+ weights?: number[];
229
273
  source?: FontSource;
230
274
  org?: string;
231
275
  };
232
276
  display: {
233
277
  family: string;
234
278
  weight: number;
279
+ weights?: number[];
235
280
  source?: FontSource;
236
281
  org?: string;
237
282
  };
238
283
  body: {
239
284
  family: string;
240
285
  weight: number;
286
+ weights?: number[];
241
287
  source?: FontSource;
242
288
  org?: string;
243
289
  };
244
290
  mono: {
245
291
  family: string;
246
292
  };
293
+ /**
294
+ * Per-slot Material `TextTheme` overrides, passed through from the
295
+ * raw config. Empty object when none supplied. Flutter adapter
296
+ * consumes these; other adapters may ignore them.
297
+ */
298
+ slots: Partial<Record<MaterialTextSlot, TextSlotOverride>>;
247
299
  };
248
300
  spacing: {
249
301
  base: number;
@@ -262,6 +314,12 @@ interface ResolvedThemeConfig {
262
314
  lg: string;
263
315
  xl: string;
264
316
  };
317
+ strokeWidths: {
318
+ thin: number;
319
+ regular: number;
320
+ medium: number;
321
+ thick: number;
322
+ };
265
323
  motion: {
266
324
  "duration-fast": string;
267
325
  "duration-normal": string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loworbitstudio/visor-theme-engine",
3
- "version": "0.1.0",
3
+ "version": "0.4.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",
@@ -35,7 +35,12 @@
35
35
  "design-system",
36
36
  "theme",
37
37
  "oklch",
38
- "tokens"
38
+ "tokens",
39
+ "react",
40
+ "css-variables",
41
+ "theming",
42
+ "color-system",
43
+ "wcag"
39
44
  ],
40
45
  "license": "MIT",
41
46
  "repository": {
@@ -184,6 +184,29 @@
184
184
  "normal": { "type": "string" },
185
185
  "wide": { "type": "string" }
186
186
  }
187
+ },
188
+ "slots": {
189
+ "type": "object",
190
+ "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.",
191
+ "additionalProperties": false,
192
+ "properties": {
193
+ "displayLarge": { "$ref": "#/$defs/textSlotOverride" },
194
+ "displayMedium": { "$ref": "#/$defs/textSlotOverride" },
195
+ "displaySmall": { "$ref": "#/$defs/textSlotOverride" },
196
+ "headlineLarge": { "$ref": "#/$defs/textSlotOverride" },
197
+ "headlineMedium": { "$ref": "#/$defs/textSlotOverride" },
198
+ "headlineSmall": { "$ref": "#/$defs/textSlotOverride" },
199
+ "titleLarge": { "$ref": "#/$defs/textSlotOverride" },
200
+ "titleMedium": { "$ref": "#/$defs/textSlotOverride" },
201
+ "titleSmall": { "$ref": "#/$defs/textSlotOverride" },
202
+ "bodyLarge": { "$ref": "#/$defs/textSlotOverride" },
203
+ "bodyMedium": { "$ref": "#/$defs/textSlotOverride" },
204
+ "bodySmall": { "$ref": "#/$defs/textSlotOverride" },
205
+ "labelLarge": { "$ref": "#/$defs/textSlotOverride" },
206
+ "labelMedium": { "$ref": "#/$defs/textSlotOverride" },
207
+ "labelSmall": { "$ref": "#/$defs/textSlotOverride" },
208
+ "labelXSmall": { "$ref": "#/$defs/textSlotOverride" }
209
+ }
187
210
  }
188
211
  }
189
212
  },
@@ -223,6 +246,17 @@
223
246
  "xl": { "type": "string" }
224
247
  }
225
248
  },
249
+ "strokeWidths": {
250
+ "type": "object",
251
+ "description": "Stroke-width scale in pixels — used for borders, outlines, dividers, progress-indicator strokes. Defaults: thin=1, regular=1.5, medium=2, thick=2.5.",
252
+ "additionalProperties": false,
253
+ "properties": {
254
+ "thin": { "type": "number", "minimum": 0 },
255
+ "regular": { "type": "number", "minimum": 0 },
256
+ "medium": { "type": "number", "minimum": 0 },
257
+ "thick": { "type": "number", "minimum": 0 }
258
+ }
259
+ },
226
260
  "motion": {
227
261
  "type": "object",
228
262
  "description": "Motion/animation configuration.",
@@ -277,6 +311,28 @@
277
311
  { "pattern": "^hsla?\\(" },
278
312
  { "pattern": "^oklch\\(" }
279
313
  ]
314
+ },
315
+ "textSlotOverride": {
316
+ "type": "object",
317
+ "description": "Per-slot override for one Material TextTheme entry.",
318
+ "additionalProperties": false,
319
+ "properties": {
320
+ "size": {
321
+ "type": "number",
322
+ "exclusiveMinimum": 0,
323
+ "description": "Font size in logical pixels (Flutter TextStyle.fontSize)."
324
+ },
325
+ "weight": {
326
+ "type": "integer",
327
+ "minimum": 100,
328
+ "maximum": 900,
329
+ "description": "Font weight."
330
+ },
331
+ "letter-spacing": {
332
+ "type": "number",
333
+ "description": "Letter spacing in logical pixels (Flutter TextStyle.letterSpacing). Material defaults include negative values, e.g. -0.25 for displayLarge."
334
+ }
335
+ }
280
336
  }
281
337
  }
282
338
  }