@motion-proto/live-tokens 0.55.0 → 0.56.1

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.
Files changed (38) hide show
  1. package/.claude/skills/{live-tokens-adjust-shape-space → live-tokens-adjust-geometry}/SKILL.md +5 -5
  2. package/.claude/skills/live-tokens-build-page/SKILL.md +2 -0
  3. package/.claude/skills/live-tokens-create-component/SKILL.md +20 -357
  4. package/.claude/skills/live-tokens-create-component/references/fixed-overlays.md +3 -0
  5. package/.claude/skills/live-tokens-create-component/references/intrinsics.md +58 -0
  6. package/.claude/skills/live-tokens-create-component/references/linked-siblings.md +70 -0
  7. package/.claude/skills/live-tokens-generate-theme/SKILL.md +79 -98
  8. package/.claude/skills/live-tokens-generate-theme/references/named-themes.md +18 -0
  9. package/.claude/skills/live-tokens-pair-fonts/SKILL.md +88 -0
  10. package/.claude/skills/live-tokens-pick-component/SKILL.md +4 -1
  11. package/CHANGELOG.md +108 -0
  12. package/README.md +20 -7
  13. package/bin/cli.mjs +41 -3
  14. package/bin/set-fonts.mjs +280 -0
  15. package/dist-plugin/adjust/index.d.cts +2 -2
  16. package/dist-plugin/adjust/index.d.ts +2 -2
  17. package/dist-plugin/fontPairing/index.cjs +411 -0
  18. package/dist-plugin/fontPairing/index.d.cts +109 -0
  19. package/dist-plugin/fontPairing/index.d.ts +109 -0
  20. package/dist-plugin/fontPairing/index.js +320 -0
  21. package/dist-plugin/generateColorsAndType/index.d.cts +2 -2
  22. package/dist-plugin/generateColorsAndType/index.d.ts +2 -2
  23. package/dist-plugin/index-4N-Orzzi.d.cts +3 -0
  24. package/dist-plugin/index-4N-Orzzi.d.ts +3 -0
  25. package/dist-plugin/{index-DpTIRZ2H.d.cts → themeTypes-BAqtv4XO.d.cts} +1 -3
  26. package/dist-plugin/{index-DpTIRZ2H.d.ts → themeTypes-BAqtv4XO.d.ts} +1 -3
  27. package/package.json +3 -2
  28. package/src/editor/core/fonts/applyFontPairing.ts +157 -0
  29. package/src/editor/core/fonts/fontPairing.ts +13 -6
  30. package/src/editor/core/fonts/googleFontsUrl.ts +123 -0
  31. package/src/editor/core/fonts/weightCoverage.ts +92 -0
  32. package/src/editor/docs/content/themes-workflow.md +39 -0
  33. package/src/editor/docs/content.generated.ts +1 -1
  34. package/src/editor/ui/ColorEditPanel.svelte +16 -1
  35. package/src/editor/ui/PaletteEditor.svelte +124 -31
  36. package/src/editor/ui/ProjectFontsSection.svelte +24 -36
  37. package/src/editor/ui/palette/OverridesPanel.svelte +5 -2
  38. package/src/editor/ui/palette/PaletteBase.svelte +13 -0
@@ -0,0 +1,320 @@
1
+ import {
2
+ readLiveTokensConfig,
3
+ resolveDataDirs
4
+ } from "../chunk-D3ZVKOR4.js";
5
+
6
+ // src/editor/core/fonts/fontParse.ts
7
+ function parseGoogleFontsUrl(url2) {
8
+ let u;
9
+ try {
10
+ u = new URL(url2);
11
+ } catch {
12
+ return null;
13
+ }
14
+ if (!u.hostname.includes("fonts.googleapis.com")) return null;
15
+ const familyParams = u.searchParams.getAll("family");
16
+ if (familyParams.length === 0) return null;
17
+ return familyParams.map((raw) => {
18
+ const [rawName, spec] = raw.split(":");
19
+ const name = decodeURIComponent(rawName).replace(/\+/g, " ");
20
+ const parsed = { name };
21
+ if (spec) {
22
+ const axisMatch = spec.match(/(?:ital,)?wght@([^&]+)/);
23
+ const italics = spec.startsWith("ital,");
24
+ if (italics) parsed.italics = true;
25
+ if (axisMatch) {
26
+ const tokens = axisMatch[1].split(";");
27
+ const weights = /* @__PURE__ */ new Set();
28
+ for (const tok of tokens) {
29
+ const parts = tok.split(",");
30
+ const wghtStr = parts.length === 2 ? parts[1] : parts[0];
31
+ if (wghtStr.includes("..")) {
32
+ const [lo, hi] = wghtStr.split("..").map((n) => parseInt(n, 10));
33
+ if (!isNaN(lo) && !isNaN(hi)) {
34
+ for (let w = lo; w <= hi; w += 100) weights.add(w);
35
+ }
36
+ } else {
37
+ const w = parseInt(wghtStr, 10);
38
+ if (!isNaN(w)) weights.add(w);
39
+ }
40
+ }
41
+ if (weights.size > 0) parsed.weights = [...weights].sort((a, b) => a - b);
42
+ }
43
+ }
44
+ return parsed;
45
+ });
46
+ }
47
+ function parseFontFaceText(css) {
48
+ const rules = [...css.matchAll(/@font-face\s*\{([^}]*)\}/g)];
49
+ const byName = /* @__PURE__ */ new Map();
50
+ for (const [, body] of rules) {
51
+ const nameMatch = body.match(/font-family\s*:\s*([^;]+);/i);
52
+ if (!nameMatch) continue;
53
+ const name = nameMatch[1].trim().replace(/^['"]|['"]$/g, "");
54
+ const weightMatch = body.match(/font-weight\s*:\s*([^;]+);/i);
55
+ const styleMatch = body.match(/font-style\s*:\s*([^;]+);/i);
56
+ const entry = byName.get(name) ?? { weights: /* @__PURE__ */ new Set(), italics: false };
57
+ if (weightMatch) {
58
+ const val = weightMatch[1].trim();
59
+ const rangeParts = val.split(/\s+/).map((s) => parseInt(s, 10)).filter((n) => !isNaN(n));
60
+ if (rangeParts.length === 2) {
61
+ const [lo, hi] = rangeParts;
62
+ for (let w = lo; w <= hi; w += 100) entry.weights.add(w);
63
+ } else if (rangeParts.length === 1) {
64
+ entry.weights.add(rangeParts[0]);
65
+ }
66
+ }
67
+ if (styleMatch && /italic|oblique/i.test(styleMatch[1])) entry.italics = true;
68
+ byName.set(name, entry);
69
+ }
70
+ return [...byName.entries()].map(([name, e]) => ({
71
+ name,
72
+ ...e.weights.size > 0 ? { weights: [...e.weights].sort((a, b) => a - b) } : {},
73
+ ...e.italics ? { italics: true } : {}
74
+ }));
75
+ }
76
+
77
+ // src/editor/core/fonts/fontPairing.ts
78
+ var PAIRING = ["--font-display", "--font-sans"];
79
+ function familyIndex(sources) {
80
+ const familyById = /* @__PURE__ */ new Map();
81
+ for (const source of sources) {
82
+ for (const family of source.families) familyById.set(family.id, family);
83
+ }
84
+ return familyById;
85
+ }
86
+ function resolvedFaceName(stack, familyById) {
87
+ for (const slot of stack?.slots ?? []) {
88
+ if (slot.kind === "project") {
89
+ const family = familyById.get(slot.familyId);
90
+ if (family) return family.name;
91
+ continue;
92
+ }
93
+ if (slot.kind === "system") return "System";
94
+ return slot.value;
95
+ }
96
+ return null;
97
+ }
98
+ function fontPairingLabel(stacks, sources) {
99
+ const familyById = familyIndex(sources);
100
+ return PAIRING.map((variable) => resolvedFaceName(stacks.find((s) => s.variable === variable), familyById)).filter((label) => label !== null).join(" / ");
101
+ }
102
+
103
+ // src/editor/core/fonts/applyFontPairing.ts
104
+ var SLOT_ORDER = ["display", "body", "serif", "mono"];
105
+ var SLOT_VARIABLES = {
106
+ display: "--font-display",
107
+ body: "--font-sans",
108
+ serif: "--font-serif",
109
+ mono: "--font-mono"
110
+ };
111
+ function slugifyFamily(name) {
112
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
113
+ }
114
+ function weightsFor(face) {
115
+ if (face.weights) {
116
+ return { weights: face.weights, ...face.italics ? { italics: true } : {} };
117
+ }
118
+ const parsed = parseGoogleFontsUrl(face.url)?.[0];
119
+ return {
120
+ ...parsed?.weights ? { weights: parsed.weights } : {},
121
+ ...face.italics || parsed?.italics ? { italics: true } : {}
122
+ };
123
+ }
124
+ function sourceFor(face, idPrefix) {
125
+ const id = `${idPrefix}${slugifyFamily(face.name)}`;
126
+ return {
127
+ id,
128
+ kind: "google",
129
+ url: face.url,
130
+ label: "Google Fonts",
131
+ families: [
132
+ {
133
+ id: `${id}:${slugifyFamily(face.name)}`,
134
+ name: face.name,
135
+ cssName: `"${face.name}"`,
136
+ ...weightsFor(face)
137
+ }
138
+ ]
139
+ };
140
+ }
141
+ function rewriteStack(stacks, variable, familyId) {
142
+ const stack = stacks.find((s) => s.variable === variable);
143
+ if (!stack) throw new Error(`colors and type have no ${variable} stack to rewrite`);
144
+ return stacks.map(
145
+ (s) => s === stack ? { ...s, slots: [{ kind: "project", familyId }, ...s.slots.filter((slot) => slot.kind !== "project")] } : s
146
+ );
147
+ }
148
+ function applyFontPairing(colorsAndType, pairing, { idPrefix = "src_google_" } = {}) {
149
+ const slots = SLOT_ORDER.filter((slot) => pairing[slot]);
150
+ if (slots.length === 0) throw new Error("pairing names no slot to change");
151
+ const before = colorsAndType.fontStacks ?? [];
152
+ const beforeSources = colorsAndType.fontSources ?? [];
153
+ const beforeIndex = familyIndex(beforeSources);
154
+ const stamped = slots.map((slot) => ({ slot, source: sourceFor(pairing[slot], idPrefix) }));
155
+ const stampedIds = new Set(stamped.map((s) => s.source.id));
156
+ let stacks = before;
157
+ const changes = [];
158
+ for (const { slot, source } of stamped) {
159
+ const variable = SLOT_VARIABLES[slot];
160
+ const from = resolvedFaceName(before.find((s) => s.variable === variable), beforeIndex);
161
+ stacks = rewriteStack(stacks, variable, source.families[0].id);
162
+ changes.push({ slot, variable, from, to: source.families[0].name });
163
+ }
164
+ const referenced = new Set(
165
+ stacks.flatMap((s) => s.slots).filter((s) => s.kind === "project").map((s) => s.familyId)
166
+ );
167
+ const kept = [...beforeSources.filter((s) => !stampedIds.has(s.id)), ...stamped.map((s) => s.source)];
168
+ const sources = kept.filter((s) => s.families.some((f) => referenced.has(f.id)));
169
+ const dropped = kept.filter((s) => !sources.includes(s)).map((s) => ({ id: s.id, names: s.families.map((f) => f.name) }));
170
+ const next = { ...colorsAndType, fontSources: sources, fontStacks: stacks };
171
+ const changed = JSON.stringify([beforeSources, before]) !== JSON.stringify([sources, stacks]);
172
+ return {
173
+ colorsAndType: next,
174
+ report: { changes: changes.filter((c) => c.from !== c.to), dropped, changed }
175
+ };
176
+ }
177
+
178
+ // src/editor/core/fonts/googleFontsUrl.ts
179
+ var LADDER = [100, 200, 300, 400, 500, 600, 700, 800, 900];
180
+ function familyParam(name) {
181
+ return name.trim().replace(/\s+/g, "+");
182
+ }
183
+ function url(name, spec) {
184
+ const family = spec ? `${familyParam(name)}:${spec}` : familyParam(name);
185
+ return `https://fonts.googleapis.com/css2?family=${family}&display=swap`;
186
+ }
187
+ function discoveryUrl(name) {
188
+ const pairs = [
189
+ ...LADDER.map((w) => `0,${w}`),
190
+ ...LADDER.map((w) => `1,${w}`)
191
+ ];
192
+ return url(name, `ital,wght@${pairs.join(";")}`);
193
+ }
194
+ function isContiguous(weights) {
195
+ return weights.length > 1 && weights.every((w, i) => i === 0 || w - weights[i - 1] === 100);
196
+ }
197
+ function persistUrlFor(name, weights, italics) {
198
+ if (weights.length === 0 || weights.length === 1 && weights[0] === 400 && !italics) {
199
+ return url(name);
200
+ }
201
+ if (isContiguous(weights)) {
202
+ const range = `${weights[0]}..${weights[weights.length - 1]}`;
203
+ return url(name, italics ? `ital,wght@0,${range};1,${range}` : `wght@${range}`);
204
+ }
205
+ const spec = italics ? `ital,wght@${[...weights.map((w) => `0,${w}`), ...weights.map((w) => `1,${w}`)].join(";")}` : `wght@${weights.join(";")}`;
206
+ return url(name, spec);
207
+ }
208
+ function censusFrom(css, requested) {
209
+ const families = parseFontFaceText(css);
210
+ if (families.length === 0) {
211
+ throw new Error(`Google Fonts returned no @font-face rules for "${requested}".`);
212
+ }
213
+ const family = families[0];
214
+ return {
215
+ name: family.name,
216
+ weights: family.weights ?? [],
217
+ italics: family.italics === true
218
+ };
219
+ }
220
+ async function resolveGoogleFont(name, fetcher) {
221
+ const probeUrl = discoveryUrl(name);
222
+ const probe = await fetcher(probeUrl);
223
+ if (!probe.ok) {
224
+ throw new Error(
225
+ `"${name}" is not on Google Fonts (the API answered ${probe.status}). Check the spelling against fonts.google.com.`
226
+ );
227
+ }
228
+ const census = censusFrom(await probe.text(), name);
229
+ const candidates = [persistUrlFor(census.name, census.weights, census.italics)];
230
+ const enumerated = census.weights.length > 0 ? url(
231
+ census.name,
232
+ census.italics ? `ital,wght@${[...census.weights.map((w) => `0,${w}`), ...census.weights.map((w) => `1,${w}`)].join(";")}` : `wght@${census.weights.join(";")}`
233
+ ) : null;
234
+ if (enumerated && !candidates.includes(enumerated)) candidates.push(enumerated);
235
+ const bare = url(census.name);
236
+ if (!candidates.includes(bare)) candidates.push(bare);
237
+ for (const candidate of candidates) {
238
+ const res = await fetcher(candidate);
239
+ if (!res.ok) continue;
240
+ const served = censusFrom(await res.text(), census.name);
241
+ return {
242
+ name: census.name,
243
+ url: candidate,
244
+ weights: served.weights,
245
+ italics: served.italics,
246
+ probeUrl
247
+ };
248
+ }
249
+ throw new Error(`Could not build a working Google Fonts URL for "${census.name}".`);
250
+ }
251
+
252
+ // src/editor/core/fonts/fontWeightAvailability.ts
253
+ function inferFontFamilyVariable(weightVariable) {
254
+ return weightVariable.endsWith("-font-weight") ? `${weightVariable.slice(0, -"-font-weight".length)}-font-family` : null;
255
+ }
256
+
257
+ // src/editor/core/fonts/weightCoverage.ts
258
+ var STACK_REF = /var\(\s*(--font-(?:display|sans|serif|mono))\s*\)/;
259
+ var VAR_REF = /var\(\s*(--[a-z0-9-]+)/i;
260
+ function declarations(css) {
261
+ const decls = /* @__PURE__ */ new Map();
262
+ for (const [, name, value] of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;{}]+);/gi)) {
263
+ decls.set(name, value.trim());
264
+ }
265
+ return decls;
266
+ }
267
+ function requiredWeights(tokensCss, overrides = {}) {
268
+ const decls = declarations(tokensCss);
269
+ for (const [name, value] of Object.entries(overrides)) decls.set(name, String(value).trim());
270
+ const scale = /* @__PURE__ */ new Map();
271
+ for (const [name, value] of decls) {
272
+ if (name.startsWith("--font-weight-") && /^\d+$/.test(value)) scale.set(name, Number(value));
273
+ }
274
+ const seen = /* @__PURE__ */ new Set();
275
+ const out = [];
276
+ for (const [name, value] of decls) {
277
+ const familyVariable = inferFontFamilyVariable(name);
278
+ if (!familyVariable) continue;
279
+ const stack = decls.get(familyVariable)?.match(STACK_REF)?.[1];
280
+ if (!stack) continue;
281
+ const weight = /^\d+$/.test(value) ? Number(value) : scale.get(value.match(VAR_REF)?.[1] ?? "");
282
+ if (weight === void 0) continue;
283
+ const key = `${stack}:${weight}`;
284
+ if (seen.has(key)) continue;
285
+ seen.add(key);
286
+ out.push({ variable: name, stack, weight });
287
+ }
288
+ return out;
289
+ }
290
+ function weightCoverage(requirements, faces) {
291
+ const out = [];
292
+ for (const [stack, face] of Object.entries(faces)) {
293
+ const required = [...new Set(requirements.filter((r) => r.stack === stack).map((r) => r.weight))].sort(
294
+ (a, b) => a - b
295
+ );
296
+ out.push({
297
+ stack,
298
+ family: face.name,
299
+ required,
300
+ available: face.weights,
301
+ missing: required.filter((w) => !face.weights.includes(w)),
302
+ italics: face.italics
303
+ });
304
+ }
305
+ return out;
306
+ }
307
+ export {
308
+ SLOT_ORDER,
309
+ SLOT_VARIABLES,
310
+ applyFontPairing,
311
+ discoveryUrl,
312
+ fontPairingLabel,
313
+ persistUrlFor,
314
+ readLiveTokensConfig,
315
+ requiredWeights,
316
+ resolveDataDirs,
317
+ resolveGoogleFont,
318
+ slugifyFamily,
319
+ weightCoverage
320
+ };
@@ -1,6 +1,6 @@
1
- import { F as FontSource, c as FontStack, G as GradientDiskToken, O as Oklch, H as HarmonyMode, a as ColorsAndType } from '../index-DpTIRZ2H.cjs';
2
- export { C as CURRENT_COMPONENT_SCHEMA_VERSION } from '../index-DpTIRZ2H.cjs';
1
+ import { F as FontSource, b as FontStack, G as GradientDiskToken, O as Oklch, H as HarmonyMode, C as ColorsAndType } from '../themeTypes-BAqtv4XO.cjs';
3
2
  export { r as readLiveTokensConfig, a as resolveDataDirs } from '../dataPaths-DBN0RPuT.cjs';
3
+ export { C as CURRENT_COMPONENT_SCHEMA_VERSION } from '../index-4N-Orzzi.cjs';
4
4
 
5
5
  /**
6
6
  * Pure palette → CSS-variable derivation.
@@ -1,6 +1,6 @@
1
- import { F as FontSource, c as FontStack, G as GradientDiskToken, O as Oklch, H as HarmonyMode, a as ColorsAndType } from '../index-DpTIRZ2H.js';
2
- export { C as CURRENT_COMPONENT_SCHEMA_VERSION } from '../index-DpTIRZ2H.js';
1
+ import { F as FontSource, b as FontStack, G as GradientDiskToken, O as Oklch, H as HarmonyMode, C as ColorsAndType } from '../themeTypes-BAqtv4XO.js';
3
2
  export { r as readLiveTokensConfig, a as resolveDataDirs } from '../dataPaths-DBN0RPuT.js';
3
+ export { C as CURRENT_COMPONENT_SCHEMA_VERSION } from '../index-4N-Orzzi.js';
4
4
 
5
5
  /**
6
6
  * Pure palette → CSS-variable derivation.
@@ -0,0 +1,3 @@
1
+ declare const CURRENT_COMPONENT_SCHEMA_VERSION: number;
2
+
3
+ export { CURRENT_COMPONENT_SCHEMA_VERSION as C };
@@ -0,0 +1,3 @@
1
+ declare const CURRENT_COMPONENT_SCHEMA_VERSION: number;
2
+
3
+ export { CURRENT_COMPONENT_SCHEMA_VERSION as C };
@@ -246,6 +246,4 @@ interface ComponentConfig {
246
246
  schemaVersion?: number;
247
247
  }
248
248
 
249
- declare const CURRENT_COMPONENT_SCHEMA_VERSION: number;
250
-
251
- export { type AliasDiskValue as A, CURRENT_COMPONENT_SCHEMA_VERSION as C, type FontSource as F, type GradientDiskToken as G, type HarmonyMode as H, type Oklch as O, type ColorsAndType as a, type ComponentConfig as b, type FontStack as c };
249
+ export type { AliasDiskValue as A, ColorsAndType as C, FontSource as F, GradientDiskToken as G, HarmonyMode as H, Oklch as O, ComponentConfig as a, FontStack as b, FontStackVariable as c };
@@ -246,6 +246,4 @@ interface ComponentConfig {
246
246
  schemaVersion?: number;
247
247
  }
248
248
 
249
- declare const CURRENT_COMPONENT_SCHEMA_VERSION: number;
250
-
251
- export { type AliasDiskValue as A, CURRENT_COMPONENT_SCHEMA_VERSION as C, type FontSource as F, type GradientDiskToken as G, type HarmonyMode as H, type Oklch as O, type ColorsAndType as a, type ComponentConfig as b, type FontStack as c };
249
+ export type { AliasDiskValue as A, ColorsAndType as C, FontSource as F, GradientDiskToken as G, HarmonyMode as H, Oklch as O, ComponentConfig as a, FontStack as b, FontStackVariable as c };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.55.0",
3
+ "version": "0.56.1",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -129,13 +129,14 @@
129
129
  "check:docs-content": "node scripts/sync-docs.mjs --check",
130
130
  "check:token-contract": "node scripts/check-token-contract.mjs",
131
131
  "check:preset-themes": "node scripts/check-preset-themes.mjs",
132
+ "check:skills": "node scripts/check-skills.mjs",
132
133
  "sync:component-defaults": "node scripts/sync-component-defaults.mjs --write",
133
134
  "sync:docs": "node scripts/sync-docs.mjs --write",
134
135
  "seed:preset-theme": "node scripts/seed-preset-theme.mjs",
135
136
  "collapse:theme": "node scripts/collapse-theme-to-default.mjs",
136
137
  "check:smoke-install": "bash scripts/smoke-install.sh",
137
138
  "check:smoke-create": "bash scripts/smoke-create.sh",
138
- "prepublishOnly": "npm run check:no-style-imports && npm run check:no-tooling-imports && npm run check:slot-prose && npm run check:overlay-portal && npm run check:editor-font-isolation && npm run check:component-defaults && npm run check:production-is-default && npm run check:docs-content && npm run build:lib && npm run check:token-contract && npm run check:preset-themes && npm run check:smoke-install && npm run check:smoke-create"
139
+ "prepublishOnly": "npm run check:no-style-imports && npm run check:no-tooling-imports && npm run check:slot-prose && npm run check:overlay-portal && npm run check:editor-font-isolation && npm run check:component-defaults && npm run check:production-is-default && npm run check:docs-content && npm run build:lib && npm run check:token-contract && npm run check:preset-themes && npm run check:skills && npm run check:smoke-install && npm run check:smoke-create"
139
140
  },
140
141
  "peerDependencies": {
141
142
  "@sveltejs/vite-plugin-svelte": "^7.0",
@@ -0,0 +1,157 @@
1
+ import type {
2
+ ColorsAndType,
3
+ FontFamily,
4
+ FontSource,
5
+ FontStack,
6
+ FontStackVariable,
7
+ } from '../themes/themeTypes';
8
+ import { parseGoogleFontsUrl } from './fontParse';
9
+ import { familyIndex, resolvedFaceName } from './fontPairing';
10
+
11
+ export type PairingSlot = 'display' | 'body' | 'serif' | 'mono';
12
+
13
+ export interface PairingFace {
14
+ name: string;
15
+ url: string;
16
+ weights?: number[];
17
+ italics?: boolean;
18
+ }
19
+
20
+ export type FontPairing = Partial<Record<PairingSlot, PairingFace>>;
21
+
22
+ export interface PairingChange {
23
+ slot: PairingSlot;
24
+ variable: FontStackVariable;
25
+ from: string | null;
26
+ to: string;
27
+ }
28
+
29
+ export interface DroppedSource {
30
+ id: string;
31
+ names: string[];
32
+ }
33
+
34
+ export interface FontPairingReport {
35
+ changes: PairingChange[];
36
+ dropped: DroppedSource[];
37
+ changed: boolean;
38
+ }
39
+
40
+ export interface FontPairingResult {
41
+ colorsAndType: ColorsAndType;
42
+ report: FontPairingReport;
43
+ }
44
+
45
+ /** Slot order is also the order stamped sources land in `fontSources`;
46
+ * `check:preset-themes` reads the preset pair back out positionally. */
47
+ export const SLOT_ORDER: PairingSlot[] = ['display', 'body', 'serif', 'mono'];
48
+
49
+ export const SLOT_VARIABLES: Record<PairingSlot, FontStackVariable> = {
50
+ display: '--font-display',
51
+ body: '--font-sans',
52
+ serif: '--font-serif',
53
+ mono: '--font-mono',
54
+ };
55
+
56
+ export function slugifyFamily(name: string): string {
57
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
58
+ }
59
+
60
+ function weightsFor(face: PairingFace): Pick<FontFamily, 'weights' | 'italics'> {
61
+ if (face.weights) {
62
+ return { weights: face.weights, ...(face.italics ? { italics: true } : {}) };
63
+ }
64
+ const parsed = parseGoogleFontsUrl(face.url)?.[0];
65
+ return {
66
+ ...(parsed?.weights ? { weights: parsed.weights } : {}),
67
+ ...(face.italics || parsed?.italics ? { italics: true } : {}),
68
+ };
69
+ }
70
+
71
+ function sourceFor(face: PairingFace, idPrefix: string): FontSource {
72
+ const id = `${idPrefix}${slugifyFamily(face.name)}`;
73
+ return {
74
+ id,
75
+ kind: 'google',
76
+ url: face.url,
77
+ label: 'Google Fonts',
78
+ families: [
79
+ {
80
+ id: `${id}:${slugifyFamily(face.name)}`,
81
+ name: face.name,
82
+ cssName: `"${face.name}"`,
83
+ ...weightsFor(face),
84
+ },
85
+ ],
86
+ };
87
+ }
88
+
89
+ function rewriteStack(stacks: FontStack[], variable: FontStackVariable, familyId: string): FontStack[] {
90
+ const stack = stacks.find((s) => s.variable === variable);
91
+ if (!stack) throw new Error(`colors and type have no ${variable} stack to rewrite`);
92
+ return stacks.map((s) =>
93
+ s === stack
94
+ ? { ...s, slots: [{ kind: 'project' as const, familyId }, ...s.slots.filter((slot) => slot.kind !== 'project')] }
95
+ : s,
96
+ );
97
+ }
98
+
99
+ export interface ApplyFontPairingOptions {
100
+ /** `src_preset_` for the shipped presets, so `check:preset-themes` can still
101
+ * tell a stamped source from one the user added. */
102
+ idPrefix?: string;
103
+ }
104
+
105
+ /**
106
+ * Bind each named slot to a Google Fonts family: stamp the source, put that
107
+ * family at the head of the slot's stack, and keep every fallback behind it.
108
+ * Slots the pairing omits are left exactly as they are.
109
+ */
110
+ export function applyFontPairing(
111
+ colorsAndType: ColorsAndType,
112
+ pairing: FontPairing,
113
+ { idPrefix = 'src_google_' }: ApplyFontPairingOptions = {},
114
+ ): FontPairingResult {
115
+ const slots = SLOT_ORDER.filter((slot) => pairing[slot]);
116
+ if (slots.length === 0) throw new Error('pairing names no slot to change');
117
+
118
+ const before = colorsAndType.fontStacks ?? [];
119
+ const beforeSources = colorsAndType.fontSources ?? [];
120
+ const beforeIndex = familyIndex(beforeSources);
121
+
122
+ const stamped = slots.map((slot) => ({ slot, source: sourceFor(pairing[slot]!, idPrefix) }));
123
+ const stampedIds = new Set(stamped.map((s) => s.source.id));
124
+
125
+ let stacks = before;
126
+ const changes: PairingChange[] = [];
127
+ for (const { slot, source } of stamped) {
128
+ const variable = SLOT_VARIABLES[slot];
129
+ const from = resolvedFaceName(before.find((s) => s.variable === variable), beforeIndex);
130
+ stacks = rewriteStack(stacks, variable, source.families[0].id);
131
+ changes.push({ slot, variable, from, to: source.families[0].name });
132
+ }
133
+
134
+ // A source displaced from a rewritten stack would still ship as a
135
+ // render-blocking @import in every consumer's fonts.css, so drop any source
136
+ // no remaining stack references.
137
+ const referenced = new Set(
138
+ stacks
139
+ .flatMap((s) => s.slots)
140
+ .filter((s) => s.kind === 'project')
141
+ .map((s) => s.familyId),
142
+ );
143
+ const kept = [...beforeSources.filter((s) => !stampedIds.has(s.id)), ...stamped.map((s) => s.source)];
144
+ const sources = kept.filter((s) => s.families.some((f) => referenced.has(f.id)));
145
+ const dropped = kept
146
+ .filter((s) => !sources.includes(s))
147
+ .map((s) => ({ id: s.id, names: s.families.map((f) => f.name) }));
148
+
149
+ const next: ColorsAndType = { ...colorsAndType, fontSources: sources, fontStacks: stacks };
150
+ const changed =
151
+ JSON.stringify([beforeSources, before]) !== JSON.stringify([sources, stacks]);
152
+
153
+ return {
154
+ colorsAndType: next,
155
+ report: { changes: changes.filter((c) => c.from !== c.to), dropped, changed },
156
+ };
157
+ }
@@ -8,7 +8,17 @@ import type { FontFamily, FontSource, FontStack, FontStackVariable } from '../th
8
8
 
9
9
  const PAIRING: FontStackVariable[] = ['--font-display', '--font-sans'];
10
10
 
11
- function slotLabel(
11
+ export function familyIndex(sources: FontSource[]): Map<string, FontFamily> {
12
+ const familyById = new Map<string, FontFamily>();
13
+ for (const source of sources) {
14
+ for (const family of source.families) familyById.set(family.id, family);
15
+ }
16
+ return familyById;
17
+ }
18
+
19
+ /** The face the page actually shows for one stack: the first slot that
20
+ * resolves, named as the user named it. */
21
+ export function resolvedFaceName(
12
22
  stack: FontStack | undefined,
13
23
  familyById: Map<string, FontFamily>,
14
24
  ): string | null {
@@ -25,11 +35,8 @@ function slotLabel(
25
35
  }
26
36
 
27
37
  export function fontPairingLabel(stacks: FontStack[], sources: FontSource[]): string {
28
- const familyById = new Map<string, FontFamily>();
29
- for (const source of sources) {
30
- for (const family of source.families) familyById.set(family.id, family);
31
- }
32
- return PAIRING.map((variable) => slotLabel(stacks.find((s) => s.variable === variable), familyById))
38
+ const familyById = familyIndex(sources);
39
+ return PAIRING.map((variable) => resolvedFaceName(stacks.find((s) => s.variable === variable), familyById))
33
40
  .filter((label): label is string => label !== null)
34
41
  .join(' / ');
35
42
  }