@loworbitstudio/visor-theme-engine 0.11.0 → 0.13.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 { c as GeneratedPrimitives, i as SemanticTokens, R as ResolvedThemeConfig } from '../types-Dwc1V0Nc.js';
1
+ import { g as GeneratedPrimitives, m as SemanticTokens, R as ResolvedThemeConfig } from '../types-CSO2avFQ.js';
2
2
 
3
3
  /**
4
4
  * Adapter types for the Visor theme engine.
@@ -195,8 +195,15 @@ declare function flutterAdapter(input: AdapterInput, options?: FlutterAdapterOpt
195
195
  * order — defense in depth, so whichever stylesheet loads first establishes
196
196
  * the cascade.
197
197
  */
198
- /** Layer order declaration — must appear before any @layer blocks. */
199
- declare const LAYER_ORDER = "@layer visor-primitives, visor-semantic, visor-adaptive, visor-bridge;";
198
+ /**
199
+ * Layer order declaration must appear before any @layer blocks.
200
+ *
201
+ * `visor-brand` (VI-470) is ordered immediately after `visor-semantic`: brand
202
+ * asset vars (`--brand-*`) sit above semantic tokens so brand overrides stay
203
+ * cleanly separable, while still below `visor-adaptive` chrome and the
204
+ * `visor-bridge` framework layer.
205
+ */
206
+ declare const LAYER_ORDER = "@layer visor-primitives, visor-semantic, visor-brand, visor-adaptive, visor-bridge;";
200
207
  /**
201
208
  * Wrap CSS content in a named @layer block.
202
209
  */
@@ -15,12 +15,13 @@ import {
15
15
  generateTextScaleAliasDecls,
16
16
  header,
17
17
  parseColor,
18
+ resolveThemeBrand,
18
19
  resolveThemeFonts,
19
20
  sectionComment
20
- } from "../chunk-43TVIXUS.js";
21
+ } from "../chunk-KFTTL3XP.js";
21
22
 
22
23
  // src/adapters/layers.ts
23
- var LAYER_ORDER = "@layer visor-primitives, visor-semantic, visor-adaptive, visor-bridge;";
24
+ var LAYER_ORDER = "@layer visor-primitives, visor-semantic, visor-brand, visor-adaptive, visor-bridge;";
24
25
  function wrapInLayer(layerName, css) {
25
26
  const trimmed = css.trim();
26
27
  if (!trimmed) return "";
@@ -629,10 +630,13 @@ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
629
630
  }`);
630
631
  }
631
632
  semanticLines.push("");
633
+ const brandResult = resolveThemeBrand(input.config.brand, { scope: scopeClass });
632
634
  const adaptiveLayer = wrapInLayer("visor-adaptive", lines.join("\n").trim());
633
635
  const semanticLayer = wrapInLayer("visor-semantic", semanticLines.join("\n").trim());
636
+ const brandLayer = wrapInLayer("visor-brand", brandResult.css);
634
637
  const head = fontLines.length > 0 ? fontLines.join("\n") + "\n" : "";
635
- return head + LAYER_ORDER + "\n\n" + semanticLayer + "\n\n" + adaptiveLayer + "\n";
638
+ const layerBlocks = [semanticLayer, brandLayer, adaptiveLayer].filter(Boolean);
639
+ return head + LAYER_ORDER + "\n\n" + layerBlocks.join("\n\n") + "\n";
636
640
  }
637
641
 
638
642
  // src/flutter/color-to-dart.ts
@@ -217,11 +217,11 @@ function resolveFont(family, options = {}) {
217
217
  };
218
218
  }
219
219
  if (explicitSource === "fontshare") {
220
- const cssUrl = buildFontshareCssUrl(family, requestedWeights, italic, display);
220
+ const cssUrl2 = buildFontshareCssUrl(family, requestedWeights, italic, display);
221
221
  return {
222
222
  family,
223
223
  source: "fontshare",
224
- cssUrl,
224
+ cssUrl: cssUrl2,
225
225
  weights: requestedWeights,
226
226
  italic,
227
227
  display,
@@ -256,7 +256,7 @@ function resolveFont(family, options = {}) {
256
256
  const weights = availableWeights.length > 0 ? availableWeights : catalogEntry.weights;
257
257
  const hasItalic = catalogEntry.styles.includes("italic");
258
258
  const resolvedItalic = italic && hasItalic;
259
- const cssUrl = buildGoogleFontsCssUrl(
259
+ const cssUrl2 = buildGoogleFontsCssUrl(
260
260
  catalogEntry.family,
261
261
  weights,
262
262
  resolvedItalic,
@@ -266,7 +266,7 @@ function resolveFont(family, options = {}) {
266
266
  family: catalogEntry.family,
267
267
  // Use canonical casing from catalog
268
268
  source: "google-fonts",
269
- cssUrl,
269
+ cssUrl: cssUrl2,
270
270
  weights,
271
271
  italic: resolvedItalic,
272
272
  display,
@@ -711,6 +711,180 @@ function resolveThemeFonts(typography, options) {
711
711
  };
712
712
  }
713
713
 
714
+ // src/brand/resolve.ts
715
+ var VISOR_BRANDS_CDN = "https://brands.visor.design";
716
+ var DEFAULT_FORMAT = "svg";
717
+ var DEFAULT_BRAND_SOURCE = "visor-brands";
718
+ var VISOR_DEFAULT_BRAND_PATH = "/themes/visor/brand";
719
+ var DEFAULT_VISOR_BRAND = {
720
+ org: "low-orbit-studio",
721
+ source: "local",
722
+ logo: {
723
+ slug: "visor",
724
+ formats: ["svg"],
725
+ light: `${VISOR_DEFAULT_BRAND_PATH}/visor-logo-light.svg`,
726
+ dark: `${VISOR_DEFAULT_BRAND_PATH}/visor-logo-dark.svg`,
727
+ // Aspect ratios are the real SVG viewBoxes from the VI-469 asset set.
728
+ aspectRatio: "1269.97 / 540",
729
+ clearSpace: "0.5rem"
730
+ },
731
+ brandmark: {
732
+ slug: "visor",
733
+ formats: ["svg"],
734
+ // Single-file mark — same asset on light and dark surfaces.
735
+ light: `${VISOR_DEFAULT_BRAND_PATH}/visor-brandmark.svg`,
736
+ dark: `${VISOR_DEFAULT_BRAND_PATH}/visor-brandmark.svg`,
737
+ aspectRatio: "1 / 1",
738
+ clearSpace: "0.25rem"
739
+ },
740
+ wordmark: {
741
+ slug: "visor",
742
+ formats: ["svg"],
743
+ light: `${VISOR_DEFAULT_BRAND_PATH}/visor-wordmark-light.svg`,
744
+ dark: `${VISOR_DEFAULT_BRAND_PATH}/visor-wordmark-dark.svg`,
745
+ aspectRatio: "1100 / 316",
746
+ clearSpace: "0.5rem"
747
+ },
748
+ monochrome: {
749
+ slug: "visor",
750
+ formats: ["svg"],
751
+ // Single-file mark, tinted via mask-image + currentColor.
752
+ light: `${VISOR_DEFAULT_BRAND_PATH}/visor-monochrome.svg`,
753
+ dark: `${VISOR_DEFAULT_BRAND_PATH}/visor-monochrome.svg`,
754
+ aspectRatio: "2210 / 636",
755
+ clearSpace: "0.25rem"
756
+ },
757
+ favicon: {
758
+ slug: "visor",
759
+ formats: ["svg", "png"],
760
+ // The Visor symbol (brandmark) doubles as the favicon source; Phase 2's
761
+ // build step generates the sized .ico/.png set from it (§4.D).
762
+ light: `${VISOR_DEFAULT_BRAND_PATH}/visor-favicon.svg`,
763
+ dark: `${VISOR_DEFAULT_BRAND_PATH}/visor-favicon.svg`,
764
+ aspectRatio: "1 / 1"
765
+ }
766
+ };
767
+ function buildVisorBrandUrl(org, slug, variant, mode, format, cdnBase) {
768
+ const base = cdnBase ?? VISOR_BRANDS_CDN;
769
+ const orgSegment = org ? `/${org}` : "";
770
+ const modeSuffix = mode === "dark" ? "-dark" : "";
771
+ return `${base}${orgSegment}/${slug}/${variant}${modeSuffix}.${format}`;
772
+ }
773
+ function buildLocalBrandPath(slug, variant, mode, format, explicit) {
774
+ if (explicit) return explicit;
775
+ const modeSuffix = mode === "dark" ? "-dark" : "";
776
+ return `/themes/${slug}/brand/${variant}${modeSuffix}.${format}`;
777
+ }
778
+ function resolveBrandSlot(variant, slot, options) {
779
+ const source = options.source;
780
+ const format = slot.formats?.[0] ?? DEFAULT_FORMAT;
781
+ const slug = slot.slug ?? variant;
782
+ const clearSpace = slot.clearSpace ?? null;
783
+ const aspectRatio = slot.aspectRatio ?? null;
784
+ if (source === "local") {
785
+ const light2 = buildLocalBrandPath(slug, variant, "light", format, slot.light);
786
+ const dark2 = buildLocalBrandPath(slug, variant, "dark", format, slot.dark);
787
+ const guidance = slot.light || slot.dark ? null : `Brand variant "${variant}" is a local asset. Place the file(s) at public${light2}${light2 !== dark2 ? ` and public${dark2}` : ""} in your project, or set brand.${variant}.light / .dark to an explicit public/-relative path.`;
788
+ return { variant, source, light: light2, dark: dark2, clearSpace, aspectRatio, guidance };
789
+ }
790
+ const org = options.org ?? "";
791
+ const light = slot.light ?? buildVisorBrandUrl(org, slug, variant, "light", format, options.cdnBase);
792
+ const dark = slot.dark ?? buildVisorBrandUrl(org, slug, variant, "dark", format, options.cdnBase);
793
+ return { variant, source, light, dark, clearSpace, aspectRatio, guidance: null };
794
+ }
795
+ function resolveBrandSource(brand) {
796
+ return brand.source ?? DEFAULT_BRAND_SOURCE;
797
+ }
798
+
799
+ // src/brand/types.ts
800
+ var BRAND_VARIANTS = [
801
+ "logo",
802
+ "brandmark",
803
+ "wordmark",
804
+ "monochrome",
805
+ "favicon",
806
+ "animated"
807
+ ];
808
+
809
+ // src/brand/pipeline.ts
810
+ function cssUrl(value) {
811
+ return `url("${value}")`;
812
+ }
813
+ function staticDeclsFor(r) {
814
+ const decls = [];
815
+ decls.push(`--brand-${r.variant}-light: ${cssUrl(r.light)};`);
816
+ decls.push(`--brand-${r.variant}-dark: ${cssUrl(r.dark)};`);
817
+ if (r.clearSpace !== null) {
818
+ decls.push(`--brand-${r.variant}-clear-space: ${r.clearSpace};`);
819
+ }
820
+ if (r.aspectRatio !== null) {
821
+ decls.push(`--brand-${r.variant}-aspect-ratio: ${r.aspectRatio};`);
822
+ }
823
+ return decls;
824
+ }
825
+ function modeDecl(r, mode) {
826
+ return `--brand-${r.variant}: ${cssUrl(r[mode])};`;
827
+ }
828
+ function block(selector, decls) {
829
+ if (decls.length === 0) return "";
830
+ return [`${selector} {`, ...decls.map((d) => ` ${d}`), "}"].join("\n");
831
+ }
832
+ function generateBrandCSS(resolutions, scope) {
833
+ if (resolutions.length === 0) return "";
834
+ const baseSelector = scope ? scope : ":root";
835
+ const lightSelector = scope ? `html:not(.dark) ${scope}` : ":root";
836
+ const darkSelector = scope ? `.dark ${scope}` : ".dark";
837
+ const pcsSelector = scope ? `${scope}:not(.light)` : ":root:not(.light)";
838
+ const lines = [];
839
+ const staticDecls = resolutions.flatMap(staticDeclsFor);
840
+ lines.push("/* --- Brand: forced-mode aliases + tokens --- */");
841
+ lines.push(block(baseSelector, staticDecls));
842
+ lines.push("");
843
+ const lightDecls = resolutions.map((r) => modeDecl(r, "light"));
844
+ lines.push("/* --- Brand: variants (light) --- */");
845
+ lines.push(block(lightSelector, lightDecls));
846
+ lines.push("");
847
+ const darkDecls = resolutions.map((r) => modeDecl(r, "dark"));
848
+ lines.push("/* --- Brand: variants (dark) \u2014 manual toggle --- */");
849
+ lines.push(block(darkSelector, darkDecls));
850
+ lines.push("");
851
+ lines.push("/* --- Brand: variants (dark) \u2014 prefers-color-scheme --- */");
852
+ const inner = block(pcsSelector, darkDecls);
853
+ lines.push(
854
+ `@media (prefers-color-scheme: dark) {
855
+ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
856
+ }`
857
+ );
858
+ return lines.join("\n").trim();
859
+ }
860
+ function resolveThemeBrand(brand, options) {
861
+ const effective = brand ?? DEFAULT_VISOR_BRAND;
862
+ const scope = options?.scope ?? "";
863
+ const source = resolveBrandSource(effective);
864
+ const org = effective.org ?? null;
865
+ const cdnBase = effective["cdn-overrides"]?.["visor-brands"] ?? null;
866
+ const slotOptions = { source, org, cdnBase };
867
+ const warnings = [];
868
+ const variants = [];
869
+ const custom = [];
870
+ for (const variant of BRAND_VARIANTS) {
871
+ const slot = effective[variant];
872
+ if (!slot) continue;
873
+ const resolution = resolveBrandSlot(variant, slot, slotOptions);
874
+ variants.push(resolution);
875
+ if (resolution.guidance) warnings.push(resolution.guidance);
876
+ }
877
+ if (effective.custom) {
878
+ for (const [key, slot] of Object.entries(effective.custom)) {
879
+ const resolution = resolveBrandSlot(key, slot, slotOptions);
880
+ custom.push(resolution);
881
+ if (resolution.guidance) warnings.push(resolution.guidance);
882
+ }
883
+ }
884
+ const css = generateBrandCSS([...variants, ...custom], scope);
885
+ return { variants, custom, css, warnings };
886
+ }
887
+
714
888
  // src/color.ts
715
889
  function normalizeHex(hex) {
716
890
  let color = hex.replace(/^#/, "");
@@ -1199,7 +1373,7 @@ function sectionComment(label) {
1199
1373
  return `
1200
1374
  /* --- ${label} --- */`;
1201
1375
  }
1202
- function block(selector, declarations) {
1376
+ function block2(selector, declarations) {
1203
1377
  if (declarations.length === 0) return "";
1204
1378
  return [selector + " {", ...declarations.map((d) => ` ${d}`), "}", ""].join(
1205
1379
  "\n"
@@ -1356,20 +1530,20 @@ function generatePrimitivesCss(primitives, config, options) {
1356
1530
  const host = options?.scopePrefix ?? ":root";
1357
1531
  lines.push(sectionComment("Primitive: Colors"));
1358
1532
  lines.push(
1359
- block(host, [generateColorPrimitives(primitives)])
1533
+ block2(host, [generateColorPrimitives(primitives)])
1360
1534
  );
1361
1535
  lines.push(sectionComment("Primitive: Spacing"));
1362
- lines.push(block(host, generateSpacingPrimitives(config)));
1536
+ lines.push(block2(host, generateSpacingPrimitives(config)));
1363
1537
  lines.push(sectionComment("Primitive: Border Radius"));
1364
- lines.push(block(host, generateRadiusPrimitives(config)));
1538
+ lines.push(block2(host, generateRadiusPrimitives(config)));
1365
1539
  lines.push(sectionComment("Primitive: Typography"));
1366
- lines.push(block(host, generateTypographyPrimitives(config, options?.aliasedFamilies)));
1540
+ lines.push(block2(host, generateTypographyPrimitives(config, options?.aliasedFamilies)));
1367
1541
  lines.push(sectionComment("Primitive: Shadows"));
1368
- lines.push(block(host, generateShadowPrimitives(config)));
1542
+ lines.push(block2(host, generateShadowPrimitives(config)));
1369
1543
  lines.push(sectionComment("Primitive: Motion"));
1370
- lines.push(block(host, generateMotionPrimitives(config)));
1544
+ lines.push(block2(host, generateMotionPrimitives(config)));
1371
1545
  lines.push(sectionComment("Primitive: Miscellaneous"));
1372
- lines.push(block(host, generateMiscPrimitives()));
1546
+ lines.push(block2(host, generateMiscPrimitives()));
1373
1547
  return header("Visor Theme \u2014 Primitives") + lines.join("\n");
1374
1548
  }
1375
1549
  function hairlineDeclName(name) {
@@ -1381,32 +1555,32 @@ function generateSemanticCss(tokens) {
1381
1555
  const textDecls = Object.entries(tokens.text).map(
1382
1556
  ([name, { light }]) => `--text-${name}: ${light};`
1383
1557
  );
1384
- lines.push(block(":root", textDecls));
1558
+ lines.push(block2(":root", textDecls));
1385
1559
  lines.push(sectionComment("Semantic: Surface"));
1386
1560
  const surfaceDecls = Object.entries(tokens.surface).map(
1387
1561
  ([name, { light }]) => `--surface-${name}: ${light};`
1388
1562
  );
1389
- lines.push(block(":root", surfaceDecls));
1563
+ lines.push(block2(":root", surfaceDecls));
1390
1564
  lines.push(sectionComment("Semantic: Border"));
1391
1565
  const borderDecls = Object.entries(tokens.border).map(
1392
1566
  ([name, { light }]) => `--border-${name}: ${light};`
1393
1567
  );
1394
- lines.push(block(":root", borderDecls));
1568
+ lines.push(block2(":root", borderDecls));
1395
1569
  lines.push(sectionComment("Semantic: Interactive"));
1396
1570
  const interactiveDecls = Object.entries(tokens.interactive).map(
1397
1571
  ([name, { light }]) => `--interactive-${name}: ${light};`
1398
1572
  );
1399
- lines.push(block(":root", interactiveDecls));
1573
+ lines.push(block2(":root", interactiveDecls));
1400
1574
  lines.push(sectionComment("Semantic: Intent (aliases)"));
1401
1575
  const intentDecls = Object.entries(tokens.intent).map(
1402
1576
  ([name, { light }]) => `--${name}: ${light};`
1403
1577
  );
1404
- lines.push(block(":root", intentDecls));
1578
+ lines.push(block2(":root", intentDecls));
1405
1579
  lines.push(sectionComment("Semantic: Hairline (aliases)"));
1406
1580
  const hairlineDecls = Object.entries(tokens.hairline).map(
1407
1581
  ([name, { light }]) => `${hairlineDeclName(name)}: ${light};`
1408
1582
  );
1409
- lines.push(block(":root", hairlineDecls));
1583
+ lines.push(block2(":root", hairlineDecls));
1410
1584
  return header("Visor Theme \u2014 Semantic") + lines.join("\n");
1411
1585
  }
1412
1586
  var TEXT_SCALE_ALIASES = [
@@ -1474,17 +1648,17 @@ function generateLightCss(tokens, options) {
1474
1648
  const { textDecls, surfaceDecls, borderDecls, interactiveDecls, intentDecls, hairlineDecls } = buildAdaptiveDecls(tokens, "light");
1475
1649
  const host = options?.scopePrefix ?? ":root";
1476
1650
  lines.push(sectionComment("Adaptive: Text (light)"));
1477
- lines.push(block(host, textDecls));
1651
+ lines.push(block2(host, textDecls));
1478
1652
  lines.push(sectionComment("Adaptive: Surface (light)"));
1479
- lines.push(block(host, surfaceDecls));
1653
+ lines.push(block2(host, surfaceDecls));
1480
1654
  lines.push(sectionComment("Adaptive: Border (light)"));
1481
- lines.push(block(host, borderDecls));
1655
+ lines.push(block2(host, borderDecls));
1482
1656
  lines.push(sectionComment("Adaptive: Interactive (light)"));
1483
- lines.push(block(host, interactiveDecls));
1657
+ lines.push(block2(host, interactiveDecls));
1484
1658
  lines.push(sectionComment("Adaptive: Intent aliases (light)"));
1485
- lines.push(block(host, intentDecls));
1659
+ lines.push(block2(host, intentDecls));
1486
1660
  lines.push(sectionComment("Adaptive: Hairline aliases (light)"));
1487
- lines.push(block(host, hairlineDecls));
1661
+ lines.push(block2(host, hairlineDecls));
1488
1662
  return header("Visor Theme \u2014 Light") + lines.join("\n");
1489
1663
  }
1490
1664
  function generateDarkCss(tokens, options) {
@@ -1495,23 +1669,23 @@ function generateDarkCss(tokens, options) {
1495
1669
  const darkSelector = darkSelectors.join(",\n");
1496
1670
  const prefersSelector = prefix ? `${prefix}:not(.light):not(.theme-light):not([data-theme="light"])` : ':root:not(.light):not(.theme-light):not([data-theme="light"])';
1497
1671
  lines.push(sectionComment("Adaptive: Text (dark) \u2014 manual toggle"));
1498
- lines.push(block(darkSelector, textDecls));
1672
+ lines.push(block2(darkSelector, textDecls));
1499
1673
  lines.push(sectionComment("Adaptive: Surface (dark) \u2014 manual toggle"));
1500
- lines.push(block(darkSelector, surfaceDecls));
1674
+ lines.push(block2(darkSelector, surfaceDecls));
1501
1675
  lines.push(sectionComment("Adaptive: Border (dark) \u2014 manual toggle"));
1502
- lines.push(block(darkSelector, borderDecls));
1676
+ lines.push(block2(darkSelector, borderDecls));
1503
1677
  lines.push(sectionComment("Adaptive: Interactive (dark) \u2014 manual toggle"));
1504
- lines.push(block(darkSelector, interactiveDecls));
1678
+ lines.push(block2(darkSelector, interactiveDecls));
1505
1679
  lines.push(sectionComment("Adaptive: Intent aliases (dark) \u2014 manual toggle"));
1506
- lines.push(block(darkSelector, intentDecls));
1680
+ lines.push(block2(darkSelector, intentDecls));
1507
1681
  lines.push(sectionComment("Adaptive: Hairline aliases (dark) \u2014 manual toggle"));
1508
- lines.push(block(darkSelector, hairlineDecls));
1682
+ lines.push(block2(darkSelector, hairlineDecls));
1509
1683
  lines.push(
1510
1684
  sectionComment("Adaptive: Text (dark) \u2014 prefers-color-scheme")
1511
1685
  );
1512
1686
  lines.push(
1513
1687
  `@media (prefers-color-scheme: dark) {
1514
- ${block(prefersSelector, textDecls)}}`
1688
+ ${block2(prefersSelector, textDecls)}}`
1515
1689
  );
1516
1690
  lines.push("");
1517
1691
  lines.push(
@@ -1519,7 +1693,7 @@ ${block(prefersSelector, textDecls)}}`
1519
1693
  );
1520
1694
  lines.push(
1521
1695
  `@media (prefers-color-scheme: dark) {
1522
- ${block(prefersSelector, surfaceDecls)}}`
1696
+ ${block2(prefersSelector, surfaceDecls)}}`
1523
1697
  );
1524
1698
  lines.push("");
1525
1699
  lines.push(
@@ -1527,7 +1701,7 @@ ${block(prefersSelector, surfaceDecls)}}`
1527
1701
  );
1528
1702
  lines.push(
1529
1703
  `@media (prefers-color-scheme: dark) {
1530
- ${block(prefersSelector, borderDecls)}}`
1704
+ ${block2(prefersSelector, borderDecls)}}`
1531
1705
  );
1532
1706
  lines.push("");
1533
1707
  lines.push(
@@ -1535,7 +1709,7 @@ ${block(prefersSelector, borderDecls)}}`
1535
1709
  );
1536
1710
  lines.push(
1537
1711
  `@media (prefers-color-scheme: dark) {
1538
- ${block(prefersSelector, interactiveDecls)}}`
1712
+ ${block2(prefersSelector, interactiveDecls)}}`
1539
1713
  );
1540
1714
  lines.push("");
1541
1715
  lines.push(
@@ -1543,7 +1717,7 @@ ${block(prefersSelector, interactiveDecls)}}`
1543
1717
  );
1544
1718
  lines.push(
1545
1719
  `@media (prefers-color-scheme: dark) {
1546
- ${block(prefersSelector, intentDecls)}}`
1720
+ ${block2(prefersSelector, intentDecls)}}`
1547
1721
  );
1548
1722
  lines.push("");
1549
1723
  lines.push(
@@ -1551,7 +1725,7 @@ ${block(prefersSelector, intentDecls)}}`
1551
1725
  );
1552
1726
  lines.push(
1553
1727
  `@media (prefers-color-scheme: dark) {
1554
- ${block(prefersSelector, hairlineDecls)}}`
1728
+ ${block2(prefersSelector, hairlineDecls)}}`
1555
1729
  );
1556
1730
  lines.push("");
1557
1731
  return header("Visor Theme \u2014 Dark") + lines.join("\n");
@@ -1618,6 +1792,14 @@ export {
1618
1792
  generatePreloadLinks,
1619
1793
  generateStylesheetLinks,
1620
1794
  resolveThemeFonts,
1795
+ VISOR_BRANDS_CDN,
1796
+ VISOR_DEFAULT_BRAND_PATH,
1797
+ DEFAULT_VISOR_BRAND,
1798
+ buildVisorBrandUrl,
1799
+ resolveBrandSlot,
1800
+ resolveBrandSource,
1801
+ BRAND_VARIANTS,
1802
+ resolveThemeBrand,
1621
1803
  normalizeHex,
1622
1804
  isValidHex,
1623
1805
  hexToRgb,
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-Dwc1V0Nc.js';
2
- export { k as ColorFormat, l as FontSource, m as RGBA, n as SemanticTokenValue } from './types-Dwc1V0Nc.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-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';
3
3
 
4
4
  /**
5
5
  * Font resolver — maps font family names to loadable font resources.
@@ -150,6 +150,92 @@ interface FontCoverageResult {
150
150
  declare function formatFontCoverageError(filename: string, declaredAt: string, family: string): string;
151
151
  declare function validateFontCoverage(css: string): FontCoverageResult;
152
152
 
153
+ /**
154
+ * Brand-asset resolver — maps a `brand` block's slots to loadable asset URLs.
155
+ *
156
+ * Mirrors `fonts/resolve.ts`:
157
+ * - `VISOR_BRANDS_CDN` is the default CDN base (parallels `VISOR_FONTS_CDN`).
158
+ * - `buildVisorBrandUrl()` constructs a CDN URL for a variant (Phase 2+).
159
+ * - `source: local` resolves to a path under the consuming app's `public/`.
160
+ * - `DEFAULT_VISOR_BRAND` is the Visor default brand stock themes fall back to.
161
+ */
162
+
163
+ /** Default CDN base for brand assets (Phase 2+; unused while source is `local`). */
164
+ declare const VISOR_BRANDS_CDN = "https://brands.visor.design";
165
+ /**
166
+ * Local public/ path prefix for the Visor default brand. VI-469 produces the
167
+ * SVGs at this canonical location (`packages/docs/public/themes/visor/brand/`),
168
+ * served from the docs app's `public/` root as `/themes/visor/brand/...`.
169
+ */
170
+ declare const VISOR_DEFAULT_BRAND_PATH = "/themes/visor/brand";
171
+ /**
172
+ * The Visor default brand. Stock themes that omit a `brand` block resolve to
173
+ * this — they are not logo-less (D3). Declared as `source: local` so Phase 1
174
+ * resolves it to the bundled SVGs at `/themes/visor/brand/` (no CDN).
175
+ *
176
+ * Aspect ratios and clear-space are pinned per variant (Q6). These are the
177
+ * canonical Visor mark dimensions; per-theme `brand` blocks override any slot.
178
+ */
179
+ declare const DEFAULT_VISOR_BRAND: VisorBrand;
180
+ /**
181
+ * Build a visor-brands CDN URL for a brand variant (Phase 2+).
182
+ *
183
+ * Default pattern: `https://brands.visor.design/{org}/{slug}/{variant}[-dark].{format}`.
184
+ * When `cdnBase` is provided, that base replaces `VISOR_BRANDS_CDN`. When `org`
185
+ * is empty (the CDN base already encodes the namespace), the org segment is
186
+ * dropped: `{cdnBase}/{slug}/{variant}[-dark].{format}`.
187
+ *
188
+ * Mirrors `buildVisorFontUrl`. Content-hashing (Q5) is a Phase 2 concern and is
189
+ * intentionally omitted here.
190
+ */
191
+ declare function buildVisorBrandUrl(org: string, slug: string, variant: string, mode: "light" | "dark", format: string, cdnBase?: string | null): string;
192
+ /**
193
+ * Resolve a single brand slot to a `BrandResolution`.
194
+ *
195
+ * Resolution order (mirrors the fonts resolver):
196
+ * 1. `source: local` → public/ paths (Phase 1).
197
+ * 2. `source: visor-brands` → CDN URLs via `buildVisorBrandUrl` (Phase 2+).
198
+ *
199
+ * `org` / `source` / `cdnBase` fall back to the brand-block-wide defaults when
200
+ * the slot omits them.
201
+ */
202
+ declare function resolveBrandSlot(variant: string, slot: BrandSlot, options: {
203
+ source: BrandSource;
204
+ org: string | null;
205
+ cdnBase?: string | null;
206
+ }): BrandResolution;
207
+ /** Resolve the brand-block-wide source default (slot-level `source` is not supported). */
208
+ declare function resolveBrandSource(brand: VisorBrand): BrandSource;
209
+
210
+ /**
211
+ * Theme brand pipeline — connects brand resolution to the .visor.yaml flow.
212
+ *
213
+ * Takes the `brand` block from a .visor.yaml file and produces:
214
+ * - Resolved brand assets (local public/ paths or CDN URLs).
215
+ * - Mode-scoped `--brand-{variant}` CSS custom properties, plus explicit
216
+ * `--brand-{variant}-light` / `-dark` forced-mode aliases (Q1).
217
+ * - Per-variant `--brand-{variant}-clear-space` / `-aspect-ratio` tokens (Q6).
218
+ * - Warnings for assets needing manual setup.
219
+ *
220
+ * The emitted CSS is a raw declaration body (no @layer wrapper). Adapters wrap
221
+ * it in `@layer visor-brand` (ordered after `visor-semantic`).
222
+ */
223
+
224
+ /**
225
+ * Resolve all brand assets for a theme's `brand` configuration.
226
+ *
227
+ * Main entry point for the brand pipeline — called during .visor.yaml import.
228
+ * When `brand` is omitted, falls back to the Visor default brand (D3) so stock
229
+ * themes are never logo-less.
230
+ *
231
+ * @param brand The resolved `brand` block (or undefined → Visor default).
232
+ * @param options `scope` — optional scope selector (e.g. `.blackout-theme`)
233
+ * that prefixes the emitted selectors for class-scoped adapters.
234
+ */
235
+ declare function resolveThemeBrand(brand: VisorBrand | undefined, options?: {
236
+ scope?: string;
237
+ }): ThemeBrandResult;
238
+
153
239
  /**
154
240
  * Import Pipeline
155
241
  *
@@ -501,6 +587,86 @@ var properties = {
501
587
  }
502
588
  }
503
589
  },
590
+ brand: {
591
+ type: "object",
592
+ description: "Brand-asset declarations (VI-470). Per-slot logo/brandmark/wordmark/etc. entries resolved to asset URLs and emitted as mode-scoped --brand-* CSS variables in a dedicated visor-brand cascade layer. Omitted → the Visor default brand (stock themes are not logo-less).",
593
+ additionalProperties: false,
594
+ properties: {
595
+ org: {
596
+ type: "string",
597
+ description: "CDN namespace for brand assets. Required when source is \"visor-brands\" (unless cdn-overrides.visor-brands is set)."
598
+ },
599
+ source: {
600
+ type: "string",
601
+ "enum": [
602
+ "visor-brands",
603
+ "local"
604
+ ],
605
+ description: "Where assets resolve from. Default: \"visor-brands\". Phase 1 supports \"local\" (resolves to public/ paths); CDN resolution lands later."
606
+ },
607
+ "cdn-overrides": {
608
+ type: "object",
609
+ description: "Per-source CDN base URL override. Only visor-brands is supported.",
610
+ additionalProperties: false,
611
+ properties: {
612
+ "visor-brands": {
613
+ type: "string",
614
+ minLength: 1,
615
+ description: "CDN base URL that replaces brands.visor.design for source: visor-brands slots."
616
+ }
617
+ }
618
+ },
619
+ logo: {
620
+ $ref: "#/$defs/brandSlot"
621
+ },
622
+ brandmark: {
623
+ $ref: "#/$defs/brandSlot"
624
+ },
625
+ wordmark: {
626
+ $ref: "#/$defs/brandSlot"
627
+ },
628
+ monochrome: {
629
+ $ref: "#/$defs/brandSlot"
630
+ },
631
+ favicon: {
632
+ $ref: "#/$defs/brandSlot"
633
+ },
634
+ animated: {
635
+ $ref: "#/$defs/brandSlot"
636
+ },
637
+ custom: {
638
+ type: "object",
639
+ description: "Operator-defined slots, addressed by key.",
640
+ additionalProperties: {
641
+ $ref: "#/$defs/brandSlot"
642
+ }
643
+ }
644
+ },
645
+ "if": {
646
+ properties: {
647
+ source: {
648
+ "const": "visor-brands"
649
+ }
650
+ },
651
+ required: [
652
+ "source"
653
+ ]
654
+ },
655
+ then: {
656
+ anyOf: [
657
+ {
658
+ required: [
659
+ "org"
660
+ ]
661
+ },
662
+ {
663
+ required: [
664
+ "cdn-overrides"
665
+ ]
666
+ }
667
+ ]
668
+ }
669
+ },
504
670
  spacing: {
505
671
  type: "object",
506
672
  description: "Spacing configuration.",
@@ -667,6 +833,40 @@ var $defs = {
667
833
  }
668
834
  ]
669
835
  },
836
+ brandSlot: {
837
+ type: "object",
838
+ description: "A single brand-variant declaration. Resolves to light + dark asset URLs; clearSpace and aspectRatio are tokenized per variant (VI-470, Q6).",
839
+ additionalProperties: false,
840
+ properties: {
841
+ slug: {
842
+ type: "string",
843
+ description: "CDN/asset-set slug. Required when source is visor-brands (Phase 2+)."
844
+ },
845
+ formats: {
846
+ type: "array",
847
+ items: {
848
+ type: "string"
849
+ },
850
+ description: "Preferred asset formats, first wins (e.g. [\"svg\"], [\"svg\", \"png\"])."
851
+ },
852
+ light: {
853
+ type: "string",
854
+ description: "Explicit light-mode asset filename or public/-relative path (overrides the inferred name)."
855
+ },
856
+ dark: {
857
+ type: "string",
858
+ description: "Explicit dark-mode asset filename or public/-relative path (overrides the inferred name)."
859
+ },
860
+ clearSpace: {
861
+ type: "string",
862
+ description: "Tokenized safe-zone padding enforced by <Logo> (e.g. \"0.5rem\"). Emitted as --brand-{variant}-clear-space."
863
+ },
864
+ aspectRatio: {
865
+ type: "string",
866
+ description: "Tokenized locked aspect ratio (e.g. \"3 / 1\"); else derived from the SVG viewBox. Emitted as --brand-{variant}-aspect-ratio."
867
+ }
868
+ }
869
+ },
670
870
  textSlotOverride: {
671
871
  type: "object",
672
872
  description: "Per-slot override for one Material TextTheme entry.",
@@ -1021,4 +1221,4 @@ declare function cleanFontValue(val: string): string;
1021
1221
  */
1022
1222
  declare function extractFromCSS(files: CSSFile[], name?: string): ExtractionResult;
1023
1223
 
1024
- export { type CSSFile, ColorRole, type Confidence, 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, ThemeData, ThemeFontResult, ThemeOutput, type ThemeValidationResult, VISOR_FONTS_CDN, type ValidationIssue, type ValidationSeverity, VisorThemeConfig, VisorTypography, applyOverrides, assignSemanticTokens, 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, resolveConfig, resolveFont, resolveThemeFonts, rgbToHex, serializeColor, validate, validateConfig, validateFontCoverage, visorTheme_schema as visorThemeSchema };
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 };
package/dist/index.js CHANGED
@@ -1,8 +1,13 @@
1
1
  import {
2
+ BRAND_VARIANTS,
3
+ DEFAULT_VISOR_BRAND,
2
4
  FONT_WEIGHT_ALIASES,
3
5
  MATERIAL_TEXT_SLOTS,
4
6
  TAILWIND_GRAY,
7
+ VISOR_BRANDS_CDN,
8
+ VISOR_DEFAULT_BRAND_PATH,
5
9
  VISOR_FONTS_CDN,
10
+ buildVisorBrandUrl,
6
11
  buildVisorFontUrl,
7
12
  clampToSrgb,
8
13
  compositeOverBackground,
@@ -29,12 +34,15 @@ import {
29
34
  parseHsla,
30
35
  parseOklch,
31
36
  parseRgba,
37
+ resolveBrandSlot,
38
+ resolveBrandSource,
32
39
  resolveFont,
40
+ resolveThemeBrand,
33
41
  resolveThemeFonts,
34
42
  rgbToHex,
35
43
  rgbToOklch,
36
44
  serializeColor
37
- } from "./chunk-43TVIXUS.js";
45
+ } from "./chunk-KFTTL3XP.js";
38
46
 
39
47
  // src/fonts/validate-coverage.ts
40
48
  var FONT_VAR_RE = /--font-(heading|display|body|sans|mono)\s*:\s*([^;]+);/g;
@@ -418,6 +426,55 @@ var visor_theme_schema_default = {
418
426
  }
419
427
  }
420
428
  },
429
+ brand: {
430
+ type: "object",
431
+ description: "Brand-asset declarations (VI-470). Per-slot logo/brandmark/wordmark/etc. entries resolved to asset URLs and emitted as mode-scoped --brand-* CSS variables in a dedicated visor-brand cascade layer. Omitted \u2192 the Visor default brand (stock themes are not logo-less).",
432
+ additionalProperties: false,
433
+ properties: {
434
+ org: {
435
+ type: "string",
436
+ description: 'CDN namespace for brand assets. Required when source is "visor-brands" (unless cdn-overrides.visor-brands is set).'
437
+ },
438
+ source: {
439
+ type: "string",
440
+ enum: ["visor-brands", "local"],
441
+ description: 'Where assets resolve from. Default: "visor-brands". Phase 1 supports "local" (resolves to public/ paths); CDN resolution lands later.'
442
+ },
443
+ "cdn-overrides": {
444
+ type: "object",
445
+ description: "Per-source CDN base URL override. Only visor-brands is supported.",
446
+ additionalProperties: false,
447
+ properties: {
448
+ "visor-brands": {
449
+ type: "string",
450
+ minLength: 1,
451
+ description: "CDN base URL that replaces brands.visor.design for source: visor-brands slots."
452
+ }
453
+ }
454
+ },
455
+ logo: { $ref: "#/$defs/brandSlot" },
456
+ brandmark: { $ref: "#/$defs/brandSlot" },
457
+ wordmark: { $ref: "#/$defs/brandSlot" },
458
+ monochrome: { $ref: "#/$defs/brandSlot" },
459
+ favicon: { $ref: "#/$defs/brandSlot" },
460
+ animated: { $ref: "#/$defs/brandSlot" },
461
+ custom: {
462
+ type: "object",
463
+ description: "Operator-defined slots, addressed by key.",
464
+ additionalProperties: { $ref: "#/$defs/brandSlot" }
465
+ }
466
+ },
467
+ if: {
468
+ properties: { source: { const: "visor-brands" } },
469
+ required: ["source"]
470
+ },
471
+ then: {
472
+ anyOf: [
473
+ { required: ["org"] },
474
+ { required: ["cdn-overrides"] }
475
+ ]
476
+ }
477
+ },
421
478
  spacing: {
422
479
  type: "object",
423
480
  description: "Spacing configuration.",
@@ -532,6 +589,38 @@ var visor_theme_schema_default = {
532
589
  { pattern: "^oklch\\(" }
533
590
  ]
534
591
  },
592
+ brandSlot: {
593
+ type: "object",
594
+ description: "A single brand-variant declaration. Resolves to light + dark asset URLs; clearSpace and aspectRatio are tokenized per variant (VI-470, Q6).",
595
+ additionalProperties: false,
596
+ properties: {
597
+ slug: {
598
+ type: "string",
599
+ description: "CDN/asset-set slug. Required when source is visor-brands (Phase 2+)."
600
+ },
601
+ formats: {
602
+ type: "array",
603
+ items: { type: "string" },
604
+ description: 'Preferred asset formats, first wins (e.g. ["svg"], ["svg", "png"]).'
605
+ },
606
+ light: {
607
+ type: "string",
608
+ description: "Explicit light-mode asset filename or public/-relative path (overrides the inferred name)."
609
+ },
610
+ dark: {
611
+ type: "string",
612
+ description: "Explicit dark-mode asset filename or public/-relative path (overrides the inferred name)."
613
+ },
614
+ clearSpace: {
615
+ type: "string",
616
+ description: 'Tokenized safe-zone padding enforced by <Logo> (e.g. "0.5rem"). Emitted as --brand-{variant}-clear-space.'
617
+ },
618
+ aspectRatio: {
619
+ type: "string",
620
+ description: 'Tokenized locked aspect ratio (e.g. "3 / 1"); else derived from the SVG viewBox. Emitted as --brand-{variant}-aspect-ratio.'
621
+ }
622
+ }
623
+ },
535
624
  textSlotOverride: {
536
625
  type: "object",
537
626
  description: "Per-slot override for one Material TextTheme entry.",
@@ -567,6 +656,7 @@ var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
567
656
  "colors",
568
657
  "colors-dark",
569
658
  "typography",
659
+ "brand",
570
660
  "spacing",
571
661
  "radius",
572
662
  "shadows",
@@ -607,6 +697,22 @@ var KNOWN_SHADOW_KEYS = /* @__PURE__ */ new Set(["xs", "sm", "md", "lg", "xl"]);
607
697
  var KNOWN_STROKE_WIDTH_KEYS = /* @__PURE__ */ new Set(["thin", "regular", "medium", "thick"]);
608
698
  var KNOWN_MOTION_KEYS = /* @__PURE__ */ new Set(["duration-fast", "duration-normal", "duration-slow", "easing", "easing-overshoot"]);
609
699
  var KNOWN_OVERRIDES_KEYS = /* @__PURE__ */ new Set(["light", "dark"]);
700
+ var KNOWN_BRAND_KEYS = /* @__PURE__ */ new Set([
701
+ "org",
702
+ "source",
703
+ "cdn-overrides",
704
+ "logo",
705
+ "brandmark",
706
+ "wordmark",
707
+ "monochrome",
708
+ "favicon",
709
+ "animated",
710
+ "custom"
711
+ ]);
712
+ var KNOWN_BRAND_STANDARD_SLOTS = ["logo", "brandmark", "wordmark", "monochrome", "favicon", "animated"];
713
+ var KNOWN_BRAND_SLOT_KEYS = /* @__PURE__ */ new Set(["slug", "formats", "light", "dark", "clearSpace", "aspectRatio"]);
714
+ var KNOWN_BRAND_SOURCES = /* @__PURE__ */ new Set(["visor-brands", "local"]);
715
+ var KNOWN_BRAND_CDN_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["visor-brands"]);
610
716
  function checkUnknownKeys(obj, errors) {
611
717
  for (const key of Object.keys(obj)) {
612
718
  if (!KNOWN_TOP_LEVEL_KEYS.has(key)) {
@@ -702,6 +808,46 @@ function checkUnknownKeys(obj, errors) {
702
808
  }
703
809
  }
704
810
  }
811
+ if (typeof obj.brand === "object" && obj.brand !== null) {
812
+ const brand = obj.brand;
813
+ for (const key of Object.keys(brand)) {
814
+ if (!KNOWN_BRAND_KEYS.has(key)) {
815
+ errors.push(`Unknown key 'brand.${key}'. Valid keys: ${[...KNOWN_BRAND_KEYS].join(", ")}`);
816
+ }
817
+ }
818
+ if (typeof brand["cdn-overrides"] === "object" && brand["cdn-overrides"] !== null) {
819
+ for (const key of Object.keys(brand["cdn-overrides"])) {
820
+ if (!KNOWN_BRAND_CDN_OVERRIDE_KEYS.has(key)) {
821
+ errors.push(`Unknown key 'brand.cdn-overrides.${key}'. Valid keys: ${[...KNOWN_BRAND_CDN_OVERRIDE_KEYS].join(", ")}`);
822
+ }
823
+ }
824
+ }
825
+ for (const slot of KNOWN_BRAND_STANDARD_SLOTS) {
826
+ const slotObj = brand[slot];
827
+ if (typeof slotObj === "object" && slotObj !== null) {
828
+ for (const key of Object.keys(slotObj)) {
829
+ if (!KNOWN_BRAND_SLOT_KEYS.has(key)) {
830
+ errors.push(`Unknown key 'brand.${slot}.${key}'. Valid keys: ${[...KNOWN_BRAND_SLOT_KEYS].join(", ")}`);
831
+ }
832
+ }
833
+ }
834
+ }
835
+ if (typeof brand.custom === "object" && brand.custom !== null) {
836
+ const custom = brand.custom;
837
+ for (const slotName of Object.keys(custom)) {
838
+ const slotObj = custom[slotName];
839
+ if (typeof slotObj !== "object" || slotObj === null) {
840
+ errors.push(`'brand.custom.${slotName}' must be an object with optional slug/formats/light/dark/clearSpace/aspectRatio fields`);
841
+ continue;
842
+ }
843
+ for (const key of Object.keys(slotObj)) {
844
+ if (!KNOWN_BRAND_SLOT_KEYS.has(key)) {
845
+ errors.push(`Unknown key 'brand.custom.${slotName}.${key}'. Valid keys: ${[...KNOWN_BRAND_SLOT_KEYS].join(", ")}`);
846
+ }
847
+ }
848
+ }
849
+ }
850
+ }
705
851
  if (typeof obj.spacing === "object" && obj.spacing !== null) {
706
852
  for (const key of Object.keys(obj.spacing)) {
707
853
  if (!KNOWN_SPACING_KEYS.has(key)) {
@@ -857,6 +1003,60 @@ function validateConfig(config) {
857
1003
  }
858
1004
  }
859
1005
  }
1006
+ if (obj.brand !== void 0) {
1007
+ if (typeof obj.brand !== "object" || obj.brand === null) {
1008
+ errors.push("'brand' must be an object");
1009
+ } else {
1010
+ const brand = obj.brand;
1011
+ if (brand.source !== void 0 && !KNOWN_BRAND_SOURCES.has(brand.source)) {
1012
+ errors.push(`'brand.source' must be one of: ${[...KNOWN_BRAND_SOURCES].join(", ")}`);
1013
+ }
1014
+ const brandCdn = brand["cdn-overrides"];
1015
+ const visorBrandsOverride = brandCdn?.["visor-brands"];
1016
+ if (visorBrandsOverride !== void 0 && typeof visorBrandsOverride !== "string") {
1017
+ errors.push("'brand.cdn-overrides.visor-brands' must be a string URL");
1018
+ }
1019
+ if (typeof visorBrandsOverride === "string" && visorBrandsOverride.length === 0) {
1020
+ errors.push("'brand.cdn-overrides.visor-brands' must not be empty");
1021
+ }
1022
+ const orgOptional = typeof visorBrandsOverride === "string" && visorBrandsOverride.length > 0;
1023
+ if (brand.source === "visor-brands" && !orgOptional && !brand.org) {
1024
+ errors.push("'brand.org' is required when brand.source is 'visor-brands' (unless brand.cdn-overrides.visor-brands is set)");
1025
+ }
1026
+ const allSlots = [];
1027
+ for (const slot of KNOWN_BRAND_STANDARD_SLOTS) {
1028
+ if (typeof brand[slot] === "object" && brand[slot] !== null) {
1029
+ allSlots.push(brand[slot]);
1030
+ }
1031
+ }
1032
+ if (typeof brand.custom === "object" && brand.custom !== null) {
1033
+ for (const slot of Object.values(brand.custom)) {
1034
+ if (typeof slot === "object" && slot !== null) allSlots.push(slot);
1035
+ }
1036
+ }
1037
+ for (const slot of allSlots) {
1038
+ if (slot.formats !== void 0) {
1039
+ if (!Array.isArray(slot.formats) || !slot.formats.every((f) => typeof f === "string")) {
1040
+ errors.push(`'brand.<slot>.formats' must be an array of format strings (e.g., ["svg", "png"])`);
1041
+ }
1042
+ }
1043
+ }
1044
+ if (typeof brand.animated === "object" && brand.animated !== null) {
1045
+ const animated = brand.animated;
1046
+ if (Array.isArray(animated.formats) && !animated.formats.every(
1047
+ (f) => typeof f === "string" && f.toLowerCase() === "svg"
1048
+ )) {
1049
+ errors.push("'brand.animated.formats' must be SVG-only (the animated slot accepts self-contained animated SVGs only)");
1050
+ }
1051
+ for (const mode of ["light", "dark"]) {
1052
+ const p = animated[mode];
1053
+ if (typeof p === "string" && !p.toLowerCase().endsWith(".svg")) {
1054
+ errors.push(`'brand.animated.${mode}' must be an .svg path (the animated slot is SVG-only)`);
1055
+ }
1056
+ }
1057
+ }
1058
+ }
1059
+ }
860
1060
  if (obj.overrides !== void 0) {
861
1061
  if (typeof obj.overrides !== "object" || obj.overrides === null) {
862
1062
  errors.push("'overrides' must be an object");
@@ -917,6 +1117,23 @@ var DEFAULTS = {
917
1117
  easing: "cubic-bezier(0.4, 0, 0.2, 1)"
918
1118
  }
919
1119
  };
1120
+ function resolveBrand(brand) {
1121
+ if (!brand) return DEFAULT_VISOR_BRAND;
1122
+ return {
1123
+ org: brand.org ?? DEFAULT_VISOR_BRAND.org,
1124
+ source: brand.source ?? DEFAULT_VISOR_BRAND.source,
1125
+ ...brand["cdn-overrides"] && { "cdn-overrides": brand["cdn-overrides"] },
1126
+ logo: brand.logo ?? DEFAULT_VISOR_BRAND.logo,
1127
+ brandmark: brand.brandmark ?? DEFAULT_VISOR_BRAND.brandmark,
1128
+ wordmark: brand.wordmark ?? DEFAULT_VISOR_BRAND.wordmark,
1129
+ monochrome: brand.monochrome ?? DEFAULT_VISOR_BRAND.monochrome,
1130
+ favicon: brand.favicon ?? DEFAULT_VISOR_BRAND.favicon,
1131
+ // animated is optional with no Visor default (D2): pass through only when a
1132
+ // theme declares it, so undeclared themes emit no --brand-animated.
1133
+ ...brand.animated && { animated: brand.animated },
1134
+ ...brand.custom && { custom: brand.custom }
1135
+ };
1136
+ }
920
1137
  function resolveConfig(config) {
921
1138
  const colors = config.colors;
922
1139
  const originalColors = {};
@@ -997,6 +1214,7 @@ function resolveConfig(config) {
997
1214
  },
998
1215
  slots: config.typography?.slots ?? {}
999
1216
  },
1217
+ brand: resolveBrand(config.brand),
1000
1218
  spacing: {
1001
1219
  base: config.spacing?.base ?? DEFAULTS.spacing.base
1002
1220
  },
@@ -1208,7 +1426,7 @@ var SEMANTIC_SURFACE_MAP = {
1208
1426
  // VI-478: status soft tints (BL-193) — alpha overlays, semantically distinct
1209
1427
  // from the OPAQUE `surface-{status}-subtle` above (do NOT alias them together).
1210
1428
  // Default to a color-mix of the status color so they track the theme; themes
1211
- // pin exact values via overrides (blacklight-underground: success @10%,
1429
+ // pin exact values via overrides (blacklight-pro: success @10%,
1212
1430
  // warning/error @12%).
1213
1431
  "success-soft": {
1214
1432
  light: { constant: "color-mix(in srgb, var(--color-success-500) 10%, transparent)" },
@@ -1305,7 +1523,7 @@ var SEMANTIC_INTERACTIVE_MAP = {
1305
1523
  // VI-478: brand-derived alpha-overlay helpers (BL-193). `soft`/`glow` are
1306
1524
  // alpha overlays that track the theme's primary via color-mix (distinct from
1307
1525
  // any opaque surface); `strong` is a solid lightened-brand emphasis color.
1308
- // Themes pin exact values via overrides — e.g. blacklight-underground sets
1526
+ // Themes pin exact values via overrides — e.g. blacklight-pro sets
1309
1527
  // soft @12% / glow @32% / strong #FFD050.
1310
1528
  "primary-soft": {
1311
1529
  light: { constant: "color-mix(in srgb, var(--color-primary-500) 12%, transparent)" },
@@ -2997,12 +3215,17 @@ function extractFromCSS(files, name = "extracted-theme") {
2997
3215
  return { config, tokens, unmapped, warnings };
2998
3216
  }
2999
3217
  export {
3218
+ BRAND_VARIANTS,
3219
+ DEFAULT_VISOR_BRAND,
3000
3220
  FONT_WEIGHT_ALIASES,
3001
3221
  SEMANTIC_MAP,
3002
3222
  TAILWIND_GRAY,
3223
+ VISOR_BRANDS_CDN,
3224
+ VISOR_DEFAULT_BRAND_PATH,
3003
3225
  VISOR_FONTS_CDN,
3004
3226
  applyOverrides,
3005
3227
  assignSemanticTokens,
3228
+ buildVisorBrandUrl,
3006
3229
  buildVisorFontUrl,
3007
3230
  clampToSrgb,
3008
3231
  cleanFontValue,
@@ -3042,8 +3265,11 @@ export {
3042
3265
  parseHsla,
3043
3266
  parseOklch,
3044
3267
  parseRgba,
3268
+ resolveBrandSlot,
3269
+ resolveBrandSource,
3045
3270
  resolveConfig,
3046
3271
  resolveFont,
3272
+ resolveThemeBrand,
3047
3273
  resolveThemeFonts,
3048
3274
  rgbToHex,
3049
3275
  serializeColor,
@@ -119,6 +119,111 @@ interface GoogleFontEntry {
119
119
  category: string;
120
120
  }
121
121
 
122
+ /**
123
+ * Brand-asset resolution types for the Visor theme engine.
124
+ *
125
+ * Models the fonts subsystem (`packages/theme-engine/src/fonts/`): a per-theme
126
+ * `brand` block declares logo/brandmark/wordmark/etc. slots, the resolver maps
127
+ * each to loadable asset URLs (Phase 1: local public/ paths; Phase 2+: CDN),
128
+ * and the pipeline emits `--brand-{variant}` CSS custom properties into a
129
+ * dedicated `visor-brand` cascade layer.
130
+ *
131
+ * Phase 1 (VI-470) supports `source: local` only — no CDN. The `visor-brands`
132
+ * source value is reserved in the type union so the schema stays
133
+ * forward-compatible (parallels the fonts `visor-fonts` source).
134
+ */
135
+ /** Where a brand asset is loaded from. Phase 1 resolves only `local`. */
136
+ type BrandSource = "visor-brands" | "local";
137
+ /**
138
+ * Standard brand variant slots. A fixed set covers the common lockups; custom
139
+ * operator-defined slots are addressed by key through the `custom` map.
140
+ */
141
+ type BrandVariant = "logo" | "brandmark" | "wordmark" | "monochrome" | "favicon" | "animated";
142
+ /** Ordered list of the standard brand variant slots. */
143
+ declare const BRAND_VARIANTS: readonly BrandVariant[];
144
+ /**
145
+ * A single brand-slot declaration in `.visor.yaml`. Each slot may declare a
146
+ * `slug` (CDN namespace, Phase 2+), an explicit per-mode `light`/`dark`
147
+ * filename or path, plus the tokenized `clearSpace` (safe-zone) and
148
+ * `aspectRatio` enforced by `<Logo>` (Q6).
149
+ */
150
+ interface BrandSlot {
151
+ /** CDN/asset-set slug. Required when `source: visor-brands` (Phase 2+). */
152
+ slug?: string;
153
+ /** Preferred asset formats, first wins (e.g. `["svg"]`, `["svg", "png"]`). */
154
+ formats?: string[];
155
+ /** Explicit light-mode asset filename or path (overrides the inferred name). */
156
+ light?: string;
157
+ /** Explicit dark-mode asset filename or path (overrides the inferred name). */
158
+ dark?: string;
159
+ /** Tokenized safe-zone padding enforced by `<Logo>` (Q6), e.g. "0.5rem". */
160
+ clearSpace?: string;
161
+ /** Tokenized locked aspect ratio (Q6), e.g. "3 / 1"; else derived from viewBox. */
162
+ aspectRatio?: string;
163
+ }
164
+ /**
165
+ * The `brand` block from a `.visor.yaml` file. Structured like `typography`:
166
+ * shared `org`/`source`/`cdn-overrides` defaults plus the standard slots and
167
+ * an optional `custom` map.
168
+ */
169
+ interface VisorBrand {
170
+ /** CDN namespace; required when `source: visor-brands` unless cdn-overrides is set. */
171
+ org?: string;
172
+ /** Where assets resolve from. Phase 1 supports `local` only. Default: `visor-brands`. */
173
+ source?: BrandSource;
174
+ /** Per-source CDN base URL overrides (Phase 2+). Only `visor-brands` is supported. */
175
+ "cdn-overrides"?: {
176
+ "visor-brands"?: string;
177
+ };
178
+ /** Full lockup. */
179
+ logo?: BrandSlot;
180
+ /** Symbol only. */
181
+ brandmark?: BrandSlot;
182
+ /** Type only. */
183
+ wordmark?: BrandSlot;
184
+ /** Single-color mark (tinted via CSS mask-image + currentColor). */
185
+ monochrome?: BrandSlot;
186
+ /** Favicon source. */
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;
195
+ /** Operator-defined slots, addressed by key. */
196
+ custom?: Record<string, BrandSlot>;
197
+ }
198
+ /** A single resolved brand variant — light + dark asset URLs plus its tokens. */
199
+ interface BrandResolution {
200
+ /** The variant slot this resolution covers ("logo", "brandmark", or a custom key). */
201
+ variant: string;
202
+ /** Where this asset comes from. */
203
+ source: BrandSource;
204
+ /** Resolved light-mode asset URL/path. */
205
+ light: string;
206
+ /** Resolved dark-mode asset URL/path. */
207
+ dark: string;
208
+ /** Tokenized safe-zone padding (null when not declared). */
209
+ clearSpace: string | null;
210
+ /** Tokenized locked aspect ratio (null when not declared). */
211
+ aspectRatio: string | null;
212
+ /** Human-readable guidance for assets needing manual setup (e.g. local files). */
213
+ guidance: string | null;
214
+ }
215
+ /** Result of resolving all brand assets for a theme. */
216
+ interface ThemeBrandResult {
217
+ /** Resolved standard-slot variants in declaration order. */
218
+ variants: BrandResolution[];
219
+ /** Resolved custom-slot variants in declaration order. */
220
+ custom: BrandResolution[];
221
+ /** Mode-scoped `--brand-*` CSS custom property declarations (no @layer wrapper). */
222
+ css: string;
223
+ /** Warnings for assets needing manual setup. */
224
+ warnings: string[];
225
+ }
226
+
122
227
  /**
123
228
  * Types for the Visor Theme Engine
124
229
  *
@@ -240,6 +345,13 @@ interface VisorThemeConfig {
240
345
  */
241
346
  slots?: Partial<Record<MaterialTextSlot, TextSlotOverride>>;
242
347
  };
348
+ /**
349
+ * Brand-asset declarations (VI-470). Structured like `typography` — per-slot
350
+ * logo/brandmark/wordmark/etc. entries resolved to asset URLs and emitted as
351
+ * mode-scoped `--brand-*` CSS vars in a dedicated `visor-brand` cascade layer.
352
+ * Omitted → the Visor default brand (stock themes are not logo-less).
353
+ */
354
+ brand?: VisorBrand;
243
355
  spacing?: {
244
356
  base?: number;
245
357
  };
@@ -358,6 +470,11 @@ interface ResolvedThemeConfig {
358
470
  */
359
471
  slots: Partial<Record<MaterialTextSlot, TextSlotOverride>>;
360
472
  };
473
+ /**
474
+ * Resolved brand block (VI-470). Always present — falls back to the Visor
475
+ * default brand when the source config omits `brand` (D3).
476
+ */
477
+ brand: VisorBrand;
361
478
  spacing: {
362
479
  base: number;
363
480
  };
@@ -434,4 +551,4 @@ interface ThemeData {
434
551
  output: ThemeOutput;
435
552
  }
436
553
 
437
- export type { ColorRole as C, FontResolveOptions as F, GoogleFontEntry as G, OKLCH as O, ParsedColor as P, ResolvedThemeConfig as R, SelectiveShadeScale as S, ThemeFontResult as T, VisorTypography as V, FontResolution as a, FontDisplayStrategy as b, GeneratedPrimitives as c, ThemeOutput as d, ThemeData as e, VisorThemeConfig as f, FullShadeScale as g, RGB as h, SemanticTokens as i, ShadeStep as j, ColorFormat as k, FontSource as l, RGBA as m, SemanticTokenValue as n };
554
+ export { type BrandSlot as B, type ColorRole as C, type FontResolveOptions as F, type GoogleFontEntry as G, type OKLCH as O, type ParsedColor as P, type ResolvedThemeConfig as R, type SelectiveShadeScale as S, type ThemeFontResult as T, type VisorTypography as V, type FontResolution as a, type FontDisplayStrategy as b, type VisorBrand as c, type BrandSource as d, type BrandResolution as e, type ThemeBrandResult as f, type GeneratedPrimitives as g, type ThemeOutput as h, type ThemeData as i, type VisorThemeConfig as j, type FullShadeScale as k, type RGB as l, type SemanticTokens as m, type ShadeStep as n, BRAND_VARIANTS as o, type BrandVariant as p, type ColorFormat as q, type FontSource as r, type RGBA as s, type SemanticTokenValue as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loworbitstudio/visor-theme-engine",
3
- "version": "0.11.0",
3
+ "version": "0.13.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",
@@ -222,6 +222,55 @@
222
222
  }
223
223
  }
224
224
  },
225
+ "brand": {
226
+ "type": "object",
227
+ "description": "Brand-asset declarations (VI-470). Per-slot logo/brandmark/wordmark/etc. entries resolved to asset URLs and emitted as mode-scoped --brand-* CSS variables in a dedicated visor-brand cascade layer. Omitted → the Visor default brand (stock themes are not logo-less).",
228
+ "additionalProperties": false,
229
+ "properties": {
230
+ "org": {
231
+ "type": "string",
232
+ "description": "CDN namespace for brand assets. Required when source is \"visor-brands\" (unless cdn-overrides.visor-brands is set)."
233
+ },
234
+ "source": {
235
+ "type": "string",
236
+ "enum": ["visor-brands", "local"],
237
+ "description": "Where assets resolve from. Default: \"visor-brands\". Phase 1 supports \"local\" (resolves to public/ paths); CDN resolution lands later."
238
+ },
239
+ "cdn-overrides": {
240
+ "type": "object",
241
+ "description": "Per-source CDN base URL override. Only visor-brands is supported.",
242
+ "additionalProperties": false,
243
+ "properties": {
244
+ "visor-brands": {
245
+ "type": "string",
246
+ "minLength": 1,
247
+ "description": "CDN base URL that replaces brands.visor.design for source: visor-brands slots."
248
+ }
249
+ }
250
+ },
251
+ "logo": { "$ref": "#/$defs/brandSlot" },
252
+ "brandmark": { "$ref": "#/$defs/brandSlot" },
253
+ "wordmark": { "$ref": "#/$defs/brandSlot" },
254
+ "monochrome": { "$ref": "#/$defs/brandSlot" },
255
+ "favicon": { "$ref": "#/$defs/brandSlot" },
256
+ "animated": { "$ref": "#/$defs/brandSlot" },
257
+ "custom": {
258
+ "type": "object",
259
+ "description": "Operator-defined slots, addressed by key.",
260
+ "additionalProperties": { "$ref": "#/$defs/brandSlot" }
261
+ }
262
+ },
263
+ "if": {
264
+ "properties": { "source": { "const": "visor-brands" } },
265
+ "required": ["source"]
266
+ },
267
+ "then": {
268
+ "anyOf": [
269
+ { "required": ["org"] },
270
+ { "required": ["cdn-overrides"] }
271
+ ]
272
+ }
273
+ },
225
274
  "spacing": {
226
275
  "type": "object",
227
276
  "description": "Spacing configuration.",
@@ -336,6 +385,38 @@
336
385
  { "pattern": "^oklch\\(" }
337
386
  ]
338
387
  },
388
+ "brandSlot": {
389
+ "type": "object",
390
+ "description": "A single brand-variant declaration. Resolves to light + dark asset URLs; clearSpace and aspectRatio are tokenized per variant (VI-470, Q6).",
391
+ "additionalProperties": false,
392
+ "properties": {
393
+ "slug": {
394
+ "type": "string",
395
+ "description": "CDN/asset-set slug. Required when source is visor-brands (Phase 2+)."
396
+ },
397
+ "formats": {
398
+ "type": "array",
399
+ "items": { "type": "string" },
400
+ "description": "Preferred asset formats, first wins (e.g. [\"svg\"], [\"svg\", \"png\"])."
401
+ },
402
+ "light": {
403
+ "type": "string",
404
+ "description": "Explicit light-mode asset filename or public/-relative path (overrides the inferred name)."
405
+ },
406
+ "dark": {
407
+ "type": "string",
408
+ "description": "Explicit dark-mode asset filename or public/-relative path (overrides the inferred name)."
409
+ },
410
+ "clearSpace": {
411
+ "type": "string",
412
+ "description": "Tokenized safe-zone padding enforced by <Logo> (e.g. \"0.5rem\"). Emitted as --brand-{variant}-clear-space."
413
+ },
414
+ "aspectRatio": {
415
+ "type": "string",
416
+ "description": "Tokenized locked aspect ratio (e.g. \"3 / 1\"); else derived from the SVG viewBox. Emitted as --brand-{variant}-aspect-ratio."
417
+ }
418
+ }
419
+ },
339
420
  "textSlotOverride": {
340
421
  "type": "object",
341
422
  "description": "Per-slot override for one Material TextTheme entry.",