@loworbitstudio/visor-theme-engine 0.1.0 → 0.4.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.
package/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # @loworbitstudio/visor-theme-engine
2
+
3
+ Theme engine for the [Visor](https://visor.loworbit.studio) design system — shade generation, token mapping, font resolution, and import/export for `.visor.yaml` themes.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @loworbitstudio/visor-theme-engine
9
+ ```
10
+
11
+ ## What It Does
12
+
13
+ - Generates dark and light color scales from a brand anchor color using OKLCH
14
+ - Maps theme configuration (`.visor.yaml`) to CSS custom properties
15
+ - Resolves font declarations against the Visor fonts CDN
16
+ - Exports theme bundles for use in any project
17
+ - Provides a `docsAdapter` for registering themes in fumadocs sites
18
+
19
+ ## Usage
20
+
21
+ Themes are typically managed via the Visor CLI (`visor theme sync`). Direct API usage is for advanced cases — building custom theme tooling or integrating with non-CLI workflows.
22
+
23
+ ```ts
24
+ import { generateTheme } from '@loworbitstudio/visor-theme-engine'
25
+ ```
26
+
27
+ ## Documentation
28
+
29
+ Full docs at [visor.loworbit.studio](https://visor.loworbit.studio).
@@ -1,4 +1,4 @@
1
- import { c as GeneratedPrimitives, i as SemanticTokens, R as ResolvedThemeConfig } from '../types-r7ae3WP2.js';
1
+ import { c as GeneratedPrimitives, i as SemanticTokens, R as ResolvedThemeConfig } from '../types-DgAumoCX.js';
2
2
 
3
3
  /**
4
4
  * Adapter types for the Visor theme engine.
@@ -5,10 +5,11 @@ import {
5
5
  generateDarkCss,
6
6
  generateLightCss,
7
7
  generatePrimitivesCss,
8
+ generateShadeScale,
8
9
  header,
9
10
  resolveThemeFonts,
10
11
  sectionComment
11
- } from "../chunk-ZLXFCNYF.js";
12
+ } from "../chunk-NZF2MS4L.js";
12
13
 
13
14
  // src/adapters/layers.ts
14
15
  var LAYER_ORDER = "@layer visor-primitives, visor-semantic, visor-adaptive, visor-bridge;";
@@ -427,6 +428,7 @@ function docsAdapter(input, options) {
427
428
  lines.push("");
428
429
  }
429
430
  }
431
+ const scale = input.config.typography?.scale ?? 1;
430
432
  const seenFamilies = /* @__PURE__ */ new Set();
431
433
  for (const font of fontSlots) {
432
434
  if (font && font.source === "visor-fonts" && !seenFamilies.has(font.family)) {
@@ -439,6 +441,9 @@ function docsAdapter(input, options) {
439
441
  lines.push(` font-weight: ${weight};`);
440
442
  lines.push(` font-style: ${font.italic ? "italic" : "normal"};`);
441
443
  lines.push(` font-display: ${font.display};`);
444
+ if (scale !== 1) {
445
+ lines.push(` size-adjust: ${Math.round(scale * 100)}%;`);
446
+ }
442
447
  lines.push("}");
443
448
  lines.push("");
444
449
  }
@@ -480,6 +485,25 @@ function docsAdapter(input, options) {
480
485
  lines.push("");
481
486
  lines.push("\n/* \u2500\u2500 Section 2: Dark mode overrides \u2500\u2500 */");
482
487
  const darkDecls = generateSemanticDecls2(input.tokens, "dark");
488
+ const colorsDark = input.config["colors-dark"];
489
+ const darkPrimitiveOverrides = [];
490
+ if (colorsDark?.primary) {
491
+ const darkPrimary = generateShadeScale(colorsDark.primary, "primary");
492
+ for (const step of FULL_SHADE_STEPS) {
493
+ darkPrimitiveOverrides.push(`--color-primary-${step}: ${darkPrimary[step]};`);
494
+ }
495
+ }
496
+ if (colorsDark?.accent) {
497
+ const darkAccent = generateShadeScale(colorsDark.accent, "accent");
498
+ for (const step of FULL_SHADE_STEPS) {
499
+ darkPrimitiveOverrides.push(`--color-accent-${step}: ${darkAccent[step]};`);
500
+ }
501
+ }
502
+ if (darkPrimitiveOverrides.length > 0) {
503
+ lines.push(sectionComment2("Primitive overrides (dark) \u2014 dark brand color anchors at shade 500"));
504
+ lines.push(block(`.dark ${scopeClass}`, darkPrimitiveOverrides));
505
+ lines.push("");
506
+ }
483
507
  const categories = ["Text", "Surface", "Border", "Interactive"];
484
508
  const categoryDecls = [
485
509
  Object.entries(input.tokens.text).map(([n, v]) => `--text-${n}: ${v.dark};`),
@@ -503,6 +527,14 @@ function docsAdapter(input, options) {
503
527
  const inner = block(`${scopeClass}:not(.light)`, cat.entries);
504
528
  lines.push(`@media (prefers-color-scheme: dark) {
505
529
  ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
530
+ }`);
531
+ lines.push("");
532
+ }
533
+ if (darkPrimitiveOverrides.length > 0) {
534
+ lines.push(sectionComment2("Primitive overrides (dark) \u2014 prefers-color-scheme"));
535
+ const inner = block(`${scopeClass}:not(.light)`, darkPrimitiveOverrides);
536
+ lines.push(`@media (prefers-color-scheme: dark) {
537
+ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
506
538
  }`);
507
539
  lines.push("");
508
540
  }
@@ -467,8 +467,7 @@ function resolveThemeFonts(typography, options) {
467
467
  const warnings = [];
468
468
  let headingResolution = null;
469
469
  if (typography.heading?.family) {
470
- const weights = [];
471
- if (typography.heading.weight) weights.push(typography.heading.weight);
470
+ const weights = typography.heading.weights ? [...typography.heading.weights] : typography.heading.weight ? [typography.heading.weight] : [];
472
471
  headingResolution = resolveFont(typography.heading.family, {
473
472
  weights: weights.length > 0 ? weights : void 0,
474
473
  display,
@@ -481,10 +480,15 @@ function resolveThemeFonts(typography, options) {
481
480
  }
482
481
  let bodyResolution = null;
483
482
  if (typography.body?.family) {
484
- const bodyWeights = [];
485
- if (typography.body.weight) bodyWeights.push(typography.body.weight);
486
- if (!bodyWeights.includes(400)) bodyWeights.push(400);
487
- if (!bodyWeights.includes(700)) bodyWeights.push(700);
483
+ let bodyWeights;
484
+ if (typography.body.weights) {
485
+ bodyWeights = [...typography.body.weights];
486
+ } else {
487
+ bodyWeights = [];
488
+ if (typography.body.weight) bodyWeights.push(typography.body.weight);
489
+ if (!bodyWeights.includes(400)) bodyWeights.push(400);
490
+ if (!bodyWeights.includes(700)) bodyWeights.push(700);
491
+ }
488
492
  if (headingResolution && typography.body.family.toLowerCase() === headingResolution.family.toLowerCase()) {
489
493
  const mergedWeights = Array.from(
490
494
  /* @__PURE__ */ new Set([...headingResolution.weights, ...bodyWeights])
@@ -510,19 +514,22 @@ function resolveThemeFonts(typography, options) {
510
514
  }
511
515
  let displayResolution = null;
512
516
  if (typography.display?.family) {
513
- const displayWeights = [];
514
- if (typography.display.weight) displayWeights.push(typography.display.weight);
517
+ const displayWeights = typography.display.weights ? [...typography.display.weights] : typography.display.weight ? [typography.display.weight] : [];
515
518
  if (headingResolution && typography.display.family.toLowerCase() === headingResolution.family.toLowerCase()) {
516
- const mergedWeights = Array.from(
517
- /* @__PURE__ */ new Set([...headingResolution.weights, ...displayWeights])
518
- ).sort((a, b) => a - b);
519
- headingResolution = resolveFont(typography.heading.family, {
520
- weights: mergedWeights,
521
- display,
522
- source: typography.heading.source,
523
- org: typography.heading.org
524
- });
525
- displayResolution = headingResolution;
519
+ if (typography.heading?.weights) {
520
+ displayResolution = headingResolution;
521
+ } else {
522
+ const mergedWeights = Array.from(
523
+ /* @__PURE__ */ new Set([...headingResolution.weights, ...displayWeights])
524
+ ).sort((a, b) => a - b);
525
+ headingResolution = resolveFont(typography.heading.family, {
526
+ weights: mergedWeights,
527
+ display,
528
+ source: typography.heading.source,
529
+ org: typography.heading.org
530
+ });
531
+ displayResolution = headingResolution;
532
+ }
526
533
  } else if (bodyResolution && typography.display.family.toLowerCase() === bodyResolution.family.toLowerCase()) {
527
534
  const mergedWeights = Array.from(
528
535
  /* @__PURE__ */ new Set([...bodyResolution.weights, ...displayWeights])
@@ -957,9 +964,9 @@ var LIGHTNESS_TARGETS = {
957
964
  200: 0.87,
958
965
  300: 0.78,
959
966
  400: 0.65,
960
- 500: 0.55,
961
- 600: -1,
962
- // placeholder — replaced by input L at anchor
967
+ 500: -1,
968
+ // placeholder — replaced by input L at anchor (brand color lives at 500)
969
+ 600: 0.45,
963
970
  700: 0.38,
964
971
  800: 0.3,
965
972
  900: 0.22,
@@ -979,8 +986,8 @@ var CHROMA_MULTIPLIERS = {
979
986
  950: 0.5
980
987
  };
981
988
  var ANCHOR_SHADE = {
982
- primary: 600,
983
- accent: 600,
989
+ primary: 500,
990
+ accent: 500,
984
991
  neutral: 500,
985
992
  success: 500,
986
993
  warning: 500,
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-r7ae3WP2.js';
2
- export { k as ColorFormat, l as FontSource, m as RGBA, n as SemanticTokenValue } from './types-r7ae3WP2.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-DgAumoCX.js';
2
+ export { k as ColorFormat, l as FontSource, m as RGBA, n as SemanticTokenValue } from './types-DgAumoCX.js';
3
3
 
4
4
  /**
5
5
  * Font resolver — maps font family names to loadable font resources.
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  rgbToHex,
32
32
  rgbToOklch,
33
33
  serializeColor
34
- } from "./chunk-ZLXFCNYF.js";
34
+ } from "./chunk-NZF2MS4L.js";
35
35
 
36
36
  // src/pipeline.ts
37
37
  import { parse as parseYaml } from "yaml";
@@ -325,6 +325,8 @@ var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
325
325
  "name",
326
326
  "version",
327
327
  "group",
328
+ "label",
329
+ "default-mode",
328
330
  "colors",
329
331
  "colors-dark",
330
332
  "typography",
@@ -353,7 +355,7 @@ var KNOWN_TYPOGRAPHY_KEYS = /* @__PURE__ */ new Set([
353
355
  "letter-spacing",
354
356
  "scale"
355
357
  ]);
356
- var KNOWN_TYPOGRAPHY_FONT_KEYS = /* @__PURE__ */ new Set(["family", "weight", "source", "org"]);
358
+ var KNOWN_TYPOGRAPHY_FONT_KEYS = /* @__PURE__ */ new Set(["family", "weight", "weights", "source", "org"]);
357
359
  var KNOWN_TYPOGRAPHY_MONO_KEYS = /* @__PURE__ */ new Set(["family"]);
358
360
  var KNOWN_LETTER_SPACING_KEYS = /* @__PURE__ */ new Set(["tight", "normal", "wide"]);
359
361
  var KNOWN_SPACING_KEYS = /* @__PURE__ */ new Set(["base"]);
@@ -473,6 +475,15 @@ function validateConfig(config) {
473
475
  if (obj.version !== 1) {
474
476
  errors.push("'version' must be 1");
475
477
  }
478
+ if (obj.label !== void 0 && typeof obj.label !== "string") {
479
+ errors.push("'label' must be a string (optional display name override)");
480
+ }
481
+ if (obj["default-mode"] !== void 0) {
482
+ const mode = obj["default-mode"];
483
+ if (mode !== "dark" && mode !== "light") {
484
+ errors.push("'default-mode' must be either 'dark' or 'light'");
485
+ }
486
+ }
476
487
  if (typeof obj.colors !== "object" || obj.colors === null) {
477
488
  errors.push("'colors' is required and must be an object");
478
489
  return { valid: false, errors };
@@ -530,6 +541,11 @@ function validateConfig(config) {
530
541
  if (font && font.source === "visor-fonts" && !font.org) {
531
542
  errors.push(`'typography.${slot}.org' is required when source is 'visor-fonts'`);
532
543
  }
544
+ if (font && font.weights !== void 0) {
545
+ if (!Array.isArray(font.weights) || !font.weights.every((w) => typeof w === "number" && w > 0)) {
546
+ errors.push(`'typography.${slot}.weights' must be an array of positive numbers (e.g., [300, 500])`);
547
+ }
548
+ }
533
549
  }
534
550
  }
535
551
  if (obj.overrides !== void 0) {
@@ -636,19 +652,22 @@ function resolveConfig(config) {
636
652
  family: config.typography?.heading?.family ?? DEFAULTS.typography.heading.family,
637
653
  weight: config.typography?.heading?.weight ?? DEFAULTS.typography.heading.weight,
638
654
  ...config.typography?.heading?.source && { source: config.typography.heading.source },
639
- ...config.typography?.heading?.org && { org: config.typography.heading.org }
655
+ ...config.typography?.heading?.org && { org: config.typography.heading.org },
656
+ ...config.typography?.heading?.weights && { weights: config.typography.heading.weights }
640
657
  },
641
658
  display: {
642
659
  family: config.typography?.display?.family ?? config.typography?.heading?.family ?? DEFAULTS.typography.heading.family,
643
660
  weight: config.typography?.display?.weight ?? 400,
644
661
  ...config.typography?.display?.source && { source: config.typography.display.source },
645
- ...config.typography?.display?.org && { org: config.typography.display.org }
662
+ ...config.typography?.display?.org && { org: config.typography.display.org },
663
+ ...config.typography?.display?.weights && { weights: config.typography.display.weights }
646
664
  },
647
665
  body: {
648
666
  family: config.typography?.body?.family ?? DEFAULTS.typography.body.family,
649
667
  weight: config.typography?.body?.weight ?? DEFAULTS.typography.body.weight,
650
668
  ...config.typography?.body?.source && { source: config.typography.body.source },
651
- ...config.typography?.body?.org && { org: config.typography.body.org }
669
+ ...config.typography?.body?.org && { org: config.typography.body.org },
670
+ ...config.typography?.body?.weights && { weights: config.typography.body.weights }
652
671
  },
653
672
  mono: {
654
673
  family: config.typography?.mono?.family ?? DEFAULTS.typography.mono.family
@@ -1835,6 +1854,51 @@ function checkRadiusScale(config, issues) {
1835
1854
  );
1836
1855
  }
1837
1856
  }
1857
+ function checkDarkLightParity(config, issues) {
1858
+ if (!config.colors) return;
1859
+ const colorKeys = Object.keys(config.colors).filter((k) => k !== "primary");
1860
+ const hasDarkSection = config["colors-dark"] !== void 0;
1861
+ if (colorKeys.length > 0 && !hasDarkSection) {
1862
+ issues.push(
1863
+ issue(
1864
+ "warning",
1865
+ "DARK_LIGHT_PARITY",
1866
+ "Custom colors are set but no colors-dark section exists. Dark mode will use generated defaults which may not match your brand.",
1867
+ "colors-dark"
1868
+ )
1869
+ );
1870
+ return;
1871
+ }
1872
+ if (colorKeys.length > 0 && hasDarkSection) {
1873
+ const lightKeys = new Set(Object.keys(config.colors));
1874
+ const darkKeys = new Set(Object.keys(config["colors-dark"]));
1875
+ for (const key of lightKeys) {
1876
+ if (key === "primary") continue;
1877
+ if (!darkKeys.has(key)) {
1878
+ issues.push(
1879
+ issue(
1880
+ "warning",
1881
+ "DARK_LIGHT_PARITY",
1882
+ `Color "${key}" is set in colors but missing from colors-dark. Dark mode will use a generated default.`,
1883
+ "colors-dark"
1884
+ )
1885
+ );
1886
+ }
1887
+ }
1888
+ for (const key of darkKeys) {
1889
+ if (!lightKeys.has(key)) {
1890
+ issues.push(
1891
+ issue(
1892
+ "warning",
1893
+ "DARK_LIGHT_PARITY",
1894
+ `Color "${key}" is set in colors-dark but missing from colors. Light mode will use a generated default.`,
1895
+ "colors"
1896
+ )
1897
+ );
1898
+ }
1899
+ }
1900
+ }
1901
+ }
1838
1902
  function validate(config) {
1839
1903
  const errors = [];
1840
1904
  const warnings = [];
@@ -1866,6 +1930,7 @@ function validate(config) {
1866
1930
  checkColorSimilarity(typedConfig, warnings);
1867
1931
  checkMissingGlowShadow(typedConfig, warnings);
1868
1932
  checkRadiusScale(typedConfig, warnings);
1933
+ checkDarkLightParity(typedConfig, warnings);
1869
1934
  }
1870
1935
  return {
1871
1936
  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
  };
@@ -123,6 +129,10 @@ interface VisorThemeConfig {
123
129
  version: 1;
124
130
  /** Theme group for the docs site theme switcher (e.g. 'Visor', 'Client', 'Low Orbit'). Used by `visor theme sync`. */
125
131
  group?: string;
132
+ /** Optional display label override for the theme switcher (e.g. 'ENTR', 'SoleSpark'). Falls back to title-cased name. */
133
+ label?: string;
134
+ /** Default color mode to force when the theme is activated ('dark' or 'light'). If unset, user/system preference applies. */
135
+ "default-mode"?: "dark" | "light";
126
136
  colors: {
127
137
  primary: string;
128
138
  accent?: string;
@@ -150,18 +160,21 @@ interface VisorThemeConfig {
150
160
  heading?: {
151
161
  family?: string;
152
162
  weight?: number;
163
+ weights?: number[];
153
164
  source?: FontSource;
154
165
  org?: string;
155
166
  };
156
167
  display?: {
157
168
  family?: string;
158
169
  weight?: number;
170
+ weights?: number[];
159
171
  source?: FontSource;
160
172
  org?: string;
161
173
  };
162
174
  body?: {
163
175
  family?: string;
164
176
  weight?: number;
177
+ weights?: number[];
165
178
  source?: FontSource;
166
179
  org?: string;
167
180
  };
@@ -226,18 +239,21 @@ interface ResolvedThemeConfig {
226
239
  heading: {
227
240
  family: string;
228
241
  weight: number;
242
+ weights?: number[];
229
243
  source?: FontSource;
230
244
  org?: string;
231
245
  };
232
246
  display: {
233
247
  family: string;
234
248
  weight: number;
249
+ weights?: number[];
235
250
  source?: FontSource;
236
251
  org?: string;
237
252
  };
238
253
  body: {
239
254
  family: string;
240
255
  weight: number;
256
+ weights?: number[];
241
257
  source?: FontSource;
242
258
  org?: string;
243
259
  };
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.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",
@@ -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": {