@loworbitstudio/visor-theme-engine 0.13.0 → 0.14.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 { g as GeneratedPrimitives, m as SemanticTokens, R as ResolvedThemeConfig } from '../types-CSO2avFQ.js';
1
+ import { g as GeneratedPrimitives, m as SemanticTokens, R as ResolvedThemeConfig } from '../types-BDRXkldG.js';
2
2
 
3
3
  /**
4
4
  * Adapter types for the Visor theme engine.
@@ -4,6 +4,7 @@ import {
4
4
  SELECTIVE_SHADE_STEPS,
5
5
  aliasFamily,
6
6
  buildVisorFontUrl,
7
+ collectBrandPassthrough,
7
8
  fontStack,
8
9
  generateDarkCss,
9
10
  generateHairlineDecls,
@@ -18,7 +19,53 @@ import {
18
19
  resolveThemeBrand,
19
20
  resolveThemeFonts,
20
21
  sectionComment
21
- } from "../chunk-KFTTL3XP.js";
22
+ } from "../chunk-YDRQQIOB.js";
23
+
24
+ // src/adapters/brand-passthrough.ts
25
+ var SENTINEL_COLOR = "#ff00ff";
26
+ function isDevBuild() {
27
+ return process.env.NODE_ENV !== "production";
28
+ }
29
+ function isUnresolved(value) {
30
+ return typeof value !== "string" || value.trim().length === 0;
31
+ }
32
+ function declFor(key, value) {
33
+ if (isUnresolved(value)) {
34
+ if (isDevBuild()) {
35
+ return `--${key}: ${SENTINEL_COLOR}; /* [visor-brand] UNRESOLVED pass-through value */`;
36
+ }
37
+ return `--${key}: ${value};`;
38
+ }
39
+ return `--${key}: ${value};`;
40
+ }
41
+ function indentBlock(selector, decls) {
42
+ if (decls.length === 0) return "";
43
+ return [selector + " {", ...decls.map((d) => ` ${d}`), "}"].join("\n");
44
+ }
45
+ function generateBrandPassthroughCss(passthrough, selectors) {
46
+ const lightKeys = Object.keys(passthrough.light);
47
+ const darkKeys = Object.keys(passthrough.dark);
48
+ if (lightKeys.length === 0 && darkKeys.length === 0) return "";
49
+ const blocks = [];
50
+ if (isDevBuild()) {
51
+ const names = [.../* @__PURE__ */ new Set([...lightKeys, ...darkKeys])].map((k) => `--${k}`).join(", ");
52
+ const count = lightKeys.length + darkKeys.length;
53
+ blocks.push(`/* [visor-brand] ${count} passthrough: ${names} */`);
54
+ }
55
+ if (lightKeys.length > 0) {
56
+ const decls = lightKeys.map((k) => declFor(k, passthrough.light[k]));
57
+ blocks.push(indentBlock(selectors.light, decls));
58
+ }
59
+ if (darkKeys.length > 0) {
60
+ const decls = darkKeys.map((k) => declFor(k, passthrough.dark[k]));
61
+ blocks.push(indentBlock(selectors.dark, decls));
62
+ const prefersInner = indentBlock(selectors.prefers, decls).split("\n").map((l) => ` ${l}`).join("\n");
63
+ blocks.push(`@media (prefers-color-scheme: dark) {
64
+ ${prefersInner}
65
+ }`);
66
+ }
67
+ return blocks.filter(Boolean).join("\n\n");
68
+ }
22
69
 
23
70
  // src/adapters/layers.ts
24
71
  var LAYER_ORDER = "@layer visor-primitives, visor-semantic, visor-brand, visor-adaptive, visor-bridge;";
@@ -102,6 +149,17 @@ function nextjsAdapter(input, options) {
102
149
  );
103
150
  lines.push(wrapInLayer("visor-primitives", primitivesBody));
104
151
  lines.push("");
152
+ const passthrough = collectBrandPassthrough(input.tokens, input.config.overrides);
153
+ const darkSelectors = scopePrefix ? [`${scopePrefix}.dark`, `${scopePrefix}.theme-dark`, `${scopePrefix}[data-theme="dark"]`] : [".dark", ".theme-dark", '[data-theme="dark"]'];
154
+ const passthroughCss = generateBrandPassthroughCss(passthrough, {
155
+ light: scopePrefix ?? ":root",
156
+ dark: darkSelectors.join(",\n"),
157
+ prefers: scopePrefix ? `${scopePrefix}:not(.light):not(.theme-light):not([data-theme="light"])` : ':root:not(.light):not(.theme-light):not([data-theme="light"])'
158
+ });
159
+ if (passthroughCss) {
160
+ lines.push(wrapInLayer("visor-brand", passthroughCss));
161
+ lines.push("");
162
+ }
105
163
  const lightBody = stripHeader(generateLightCss(input.tokens, { scopePrefix }));
106
164
  const darkBody = stripHeader(generateDarkCss(input.tokens, { scopePrefix }));
107
165
  lines.push(
@@ -562,7 +620,7 @@ function docsAdapter(input, options) {
562
620
  ];
563
621
  for (const cat of pcsCategories) {
564
622
  lines.push(sectionComment2(`Adaptive: ${cat.label} (dark) \u2014 prefers-color-scheme`));
565
- const inner = block(`${scopeClass}:not(.light)`, cat.entries);
623
+ const inner = block(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, cat.entries);
566
624
  lines.push(`@media (prefers-color-scheme: dark) {
567
625
  ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
568
626
  }`);
@@ -570,7 +628,7 @@ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
570
628
  }
571
629
  if (darkPrimitiveOverrides.length > 0) {
572
630
  lines.push(sectionComment2("Primitive overrides (dark) \u2014 prefers-color-scheme"));
573
- const inner = block(`${scopeClass}:not(.light)`, darkPrimitiveOverrides);
631
+ const inner = block(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, darkPrimitiveOverrides);
574
632
  lines.push(`@media (prefers-color-scheme: dark) {
575
633
  ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
576
634
  }`);
@@ -616,7 +674,7 @@ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
616
674
  semanticLines.push("");
617
675
  semanticLines.push(sectionComment2("Intent aliases (dark) \u2014 prefers-color-scheme"));
618
676
  {
619
- const inner = block(`${scopeClass}:not(.light)`, generateIntentDecls(input.tokens, "dark"));
677
+ const inner = block(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, generateIntentDecls(input.tokens, "dark"));
620
678
  semanticLines.push(`@media (prefers-color-scheme: dark) {
621
679
  ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
622
680
  }`);
@@ -624,16 +682,25 @@ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
624
682
  semanticLines.push("");
625
683
  semanticLines.push(sectionComment2("Hairline aliases (dark) \u2014 prefers-color-scheme"));
626
684
  {
627
- const inner = block(`${scopeClass}:not(.light)`, generateHairlineDecls(input.tokens, "dark"));
685
+ const inner = block(`${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`, generateHairlineDecls(input.tokens, "dark"));
628
686
  semanticLines.push(`@media (prefers-color-scheme: dark) {
629
687
  ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
630
688
  }`);
631
689
  }
632
690
  semanticLines.push("");
633
691
  const brandResult = resolveThemeBrand(input.config.brand, { scope: scopeClass });
692
+ const passthroughCss = generateBrandPassthroughCss(
693
+ collectBrandPassthrough(input.tokens, input.config.overrides),
694
+ {
695
+ light: `html:not(.dark) ${scopeClass}`,
696
+ dark: `.dark ${scopeClass}`,
697
+ prefers: `${scopeClass}:not(.light):not(.theme-light):not([data-theme="light"])`
698
+ }
699
+ );
634
700
  const adaptiveLayer = wrapInLayer("visor-adaptive", lines.join("\n").trim());
635
701
  const semanticLayer = wrapInLayer("visor-semantic", semanticLines.join("\n").trim());
636
- const brandLayer = wrapInLayer("visor-brand", brandResult.css);
702
+ const brandLayerBody = [brandResult.css, passthroughCss].filter(Boolean).join("\n\n");
703
+ const brandLayer = wrapInLayer("visor-brand", brandLayerBody);
637
704
  const head = fontLines.length > 0 ? fontLines.join("\n") + "\n" : "";
638
705
  const layerBlocks = [semanticLayer, brandLayer, adaptiveLayer].filter(Boolean);
639
706
  return head + LAYER_ORDER + "\n\n" + layerBlocks.join("\n\n") + "\n";
@@ -1347,6 +1347,91 @@ function generateShadeScale(color, role) {
1347
1347
  return scale;
1348
1348
  }
1349
1349
 
1350
+ // src/overrides.ts
1351
+ var TOKEN_CATEGORIES = [
1352
+ { prefix: "text-", key: "text" },
1353
+ { prefix: "surface-", key: "surface" },
1354
+ { prefix: "border-", key: "border" },
1355
+ { prefix: "interactive-", key: "interactive" },
1356
+ { prefix: "hairline-", key: "hairline" }
1357
+ ];
1358
+ function findToken(key, tokens) {
1359
+ if (key === "hairline" && "default" in tokens.hairline) {
1360
+ return { group: tokens.hairline, name: "default" };
1361
+ }
1362
+ for (const { prefix, key: groupKey } of TOKEN_CATEGORIES) {
1363
+ if (key.startsWith(prefix)) {
1364
+ const name = key.slice(prefix.length);
1365
+ if (name in tokens[groupKey]) {
1366
+ return { group: tokens[groupKey], name };
1367
+ }
1368
+ }
1369
+ }
1370
+ if (key in tokens.intent) {
1371
+ return { group: tokens.intent, name: key };
1372
+ }
1373
+ return null;
1374
+ }
1375
+ function isRecognizedOverrideKey(key, tokens) {
1376
+ return findToken(key, tokens) !== null;
1377
+ }
1378
+ function collectBrandPassthrough(tokens, overrides) {
1379
+ const passthrough = { light: {}, dark: {} };
1380
+ if (!overrides) return passthrough;
1381
+ for (const mode of ["light", "dark"]) {
1382
+ const modeOverrides = overrides[mode];
1383
+ if (!modeOverrides) continue;
1384
+ for (const [key, value] of Object.entries(modeOverrides)) {
1385
+ if (!isRecognizedOverrideKey(key, tokens)) {
1386
+ passthrough[mode][key] = value;
1387
+ }
1388
+ }
1389
+ }
1390
+ return passthrough;
1391
+ }
1392
+ function hasBrandPassthrough(passthrough) {
1393
+ return Object.keys(passthrough.light).length > 0 || Object.keys(passthrough.dark).length > 0;
1394
+ }
1395
+ function applyOverrides(tokens, overrides) {
1396
+ if (!overrides) return tokens;
1397
+ const result = {
1398
+ text: { ...tokens.text },
1399
+ surface: { ...tokens.surface },
1400
+ border: { ...tokens.border },
1401
+ interactive: { ...tokens.interactive },
1402
+ intent: { ...tokens.intent },
1403
+ hairline: { ...tokens.hairline }
1404
+ };
1405
+ for (const group of ["text", "surface", "border", "interactive", "intent", "hairline"]) {
1406
+ for (const [name, value] of Object.entries(result[group])) {
1407
+ result[group][name] = { ...value };
1408
+ }
1409
+ }
1410
+ if (overrides.light) {
1411
+ for (const [key, value] of Object.entries(overrides.light)) {
1412
+ const match = findToken(key, result);
1413
+ if (match) {
1414
+ match.group[match.name] = {
1415
+ ...match.group[match.name],
1416
+ light: value
1417
+ };
1418
+ }
1419
+ }
1420
+ }
1421
+ if (overrides.dark) {
1422
+ for (const [key, value] of Object.entries(overrides.dark)) {
1423
+ const match = findToken(key, result);
1424
+ if (match) {
1425
+ match.group[match.name] = {
1426
+ ...match.group[match.name],
1427
+ dark: value
1428
+ };
1429
+ }
1430
+ }
1431
+ }
1432
+ return result;
1433
+ }
1434
+
1350
1435
  // src/fonts/theme-alias.ts
1351
1436
  var EMPTY_ALIASES = /* @__PURE__ */ new Map();
1352
1437
  function aliasFamily(family, themeSlug) {
@@ -1822,6 +1907,9 @@ export {
1822
1907
  SELECTIVE_SHADE_STEPS,
1823
1908
  TAILWIND_GRAY,
1824
1909
  generateShadeScale,
1910
+ collectBrandPassthrough,
1911
+ hasBrandPassthrough,
1912
+ applyOverrides,
1825
1913
  aliasFamily,
1826
1914
  fontStack,
1827
1915
  header,
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { F as FontResolveOptions, a as FontResolution, V as VisorTypography, b as FontDisplayStrategy, T as ThemeFontResult, G as GoogleFontEntry, c as VisorBrand, B as BrandSlot, d as BrandSource, e as BrandResolution, f as ThemeBrandResult, R as ResolvedThemeConfig, g as GeneratedPrimitives, h as ThemeOutput, i as ThemeData, j as VisorThemeConfig, k as FullShadeScale, C as ColorRole, S as SelectiveShadeScale, l as RGB, P as ParsedColor, O as OKLCH, m as SemanticTokens, n as ShadeStep } from './types-CSO2avFQ.js';
2
- export { o as BRAND_VARIANTS, p as BrandVariant, q as ColorFormat, r as FontSource, s as RGBA, t as SemanticTokenValue } from './types-CSO2avFQ.js';
1
+ import { F as FontResolveOptions, a as FontResolution, V as VisorTypography, b as FontDisplayStrategy, T as ThemeFontResult, G as GoogleFontEntry, c as VisorBrand, B as BrandSlot, d as BrandSource, e as BrandResolution, f as ThemeBrandResult, R as ResolvedThemeConfig, g as GeneratedPrimitives, h as ThemeOutput, i as ThemeData, j as VisorThemeConfig, k as FullShadeScale, C as ColorRole, S as SelectiveShadeScale, l as RGB, P as ParsedColor, O as OKLCH, m as SemanticTokens, n as ShadeStep } from './types-BDRXkldG.js';
2
+ export { o as BRAND_VARIANTS, p as BrandVariant, q as ColorFormat, r as FontSource, s as RGBA, t as SemanticTokenValue } from './types-BDRXkldG.js';
3
3
 
4
4
  /**
5
5
  * Font resolver — maps font family names to loadable font resources.
@@ -315,6 +315,18 @@ var properties = {
315
315
  type: "string",
316
316
  description: "Theme group for the docs site theme switcher (e.g. 'Visor', 'Client', 'Low Orbit'). Used by `visor theme sync`. Defaults to folder-based grouping when omitted."
317
317
  },
318
+ label: {
319
+ type: "string",
320
+ description: "Human-readable display name for the theme (e.g. 'Blacklight Pro'). Overrides the name-derived label in the docs theme switcher. Optional."
321
+ },
322
+ "default-mode": {
323
+ type: "string",
324
+ "enum": [
325
+ "light",
326
+ "dark"
327
+ ],
328
+ description: "Default color mode when activating this theme. When set, the docs site forces this mode on theme switch (unless the user has a stored mode preference). Optional."
329
+ },
318
330
  colors: {
319
331
  type: "object",
320
332
  description: "Color definitions for light mode. Only primary is required — all others have sensible defaults.",
@@ -946,6 +958,21 @@ interface ThemeValidationResult {
946
958
  errors: ValidationIssue[];
947
959
  warnings: ValidationIssue[];
948
960
  }
961
+ /**
962
+ * Options for the validate() function.
963
+ */
964
+ interface ValidateOptions {
965
+ /**
966
+ * When true, promote DARK_LIGHT_PARITY warnings and the
967
+ * "colors.neutral present without colors-dark.neutral" check
968
+ * from warning to error. Use this in CI to enforce the
969
+ * "always both modes" authoring convention.
970
+ *
971
+ * Opt-in today; flip to the default after all convergent
972
+ * themes add their dark neutral (see VI-495 docs).
973
+ */
974
+ strictDark?: boolean;
975
+ }
949
976
  /**
950
977
  * Validate a theme config comprehensively.
951
978
  *
@@ -953,9 +980,10 @@ interface ThemeValidationResult {
953
980
  * Results are JSON-serializable for CLI `--json` output.
954
981
  *
955
982
  * @param config - A parsed theme config object (from YAML or programmatic)
983
+ * @param options - Optional validator flags (e.g. strictDark)
956
984
  * @returns ThemeValidationResult with errors[], warnings[], and valid boolean
957
985
  */
958
- declare function validate(config: unknown): ThemeValidationResult;
986
+ declare function validate(config: unknown, options?: ValidateOptions): ThemeValidationResult;
959
987
 
960
988
  /**
961
989
  * Shade Scale Generation
@@ -1062,6 +1090,30 @@ declare function assignSemanticTokens(lightPrimitives: GeneratedPrimitives, dark
1062
1090
  * replacing derived token values with user-specified values.
1063
1091
  */
1064
1092
 
1093
+ /** Pass-through brand tokens collected per mode (VI-493). */
1094
+ interface BrandPassthrough {
1095
+ light: Record<string, string>;
1096
+ dark: Record<string, string>;
1097
+ }
1098
+ /**
1099
+ * Collect unrecognized override keys into a brand-passthrough map (VI-493).
1100
+ *
1101
+ * Any `overrides.{light,dark}` key that does NOT map to a recognized semantic,
1102
+ * intent, or hairline token is captured here verbatim (key + value). These were
1103
+ * previously DROPPED silently by `applyOverrides`; the adapters now emit them as
1104
+ * bare `--<key>` custom properties inside `@layer visor-brand`, ending the
1105
+ * dual-source-of-truth between `.visor.yaml` and hand-maintained `:root` blocks.
1106
+ *
1107
+ * Recognized tokens are excluded — they continue to flow through the normal
1108
+ * semantic pipeline. Pass-through tokens are legitimately mode-asymmetric (a key
1109
+ * may appear in `light` only, `dark` only, or both); no both-modes rule applies.
1110
+ */
1111
+ declare function collectBrandPassthrough(tokens: SemanticTokens, overrides?: {
1112
+ light?: Record<string, string>;
1113
+ dark?: Record<string, string>;
1114
+ }): BrandPassthrough;
1115
+ /** True when the passthrough map carries at least one token in either mode. */
1116
+ declare function hasBrandPassthrough(passthrough: BrandPassthrough): boolean;
1065
1117
  /**
1066
1118
  * Apply override values to semantic tokens.
1067
1119
  * Returns a new SemanticTokens with overrides applied (does not mutate input).
@@ -1221,4 +1273,4 @@ declare function cleanFontValue(val: string): string;
1221
1273
  */
1222
1274
  declare function extractFromCSS(files: CSSFile[], name?: string): ExtractionResult;
1223
1275
 
1224
- export { BrandResolution, BrandSlot, BrandSource, type CSSFile, ColorRole, type Confidence, DEFAULT_VISOR_BRAND, type ExtractedToken, type ExtractionResult, FONT_WEIGHT_ALIASES, type FontCoverageError, type FontCoverageResult, FontDisplayStrategy, type FontFaceDeclaration, FontResolution, FontResolveOptions, FullShadeScale, GeneratedPrimitives, GoogleFontEntry, OKLCH, ParsedColor, RGB, ResolvedThemeConfig, SEMANTIC_MAP, SelectiveShadeScale, SemanticTokens, ShadeStep, TAILWIND_GRAY, ThemeBrandResult, ThemeData, ThemeFontResult, ThemeOutput, type ThemeValidationResult, VISOR_BRANDS_CDN, VISOR_DEFAULT_BRAND_PATH, VISOR_FONTS_CDN, type ValidationIssue, type ValidationSeverity, VisorBrand, VisorThemeConfig, VisorTypography, applyOverrides, assignSemanticTokens, buildVisorBrandUrl, buildVisorFontUrl, clampToSrgb, cleanFontValue, compositeOverBackground, exportTheme, extractFromCSS, formatFontCoverageError, 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, resolveBrandSlot, resolveBrandSource, resolveConfig, resolveFont, resolveThemeBrand, resolveThemeFonts, rgbToHex, serializeColor, validate, validateConfig, validateFontCoverage, visorTheme_schema as visorThemeSchema };
1276
+ export { type BrandPassthrough, BrandResolution, BrandSlot, BrandSource, type CSSFile, ColorRole, type Confidence, DEFAULT_VISOR_BRAND, type ExtractedToken, type ExtractionResult, FONT_WEIGHT_ALIASES, type FontCoverageError, type FontCoverageResult, FontDisplayStrategy, type FontFaceDeclaration, FontResolution, FontResolveOptions, FullShadeScale, GeneratedPrimitives, GoogleFontEntry, OKLCH, ParsedColor, RGB, ResolvedThemeConfig, SEMANTIC_MAP, SelectiveShadeScale, SemanticTokens, ShadeStep, TAILWIND_GRAY, ThemeBrandResult, ThemeData, ThemeFontResult, ThemeOutput, type ThemeValidationResult, VISOR_BRANDS_CDN, VISOR_DEFAULT_BRAND_PATH, VISOR_FONTS_CDN, type ValidateOptions, type ValidationIssue, type ValidationSeverity, VisorBrand, VisorThemeConfig, VisorTypography, applyOverrides, assignSemanticTokens, buildVisorBrandUrl, buildVisorFontUrl, clampToSrgb, cleanFontValue, collectBrandPassthrough, compositeOverBackground, exportTheme, extractFromCSS, formatFontCoverageError, generateDarkCss, generateFullBundleCss, generateLightCss, generatePreloadLinks, generatePrimitives, generatePrimitivesCss, generateSemanticCss, generateShadeScale, generateStylesheetLinks, generateTheme, generateThemeData, generateThemeDataFromConfig, generateThemeFromConfig, getContrastRatio, googleFontsCatalog, hasBrandPassthrough, hexToOklch, hexToRgb, isValidColor, isValidHex, isVisorThemeConfig, lookupFontWeightAlias, lookupGoogleFont, normalizeHex, oklchToHex, parseCSSDeclarations, parseColor, parseConfig, parseFontFaceDeclarations, parseHex, parseHsla, parseOklch, parseRgba, resolveBrandSlot, resolveBrandSource, resolveConfig, resolveFont, resolveThemeBrand, resolveThemeFonts, rgbToHex, serializeColor, validate, validateConfig, validateFontCoverage, visorTheme_schema as visorThemeSchema };
package/dist/index.js CHANGED
@@ -7,9 +7,11 @@ import {
7
7
  VISOR_BRANDS_CDN,
8
8
  VISOR_DEFAULT_BRAND_PATH,
9
9
  VISOR_FONTS_CDN,
10
+ applyOverrides,
10
11
  buildVisorBrandUrl,
11
12
  buildVisorFontUrl,
12
13
  clampToSrgb,
14
+ collectBrandPassthrough,
13
15
  compositeOverBackground,
14
16
  generateDarkCss,
15
17
  generateFullBundleCss,
@@ -21,6 +23,7 @@ import {
21
23
  generateStylesheetLinks,
22
24
  getContrastRatio,
23
25
  googleFontsCatalog,
26
+ hasBrandPassthrough,
24
27
  hexToOklch,
25
28
  hexToRgb,
26
29
  isValidColor,
@@ -42,7 +45,7 @@ import {
42
45
  rgbToHex,
43
46
  rgbToOklch,
44
47
  serializeColor
45
- } from "./chunk-KFTTL3XP.js";
48
+ } from "./chunk-YDRQQIOB.js";
46
49
 
47
50
  // src/fonts/validate-coverage.ts
48
51
  var FONT_VAR_RE = /--font-(heading|display|body|sans|mono)\s*:\s*([^;]+);/g;
@@ -224,6 +227,15 @@ var visor_theme_schema_default = {
224
227
  type: "string",
225
228
  description: "Theme group for the docs site theme switcher (e.g. 'Visor', 'Client', 'Low Orbit'). Used by `visor theme sync`. Defaults to folder-based grouping when omitted."
226
229
  },
230
+ label: {
231
+ type: "string",
232
+ description: "Human-readable display name for the theme (e.g. 'Blacklight Pro'). Overrides the name-derived label in the docs theme switcher. Optional."
233
+ },
234
+ "default-mode": {
235
+ type: "string",
236
+ enum: ["light", "dark"],
237
+ description: "Default color mode when activating this theme. When set, the docs site forces this mode on theme switch (unless the user has a stored mode preference). Optional."
238
+ },
227
239
  colors: {
228
240
  type: "object",
229
241
  description: "Color definitions for light mode. Only primary is required \u2014 all others have sensible defaults.",
@@ -1165,6 +1177,7 @@ function resolveConfig(config) {
1165
1177
  return {
1166
1178
  name: config.name,
1167
1179
  ...config.label !== void 0 && { label: config.label },
1180
+ ...config["default-mode"] !== void 0 && { "default-mode": config["default-mode"] },
1168
1181
  version: 1,
1169
1182
  colors: {
1170
1183
  primary: colors.primary,
@@ -1586,11 +1599,14 @@ var SEMANTIC_INTENT_MAP = {
1586
1599
  light: { role: "primary", shade: 500 },
1587
1600
  dark: { role: "primary", shade: 500 }
1588
1601
  },
1589
- // Text color paired with --primary backgrounds. Default white; themes whose
1590
- // primary fails AA on white pin to a graphite via overrides (entr does this).
1602
+ // Single-source alias of --interactive-primary-text. Default white (same value
1603
+ // as the interactive group); themes that need a different value (e.g. entr)
1604
+ // override via overrides.{light,dark}["primary-text"] which replaces this alias
1605
+ // with the explicit override value. Hand-authored static CSS (blackout-theme.css,
1606
+ // neutral-theme.css) should consume --primary-text via this alias path.
1591
1607
  "primary-text": {
1592
- light: { constant: "#ffffff" },
1593
- dark: { constant: "#ffffff" }
1608
+ light: { constant: "var(--interactive-primary-text)" },
1609
+ dark: { constant: "var(--interactive-primary-text)" }
1594
1610
  },
1595
1611
  accent: {
1596
1612
  light: { role: "accent", shade: 500 },
@@ -1695,71 +1711,6 @@ function assignSemanticTokens(lightPrimitives, darkPrimitives, config) {
1695
1711
  return { text, surface, border, interactive, intent, hairline };
1696
1712
  }
1697
1713
 
1698
- // src/overrides.ts
1699
- var TOKEN_CATEGORIES = [
1700
- { prefix: "text-", key: "text" },
1701
- { prefix: "surface-", key: "surface" },
1702
- { prefix: "border-", key: "border" },
1703
- { prefix: "interactive-", key: "interactive" },
1704
- { prefix: "hairline-", key: "hairline" }
1705
- ];
1706
- function findToken(key, tokens) {
1707
- if (key === "hairline" && "default" in tokens.hairline) {
1708
- return { group: tokens.hairline, name: "default" };
1709
- }
1710
- for (const { prefix, key: groupKey } of TOKEN_CATEGORIES) {
1711
- if (key.startsWith(prefix)) {
1712
- const name = key.slice(prefix.length);
1713
- if (name in tokens[groupKey]) {
1714
- return { group: tokens[groupKey], name };
1715
- }
1716
- }
1717
- }
1718
- if (key in tokens.intent) {
1719
- return { group: tokens.intent, name: key };
1720
- }
1721
- return null;
1722
- }
1723
- function applyOverrides(tokens, overrides) {
1724
- if (!overrides) return tokens;
1725
- const result = {
1726
- text: { ...tokens.text },
1727
- surface: { ...tokens.surface },
1728
- border: { ...tokens.border },
1729
- interactive: { ...tokens.interactive },
1730
- intent: { ...tokens.intent },
1731
- hairline: { ...tokens.hairline }
1732
- };
1733
- for (const group of ["text", "surface", "border", "interactive", "intent", "hairline"]) {
1734
- for (const [name, value] of Object.entries(result[group])) {
1735
- result[group][name] = { ...value };
1736
- }
1737
- }
1738
- if (overrides.light) {
1739
- for (const [key, value] of Object.entries(overrides.light)) {
1740
- const match = findToken(key, result);
1741
- if (match) {
1742
- match.group[match.name] = {
1743
- ...match.group[match.name],
1744
- light: value
1745
- };
1746
- }
1747
- }
1748
- }
1749
- if (overrides.dark) {
1750
- for (const [key, value] of Object.entries(overrides.dark)) {
1751
- const match = findToken(key, result);
1752
- if (match) {
1753
- match.group[match.name] = {
1754
- ...match.group[match.name],
1755
- dark: value
1756
- };
1757
- }
1758
- }
1759
- }
1760
- return result;
1761
- }
1762
-
1763
1714
  // src/pipeline.ts
1764
1715
  function generatePrimitives(config) {
1765
1716
  return {
@@ -2361,7 +2312,7 @@ function checkOverrides(config, issues) {
2361
2312
  issue(
2362
2313
  "warning",
2363
2314
  "UNKNOWN_OVERRIDE_KEY",
2364
- `'overrides.${mode}.${key}' does not match any known semantic token. Valid tokens include: text-primary, surface-page, border-default, interactive-primary-bg, etc.`,
2315
+ `'overrides.${mode}.${key}' does not match any known semantic token; it will be emitted as a bare '--${key}' custom property in @layer visor-brand (brand pass-through). If you meant to override a semantic token, valid tokens include: text-primary, surface-page, border-default, interactive-primary-bg, etc.`,
2365
2316
  `overrides.${mode}.${key}`
2366
2317
  )
2367
2318
  );
@@ -2642,14 +2593,15 @@ function checkRadiusScale(config, issues) {
2642
2593
  );
2643
2594
  }
2644
2595
  }
2645
- function checkDarkLightParity(config, issues) {
2596
+ function checkDarkLightParity(config, issues, opts) {
2646
2597
  if (!config.colors) return;
2647
2598
  const colorKeys = Object.keys(config.colors).filter((k) => k !== "primary");
2648
2599
  const hasDarkSection = config["colors-dark"] !== void 0;
2649
2600
  if (colorKeys.length > 0 && !hasDarkSection) {
2601
+ const severity = opts.strictDark ? "error" : "warning";
2650
2602
  issues.push(
2651
2603
  issue(
2652
- "warning",
2604
+ severity,
2653
2605
  "DARK_LIGHT_PARITY",
2654
2606
  "Custom colors are set but no colors-dark section exists. Dark mode will use generated defaults which may not match your brand.",
2655
2607
  "colors-dark"
@@ -2663,9 +2615,10 @@ function checkDarkLightParity(config, issues) {
2663
2615
  for (const key of lightKeys) {
2664
2616
  if (key === "primary") continue;
2665
2617
  if (!darkKeys.has(key)) {
2618
+ const severity = opts.strictDark ? "error" : "warning";
2666
2619
  issues.push(
2667
2620
  issue(
2668
- "warning",
2621
+ severity,
2669
2622
  "DARK_LIGHT_PARITY",
2670
2623
  `Color "${key}" is set in colors but missing from colors-dark. Dark mode will use a generated default.`,
2671
2624
  "colors-dark"
@@ -2687,7 +2640,8 @@ function checkDarkLightParity(config, issues) {
2687
2640
  }
2688
2641
  }
2689
2642
  }
2690
- function validate(config) {
2643
+ function validate(config, options) {
2644
+ const opts = options || {};
2691
2645
  const errors = [];
2692
2646
  const warnings = [];
2693
2647
  const structurallyValid = checkStructuralIntegrity(config, errors);
@@ -2719,7 +2673,11 @@ function validate(config) {
2719
2673
  checkColorSimilarity(typedConfig, warnings);
2720
2674
  checkMissingGlowShadow(typedConfig, warnings);
2721
2675
  checkRadiusScale(typedConfig, warnings);
2722
- checkDarkLightParity(typedConfig, warnings);
2676
+ const parityIssues = [];
2677
+ checkDarkLightParity(typedConfig, parityIssues, opts);
2678
+ for (const iss of parityIssues) {
2679
+ (iss.severity === "error" ? errors : warnings).push(iss);
2680
+ }
2723
2681
  }
2724
2682
  return {
2725
2683
  valid: errors.length === 0,
@@ -3229,6 +3187,7 @@ export {
3229
3187
  buildVisorFontUrl,
3230
3188
  clampToSrgb,
3231
3189
  cleanFontValue,
3190
+ collectBrandPassthrough,
3232
3191
  compositeOverBackground,
3233
3192
  exportTheme,
3234
3193
  extractFromCSS,
@@ -3248,6 +3207,7 @@ export {
3248
3207
  generateThemeFromConfig,
3249
3208
  getContrastRatio,
3250
3209
  googleFontsCatalog,
3210
+ hasBrandPassthrough,
3251
3211
  hexToOklch,
3252
3212
  hexToRgb,
3253
3213
  isValidColor,
@@ -412,6 +412,8 @@ interface ResolvedThemeConfig {
412
412
  name: string;
413
413
  /** Optional display label override forwarded from VisorThemeConfig.label. */
414
414
  label?: string;
415
+ /** Default color mode forwarded from VisorThemeConfig["default-mode"]. */
416
+ "default-mode"?: "dark" | "light";
415
417
  version: 1;
416
418
  colors: {
417
419
  primary: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loworbitstudio/visor-theme-engine",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
@@ -20,6 +20,15 @@
20
20
  "type": "string",
21
21
  "description": "Theme group for the docs site theme switcher (e.g. 'Visor', 'Client', 'Low Orbit'). Used by `visor theme sync`. Defaults to folder-based grouping when omitted."
22
22
  },
23
+ "label": {
24
+ "type": "string",
25
+ "description": "Human-readable display name for the theme (e.g. 'Blacklight Pro'). Overrides the name-derived label in the docs theme switcher. Optional."
26
+ },
27
+ "default-mode": {
28
+ "type": "string",
29
+ "enum": ["light", "dark"],
30
+ "description": "Default color mode when activating this theme. When set, the docs site forces this mode on theme switch (unless the user has a stored mode preference). Optional."
31
+ },
23
32
  "colors": {
24
33
  "type": "object",
25
34
  "description": "Color definitions for light mode. Only primary is required — all others have sensible defaults.",