@loworbitstudio/visor-theme-engine 0.11.0 → 0.12.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-BKEkyelS.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-B56A5DE6.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,179 @@ 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
+ ];
807
+
808
+ // src/brand/pipeline.ts
809
+ function cssUrl(value) {
810
+ return `url("${value}")`;
811
+ }
812
+ function staticDeclsFor(r) {
813
+ const decls = [];
814
+ decls.push(`--brand-${r.variant}-light: ${cssUrl(r.light)};`);
815
+ decls.push(`--brand-${r.variant}-dark: ${cssUrl(r.dark)};`);
816
+ if (r.clearSpace !== null) {
817
+ decls.push(`--brand-${r.variant}-clear-space: ${r.clearSpace};`);
818
+ }
819
+ if (r.aspectRatio !== null) {
820
+ decls.push(`--brand-${r.variant}-aspect-ratio: ${r.aspectRatio};`);
821
+ }
822
+ return decls;
823
+ }
824
+ function modeDecl(r, mode) {
825
+ return `--brand-${r.variant}: ${cssUrl(r[mode])};`;
826
+ }
827
+ function block(selector, decls) {
828
+ if (decls.length === 0) return "";
829
+ return [`${selector} {`, ...decls.map((d) => ` ${d}`), "}"].join("\n");
830
+ }
831
+ function generateBrandCSS(resolutions, scope) {
832
+ if (resolutions.length === 0) return "";
833
+ const baseSelector = scope ? scope : ":root";
834
+ const lightSelector = scope ? `html:not(.dark) ${scope}` : ":root";
835
+ const darkSelector = scope ? `.dark ${scope}` : ".dark";
836
+ const pcsSelector = scope ? `${scope}:not(.light)` : ":root:not(.light)";
837
+ const lines = [];
838
+ const staticDecls = resolutions.flatMap(staticDeclsFor);
839
+ lines.push("/* --- Brand: forced-mode aliases + tokens --- */");
840
+ lines.push(block(baseSelector, staticDecls));
841
+ lines.push("");
842
+ const lightDecls = resolutions.map((r) => modeDecl(r, "light"));
843
+ lines.push("/* --- Brand: variants (light) --- */");
844
+ lines.push(block(lightSelector, lightDecls));
845
+ lines.push("");
846
+ const darkDecls = resolutions.map((r) => modeDecl(r, "dark"));
847
+ lines.push("/* --- Brand: variants (dark) \u2014 manual toggle --- */");
848
+ lines.push(block(darkSelector, darkDecls));
849
+ lines.push("");
850
+ lines.push("/* --- Brand: variants (dark) \u2014 prefers-color-scheme --- */");
851
+ const inner = block(pcsSelector, darkDecls);
852
+ lines.push(
853
+ `@media (prefers-color-scheme: dark) {
854
+ ${inner.split("\n").map((l) => ` ${l}`).join("\n")}
855
+ }`
856
+ );
857
+ return lines.join("\n").trim();
858
+ }
859
+ function resolveThemeBrand(brand, options) {
860
+ const effective = brand ?? DEFAULT_VISOR_BRAND;
861
+ const scope = options?.scope ?? "";
862
+ const source = resolveBrandSource(effective);
863
+ const org = effective.org ?? null;
864
+ const cdnBase = effective["cdn-overrides"]?.["visor-brands"] ?? null;
865
+ const slotOptions = { source, org, cdnBase };
866
+ const warnings = [];
867
+ const variants = [];
868
+ const custom = [];
869
+ for (const variant of BRAND_VARIANTS) {
870
+ const slot = effective[variant];
871
+ if (!slot) continue;
872
+ const resolution = resolveBrandSlot(variant, slot, slotOptions);
873
+ variants.push(resolution);
874
+ if (resolution.guidance) warnings.push(resolution.guidance);
875
+ }
876
+ if (effective.custom) {
877
+ for (const [key, slot] of Object.entries(effective.custom)) {
878
+ const resolution = resolveBrandSlot(key, slot, slotOptions);
879
+ custom.push(resolution);
880
+ if (resolution.guidance) warnings.push(resolution.guidance);
881
+ }
882
+ }
883
+ const css = generateBrandCSS([...variants, ...custom], scope);
884
+ return { variants, custom, css, warnings };
885
+ }
886
+
714
887
  // src/color.ts
715
888
  function normalizeHex(hex) {
716
889
  let color = hex.replace(/^#/, "");
@@ -1199,7 +1372,7 @@ function sectionComment(label) {
1199
1372
  return `
1200
1373
  /* --- ${label} --- */`;
1201
1374
  }
1202
- function block(selector, declarations) {
1375
+ function block2(selector, declarations) {
1203
1376
  if (declarations.length === 0) return "";
1204
1377
  return [selector + " {", ...declarations.map((d) => ` ${d}`), "}", ""].join(
1205
1378
  "\n"
@@ -1356,20 +1529,20 @@ function generatePrimitivesCss(primitives, config, options) {
1356
1529
  const host = options?.scopePrefix ?? ":root";
1357
1530
  lines.push(sectionComment("Primitive: Colors"));
1358
1531
  lines.push(
1359
- block(host, [generateColorPrimitives(primitives)])
1532
+ block2(host, [generateColorPrimitives(primitives)])
1360
1533
  );
1361
1534
  lines.push(sectionComment("Primitive: Spacing"));
1362
- lines.push(block(host, generateSpacingPrimitives(config)));
1535
+ lines.push(block2(host, generateSpacingPrimitives(config)));
1363
1536
  lines.push(sectionComment("Primitive: Border Radius"));
1364
- lines.push(block(host, generateRadiusPrimitives(config)));
1537
+ lines.push(block2(host, generateRadiusPrimitives(config)));
1365
1538
  lines.push(sectionComment("Primitive: Typography"));
1366
- lines.push(block(host, generateTypographyPrimitives(config, options?.aliasedFamilies)));
1539
+ lines.push(block2(host, generateTypographyPrimitives(config, options?.aliasedFamilies)));
1367
1540
  lines.push(sectionComment("Primitive: Shadows"));
1368
- lines.push(block(host, generateShadowPrimitives(config)));
1541
+ lines.push(block2(host, generateShadowPrimitives(config)));
1369
1542
  lines.push(sectionComment("Primitive: Motion"));
1370
- lines.push(block(host, generateMotionPrimitives(config)));
1543
+ lines.push(block2(host, generateMotionPrimitives(config)));
1371
1544
  lines.push(sectionComment("Primitive: Miscellaneous"));
1372
- lines.push(block(host, generateMiscPrimitives()));
1545
+ lines.push(block2(host, generateMiscPrimitives()));
1373
1546
  return header("Visor Theme \u2014 Primitives") + lines.join("\n");
1374
1547
  }
1375
1548
  function hairlineDeclName(name) {
@@ -1381,32 +1554,32 @@ function generateSemanticCss(tokens) {
1381
1554
  const textDecls = Object.entries(tokens.text).map(
1382
1555
  ([name, { light }]) => `--text-${name}: ${light};`
1383
1556
  );
1384
- lines.push(block(":root", textDecls));
1557
+ lines.push(block2(":root", textDecls));
1385
1558
  lines.push(sectionComment("Semantic: Surface"));
1386
1559
  const surfaceDecls = Object.entries(tokens.surface).map(
1387
1560
  ([name, { light }]) => `--surface-${name}: ${light};`
1388
1561
  );
1389
- lines.push(block(":root", surfaceDecls));
1562
+ lines.push(block2(":root", surfaceDecls));
1390
1563
  lines.push(sectionComment("Semantic: Border"));
1391
1564
  const borderDecls = Object.entries(tokens.border).map(
1392
1565
  ([name, { light }]) => `--border-${name}: ${light};`
1393
1566
  );
1394
- lines.push(block(":root", borderDecls));
1567
+ lines.push(block2(":root", borderDecls));
1395
1568
  lines.push(sectionComment("Semantic: Interactive"));
1396
1569
  const interactiveDecls = Object.entries(tokens.interactive).map(
1397
1570
  ([name, { light }]) => `--interactive-${name}: ${light};`
1398
1571
  );
1399
- lines.push(block(":root", interactiveDecls));
1572
+ lines.push(block2(":root", interactiveDecls));
1400
1573
  lines.push(sectionComment("Semantic: Intent (aliases)"));
1401
1574
  const intentDecls = Object.entries(tokens.intent).map(
1402
1575
  ([name, { light }]) => `--${name}: ${light};`
1403
1576
  );
1404
- lines.push(block(":root", intentDecls));
1577
+ lines.push(block2(":root", intentDecls));
1405
1578
  lines.push(sectionComment("Semantic: Hairline (aliases)"));
1406
1579
  const hairlineDecls = Object.entries(tokens.hairline).map(
1407
1580
  ([name, { light }]) => `${hairlineDeclName(name)}: ${light};`
1408
1581
  );
1409
- lines.push(block(":root", hairlineDecls));
1582
+ lines.push(block2(":root", hairlineDecls));
1410
1583
  return header("Visor Theme \u2014 Semantic") + lines.join("\n");
1411
1584
  }
1412
1585
  var TEXT_SCALE_ALIASES = [
@@ -1474,17 +1647,17 @@ function generateLightCss(tokens, options) {
1474
1647
  const { textDecls, surfaceDecls, borderDecls, interactiveDecls, intentDecls, hairlineDecls } = buildAdaptiveDecls(tokens, "light");
1475
1648
  const host = options?.scopePrefix ?? ":root";
1476
1649
  lines.push(sectionComment("Adaptive: Text (light)"));
1477
- lines.push(block(host, textDecls));
1650
+ lines.push(block2(host, textDecls));
1478
1651
  lines.push(sectionComment("Adaptive: Surface (light)"));
1479
- lines.push(block(host, surfaceDecls));
1652
+ lines.push(block2(host, surfaceDecls));
1480
1653
  lines.push(sectionComment("Adaptive: Border (light)"));
1481
- lines.push(block(host, borderDecls));
1654
+ lines.push(block2(host, borderDecls));
1482
1655
  lines.push(sectionComment("Adaptive: Interactive (light)"));
1483
- lines.push(block(host, interactiveDecls));
1656
+ lines.push(block2(host, interactiveDecls));
1484
1657
  lines.push(sectionComment("Adaptive: Intent aliases (light)"));
1485
- lines.push(block(host, intentDecls));
1658
+ lines.push(block2(host, intentDecls));
1486
1659
  lines.push(sectionComment("Adaptive: Hairline aliases (light)"));
1487
- lines.push(block(host, hairlineDecls));
1660
+ lines.push(block2(host, hairlineDecls));
1488
1661
  return header("Visor Theme \u2014 Light") + lines.join("\n");
1489
1662
  }
1490
1663
  function generateDarkCss(tokens, options) {
@@ -1495,23 +1668,23 @@ function generateDarkCss(tokens, options) {
1495
1668
  const darkSelector = darkSelectors.join(",\n");
1496
1669
  const prefersSelector = prefix ? `${prefix}:not(.light):not(.theme-light):not([data-theme="light"])` : ':root:not(.light):not(.theme-light):not([data-theme="light"])';
1497
1670
  lines.push(sectionComment("Adaptive: Text (dark) \u2014 manual toggle"));
1498
- lines.push(block(darkSelector, textDecls));
1671
+ lines.push(block2(darkSelector, textDecls));
1499
1672
  lines.push(sectionComment("Adaptive: Surface (dark) \u2014 manual toggle"));
1500
- lines.push(block(darkSelector, surfaceDecls));
1673
+ lines.push(block2(darkSelector, surfaceDecls));
1501
1674
  lines.push(sectionComment("Adaptive: Border (dark) \u2014 manual toggle"));
1502
- lines.push(block(darkSelector, borderDecls));
1675
+ lines.push(block2(darkSelector, borderDecls));
1503
1676
  lines.push(sectionComment("Adaptive: Interactive (dark) \u2014 manual toggle"));
1504
- lines.push(block(darkSelector, interactiveDecls));
1677
+ lines.push(block2(darkSelector, interactiveDecls));
1505
1678
  lines.push(sectionComment("Adaptive: Intent aliases (dark) \u2014 manual toggle"));
1506
- lines.push(block(darkSelector, intentDecls));
1679
+ lines.push(block2(darkSelector, intentDecls));
1507
1680
  lines.push(sectionComment("Adaptive: Hairline aliases (dark) \u2014 manual toggle"));
1508
- lines.push(block(darkSelector, hairlineDecls));
1681
+ lines.push(block2(darkSelector, hairlineDecls));
1509
1682
  lines.push(
1510
1683
  sectionComment("Adaptive: Text (dark) \u2014 prefers-color-scheme")
1511
1684
  );
1512
1685
  lines.push(
1513
1686
  `@media (prefers-color-scheme: dark) {
1514
- ${block(prefersSelector, textDecls)}}`
1687
+ ${block2(prefersSelector, textDecls)}}`
1515
1688
  );
1516
1689
  lines.push("");
1517
1690
  lines.push(
@@ -1519,7 +1692,7 @@ ${block(prefersSelector, textDecls)}}`
1519
1692
  );
1520
1693
  lines.push(
1521
1694
  `@media (prefers-color-scheme: dark) {
1522
- ${block(prefersSelector, surfaceDecls)}}`
1695
+ ${block2(prefersSelector, surfaceDecls)}}`
1523
1696
  );
1524
1697
  lines.push("");
1525
1698
  lines.push(
@@ -1527,7 +1700,7 @@ ${block(prefersSelector, surfaceDecls)}}`
1527
1700
  );
1528
1701
  lines.push(
1529
1702
  `@media (prefers-color-scheme: dark) {
1530
- ${block(prefersSelector, borderDecls)}}`
1703
+ ${block2(prefersSelector, borderDecls)}}`
1531
1704
  );
1532
1705
  lines.push("");
1533
1706
  lines.push(
@@ -1535,7 +1708,7 @@ ${block(prefersSelector, borderDecls)}}`
1535
1708
  );
1536
1709
  lines.push(
1537
1710
  `@media (prefers-color-scheme: dark) {
1538
- ${block(prefersSelector, interactiveDecls)}}`
1711
+ ${block2(prefersSelector, interactiveDecls)}}`
1539
1712
  );
1540
1713
  lines.push("");
1541
1714
  lines.push(
@@ -1543,7 +1716,7 @@ ${block(prefersSelector, interactiveDecls)}}`
1543
1716
  );
1544
1717
  lines.push(
1545
1718
  `@media (prefers-color-scheme: dark) {
1546
- ${block(prefersSelector, intentDecls)}}`
1719
+ ${block2(prefersSelector, intentDecls)}}`
1547
1720
  );
1548
1721
  lines.push("");
1549
1722
  lines.push(
@@ -1551,7 +1724,7 @@ ${block(prefersSelector, intentDecls)}}`
1551
1724
  );
1552
1725
  lines.push(
1553
1726
  `@media (prefers-color-scheme: dark) {
1554
- ${block(prefersSelector, hairlineDecls)}}`
1727
+ ${block2(prefersSelector, hairlineDecls)}}`
1555
1728
  );
1556
1729
  lines.push("");
1557
1730
  return header("Visor Theme \u2014 Dark") + lines.join("\n");
@@ -1618,6 +1791,14 @@ export {
1618
1791
  generatePreloadLinks,
1619
1792
  generateStylesheetLinks,
1620
1793
  resolveThemeFonts,
1794
+ VISOR_BRANDS_CDN,
1795
+ VISOR_DEFAULT_BRAND_PATH,
1796
+ DEFAULT_VISOR_BRAND,
1797
+ buildVisorBrandUrl,
1798
+ resolveBrandSlot,
1799
+ resolveBrandSource,
1800
+ BRAND_VARIANTS,
1801
+ resolveThemeBrand,
1621
1802
  normalizeHex,
1622
1803
  isValidHex,
1623
1804
  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-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';
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,83 @@ 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
+ custom: {
635
+ type: "object",
636
+ description: "Operator-defined slots, addressed by key.",
637
+ additionalProperties: {
638
+ $ref: "#/$defs/brandSlot"
639
+ }
640
+ }
641
+ },
642
+ "if": {
643
+ properties: {
644
+ source: {
645
+ "const": "visor-brands"
646
+ }
647
+ },
648
+ required: [
649
+ "source"
650
+ ]
651
+ },
652
+ then: {
653
+ anyOf: [
654
+ {
655
+ required: [
656
+ "org"
657
+ ]
658
+ },
659
+ {
660
+ required: [
661
+ "cdn-overrides"
662
+ ]
663
+ }
664
+ ]
665
+ }
666
+ },
504
667
  spacing: {
505
668
  type: "object",
506
669
  description: "Spacing configuration.",
@@ -667,6 +830,40 @@ var $defs = {
667
830
  }
668
831
  ]
669
832
  },
833
+ brandSlot: {
834
+ type: "object",
835
+ description: "A single brand-variant declaration. Resolves to light + dark asset URLs; clearSpace and aspectRatio are tokenized per variant (VI-470, Q6).",
836
+ additionalProperties: false,
837
+ properties: {
838
+ slug: {
839
+ type: "string",
840
+ description: "CDN/asset-set slug. Required when source is visor-brands (Phase 2+)."
841
+ },
842
+ formats: {
843
+ type: "array",
844
+ items: {
845
+ type: "string"
846
+ },
847
+ description: "Preferred asset formats, first wins (e.g. [\"svg\"], [\"svg\", \"png\"])."
848
+ },
849
+ light: {
850
+ type: "string",
851
+ description: "Explicit light-mode asset filename or public/-relative path (overrides the inferred name)."
852
+ },
853
+ dark: {
854
+ type: "string",
855
+ description: "Explicit dark-mode asset filename or public/-relative path (overrides the inferred name)."
856
+ },
857
+ clearSpace: {
858
+ type: "string",
859
+ description: "Tokenized safe-zone padding enforced by <Logo> (e.g. \"0.5rem\"). Emitted as --brand-{variant}-clear-space."
860
+ },
861
+ aspectRatio: {
862
+ type: "string",
863
+ description: "Tokenized locked aspect ratio (e.g. \"3 / 1\"); else derived from the SVG viewBox. Emitted as --brand-{variant}-aspect-ratio."
864
+ }
865
+ }
866
+ },
670
867
  textSlotOverride: {
671
868
  type: "object",
672
869
  description: "Per-slot override for one Material TextTheme entry.",
@@ -1021,4 +1218,4 @@ declare function cleanFontValue(val: string): string;
1021
1218
  */
1022
1219
  declare function extractFromCSS(files: CSSFile[], name?: string): ExtractionResult;
1023
1220
 
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 };
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 };
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-B56A5DE6.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,54 @@ 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
+ custom: {
461
+ type: "object",
462
+ description: "Operator-defined slots, addressed by key.",
463
+ additionalProperties: { $ref: "#/$defs/brandSlot" }
464
+ }
465
+ },
466
+ if: {
467
+ properties: { source: { const: "visor-brands" } },
468
+ required: ["source"]
469
+ },
470
+ then: {
471
+ anyOf: [
472
+ { required: ["org"] },
473
+ { required: ["cdn-overrides"] }
474
+ ]
475
+ }
476
+ },
421
477
  spacing: {
422
478
  type: "object",
423
479
  description: "Spacing configuration.",
@@ -532,6 +588,38 @@ var visor_theme_schema_default = {
532
588
  { pattern: "^oklch\\(" }
533
589
  ]
534
590
  },
591
+ brandSlot: {
592
+ type: "object",
593
+ description: "A single brand-variant declaration. Resolves to light + dark asset URLs; clearSpace and aspectRatio are tokenized per variant (VI-470, Q6).",
594
+ additionalProperties: false,
595
+ properties: {
596
+ slug: {
597
+ type: "string",
598
+ description: "CDN/asset-set slug. Required when source is visor-brands (Phase 2+)."
599
+ },
600
+ formats: {
601
+ type: "array",
602
+ items: { type: "string" },
603
+ description: 'Preferred asset formats, first wins (e.g. ["svg"], ["svg", "png"]).'
604
+ },
605
+ light: {
606
+ type: "string",
607
+ description: "Explicit light-mode asset filename or public/-relative path (overrides the inferred name)."
608
+ },
609
+ dark: {
610
+ type: "string",
611
+ description: "Explicit dark-mode asset filename or public/-relative path (overrides the inferred name)."
612
+ },
613
+ clearSpace: {
614
+ type: "string",
615
+ description: 'Tokenized safe-zone padding enforced by <Logo> (e.g. "0.5rem"). Emitted as --brand-{variant}-clear-space.'
616
+ },
617
+ aspectRatio: {
618
+ type: "string",
619
+ description: 'Tokenized locked aspect ratio (e.g. "3 / 1"); else derived from the SVG viewBox. Emitted as --brand-{variant}-aspect-ratio.'
620
+ }
621
+ }
622
+ },
535
623
  textSlotOverride: {
536
624
  type: "object",
537
625
  description: "Per-slot override for one Material TextTheme entry.",
@@ -567,6 +655,7 @@ var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
567
655
  "colors",
568
656
  "colors-dark",
569
657
  "typography",
658
+ "brand",
570
659
  "spacing",
571
660
  "radius",
572
661
  "shadows",
@@ -607,6 +696,21 @@ var KNOWN_SHADOW_KEYS = /* @__PURE__ */ new Set(["xs", "sm", "md", "lg", "xl"]);
607
696
  var KNOWN_STROKE_WIDTH_KEYS = /* @__PURE__ */ new Set(["thin", "regular", "medium", "thick"]);
608
697
  var KNOWN_MOTION_KEYS = /* @__PURE__ */ new Set(["duration-fast", "duration-normal", "duration-slow", "easing", "easing-overshoot"]);
609
698
  var KNOWN_OVERRIDES_KEYS = /* @__PURE__ */ new Set(["light", "dark"]);
699
+ var KNOWN_BRAND_KEYS = /* @__PURE__ */ new Set([
700
+ "org",
701
+ "source",
702
+ "cdn-overrides",
703
+ "logo",
704
+ "brandmark",
705
+ "wordmark",
706
+ "monochrome",
707
+ "favicon",
708
+ "custom"
709
+ ]);
710
+ var KNOWN_BRAND_STANDARD_SLOTS = ["logo", "brandmark", "wordmark", "monochrome", "favicon"];
711
+ var KNOWN_BRAND_SLOT_KEYS = /* @__PURE__ */ new Set(["slug", "formats", "light", "dark", "clearSpace", "aspectRatio"]);
712
+ var KNOWN_BRAND_SOURCES = /* @__PURE__ */ new Set(["visor-brands", "local"]);
713
+ var KNOWN_BRAND_CDN_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["visor-brands"]);
610
714
  function checkUnknownKeys(obj, errors) {
611
715
  for (const key of Object.keys(obj)) {
612
716
  if (!KNOWN_TOP_LEVEL_KEYS.has(key)) {
@@ -702,6 +806,46 @@ function checkUnknownKeys(obj, errors) {
702
806
  }
703
807
  }
704
808
  }
809
+ if (typeof obj.brand === "object" && obj.brand !== null) {
810
+ const brand = obj.brand;
811
+ for (const key of Object.keys(brand)) {
812
+ if (!KNOWN_BRAND_KEYS.has(key)) {
813
+ errors.push(`Unknown key 'brand.${key}'. Valid keys: ${[...KNOWN_BRAND_KEYS].join(", ")}`);
814
+ }
815
+ }
816
+ if (typeof brand["cdn-overrides"] === "object" && brand["cdn-overrides"] !== null) {
817
+ for (const key of Object.keys(brand["cdn-overrides"])) {
818
+ if (!KNOWN_BRAND_CDN_OVERRIDE_KEYS.has(key)) {
819
+ errors.push(`Unknown key 'brand.cdn-overrides.${key}'. Valid keys: ${[...KNOWN_BRAND_CDN_OVERRIDE_KEYS].join(", ")}`);
820
+ }
821
+ }
822
+ }
823
+ for (const slot of KNOWN_BRAND_STANDARD_SLOTS) {
824
+ const slotObj = brand[slot];
825
+ if (typeof slotObj === "object" && slotObj !== null) {
826
+ for (const key of Object.keys(slotObj)) {
827
+ if (!KNOWN_BRAND_SLOT_KEYS.has(key)) {
828
+ errors.push(`Unknown key 'brand.${slot}.${key}'. Valid keys: ${[...KNOWN_BRAND_SLOT_KEYS].join(", ")}`);
829
+ }
830
+ }
831
+ }
832
+ }
833
+ if (typeof brand.custom === "object" && brand.custom !== null) {
834
+ const custom = brand.custom;
835
+ for (const slotName of Object.keys(custom)) {
836
+ const slotObj = custom[slotName];
837
+ if (typeof slotObj !== "object" || slotObj === null) {
838
+ errors.push(`'brand.custom.${slotName}' must be an object with optional slug/formats/light/dark/clearSpace/aspectRatio fields`);
839
+ continue;
840
+ }
841
+ for (const key of Object.keys(slotObj)) {
842
+ if (!KNOWN_BRAND_SLOT_KEYS.has(key)) {
843
+ errors.push(`Unknown key 'brand.custom.${slotName}.${key}'. Valid keys: ${[...KNOWN_BRAND_SLOT_KEYS].join(", ")}`);
844
+ }
845
+ }
846
+ }
847
+ }
848
+ }
705
849
  if (typeof obj.spacing === "object" && obj.spacing !== null) {
706
850
  for (const key of Object.keys(obj.spacing)) {
707
851
  if (!KNOWN_SPACING_KEYS.has(key)) {
@@ -857,6 +1001,46 @@ function validateConfig(config) {
857
1001
  }
858
1002
  }
859
1003
  }
1004
+ if (obj.brand !== void 0) {
1005
+ if (typeof obj.brand !== "object" || obj.brand === null) {
1006
+ errors.push("'brand' must be an object");
1007
+ } else {
1008
+ const brand = obj.brand;
1009
+ if (brand.source !== void 0 && !KNOWN_BRAND_SOURCES.has(brand.source)) {
1010
+ errors.push(`'brand.source' must be one of: ${[...KNOWN_BRAND_SOURCES].join(", ")}`);
1011
+ }
1012
+ const brandCdn = brand["cdn-overrides"];
1013
+ const visorBrandsOverride = brandCdn?.["visor-brands"];
1014
+ if (visorBrandsOverride !== void 0 && typeof visorBrandsOverride !== "string") {
1015
+ errors.push("'brand.cdn-overrides.visor-brands' must be a string URL");
1016
+ }
1017
+ if (typeof visorBrandsOverride === "string" && visorBrandsOverride.length === 0) {
1018
+ errors.push("'brand.cdn-overrides.visor-brands' must not be empty");
1019
+ }
1020
+ const orgOptional = typeof visorBrandsOverride === "string" && visorBrandsOverride.length > 0;
1021
+ if (brand.source === "visor-brands" && !orgOptional && !brand.org) {
1022
+ errors.push("'brand.org' is required when brand.source is 'visor-brands' (unless brand.cdn-overrides.visor-brands is set)");
1023
+ }
1024
+ const allSlots = [];
1025
+ for (const slot of KNOWN_BRAND_STANDARD_SLOTS) {
1026
+ if (typeof brand[slot] === "object" && brand[slot] !== null) {
1027
+ allSlots.push(brand[slot]);
1028
+ }
1029
+ }
1030
+ if (typeof brand.custom === "object" && brand.custom !== null) {
1031
+ for (const slot of Object.values(brand.custom)) {
1032
+ if (typeof slot === "object" && slot !== null) allSlots.push(slot);
1033
+ }
1034
+ }
1035
+ for (const slot of allSlots) {
1036
+ if (slot.formats !== void 0) {
1037
+ if (!Array.isArray(slot.formats) || !slot.formats.every((f) => typeof f === "string")) {
1038
+ errors.push(`'brand.<slot>.formats' must be an array of format strings (e.g., ["svg", "png"])`);
1039
+ }
1040
+ }
1041
+ }
1042
+ }
1043
+ }
860
1044
  if (obj.overrides !== void 0) {
861
1045
  if (typeof obj.overrides !== "object" || obj.overrides === null) {
862
1046
  errors.push("'overrides' must be an object");
@@ -917,6 +1101,20 @@ var DEFAULTS = {
917
1101
  easing: "cubic-bezier(0.4, 0, 0.2, 1)"
918
1102
  }
919
1103
  };
1104
+ function resolveBrand(brand) {
1105
+ if (!brand) return DEFAULT_VISOR_BRAND;
1106
+ return {
1107
+ org: brand.org ?? DEFAULT_VISOR_BRAND.org,
1108
+ source: brand.source ?? DEFAULT_VISOR_BRAND.source,
1109
+ ...brand["cdn-overrides"] && { "cdn-overrides": brand["cdn-overrides"] },
1110
+ logo: brand.logo ?? DEFAULT_VISOR_BRAND.logo,
1111
+ brandmark: brand.brandmark ?? DEFAULT_VISOR_BRAND.brandmark,
1112
+ wordmark: brand.wordmark ?? DEFAULT_VISOR_BRAND.wordmark,
1113
+ monochrome: brand.monochrome ?? DEFAULT_VISOR_BRAND.monochrome,
1114
+ favicon: brand.favicon ?? DEFAULT_VISOR_BRAND.favicon,
1115
+ ...brand.custom && { custom: brand.custom }
1116
+ };
1117
+ }
920
1118
  function resolveConfig(config) {
921
1119
  const colors = config.colors;
922
1120
  const originalColors = {};
@@ -997,6 +1195,7 @@ function resolveConfig(config) {
997
1195
  },
998
1196
  slots: config.typography?.slots ?? {}
999
1197
  },
1198
+ brand: resolveBrand(config.brand),
1000
1199
  spacing: {
1001
1200
  base: config.spacing?.base ?? DEFAULTS.spacing.base
1002
1201
  },
@@ -2997,12 +3196,17 @@ function extractFromCSS(files, name = "extracted-theme") {
2997
3196
  return { config, tokens, unmapped, warnings };
2998
3197
  }
2999
3198
  export {
3199
+ BRAND_VARIANTS,
3200
+ DEFAULT_VISOR_BRAND,
3000
3201
  FONT_WEIGHT_ALIASES,
3001
3202
  SEMANTIC_MAP,
3002
3203
  TAILWIND_GRAY,
3204
+ VISOR_BRANDS_CDN,
3205
+ VISOR_DEFAULT_BRAND_PATH,
3003
3206
  VISOR_FONTS_CDN,
3004
3207
  applyOverrides,
3005
3208
  assignSemanticTokens,
3209
+ buildVisorBrandUrl,
3006
3210
  buildVisorFontUrl,
3007
3211
  clampToSrgb,
3008
3212
  cleanFontValue,
@@ -3042,8 +3246,11 @@ export {
3042
3246
  parseHsla,
3043
3247
  parseOklch,
3044
3248
  parseRgba,
3249
+ resolveBrandSlot,
3250
+ resolveBrandSource,
3045
3251
  resolveConfig,
3046
3252
  resolveFont,
3253
+ resolveThemeBrand,
3047
3254
  resolveThemeFonts,
3048
3255
  rgbToHex,
3049
3256
  serializeColor,
@@ -119,6 +119,104 @@ 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";
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
+ /** Operator-defined slots, addressed by key. */
189
+ custom?: Record<string, BrandSlot>;
190
+ }
191
+ /** A single resolved brand variant — light + dark asset URLs plus its tokens. */
192
+ interface BrandResolution {
193
+ /** The variant slot this resolution covers ("logo", "brandmark", or a custom key). */
194
+ variant: string;
195
+ /** Where this asset comes from. */
196
+ source: BrandSource;
197
+ /** Resolved light-mode asset URL/path. */
198
+ light: string;
199
+ /** Resolved dark-mode asset URL/path. */
200
+ dark: string;
201
+ /** Tokenized safe-zone padding (null when not declared). */
202
+ clearSpace: string | null;
203
+ /** Tokenized locked aspect ratio (null when not declared). */
204
+ aspectRatio: string | null;
205
+ /** Human-readable guidance for assets needing manual setup (e.g. local files). */
206
+ guidance: string | null;
207
+ }
208
+ /** Result of resolving all brand assets for a theme. */
209
+ interface ThemeBrandResult {
210
+ /** Resolved standard-slot variants in declaration order. */
211
+ variants: BrandResolution[];
212
+ /** Resolved custom-slot variants in declaration order. */
213
+ custom: BrandResolution[];
214
+ /** Mode-scoped `--brand-*` CSS custom property declarations (no @layer wrapper). */
215
+ css: string;
216
+ /** Warnings for assets needing manual setup. */
217
+ warnings: string[];
218
+ }
219
+
122
220
  /**
123
221
  * Types for the Visor Theme Engine
124
222
  *
@@ -240,6 +338,13 @@ interface VisorThemeConfig {
240
338
  */
241
339
  slots?: Partial<Record<MaterialTextSlot, TextSlotOverride>>;
242
340
  };
341
+ /**
342
+ * Brand-asset declarations (VI-470). Structured like `typography` — per-slot
343
+ * logo/brandmark/wordmark/etc. entries resolved to asset URLs and emitted as
344
+ * mode-scoped `--brand-*` CSS vars in a dedicated `visor-brand` cascade layer.
345
+ * Omitted → the Visor default brand (stock themes are not logo-less).
346
+ */
347
+ brand?: VisorBrand;
243
348
  spacing?: {
244
349
  base?: number;
245
350
  };
@@ -358,6 +463,11 @@ interface ResolvedThemeConfig {
358
463
  */
359
464
  slots: Partial<Record<MaterialTextSlot, TextSlotOverride>>;
360
465
  };
466
+ /**
467
+ * Resolved brand block (VI-470). Always present — falls back to the Visor
468
+ * default brand when the source config omits `brand` (D3).
469
+ */
470
+ brand: VisorBrand;
361
471
  spacing: {
362
472
  base: number;
363
473
  };
@@ -434,4 +544,4 @@ interface ThemeData {
434
544
  output: ThemeOutput;
435
545
  }
436
546
 
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 };
547
+ 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.12.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,54 @@
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
+ "custom": {
257
+ "type": "object",
258
+ "description": "Operator-defined slots, addressed by key.",
259
+ "additionalProperties": { "$ref": "#/$defs/brandSlot" }
260
+ }
261
+ },
262
+ "if": {
263
+ "properties": { "source": { "const": "visor-brands" } },
264
+ "required": ["source"]
265
+ },
266
+ "then": {
267
+ "anyOf": [
268
+ { "required": ["org"] },
269
+ { "required": ["cdn-overrides"] }
270
+ ]
271
+ }
272
+ },
225
273
  "spacing": {
226
274
  "type": "object",
227
275
  "description": "Spacing configuration.",
@@ -336,6 +384,38 @@
336
384
  { "pattern": "^oklch\\(" }
337
385
  ]
338
386
  },
387
+ "brandSlot": {
388
+ "type": "object",
389
+ "description": "A single brand-variant declaration. Resolves to light + dark asset URLs; clearSpace and aspectRatio are tokenized per variant (VI-470, Q6).",
390
+ "additionalProperties": false,
391
+ "properties": {
392
+ "slug": {
393
+ "type": "string",
394
+ "description": "CDN/asset-set slug. Required when source is visor-brands (Phase 2+)."
395
+ },
396
+ "formats": {
397
+ "type": "array",
398
+ "items": { "type": "string" },
399
+ "description": "Preferred asset formats, first wins (e.g. [\"svg\"], [\"svg\", \"png\"])."
400
+ },
401
+ "light": {
402
+ "type": "string",
403
+ "description": "Explicit light-mode asset filename or public/-relative path (overrides the inferred name)."
404
+ },
405
+ "dark": {
406
+ "type": "string",
407
+ "description": "Explicit dark-mode asset filename or public/-relative path (overrides the inferred name)."
408
+ },
409
+ "clearSpace": {
410
+ "type": "string",
411
+ "description": "Tokenized safe-zone padding enforced by <Logo> (e.g. \"0.5rem\"). Emitted as --brand-{variant}-clear-space."
412
+ },
413
+ "aspectRatio": {
414
+ "type": "string",
415
+ "description": "Tokenized locked aspect ratio (e.g. \"3 / 1\"); else derived from the SVG viewBox. Emitted as --brand-{variant}-aspect-ratio."
416
+ }
417
+ }
418
+ },
339
419
  "textSlotOverride": {
340
420
  "type": "object",
341
421
  "description": "Per-slot override for one Material TextTheme entry.",