@loworbitstudio/visor-theme-engine 0.19.0 → 0.20.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 { k as GeneratedPrimitives, s as SemanticTokens, R as ResolvedThemeConfig } from '../types-DF5StphJ.js';
1
+ import { k as GeneratedPrimitives, s as SemanticTokens, R as ResolvedThemeConfig } from '../types-BG-YT4JW.js';
2
2
 
3
3
  /**
4
4
  * Adapter types for the Visor theme engine.
@@ -37,6 +37,10 @@ interface NextJSAdapterOptions extends AdapterOptions {
37
37
  * `@layer visor-base` origination block that binds theme tokens to the page
38
38
  * root (default: true). Set false for consumers that ship Tailwind preflight
39
39
  * or their own reset. See VI-616.
40
+ *
41
+ * VI-638: the base block also carries `font-size: var(--font-size-base)`,
42
+ * which is how `typography.scale` reaches the page. A consumer that declines
43
+ * it must bind the base size itself — on `body`, not `:root`.
40
44
  */
41
45
  includeBaseLayer?: boolean;
42
46
  }
@@ -8,6 +8,8 @@ import {
8
8
  collectBrandPassthrough,
9
9
  fontStack,
10
10
  generateDarkCss,
11
+ generateFontSizeDecls,
12
+ generateFontWeightDecls,
11
13
  generateHairlineDecls,
12
14
  generateIntentDecls,
13
15
  generateLightCss,
@@ -21,7 +23,7 @@ import {
21
23
  resolveThemeBrand,
22
24
  resolveThemeFonts,
23
25
  sectionComment
24
- } from "../chunk-32DC5DAR.js";
26
+ } from "../chunk-JDYLDGUN.js";
25
27
 
26
28
  // src/adapters/brand-passthrough.ts
27
29
  var SENTINEL_COLOR = "#ff00ff";
@@ -212,7 +214,11 @@ function nextjsAdapter(input, options) {
212
214
  baseLines.push(
213
215
  block(scopePrefix ?? "body", [
214
216
  "font-family: var(--font-body);",
215
- "font-size: 1rem;",
217
+ // VI-638: the scaled ramp is the single mechanism that sets type size,
218
+ // and the base step is what the page inherits. This binding lives on
219
+ // `body` (or the body-class scope), never `:root` — the ramp is
220
+ // expressed in `rem`, so a scaled root would multiply it a second time.
221
+ "font-size: var(--font-size-base, 1rem);",
216
222
  "color: var(--text-primary);",
217
223
  "background: var(--surface-page, var(--surface-background));"
218
224
  ])
@@ -537,23 +543,8 @@ function generateTypographyDecls(config, aliases) {
537
543
  decls.push(`--font-heading: ${fontStack(headingFamily, aliases)};`);
538
544
  decls.push(`--font-body: ${fontStack(config.typography.body.family, aliases)};`);
539
545
  decls.push(`--font-mono: ${fontStack(config.typography.mono.family, aliases)};`);
540
- const fontSizes = {
541
- xs: 12,
542
- sm: 14,
543
- base: 16,
544
- lg: 18,
545
- xl: 20,
546
- "2xl": 24,
547
- "3xl": 30,
548
- "4xl": 36
549
- };
550
- for (const [name, px] of Object.entries(fontSizes)) {
551
- decls.push(`--font-size-${name}: ${px / 16}rem; /* ${px}px */`);
552
- }
553
- decls.push(`--font-weight-normal: ${config.typography.body.weight};`);
554
- decls.push("--font-weight-medium: 500;");
555
- decls.push(`--font-weight-semibold: ${config.typography.heading.weight};`);
556
- decls.push("--font-weight-bold: 700;");
546
+ decls.push(...generateFontSizeDecls(config.typography.scale));
547
+ decls.push(...generateFontWeightDecls(config.typography));
557
548
  const lineHeights = {
558
549
  none: 1,
559
550
  tight: 1.25,
@@ -686,7 +677,6 @@ function docsAdapter(input, options) {
686
677
  fontLines.push("");
687
678
  }
688
679
  }
689
- const scale = input.config.typography?.scale ?? 1;
690
680
  const emittedFamilies = /* @__PURE__ */ new Set();
691
681
  for (const font of fontSlots) {
692
682
  if (font && font.source === "visor-fonts" && !emittedFamilies.has(font.family)) {
@@ -700,9 +690,6 @@ function docsAdapter(input, options) {
700
690
  fontLines.push(` font-weight: ${weight};`);
701
691
  fontLines.push(` font-style: ${font.italic ? "italic" : "normal"};`);
702
692
  fontLines.push(` font-display: ${font.display};`);
703
- if (scale !== 1) {
704
- fontLines.push(` size-adjust: ${Math.round(scale * 100)}%;`);
705
- }
706
693
  fontLines.push("}");
707
694
  fontLines.push("");
708
695
  }
@@ -712,7 +699,11 @@ function docsAdapter(input, options) {
712
699
  lines.push("\n/* \u2500\u2500 Section 1: Shared tokens (mode-independent) \u2500\u2500 */");
713
700
  const sharedDecls = [
714
701
  "min-height: 100vh;",
715
- "font-size: 1rem;",
702
+ // VI-638: the scaled ramp is the single mechanism that sets type size, and
703
+ // the base step is what the page inherits. Safe here because the scope is a
704
+ // class on a wrapper, never `:root` — a scaled root would multiply the
705
+ // rem-expressed ramp a second time.
706
+ "font-size: var(--font-size-base, 1rem);",
716
707
  // BO-56: pin UA chrome to the brand's single mode (adaptive emits nothing).
717
708
  ...singleMode ? [`color-scheme: ${singleMode};`] : [],
718
709
  `background: var(--surface-page, var(--surface-background));`,
@@ -1928,6 +1928,99 @@ function applyOverrides(tokens, overrides) {
1928
1928
  return result;
1929
1929
  }
1930
1930
 
1931
+ // src/font-weights.ts
1932
+ var WEIGHT_RAMP_TARGETS = {
1933
+ medium: 500,
1934
+ semibold: 600,
1935
+ bold: 700
1936
+ };
1937
+ var SLOTS = ["heading", "display", "body", "mono"];
1938
+ function loadedWeights(typography) {
1939
+ const set = /* @__PURE__ */ new Set();
1940
+ for (const slot of SLOTS) {
1941
+ const cfg = typography[slot];
1942
+ if (!cfg) continue;
1943
+ if (cfg.weights?.length) {
1944
+ for (const w of cfg.weights) set.add(w);
1945
+ } else if (typeof cfg.weight === "number") {
1946
+ set.add(cfg.weight);
1947
+ }
1948
+ }
1949
+ return [...set].sort((a, b) => a - b);
1950
+ }
1951
+ function declaresWeights(typography) {
1952
+ return SLOTS.some((slot) => Boolean(typography[slot]?.weights?.length));
1953
+ }
1954
+ function matchLoadedWeight(desired, loaded) {
1955
+ if (loaded.length === 0) return desired;
1956
+ if (loaded.includes(desired)) return desired;
1957
+ const ascending = [...loaded].sort((a, b) => a - b);
1958
+ const lighter = ascending.filter((w) => w < desired).reverse();
1959
+ const heavier = ascending.filter((w) => w > desired);
1960
+ if (desired >= 400 && desired <= 500) {
1961
+ const upToFive = heavier.filter((w) => w <= 500);
1962
+ const aboveFive = heavier.filter((w) => w > 500);
1963
+ return upToFive[0] ?? lighter[0] ?? aboveFive[0];
1964
+ }
1965
+ if (desired < 400) {
1966
+ return lighter[0] ?? heavier[0];
1967
+ }
1968
+ return heavier[0] ?? lighter[0];
1969
+ }
1970
+ function resolveWeightRamp(typography) {
1971
+ const bodyWeight = typography.body?.weight ?? 400;
1972
+ if (!declaresWeights(typography)) {
1973
+ return {
1974
+ normal: bodyWeight,
1975
+ medium: WEIGHT_RAMP_TARGETS.medium,
1976
+ semibold: WEIGHT_RAMP_TARGETS.semibold,
1977
+ bold: WEIGHT_RAMP_TARGETS.bold
1978
+ };
1979
+ }
1980
+ const loaded = loadedWeights(typography);
1981
+ return {
1982
+ normal: matchLoadedWeight(bodyWeight, loaded),
1983
+ medium: matchLoadedWeight(WEIGHT_RAMP_TARGETS.medium, loaded),
1984
+ semibold: matchLoadedWeight(WEIGHT_RAMP_TARGETS.semibold, loaded),
1985
+ bold: matchLoadedWeight(WEIGHT_RAMP_TARGETS.bold, loaded)
1986
+ };
1987
+ }
1988
+ function resolveRoleWeight(declared, typography) {
1989
+ if (!declaresWeights(typography)) return declared;
1990
+ return matchLoadedWeight(declared, loadedWeights(typography));
1991
+ }
1992
+ function generateFontWeightDecls(typography) {
1993
+ const ramp = resolveWeightRamp(typography);
1994
+ const decls = [
1995
+ `--font-weight-normal: ${ramp.normal};`,
1996
+ `--font-weight-medium: ${ramp.medium};`,
1997
+ `--font-weight-semibold: ${ramp.semibold};`,
1998
+ `--font-weight-bold: ${ramp.bold};`,
1999
+ // Role primitives. These are why `semibold` was pinned in the first place:
2000
+ // packages/tokens defined `--weight-heading: var(--font-weight-semibold)`
2001
+ // and the engine emitted no per-theme value, so hijacking `semibold` was
2002
+ // the only channel a theme's heading weight had. Each role has its own
2003
+ // primitive now, and the semantic `--weight-*` roles resolve through them.
2004
+ //
2005
+ // Emitted as `--font-weight-*` primitives rather than the bare `--weight-*`
2006
+ // names on purpose: the semantic names are defined in @layer visor-semantic,
2007
+ // which wins over visor-primitives, so a theme writing them directly would
2008
+ // be overridden by the tokens package on a `:root`-scoped consumer.
2009
+ `--font-weight-heading: ${resolveRoleWeight(typography.heading.weight, typography)};`,
2010
+ `--font-weight-body: ${resolveRoleWeight(typography.body.weight, typography)};`,
2011
+ `--font-weight-display: ${resolveRoleWeight(typography.display.weight, typography)};`,
2012
+ // Retained: `--weight-display` has no competing definition in the tokens
2013
+ // package and predates this change, so consumers already reference it.
2014
+ `--weight-display: ${resolveRoleWeight(typography.display.weight, typography)};`
2015
+ ];
2016
+ if (declaresWeights(typography)) {
2017
+ for (const weight of loadedWeights(typography)) {
2018
+ decls.push(`--font-weight-${weight}: ${weight};`);
2019
+ }
2020
+ }
2021
+ return decls;
2022
+ }
2023
+
1931
2024
  // src/fonts/theme-alias.ts
1932
2025
  var EMPTY_ALIASES = /* @__PURE__ */ new Map();
1933
2026
  function aliasFamily(family, themeSlug) {
@@ -2024,33 +2117,32 @@ function generateShadowPrimitives(config) {
2024
2117
  `--shadow-xl: ${config.shadows.xl};`
2025
2118
  ];
2026
2119
  }
2120
+ var FONT_SIZE_RAMP_PX = {
2121
+ xs: 12,
2122
+ sm: 14,
2123
+ base: 16,
2124
+ lg: 18,
2125
+ xl: 20,
2126
+ "2xl": 24,
2127
+ "3xl": 30,
2128
+ "4xl": 36
2129
+ };
2130
+ function generateFontSizeDecls(scale) {
2131
+ return Object.entries(FONT_SIZE_RAMP_PX).map(([name, px]) => {
2132
+ const scaled = px * scale;
2133
+ const rem = Number((scaled / 16).toFixed(5));
2134
+ return `--font-size-${name}: ${rem}rem; /* ${Number(scaled.toFixed(2))}px */`;
2135
+ });
2136
+ }
2027
2137
  function generateTypographyPrimitives(config, aliases = EMPTY_ALIASES) {
2028
2138
  const decls = [];
2029
- const scale = config.typography.scale;
2030
- decls.push(`font-size: ${scale === 1 ? "1rem" : `${scale}rem`};`);
2031
2139
  decls.push(`--font-heading: ${fontStack(config.typography.heading.family, aliases)};`);
2032
2140
  decls.push(`--font-display: ${fontStack(config.typography.display.family, aliases)};`);
2033
2141
  decls.push(`--font-sans: ${fontStack(config.typography.body.family, aliases)};`);
2034
2142
  decls.push(`--font-body: ${fontStack(config.typography.body.family, aliases)};`);
2035
2143
  decls.push(`--font-mono: ${fontStack(config.typography.mono.family, aliases)};`);
2036
- const fontSizes = {
2037
- xs: 12,
2038
- sm: 14,
2039
- base: 16,
2040
- lg: 18,
2041
- xl: 20,
2042
- "2xl": 24,
2043
- "3xl": 30,
2044
- "4xl": 36
2045
- };
2046
- for (const [name, px] of Object.entries(fontSizes)) {
2047
- decls.push(`--font-size-${name}: ${px / 16}rem; /* ${px}px */`);
2048
- }
2049
- decls.push(`--font-weight-normal: ${config.typography.body.weight};`);
2050
- decls.push("--font-weight-medium: 500;");
2051
- decls.push(`--font-weight-semibold: ${config.typography.heading.weight};`);
2052
- decls.push("--font-weight-bold: 700;");
2053
- decls.push(`--weight-display: ${config.typography.display.weight};`);
2144
+ decls.push(...generateFontSizeDecls(config.typography.scale));
2145
+ decls.push(...generateFontWeightDecls(config.typography));
2054
2146
  const lineHeights = {
2055
2147
  none: 1,
2056
2148
  tight: 1.25,
@@ -2464,11 +2556,19 @@ export {
2464
2556
  collectBrandPassthrough,
2465
2557
  hasBrandPassthrough,
2466
2558
  applyOverrides,
2559
+ WEIGHT_RAMP_TARGETS,
2560
+ loadedWeights,
2561
+ declaresWeights,
2562
+ matchLoadedWeight,
2563
+ resolveWeightRamp,
2564
+ resolveRoleWeight,
2565
+ generateFontWeightDecls,
2467
2566
  aliasFamily,
2468
2567
  fontStack,
2469
2568
  header,
2470
2569
  sectionComment,
2471
2570
  block2 as block,
2571
+ generateFontSizeDecls,
2472
2572
  generatePrimitivesCss,
2473
2573
  generateSemanticCss,
2474
2574
  generateTextScaleAliasDecls,
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { F as FontResolveOptions, a as FontResolution, V as VisorTypography, b as FontDisplayStrategy, T as ThemeFontResult, G as GoogleFontEntry, c as VisorBrand, B as BrandSlot, d as BrandSource, e as BrandResolution, C as ColorScheme, f as ThemeBrandResult, g as BrandStrategy, h as BrandStrategyContext, i as BrandStrategyIssue, j as BrandStrategyValidationResult, S as SerializedBrandStrategy, R as ResolvedThemeConfig, k as GeneratedPrimitives, l as ThemeOutput, m as ThemeData, n as VisorThemeConfig, o as FullShadeScale, p as ColorRole, q as SelectiveShadeScale, r as RGB, P as ParsedColor, O as OKLCH, s as SemanticTokens, t as ShadeStep } from './types-DF5StphJ.js';
2
- export { u as BRAND_VARIANTS, v as BRAND_VISIBILITIES, w as BrandAccessibility, x as BrandArchetype, y as BrandBoilerplate, z as BrandColorPairing, A as BrandColorUsage, D as BrandContrastTarget, E as BrandGoverns, H as BrandLexiconEntry, I as BrandMessaging, J as BrandPersonalityTrait, K as BrandPillar, L as BrandPositioning, M as BrandStrategyIssueSeverity, N as BrandToneEntry, Q as BrandVariant, U as BrandVisibility, W as BrandVoice, X as BrandVoiceTrait, Y as COMPONENT_TOKEN_FAMILIES, Z as COMPONENT_TOKEN_FAMILY_BY_NAME, _ as ColorFormat, $ as ComponentTokenBinding, a0 as ComponentTokenBindings, a1 as ComponentTokenConsumer, a2 as ComponentTokenFamily, a3 as ComponentTokenSpec, a4 as DEFAULT_BRAND_STRATEGY_SURFACES, a5 as DEFAULT_BRAND_STRATEGY_TONE_STATES, a6 as FontSource, a7 as GOVERNS_WILDCARD, a8 as RGBA, a9 as ResolvedComponentTokens, aa as SemanticTokenValue, ab as allComponentTokenNames, ac as componentTokenName, ad as hasComponentBindings, ae as resolveComponentBindings, af as validateComponentBindings } from './types-DF5StphJ.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, C as ColorScheme, f as ThemeBrandResult, g as BrandStrategy, h as BrandStrategyContext, i as BrandStrategyIssue, j as BrandStrategyValidationResult, S as SerializedBrandStrategy, R as ResolvedThemeConfig, k as GeneratedPrimitives, l as ThemeOutput, m as ThemeData, n as VisorThemeConfig, o as FullShadeScale, p as ColorRole, q as SelectiveShadeScale, r as RGB, P as ParsedColor, O as OKLCH, s as SemanticTokens, t as ShadeStep } from './types-BG-YT4JW.js';
2
+ export { u as BRAND_VARIANTS, v as BRAND_VISIBILITIES, w as BrandAccessibility, x as BrandArchetype, y as BrandBoilerplate, z as BrandColorPairing, A as BrandColorUsage, D as BrandContrastTarget, E as BrandGoverns, H as BrandLexiconEntry, I as BrandMessaging, J as BrandPersonalityTrait, K as BrandPillar, L as BrandPositioning, M as BrandStrategyIssueSeverity, N as BrandToneEntry, Q as BrandVariant, U as BrandVisibility, W as BrandVoice, X as BrandVoiceTrait, Y as COMPONENT_TOKEN_FAMILIES, Z as COMPONENT_TOKEN_FAMILY_BY_NAME, _ as ColorFormat, $ as ComponentTokenBinding, a0 as ComponentTokenBindings, a1 as ComponentTokenConsumer, a2 as ComponentTokenFamily, a3 as ComponentTokenSpec, a4 as DEFAULT_BRAND_STRATEGY_SURFACES, a5 as DEFAULT_BRAND_STRATEGY_TONE_STATES, a6 as FontSource, a7 as GOVERNS_WILDCARD, a8 as RGBA, a9 as ResolvedComponentTokens, aa as SemanticTokenValue, ab as allComponentTokenNames, ac as componentTokenName, ad as hasComponentBindings, ae as resolveComponentBindings, af as validateComponentBindings } from './types-BG-YT4JW.js';
3
3
 
4
4
  /**
5
5
  * Font resolver — maps font family names to loadable font resources.
@@ -585,7 +585,7 @@ var properties = {
585
585
  },
586
586
  scale: {
587
587
  type: "number",
588
- description: "Type scale multiplier applied to the font-size ramp. Default: 1."
588
+ description: "Type scale multiplier applied to the font-size ramp: every --font-size-* step is multiplied by it, and the page inherits the scaled --font-size-base. Default: 1. CSS adapters only — the Flutter adapter emits Material 3 slot sizes verbatim and ignores this field."
589
589
  },
590
590
  "text-on-light": {
591
591
  type: "string",
@@ -1922,6 +1922,135 @@ declare function generateDarkCss(tokens: SemanticTokens, options?: {
1922
1922
  }): string;
1923
1923
  declare function generateFullBundleCss(primitives: GeneratedPrimitives, tokens: SemanticTokens, config: ResolvedThemeConfig): string;
1924
1924
 
1925
+ /**
1926
+ * Font-weight resolution (VI-639).
1927
+ *
1928
+ * The named weight ramp used to be three literals and one role leak:
1929
+ *
1930
+ * ```
1931
+ * --font-weight-normal: <body.weight>
1932
+ * --font-weight-medium: 500 // literal
1933
+ * --font-weight-semibold: <heading.weight> // a different concept entirely
1934
+ * --font-weight-bold: 700 // literal
1935
+ * ```
1936
+ *
1937
+ * `semibold` is not a heading. Pinning it there collapsed it onto `medium` on
1938
+ * every theme whose heading weight is 500 (the Blacklight family), onto
1939
+ * `normal` where it is 400 (knowmentum), and inverted the ramp where it is
1940
+ * above `bold` (strata emitted `semibold: 800` over `bold: 700`). Meanwhile the
1941
+ * literals ignored the `weights` array a theme actually loaded, so a theme
1942
+ * declaring `[200, 400, 600, 900]` got tokens pointing at faces it never
1943
+ * fetched — which the browser then silently substituted.
1944
+ *
1945
+ * Resolution here follows the CSS font-matching algorithm's weight-selection
1946
+ * order (CSS Fonts 4 §5.2) rather than naive nearest-numeric. The browser is
1947
+ * already performing exactly this substitution at render time, so an emitted
1948
+ * token becomes the weight that was *already rendering* — which is what makes
1949
+ * this change visually neutral everywhere except where the token was wrong.
1950
+ */
1951
+ /** Canonical targets for the named ramp above `normal`. */
1952
+ declare const WEIGHT_RAMP_TARGETS: {
1953
+ readonly medium: 500;
1954
+ readonly semibold: 600;
1955
+ readonly bold: 700;
1956
+ };
1957
+ /** The typography slots that can declare a family, weight and weights array. */
1958
+ declare const SLOTS: readonly ["heading", "display", "body", "mono"];
1959
+ type WeightedSlot = {
1960
+ weight?: number;
1961
+ weights?: number[];
1962
+ } | undefined;
1963
+ type SlottedTypography = Partial<Record<(typeof SLOTS)[number], WeightedSlot>>;
1964
+ /**
1965
+ * The weights a theme actually loads: each slot's explicit `weights` array, or
1966
+ * its single `weight` when it declares none — mirroring how the font pipeline
1967
+ * resolves the set it fetches.
1968
+ */
1969
+ declare function loadedWeights(typography: SlottedTypography): number[];
1970
+ /**
1971
+ * Whether any slot declares an explicit `weights` array.
1972
+ *
1973
+ * Resolution is gated on this. A theme that declares one has told us what it
1974
+ * loads, so we can hold every emitted token to that set. A theme that hasn't
1975
+ * keeps the historical emission untouched — snapping against a set inferred
1976
+ * from three `weight` fields would move tokens on a claim the theme never made
1977
+ * (and says nothing at all about a system font stack, which has whatever the OS
1978
+ * has). `validateFontWeights` warns about those instead.
1979
+ */
1980
+ declare function declaresWeights(typography: SlottedTypography): boolean;
1981
+ /**
1982
+ * Pick the weight a browser would actually use for `desired`, given the faces
1983
+ * the theme loaded — CSS Fonts 4 §5.2 weight-matching order:
1984
+ *
1985
+ * - exact match wins;
1986
+ * - desired 400–500: heavier faces up to 500 ascending, then lighter descending,
1987
+ * then heavier than 500 ascending;
1988
+ * - desired below 400: lighter descending, then heavier ascending;
1989
+ * - desired above 500: heavier ascending, then lighter descending.
1990
+ *
1991
+ * Returns `desired` unchanged when the theme loads nothing (nothing to match
1992
+ * against, and the system font has its own ideas).
1993
+ */
1994
+ declare function matchLoadedWeight(desired: number, loaded: readonly number[]): number;
1995
+ /** The four named ramp steps a theme emits, resolved against what it loaded. */
1996
+ interface ResolvedWeightRamp {
1997
+ normal: number;
1998
+ medium: number;
1999
+ semibold: number;
2000
+ bold: number;
2001
+ }
2002
+ /**
2003
+ * Resolve the named ramp.
2004
+ *
2005
+ * `normal` tracks the theme's body weight rather than a hard 400 — it is the
2006
+ * ramp's zero point, and Blacklight's `body.weight: 300` is a deliberate
2007
+ * choice, not a rounding error. It is still coerced to a loaded face (strata
2008
+ * declares `body.weight: 450` and loads `[400, 700, 900]`).
2009
+ *
2010
+ * `medium / semibold / bold` resolve the canonical 500 / 600 / 700 targets.
2011
+ * Names can still share a value on a family that cannot separate them — two
2012
+ * loaded faces cannot back four distinct names — but the result is now the
2013
+ * truth about what renders rather than a number nothing will honour.
2014
+ */
2015
+ declare function resolveWeightRamp(typography: SlottedTypography & {
2016
+ body?: WeightedSlot;
2017
+ }): ResolvedWeightRamp;
2018
+ /**
2019
+ * Resolve a role weight (`--weight-heading`, `--weight-body`,
2020
+ * `--weight-display`) to a loaded face. Roles carry theme intent — what this
2021
+ * theme's headings weigh — and are the channel components should read when they
2022
+ * mean "the heading weight" rather than "a step above medium".
2023
+ */
2024
+ declare function resolveRoleWeight(declared: number, typography: SlottedTypography): number;
2025
+ /**
2026
+ * Emit the shared weight declarations: the named ramp, the role tokens, and the
2027
+ * discrete ladder.
2028
+ *
2029
+ * The ladder (`--font-weight-300` … `--font-weight-800`, one per loaded face)
2030
+ * is what makes the `weights` array authoritative instead of decorative. Before
2031
+ * it, a theme could load five faces and reach four of them by name at best —
2032
+ * Blacklight's 800 was unreachable from CSS, so BL-987 had to write a literal.
2033
+ * It follows the `--text-N` / `--space-N` discrete-alias precedent from VI-451.
2034
+ */
2035
+ declare function generateFontWeightDecls(typography: {
2036
+ heading: {
2037
+ weight: number;
2038
+ weights?: number[];
2039
+ };
2040
+ display: {
2041
+ weight: number;
2042
+ weights?: number[];
2043
+ };
2044
+ body: {
2045
+ weight: number;
2046
+ weights?: number[];
2047
+ };
2048
+ mono: {
2049
+ weight?: number;
2050
+ weights?: number[];
2051
+ };
2052
+ }): string[];
2053
+
1925
2054
  /**
1926
2055
  * Theme Extraction Engine
1927
2056
  *
@@ -1997,4 +2126,4 @@ declare function cleanFontValue(val: string): string;
1997
2126
  */
1998
2127
  declare function extractFromCSS(files: CSSFile[], name?: string): ExtractionResult;
1999
2128
 
2000
- export { type BrandPassthrough, BrandResolution, BrandSlot, BrandSource, BrandStrategy, BrandStrategyContext, BrandStrategyIssue, BrandStrategyValidationResult, 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, SerializedBrandStrategy, ShadeStep, TAILWIND_GRAY, ThemeBrandResult, ThemeData, ThemeFontResult, ThemeOutput, type ThemeValidationResult, VISOR_BRANDS_CDN, VISOR_DEFAULT_BRAND_PATH, VISOR_FONTS_CDN, type ValidateOptions, type ValidationIssue, type ValidationSeverity, VisorBrand, VisorThemeConfig, VisorTypography, applyOverrides, assignSemanticTokens, buildVisorBrandUrl, buildVisorFontUrl, checkBrandStrategyCoherence, checkBrandStrategyStructure, clampToSrgb, cleanFontValue, collectBrandPassthrough, compositeOverBackground, exportTheme, extractFromCSS, formatFontCoverageError, generateDarkCss, generateFullBundleCss, generateLightCss, generatePreloadLinks, generatePrimitives, generatePrimitivesCss, generateSemanticCss, generateShadeScale, generateStylesheetLinks, generateTheme, generateThemeData, generateThemeDataFromConfig, generateThemeFromConfig, getContrastRatio, getKnownTokenRefs, getLuminance, googleFontsCatalog, hasBrandPassthrough, hexToOklch, hexToRgb, isValidColor, isValidHex, isVisorThemeConfig, lookupFontWeightAlias, lookupGoogleFont, normalizeHex, oklchToHex, parseCSSDeclarations, parseColor, parseConfig, parseFontFaceDeclarations, parseHex, parseHsla, parseOklch, parseRgba, resolveBrandSlot, resolveBrandSource, resolveConfig, resolveFont, resolveThemeBrand, resolveThemeFonts, rgbToHex, serializeBrandStrategy, serializeColor, validate, validateBrandStrategy, validateConfig, validateFontCoverage, visorTheme_schema as visorThemeSchema };
2129
+ export { type BrandPassthrough, BrandResolution, BrandSlot, BrandSource, BrandStrategy, BrandStrategyContext, BrandStrategyIssue, BrandStrategyValidationResult, 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, type ResolvedWeightRamp, SEMANTIC_MAP, SelectiveShadeScale, SemanticTokens, SerializedBrandStrategy, ShadeStep, TAILWIND_GRAY, ThemeBrandResult, ThemeData, ThemeFontResult, ThemeOutput, type ThemeValidationResult, VISOR_BRANDS_CDN, VISOR_DEFAULT_BRAND_PATH, VISOR_FONTS_CDN, type ValidateOptions, type ValidationIssue, type ValidationSeverity, VisorBrand, VisorThemeConfig, VisorTypography, WEIGHT_RAMP_TARGETS, applyOverrides, assignSemanticTokens, buildVisorBrandUrl, buildVisorFontUrl, checkBrandStrategyCoherence, checkBrandStrategyStructure, clampToSrgb, cleanFontValue, collectBrandPassthrough, compositeOverBackground, declaresWeights, exportTheme, extractFromCSS, formatFontCoverageError, generateDarkCss, generateFontWeightDecls, generateFullBundleCss, generateLightCss, generatePreloadLinks, generatePrimitives, generatePrimitivesCss, generateSemanticCss, generateShadeScale, generateStylesheetLinks, generateTheme, generateThemeData, generateThemeDataFromConfig, generateThemeFromConfig, getContrastRatio, getKnownTokenRefs, getLuminance, googleFontsCatalog, hasBrandPassthrough, hexToOklch, hexToRgb, isValidColor, isValidHex, isVisorThemeConfig, loadedWeights, lookupFontWeightAlias, lookupGoogleFont, matchLoadedWeight, normalizeHex, oklchToHex, parseCSSDeclarations, parseColor, parseConfig, parseFontFaceDeclarations, parseHex, parseHsla, parseOklch, parseRgba, resolveBrandSlot, resolveBrandSource, resolveConfig, resolveFont, resolveRoleWeight, resolveThemeBrand, resolveThemeFonts, resolveWeightRamp, rgbToHex, serializeBrandStrategy, serializeColor, validate, validateBrandStrategy, validateConfig, validateFontCoverage, visorTheme_schema as visorThemeSchema };
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  VISOR_BRANDS_CDN,
10
10
  VISOR_DEFAULT_BRAND_PATH,
11
11
  VISOR_FONTS_CDN,
12
+ WEIGHT_RAMP_TARGETS,
12
13
  allComponentTokenNames,
13
14
  applyOverrides,
14
15
  buildVisorBrandUrl,
@@ -17,7 +18,9 @@ import {
17
18
  collectBrandPassthrough,
18
19
  componentTokenName,
19
20
  compositeOverBackground,
21
+ declaresWeights,
20
22
  generateDarkCss,
23
+ generateFontWeightDecls,
21
24
  generateFullBundleCss,
22
25
  generateLightCss,
23
26
  generatePreloadLinks,
@@ -34,8 +37,10 @@ import {
34
37
  hexToRgb,
35
38
  isValidColor,
36
39
  isValidHex,
40
+ loadedWeights,
37
41
  lookupFontWeightAlias,
38
42
  lookupGoogleFont,
43
+ matchLoadedWeight,
39
44
  normalizeHex,
40
45
  oklchToHex,
41
46
  parseColor,
@@ -47,13 +52,15 @@ import {
47
52
  resolveBrandSource,
48
53
  resolveComponentBindings,
49
54
  resolveFont,
55
+ resolveRoleWeight,
50
56
  resolveThemeBrand,
51
57
  resolveThemeFonts,
58
+ resolveWeightRamp,
52
59
  rgbToHex,
53
60
  rgbToOklch,
54
61
  serializeColor,
55
62
  validateComponentBindings
56
- } from "./chunk-32DC5DAR.js";
63
+ } from "./chunk-JDYLDGUN.js";
57
64
 
58
65
  // src/fonts/validate-coverage.ts
59
66
  var FONT_VAR_RE = /--font-(heading|display|body|sans|mono)\s*:\s*([^;]+);/g;
@@ -793,7 +800,7 @@ var visor_theme_schema_default = {
793
800
  },
794
801
  scale: {
795
802
  type: "number",
796
- description: "Type scale multiplier applied to the font-size ramp. Default: 1."
803
+ description: "Type scale multiplier applied to the font-size ramp: every --font-size-* step is multiplied by it, and the page inherits the scaled --font-size-base. Default: 1. CSS adapters only \u2014 the Flutter adapter emits Material 3 slot sizes verbatim and ignores this field."
797
804
  },
798
805
  "text-on-light": {
799
806
  type: "string",
@@ -3137,6 +3144,46 @@ function checkTypeScaleCoherence(config, issues) {
3137
3144
  }
3138
3145
  }
3139
3146
  }
3147
+ function checkFontWeightCoverage(config, issues) {
3148
+ const typography = config.typography;
3149
+ if (!typography) return;
3150
+ const HOSTED = /* @__PURE__ */ new Set(["visor-fonts", "google-fonts", "fontshare"]);
3151
+ const slots = ["heading", "display", "body", "mono"];
3152
+ if (!declaresWeights(typography)) {
3153
+ const hosted = slots.filter((slot) => {
3154
+ const source = typography[slot]?.source;
3155
+ return typeof source === "string" && HOSTED.has(source);
3156
+ });
3157
+ if (hosted.length > 0) {
3158
+ issues.push(
3159
+ issue(
3160
+ "warning",
3161
+ "FONT_WEIGHTS_UNDECLARED",
3162
+ `typography.${hosted[0]} loads from '${typography[hosted[0]].source}' but no slot declares a 'weights' array. Named weight tokens fall back to the canonical 500/600/700 literals, which may not be faces this theme fetched. Declare 'weights' to make them resolve against what you actually load.`,
3163
+ `typography.${hosted[0]}.weights`
3164
+ )
3165
+ );
3166
+ }
3167
+ return;
3168
+ }
3169
+ const loaded = loadedWeights(typography);
3170
+ const ramp = resolveWeightRamp(typography);
3171
+ const collapsed = [
3172
+ ["medium", "normal"],
3173
+ ["semibold", "medium"],
3174
+ ["bold", "semibold"]
3175
+ ].filter(([heavier, lighter]) => ramp[heavier] === ramp[lighter]).map(([heavier, lighter]) => `${heavier} = ${lighter} (${ramp[heavier]})`);
3176
+ if (collapsed.length > 0) {
3177
+ issues.push(
3178
+ issue(
3179
+ "warning",
3180
+ "FONT_WEIGHT_RAMP_COLLAPSED",
3181
+ `Named weight steps share a value because this theme loads only [${loaded.join(", ")}]: ${collapsed.join(", ")}. Asking for the heavier name renders no differently from the lighter one. This is what the browser already does \u2014 load an intermediate face, or use the discrete ladder (--font-weight-${loaded.join(" / --font-weight-")}) where a real step is needed.`,
3182
+ "typography"
3183
+ )
3184
+ );
3185
+ }
3186
+ }
3140
3187
  function checkLetterSpacing(config, issues) {
3141
3188
  const ls = config.typography?.["letter-spacing"];
3142
3189
  if (!ls) return;
@@ -3643,6 +3690,7 @@ function validate(config, options) {
3643
3690
  const typedConfig = config;
3644
3691
  checkCompleteness(typedConfig, errors);
3645
3692
  checkTypeScaleCoherence(typedConfig, errors);
3693
+ checkFontWeightCoverage(typedConfig, warnings);
3646
3694
  checkLetterSpacing(typedConfig, errors);
3647
3695
  checkMotionEasing(typedConfig, errors);
3648
3696
  const durationIssues = [];
@@ -4216,6 +4264,7 @@ export {
4216
4264
  VISOR_BRANDS_CDN,
4217
4265
  VISOR_DEFAULT_BRAND_PATH,
4218
4266
  VISOR_FONTS_CDN,
4267
+ WEIGHT_RAMP_TARGETS,
4219
4268
  allComponentTokenNames,
4220
4269
  applyOverrides,
4221
4270
  assignSemanticTokens,
@@ -4228,10 +4277,12 @@ export {
4228
4277
  collectBrandPassthrough,
4229
4278
  componentTokenName,
4230
4279
  compositeOverBackground,
4280
+ declaresWeights,
4231
4281
  exportTheme,
4232
4282
  extractFromCSS,
4233
4283
  formatFontCoverageError,
4234
4284
  generateDarkCss,
4285
+ generateFontWeightDecls,
4235
4286
  generateFullBundleCss,
4236
4287
  generateLightCss,
4237
4288
  generatePreloadLinks,
@@ -4255,8 +4306,10 @@ export {
4255
4306
  isValidColor,
4256
4307
  isValidHex,
4257
4308
  isVisorThemeConfig,
4309
+ loadedWeights,
4258
4310
  lookupFontWeightAlias,
4259
4311
  lookupGoogleFont,
4312
+ matchLoadedWeight,
4260
4313
  normalizeHex,
4261
4314
  oklchToHex,
4262
4315
  parseCSSDeclarations,
@@ -4272,8 +4325,10 @@ export {
4272
4325
  resolveComponentBindings,
4273
4326
  resolveConfig,
4274
4327
  resolveFont,
4328
+ resolveRoleWeight,
4275
4329
  resolveThemeBrand,
4276
4330
  resolveThemeFonts,
4331
+ resolveWeightRamp,
4277
4332
  rgbToHex,
4278
4333
  serializeBrandStrategy,
4279
4334
  serializeColor,
@@ -678,6 +678,12 @@ interface VisorThemeConfig {
678
678
  info?: string;
679
679
  };
680
680
  typography?: {
681
+ /**
682
+ * Type-scale multiplier applied to the `--font-size-*` ramp (VI-638).
683
+ * Every step is multiplied by it, and the page inherits the scaled
684
+ * `--font-size-base`. CSS adapters only — the Flutter adapter emits
685
+ * Material 3 slot sizes verbatim. Default: 1.
686
+ */
681
687
  scale?: number;
682
688
  /**
683
689
  * VI-375: text color placed on a LIGHT interactive background. Auto-picked
@@ -867,6 +873,12 @@ interface ResolvedThemeConfig {
867
873
  };
868
874
  "colors-dark"?: VisorThemeConfig["colors-dark"];
869
875
  typography: {
876
+ /**
877
+ * Type-scale multiplier applied to the `--font-size-*` ramp (VI-638).
878
+ * Every step is multiplied by it, and the page inherits the scaled
879
+ * `--font-size-base`. CSS adapters only — the Flutter adapter emits
880
+ * Material 3 slot sizes verbatim. Default: 1.
881
+ */
870
882
  scale: number;
871
883
  /**
872
884
  * VI-375: resolved default text color for LIGHT interactive backgrounds.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loworbitstudio/visor-theme-engine",
3
- "version": "0.19.0",
3
+ "version": "0.20.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",
@@ -187,7 +187,7 @@
187
187
  },
188
188
  "scale": {
189
189
  "type": "number",
190
- "description": "Type scale multiplier applied to the font-size ramp. Default: 1."
190
+ "description": "Type scale multiplier applied to the font-size ramp: every --font-size-* step is multiplied by it, and the page inherits the scaled --font-size-base. Default: 1. CSS adapters only — the Flutter adapter emits Material 3 slot sizes verbatim and ignores this field."
191
191
  },
192
192
  "text-on-light": {
193
193
  "type": "string",