@loworbitstudio/visor-theme-engine 0.12.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-BKEkyelS.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-B56A5DE6.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";
@@ -802,7 +802,8 @@ var BRAND_VARIANTS = [
802
802
  "brandmark",
803
803
  "wordmark",
804
804
  "monochrome",
805
- "favicon"
805
+ "favicon",
806
+ "animated"
806
807
  ];
807
808
 
808
809
  // src/brand/pipeline.ts
@@ -1346,6 +1347,91 @@ function generateShadeScale(color, role) {
1346
1347
  return scale;
1347
1348
  }
1348
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
+
1349
1435
  // src/fonts/theme-alias.ts
1350
1436
  var EMPTY_ALIASES = /* @__PURE__ */ new Map();
1351
1437
  function aliasFamily(family, themeSlug) {
@@ -1821,6 +1907,9 @@ export {
1821
1907
  SELECTIVE_SHADE_STEPS,
1822
1908
  TAILWIND_GRAY,
1823
1909
  generateShadeScale,
1910
+ collectBrandPassthrough,
1911
+ hasBrandPassthrough,
1912
+ applyOverrides,
1824
1913
  aliasFamily,
1825
1914
  fontStack,
1826
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-BKEkyelS.js';
2
- export { o as BRAND_VARIANTS, p as BrandVariant, q as ColorFormat, r as FontSource, s as RGBA, t as SemanticTokenValue } from './types-BKEkyelS.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.",
@@ -631,6 +643,9 @@ var properties = {
631
643
  favicon: {
632
644
  $ref: "#/$defs/brandSlot"
633
645
  },
646
+ animated: {
647
+ $ref: "#/$defs/brandSlot"
648
+ },
634
649
  custom: {
635
650
  type: "object",
636
651
  description: "Operator-defined slots, addressed by key.",
@@ -943,6 +958,21 @@ interface ThemeValidationResult {
943
958
  errors: ValidationIssue[];
944
959
  warnings: ValidationIssue[];
945
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
+ }
946
976
  /**
947
977
  * Validate a theme config comprehensively.
948
978
  *
@@ -950,9 +980,10 @@ interface ThemeValidationResult {
950
980
  * Results are JSON-serializable for CLI `--json` output.
951
981
  *
952
982
  * @param config - A parsed theme config object (from YAML or programmatic)
983
+ * @param options - Optional validator flags (e.g. strictDark)
953
984
  * @returns ThemeValidationResult with errors[], warnings[], and valid boolean
954
985
  */
955
- declare function validate(config: unknown): ThemeValidationResult;
986
+ declare function validate(config: unknown, options?: ValidateOptions): ThemeValidationResult;
956
987
 
957
988
  /**
958
989
  * Shade Scale Generation
@@ -1059,6 +1090,30 @@ declare function assignSemanticTokens(lightPrimitives: GeneratedPrimitives, dark
1059
1090
  * replacing derived token values with user-specified values.
1060
1091
  */
1061
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;
1062
1117
  /**
1063
1118
  * Apply override values to semantic tokens.
1064
1119
  * Returns a new SemanticTokens with overrides applied (does not mutate input).
@@ -1218,4 +1273,4 @@ declare function cleanFontValue(val: string): string;
1218
1273
  */
1219
1274
  declare function extractFromCSS(files: CSSFile[], name?: string): ExtractionResult;
1220
1275
 
1221
- 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-B56A5DE6.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.",
@@ -457,6 +469,7 @@ var visor_theme_schema_default = {
457
469
  wordmark: { $ref: "#/$defs/brandSlot" },
458
470
  monochrome: { $ref: "#/$defs/brandSlot" },
459
471
  favicon: { $ref: "#/$defs/brandSlot" },
472
+ animated: { $ref: "#/$defs/brandSlot" },
460
473
  custom: {
461
474
  type: "object",
462
475
  description: "Operator-defined slots, addressed by key.",
@@ -705,9 +718,10 @@ var KNOWN_BRAND_KEYS = /* @__PURE__ */ new Set([
705
718
  "wordmark",
706
719
  "monochrome",
707
720
  "favicon",
721
+ "animated",
708
722
  "custom"
709
723
  ]);
710
- var KNOWN_BRAND_STANDARD_SLOTS = ["logo", "brandmark", "wordmark", "monochrome", "favicon"];
724
+ var KNOWN_BRAND_STANDARD_SLOTS = ["logo", "brandmark", "wordmark", "monochrome", "favicon", "animated"];
711
725
  var KNOWN_BRAND_SLOT_KEYS = /* @__PURE__ */ new Set(["slug", "formats", "light", "dark", "clearSpace", "aspectRatio"]);
712
726
  var KNOWN_BRAND_SOURCES = /* @__PURE__ */ new Set(["visor-brands", "local"]);
713
727
  var KNOWN_BRAND_CDN_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["visor-brands"]);
@@ -1039,6 +1053,20 @@ function validateConfig(config) {
1039
1053
  }
1040
1054
  }
1041
1055
  }
1056
+ if (typeof brand.animated === "object" && brand.animated !== null) {
1057
+ const animated = brand.animated;
1058
+ if (Array.isArray(animated.formats) && !animated.formats.every(
1059
+ (f) => typeof f === "string" && f.toLowerCase() === "svg"
1060
+ )) {
1061
+ errors.push("'brand.animated.formats' must be SVG-only (the animated slot accepts self-contained animated SVGs only)");
1062
+ }
1063
+ for (const mode of ["light", "dark"]) {
1064
+ const p = animated[mode];
1065
+ if (typeof p === "string" && !p.toLowerCase().endsWith(".svg")) {
1066
+ errors.push(`'brand.animated.${mode}' must be an .svg path (the animated slot is SVG-only)`);
1067
+ }
1068
+ }
1069
+ }
1042
1070
  }
1043
1071
  }
1044
1072
  if (obj.overrides !== void 0) {
@@ -1112,6 +1140,9 @@ function resolveBrand(brand) {
1112
1140
  wordmark: brand.wordmark ?? DEFAULT_VISOR_BRAND.wordmark,
1113
1141
  monochrome: brand.monochrome ?? DEFAULT_VISOR_BRAND.monochrome,
1114
1142
  favicon: brand.favicon ?? DEFAULT_VISOR_BRAND.favicon,
1143
+ // animated is optional with no Visor default (D2): pass through only when a
1144
+ // theme declares it, so undeclared themes emit no --brand-animated.
1145
+ ...brand.animated && { animated: brand.animated },
1115
1146
  ...brand.custom && { custom: brand.custom }
1116
1147
  };
1117
1148
  }
@@ -1146,6 +1177,7 @@ function resolveConfig(config) {
1146
1177
  return {
1147
1178
  name: config.name,
1148
1179
  ...config.label !== void 0 && { label: config.label },
1180
+ ...config["default-mode"] !== void 0 && { "default-mode": config["default-mode"] },
1149
1181
  version: 1,
1150
1182
  colors: {
1151
1183
  primary: colors.primary,
@@ -1407,7 +1439,7 @@ var SEMANTIC_SURFACE_MAP = {
1407
1439
  // VI-478: status soft tints (BL-193) — alpha overlays, semantically distinct
1408
1440
  // from the OPAQUE `surface-{status}-subtle` above (do NOT alias them together).
1409
1441
  // Default to a color-mix of the status color so they track the theme; themes
1410
- // pin exact values via overrides (blacklight-underground: success @10%,
1442
+ // pin exact values via overrides (blacklight-pro: success @10%,
1411
1443
  // warning/error @12%).
1412
1444
  "success-soft": {
1413
1445
  light: { constant: "color-mix(in srgb, var(--color-success-500) 10%, transparent)" },
@@ -1504,7 +1536,7 @@ var SEMANTIC_INTERACTIVE_MAP = {
1504
1536
  // VI-478: brand-derived alpha-overlay helpers (BL-193). `soft`/`glow` are
1505
1537
  // alpha overlays that track the theme's primary via color-mix (distinct from
1506
1538
  // any opaque surface); `strong` is a solid lightened-brand emphasis color.
1507
- // Themes pin exact values via overrides — e.g. blacklight-underground sets
1539
+ // Themes pin exact values via overrides — e.g. blacklight-pro sets
1508
1540
  // soft @12% / glow @32% / strong #FFD050.
1509
1541
  "primary-soft": {
1510
1542
  light: { constant: "color-mix(in srgb, var(--color-primary-500) 12%, transparent)" },
@@ -1567,11 +1599,14 @@ var SEMANTIC_INTENT_MAP = {
1567
1599
  light: { role: "primary", shade: 500 },
1568
1600
  dark: { role: "primary", shade: 500 }
1569
1601
  },
1570
- // Text color paired with --primary backgrounds. Default white; themes whose
1571
- // 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.
1572
1607
  "primary-text": {
1573
- light: { constant: "#ffffff" },
1574
- dark: { constant: "#ffffff" }
1608
+ light: { constant: "var(--interactive-primary-text)" },
1609
+ dark: { constant: "var(--interactive-primary-text)" }
1575
1610
  },
1576
1611
  accent: {
1577
1612
  light: { role: "accent", shade: 500 },
@@ -1676,71 +1711,6 @@ function assignSemanticTokens(lightPrimitives, darkPrimitives, config) {
1676
1711
  return { text, surface, border, interactive, intent, hairline };
1677
1712
  }
1678
1713
 
1679
- // src/overrides.ts
1680
- var TOKEN_CATEGORIES = [
1681
- { prefix: "text-", key: "text" },
1682
- { prefix: "surface-", key: "surface" },
1683
- { prefix: "border-", key: "border" },
1684
- { prefix: "interactive-", key: "interactive" },
1685
- { prefix: "hairline-", key: "hairline" }
1686
- ];
1687
- function findToken(key, tokens) {
1688
- if (key === "hairline" && "default" in tokens.hairline) {
1689
- return { group: tokens.hairline, name: "default" };
1690
- }
1691
- for (const { prefix, key: groupKey } of TOKEN_CATEGORIES) {
1692
- if (key.startsWith(prefix)) {
1693
- const name = key.slice(prefix.length);
1694
- if (name in tokens[groupKey]) {
1695
- return { group: tokens[groupKey], name };
1696
- }
1697
- }
1698
- }
1699
- if (key in tokens.intent) {
1700
- return { group: tokens.intent, name: key };
1701
- }
1702
- return null;
1703
- }
1704
- function applyOverrides(tokens, overrides) {
1705
- if (!overrides) return tokens;
1706
- const result = {
1707
- text: { ...tokens.text },
1708
- surface: { ...tokens.surface },
1709
- border: { ...tokens.border },
1710
- interactive: { ...tokens.interactive },
1711
- intent: { ...tokens.intent },
1712
- hairline: { ...tokens.hairline }
1713
- };
1714
- for (const group of ["text", "surface", "border", "interactive", "intent", "hairline"]) {
1715
- for (const [name, value] of Object.entries(result[group])) {
1716
- result[group][name] = { ...value };
1717
- }
1718
- }
1719
- if (overrides.light) {
1720
- for (const [key, value] of Object.entries(overrides.light)) {
1721
- const match = findToken(key, result);
1722
- if (match) {
1723
- match.group[match.name] = {
1724
- ...match.group[match.name],
1725
- light: value
1726
- };
1727
- }
1728
- }
1729
- }
1730
- if (overrides.dark) {
1731
- for (const [key, value] of Object.entries(overrides.dark)) {
1732
- const match = findToken(key, result);
1733
- if (match) {
1734
- match.group[match.name] = {
1735
- ...match.group[match.name],
1736
- dark: value
1737
- };
1738
- }
1739
- }
1740
- }
1741
- return result;
1742
- }
1743
-
1744
1714
  // src/pipeline.ts
1745
1715
  function generatePrimitives(config) {
1746
1716
  return {
@@ -2342,7 +2312,7 @@ function checkOverrides(config, issues) {
2342
2312
  issue(
2343
2313
  "warning",
2344
2314
  "UNKNOWN_OVERRIDE_KEY",
2345
- `'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.`,
2346
2316
  `overrides.${mode}.${key}`
2347
2317
  )
2348
2318
  );
@@ -2623,14 +2593,15 @@ function checkRadiusScale(config, issues) {
2623
2593
  );
2624
2594
  }
2625
2595
  }
2626
- function checkDarkLightParity(config, issues) {
2596
+ function checkDarkLightParity(config, issues, opts) {
2627
2597
  if (!config.colors) return;
2628
2598
  const colorKeys = Object.keys(config.colors).filter((k) => k !== "primary");
2629
2599
  const hasDarkSection = config["colors-dark"] !== void 0;
2630
2600
  if (colorKeys.length > 0 && !hasDarkSection) {
2601
+ const severity = opts.strictDark ? "error" : "warning";
2631
2602
  issues.push(
2632
2603
  issue(
2633
- "warning",
2604
+ severity,
2634
2605
  "DARK_LIGHT_PARITY",
2635
2606
  "Custom colors are set but no colors-dark section exists. Dark mode will use generated defaults which may not match your brand.",
2636
2607
  "colors-dark"
@@ -2644,9 +2615,10 @@ function checkDarkLightParity(config, issues) {
2644
2615
  for (const key of lightKeys) {
2645
2616
  if (key === "primary") continue;
2646
2617
  if (!darkKeys.has(key)) {
2618
+ const severity = opts.strictDark ? "error" : "warning";
2647
2619
  issues.push(
2648
2620
  issue(
2649
- "warning",
2621
+ severity,
2650
2622
  "DARK_LIGHT_PARITY",
2651
2623
  `Color "${key}" is set in colors but missing from colors-dark. Dark mode will use a generated default.`,
2652
2624
  "colors-dark"
@@ -2668,7 +2640,8 @@ function checkDarkLightParity(config, issues) {
2668
2640
  }
2669
2641
  }
2670
2642
  }
2671
- function validate(config) {
2643
+ function validate(config, options) {
2644
+ const opts = options || {};
2672
2645
  const errors = [];
2673
2646
  const warnings = [];
2674
2647
  const structurallyValid = checkStructuralIntegrity(config, errors);
@@ -2700,7 +2673,11 @@ function validate(config) {
2700
2673
  checkColorSimilarity(typedConfig, warnings);
2701
2674
  checkMissingGlowShadow(typedConfig, warnings);
2702
2675
  checkRadiusScale(typedConfig, warnings);
2703
- 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
+ }
2704
2681
  }
2705
2682
  return {
2706
2683
  valid: errors.length === 0,
@@ -3210,6 +3187,7 @@ export {
3210
3187
  buildVisorFontUrl,
3211
3188
  clampToSrgb,
3212
3189
  cleanFontValue,
3190
+ collectBrandPassthrough,
3213
3191
  compositeOverBackground,
3214
3192
  exportTheme,
3215
3193
  extractFromCSS,
@@ -3229,6 +3207,7 @@ export {
3229
3207
  generateThemeFromConfig,
3230
3208
  getContrastRatio,
3231
3209
  googleFontsCatalog,
3210
+ hasBrandPassthrough,
3232
3211
  hexToOklch,
3233
3212
  hexToRgb,
3234
3213
  isValidColor,
@@ -138,7 +138,7 @@ type BrandSource = "visor-brands" | "local";
138
138
  * Standard brand variant slots. A fixed set covers the common lockups; custom
139
139
  * operator-defined slots are addressed by key through the `custom` map.
140
140
  */
141
- type BrandVariant = "logo" | "brandmark" | "wordmark" | "monochrome" | "favicon";
141
+ type BrandVariant = "logo" | "brandmark" | "wordmark" | "monochrome" | "favicon" | "animated";
142
142
  /** Ordered list of the standard brand variant slots. */
143
143
  declare const BRAND_VARIANTS: readonly BrandVariant[];
144
144
  /**
@@ -185,6 +185,13 @@ interface VisorBrand {
185
185
  monochrome?: BrandSlot;
186
186
  /** Favicon source. */
187
187
  favicon?: BrandSlot;
188
+ /**
189
+ * Animated lockup. Optional and SVG-only (D2/D3): the asset must be a
190
+ * self-contained animated SVG (inlined `<style>`/@keyframes or SMIL) so it
191
+ * animates inside `<img>`. Stock themes omit this — absent → no
192
+ * `--brand-animated` emitted. Reduced-motion consumers fall back to `logo`.
193
+ */
194
+ animated?: BrandSlot;
188
195
  /** Operator-defined slots, addressed by key. */
189
196
  custom?: Record<string, BrandSlot>;
190
197
  }
@@ -405,6 +412,8 @@ interface ResolvedThemeConfig {
405
412
  name: string;
406
413
  /** Optional display label override forwarded from VisorThemeConfig.label. */
407
414
  label?: string;
415
+ /** Default color mode forwarded from VisorThemeConfig["default-mode"]. */
416
+ "default-mode"?: "dark" | "light";
408
417
  version: 1;
409
418
  colors: {
410
419
  primary: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loworbitstudio/visor-theme-engine",
3
- "version": "0.12.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.",
@@ -253,6 +262,7 @@
253
262
  "wordmark": { "$ref": "#/$defs/brandSlot" },
254
263
  "monochrome": { "$ref": "#/$defs/brandSlot" },
255
264
  "favicon": { "$ref": "#/$defs/brandSlot" },
265
+ "animated": { "$ref": "#/$defs/brandSlot" },
256
266
  "custom": {
257
267
  "type": "object",
258
268
  "description": "Operator-defined slots, addressed by key.",