@motion-proto/live-tokens 0.55.1 → 0.57.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.
- package/.claude/skills/{live-tokens-adjust-shape-space → live-tokens-adjust-geometry}/SKILL.md +5 -5
- package/.claude/skills/live-tokens-build-page/SKILL.md +2 -0
- package/.claude/skills/live-tokens-create-component/SKILL.md +20 -357
- package/.claude/skills/live-tokens-create-component/references/fixed-overlays.md +3 -0
- package/.claude/skills/live-tokens-create-component/references/intrinsics.md +58 -0
- package/.claude/skills/live-tokens-create-component/references/linked-siblings.md +70 -0
- package/.claude/skills/live-tokens-generate-theme/SKILL.md +79 -98
- package/.claude/skills/live-tokens-generate-theme/references/named-themes.md +18 -0
- package/.claude/skills/live-tokens-pair-fonts/SKILL.md +88 -0
- package/.claude/skills/live-tokens-pick-component/SKILL.md +4 -1
- package/CHANGELOG.md +197 -0
- package/README.md +21 -8
- package/bin/cli.mjs +42 -3
- package/bin/set-fonts.mjs +280 -0
- package/dist-plugin/adjust/index.cjs +1 -0
- package/dist-plugin/adjust/index.d.cts +3 -3
- package/dist-plugin/adjust/index.d.ts +3 -3
- package/dist-plugin/adjust/index.js +1 -1
- package/dist-plugin/{chunk-TKZBVIW5.js → chunk-E5QYON4L.js} +59 -2
- package/dist-plugin/{chunk-PB2JTK2H.js → chunk-LW4SR7AZ.js} +39 -6
- package/dist-plugin/{chunk-YR6GPXW2.js → chunk-XWXIMTWZ.js} +0 -5
- package/dist-plugin/{chunk-D3ZVKOR4.js → chunk-Y5CNFSSV.js} +1 -0
- package/dist-plugin/{dataPaths-DBN0RPuT.d.cts → dataPaths-CRfD1LdA.d.cts} +3 -0
- package/dist-plugin/{dataPaths-DBN0RPuT.d.ts → dataPaths-CRfD1LdA.d.ts} +3 -0
- package/dist-plugin/fontPairing/index.cjs +413 -0
- package/dist-plugin/fontPairing/index.d.cts +109 -0
- package/dist-plugin/fontPairing/index.d.ts +109 -0
- package/dist-plugin/fontPairing/index.js +321 -0
- package/dist-plugin/generateColorsAndType/index.cjs +22 -9
- package/dist-plugin/generateColorsAndType/index.d.cts +3 -3
- package/dist-plugin/generateColorsAndType/index.d.ts +3 -3
- package/dist-plugin/generateColorsAndType/index.js +8 -5
- package/dist-plugin/index-4N-Orzzi.d.cts +3 -0
- package/dist-plugin/index-4N-Orzzi.d.ts +3 -0
- package/dist-plugin/index.cjs +158 -9
- package/dist-plugin/index.d.cts +1 -1
- package/dist-plugin/index.d.ts +1 -1
- package/dist-plugin/index.js +71 -4
- package/dist-plugin/migrateData/index.cjs +1 -0
- package/dist-plugin/migrateData/index.d.cts +1 -1
- package/dist-plugin/migrateData/index.d.ts +1 -1
- package/dist-plugin/migrateData/index.js +1 -1
- package/dist-plugin/{index-DpTIRZ2H.d.cts → themeTypes-DSV3Zisf.d.cts} +13 -4
- package/dist-plugin/{index-DpTIRZ2H.d.ts → themeTypes-DSV3Zisf.d.ts} +13 -4
- package/dist-plugin/tokensCssMigrations/index.cjs +58 -1
- package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
- package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
- package/dist-plugin/tokensCssMigrations/index.js +3 -3
- package/package.json +5 -2
- package/src/editor/core/fonts/applyFontPairing.ts +158 -0
- package/src/editor/core/fonts/fontLoader.ts +1 -0
- package/src/editor/core/fonts/fontMigration.ts +31 -1
- package/src/editor/core/fonts/fontPairing.ts +13 -6
- package/src/editor/core/fonts/googleFontsUrl.ts +123 -0
- package/src/editor/core/fonts/weightCoverage.ts +92 -0
- package/src/editor/core/palettes/paletteDerivation.ts +9 -2
- package/src/editor/core/sketch/maskField.ts +381 -0
- package/src/editor/core/sketch/sketchLayer.ts +1028 -0
- package/src/editor/core/sketch/sketchPresetService.ts +65 -0
- package/src/editor/core/sketch/sketchPresets.ts +328 -0
- package/src/editor/core/sketch/sketchStore.ts +190 -0
- package/src/editor/core/store/editorPersistence.ts +28 -12
- package/src/editor/core/store/editorTypes.ts +4 -1
- package/src/editor/core/store/editorViewStore.ts +2 -2
- package/src/editor/core/store/gradientSource.ts +15 -1
- package/src/editor/core/themes/parsers/gradient.ts +62 -4
- package/src/editor/core/themes/slices/gradients.ts +75 -14
- package/src/editor/core/themes/themeTypes.ts +6 -1
- package/src/editor/docs/Docs.svelte +4 -3
- package/src/editor/docs/chapters.ts +1 -0
- package/src/editor/docs/content/01-overview.md +2 -1
- package/src/editor/docs/content/editing-tokens.md +5 -1
- package/src/editor/docs/content/sketch-mode.md +89 -0
- package/src/editor/docs/content/themes-workflow.md +39 -0
- package/src/editor/docs/content.generated.ts +4 -3
- package/src/editor/overlay/LiveEditorOverlay.svelte +15 -0
- package/src/editor/pages/EditorShell.svelte +43 -0
- package/src/editor/ui/EditorViewSwitcher.svelte +15 -2
- package/src/editor/ui/FontStackEditor.svelte +3 -0
- package/src/editor/ui/GradientEditor.svelte +38 -1
- package/src/editor/ui/ProjectFontsSection.svelte +24 -36
- package/src/editor/ui/UIReveal.svelte +7 -1
- package/src/editor/ui/UISegmentedControl.svelte +4 -8
- package/src/editor/ui/sections/GradientsSection.svelte +47 -1
- package/src/editor/ui/sketch/SketchDial.svelte +124 -0
- package/src/editor/ui/sketch/SketchPreview.svelte +119 -0
- package/src/editor/ui/sketch/SketchRange.svelte +185 -0
- package/src/editor/ui/sketch/SketchTab.svelte +1263 -0
- package/src/live-tokens/data/colors-and-type/autumn.json +17 -0
- package/src/live-tokens/data/colors-and-type/default.json +17 -0
- package/src/live-tokens/data/colors-and-type/halloween.json +17 -0
- package/src/live-tokens/data/colors-and-type/midnight-study.json +17 -0
- package/src/live-tokens/data/colors-and-type/ocean.json +17 -0
- package/src/live-tokens/data/colors-and-type/royal-velvet.json +17 -0
- package/src/live-tokens/data/colors-and-type/sketches.json +2520 -0
- package/src/live-tokens/data/colors-and-type/spring-meadow.json +17 -0
- package/src/live-tokens/data/colors-and-type/sunset.json +17 -0
- package/src/live-tokens/data/themes/autumn.json +17 -0
- package/src/live-tokens/data/themes/halloween.json +17 -0
- package/src/live-tokens/data/themes/midnight-study.json +17 -0
- package/src/live-tokens/data/themes/ocean.json +17 -0
- package/src/live-tokens/data/themes/royal-velvet.json +17 -0
- package/src/live-tokens/data/themes/sketches.json +3984 -0
- package/src/live-tokens/data/themes/spring-meadow.json +17 -0
- package/src/live-tokens/data/themes/sunset.json +17 -0
- package/src/live-tokens/data/tokens.generated.css +1 -0
- package/src/system/styles/tokens.css +15 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motion-proto/live-tokens",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
|
|
6
6
|
"keywords": [
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"src/live-tokens/data/colors-and-type/midnight-study.json",
|
|
31
31
|
"src/live-tokens/data/colors-and-type/ocean.json",
|
|
32
32
|
"src/live-tokens/data/colors-and-type/royal-velvet.json",
|
|
33
|
+
"src/live-tokens/data/colors-and-type/sketches.json",
|
|
33
34
|
"src/live-tokens/data/colors-and-type/spring-meadow.json",
|
|
34
35
|
"src/live-tokens/data/colors-and-type/sunset.json",
|
|
35
36
|
"src/live-tokens/data/themes/autumn.json",
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
"src/live-tokens/data/themes/midnight-study.json",
|
|
38
39
|
"src/live-tokens/data/themes/ocean.json",
|
|
39
40
|
"src/live-tokens/data/themes/royal-velvet.json",
|
|
41
|
+
"src/live-tokens/data/themes/sketches.json",
|
|
40
42
|
"src/live-tokens/data/themes/spring-meadow.json",
|
|
41
43
|
"src/live-tokens/data/themes/sunset.json",
|
|
42
44
|
"dist-plugin",
|
|
@@ -129,13 +131,14 @@
|
|
|
129
131
|
"check:docs-content": "node scripts/sync-docs.mjs --check",
|
|
130
132
|
"check:token-contract": "node scripts/check-token-contract.mjs",
|
|
131
133
|
"check:preset-themes": "node scripts/check-preset-themes.mjs",
|
|
134
|
+
"check:skills": "node scripts/check-skills.mjs",
|
|
132
135
|
"sync:component-defaults": "node scripts/sync-component-defaults.mjs --write",
|
|
133
136
|
"sync:docs": "node scripts/sync-docs.mjs --write",
|
|
134
137
|
"seed:preset-theme": "node scripts/seed-preset-theme.mjs",
|
|
135
138
|
"collapse:theme": "node scripts/collapse-theme-to-default.mjs",
|
|
136
139
|
"check:smoke-install": "bash scripts/smoke-install.sh",
|
|
137
140
|
"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"
|
|
141
|
+
"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
142
|
},
|
|
140
143
|
"peerDependencies": {
|
|
141
144
|
"@sveltejs/vite-plugin-svelte": "^7.0",
|
|
@@ -0,0 +1,158 @@
|
|
|
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' | 'editorial';
|
|
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', 'editorial'];
|
|
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
|
+
editorial: '--font-editorial',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function slugifyFamily(name: string): string {
|
|
58
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function weightsFor(face: PairingFace): Pick<FontFamily, 'weights' | 'italics'> {
|
|
62
|
+
if (face.weights) {
|
|
63
|
+
return { weights: face.weights, ...(face.italics ? { italics: true } : {}) };
|
|
64
|
+
}
|
|
65
|
+
const parsed = parseGoogleFontsUrl(face.url)?.[0];
|
|
66
|
+
return {
|
|
67
|
+
...(parsed?.weights ? { weights: parsed.weights } : {}),
|
|
68
|
+
...(face.italics || parsed?.italics ? { italics: true } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sourceFor(face: PairingFace, idPrefix: string): FontSource {
|
|
73
|
+
const id = `${idPrefix}${slugifyFamily(face.name)}`;
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
kind: 'google',
|
|
77
|
+
url: face.url,
|
|
78
|
+
label: 'Google Fonts',
|
|
79
|
+
families: [
|
|
80
|
+
{
|
|
81
|
+
id: `${id}:${slugifyFamily(face.name)}`,
|
|
82
|
+
name: face.name,
|
|
83
|
+
cssName: `"${face.name}"`,
|
|
84
|
+
...weightsFor(face),
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function rewriteStack(stacks: FontStack[], variable: FontStackVariable, familyId: string): FontStack[] {
|
|
91
|
+
const stack = stacks.find((s) => s.variable === variable);
|
|
92
|
+
if (!stack) throw new Error(`colors and type have no ${variable} stack to rewrite`);
|
|
93
|
+
return stacks.map((s) =>
|
|
94
|
+
s === stack
|
|
95
|
+
? { ...s, slots: [{ kind: 'project' as const, familyId }, ...s.slots.filter((slot) => slot.kind !== 'project')] }
|
|
96
|
+
: s,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ApplyFontPairingOptions {
|
|
101
|
+
/** `src_preset_` for the shipped presets, so `check:preset-themes` can still
|
|
102
|
+
* tell a stamped source from one the user added. */
|
|
103
|
+
idPrefix?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Bind each named slot to a Google Fonts family: stamp the source, put that
|
|
108
|
+
* family at the head of the slot's stack, and keep every fallback behind it.
|
|
109
|
+
* Slots the pairing omits are left exactly as they are.
|
|
110
|
+
*/
|
|
111
|
+
export function applyFontPairing(
|
|
112
|
+
colorsAndType: ColorsAndType,
|
|
113
|
+
pairing: FontPairing,
|
|
114
|
+
{ idPrefix = 'src_google_' }: ApplyFontPairingOptions = {},
|
|
115
|
+
): FontPairingResult {
|
|
116
|
+
const slots = SLOT_ORDER.filter((slot) => pairing[slot]);
|
|
117
|
+
if (slots.length === 0) throw new Error('pairing names no slot to change');
|
|
118
|
+
|
|
119
|
+
const before = colorsAndType.fontStacks ?? [];
|
|
120
|
+
const beforeSources = colorsAndType.fontSources ?? [];
|
|
121
|
+
const beforeIndex = familyIndex(beforeSources);
|
|
122
|
+
|
|
123
|
+
const stamped = slots.map((slot) => ({ slot, source: sourceFor(pairing[slot]!, idPrefix) }));
|
|
124
|
+
const stampedIds = new Set(stamped.map((s) => s.source.id));
|
|
125
|
+
|
|
126
|
+
let stacks = before;
|
|
127
|
+
const changes: PairingChange[] = [];
|
|
128
|
+
for (const { slot, source } of stamped) {
|
|
129
|
+
const variable = SLOT_VARIABLES[slot];
|
|
130
|
+
const from = resolvedFaceName(before.find((s) => s.variable === variable), beforeIndex);
|
|
131
|
+
stacks = rewriteStack(stacks, variable, source.families[0].id);
|
|
132
|
+
changes.push({ slot, variable, from, to: source.families[0].name });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A source displaced from a rewritten stack would still ship as a
|
|
136
|
+
// render-blocking @import in every consumer's fonts.css, so drop any source
|
|
137
|
+
// no remaining stack references.
|
|
138
|
+
const referenced = new Set(
|
|
139
|
+
stacks
|
|
140
|
+
.flatMap((s) => s.slots)
|
|
141
|
+
.filter((s) => s.kind === 'project')
|
|
142
|
+
.map((s) => s.familyId),
|
|
143
|
+
);
|
|
144
|
+
const kept = [...beforeSources.filter((s) => !stampedIds.has(s.id)), ...stamped.map((s) => s.source)];
|
|
145
|
+
const sources = kept.filter((s) => s.families.some((f) => referenced.has(f.id)));
|
|
146
|
+
const dropped = kept
|
|
147
|
+
.filter((s) => !sources.includes(s))
|
|
148
|
+
.map((s) => ({ id: s.id, names: s.families.map((f) => f.name) }));
|
|
149
|
+
|
|
150
|
+
const next: ColorsAndType = { ...colorsAndType, fontSources: sources, fontStacks: stacks };
|
|
151
|
+
const changed =
|
|
152
|
+
JSON.stringify([beforeSources, before]) !== JSON.stringify([sources, stacks]);
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
colorsAndType: next,
|
|
156
|
+
report: { changes: changes.filter((c) => c.from !== c.to), dropped, changed },
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -115,9 +115,37 @@ export function defaultFontStacks(sources: FontSource[]): FontStack[] {
|
|
|
115
115
|
{ kind: 'generic', value: 'monospace' },
|
|
116
116
|
],
|
|
117
117
|
},
|
|
118
|
+
{
|
|
119
|
+
variable: '--font-editorial',
|
|
120
|
+
slots: [
|
|
121
|
+
...pick('Manrope'),
|
|
122
|
+
{ kind: 'system', preset: 'system-ui-sans' },
|
|
123
|
+
{ kind: 'generic', value: 'sans-serif' },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
118
126
|
];
|
|
119
127
|
}
|
|
120
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Seed `--font-editorial` from `--font-sans` on files written before the
|
|
131
|
+
* editorial role existed. The role ships as "the body face until a consumer
|
|
132
|
+
* repoints it", so cloning the body stack is the migration: a theme that never
|
|
133
|
+
* touches editorial keeps rendering exactly as it did.
|
|
134
|
+
*
|
|
135
|
+
* Presence-based like the rest of this module, so re-running is a no-op and no
|
|
136
|
+
* schemaVersion step is needed.
|
|
137
|
+
*/
|
|
138
|
+
function ensureEditorialStack(stacks: FontStack[]): boolean {
|
|
139
|
+
if (stacks.some((s) => s.variable === '--font-editorial')) return false;
|
|
140
|
+
const sans = stacks.find((s) => s.variable === '--font-sans');
|
|
141
|
+
if (!sans) return false;
|
|
142
|
+
stacks.push({
|
|
143
|
+
variable: '--font-editorial',
|
|
144
|
+
slots: sans.slots.map((slot) => ({ ...slot })),
|
|
145
|
+
});
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
121
149
|
// Older themes have `kind: 'font-face'` sources with embedded cssText that
|
|
122
150
|
// references local woff2 files. The url() resolution for those embedded refs
|
|
123
151
|
// is fragile in consumers, so swap any font-face source whose cssText is for
|
|
@@ -161,9 +189,11 @@ export function migrateColorsAndTypeFonts(colorsAndType: ColorsAndType): { migra
|
|
|
161
189
|
if (!colorsAndType.fontStacks || colorsAndType.fontStacks.length === 0) {
|
|
162
190
|
colorsAndType.fontStacks = defaultFontStacks(colorsAndType.fontSources);
|
|
163
191
|
migrated = true;
|
|
192
|
+
} else if (ensureEditorialStack(colorsAndType.fontStacks)) {
|
|
193
|
+
migrated = true;
|
|
164
194
|
}
|
|
165
195
|
if (colorsAndType.cssVariables) {
|
|
166
|
-
for (const key of ['--font-display', '--font-sans', '--font-serif', '--font-mono']) {
|
|
196
|
+
for (const key of ['--font-display', '--font-sans', '--font-serif', '--font-mono', '--font-editorial']) {
|
|
167
197
|
if (key in colorsAndType.cssVariables) {
|
|
168
198
|
delete colorsAndType.cssVariables[key];
|
|
169
199
|
migrated = true;
|
|
@@ -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
|
|
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 =
|
|
29
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { parseFontFaceText } from './fontParse';
|
|
2
|
+
|
|
3
|
+
export interface CssResponse {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
status: number;
|
|
6
|
+
text: () => Promise<string>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type CssFetcher = (url: string) => Promise<CssResponse>;
|
|
10
|
+
|
|
11
|
+
export interface ResolvedGoogleFont {
|
|
12
|
+
/** As Google spells it in the returned CSS, not as the caller typed it. */
|
|
13
|
+
name: string;
|
|
14
|
+
url: string;
|
|
15
|
+
weights: number[];
|
|
16
|
+
italics: boolean;
|
|
17
|
+
probeUrl: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The shipped `--font-weight-*` scale runs 100 to 900 in hundreds, so probing
|
|
21
|
+
* that ladder discovers every weight a theme can ask for. */
|
|
22
|
+
const LADDER = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
|
23
|
+
|
|
24
|
+
function familyParam(name: string): string {
|
|
25
|
+
return name.trim().replace(/\s+/g, '+');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function url(name: string, spec?: string): string {
|
|
29
|
+
const family = spec ? `${familyParam(name)}:${spec}` : familyParam(name);
|
|
30
|
+
return `https://fonts.googleapis.com/css2?family=${family}&display=swap`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Ask for every weight in both styles. Google silently drops the ones a family
|
|
35
|
+
* does not have and answers 200 anyway, so the returned CSS is a census of the
|
|
36
|
+
* family rather than a yes/no on the request. A 400 here means the family
|
|
37
|
+
* itself is not on Google Fonts.
|
|
38
|
+
*/
|
|
39
|
+
export function discoveryUrl(name: string): string {
|
|
40
|
+
const pairs = [
|
|
41
|
+
...LADDER.map((w) => `0,${w}`),
|
|
42
|
+
...LADDER.map((w) => `1,${w}`),
|
|
43
|
+
];
|
|
44
|
+
return url(name, `ital,wght@${pairs.join(';')}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isContiguous(weights: number[]): boolean {
|
|
48
|
+
return weights.length > 1 && weights.every((w, i) => i === 0 || w - weights[i - 1] === 100);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The narrowest URL that still delivers everything the family has. A range
|
|
52
|
+
* serves one variable file; an enumeration serves one file per weight. */
|
|
53
|
+
export function persistUrlFor(name: string, weights: number[], italics: boolean): string {
|
|
54
|
+
if (weights.length === 0 || (weights.length === 1 && weights[0] === 400 && !italics)) {
|
|
55
|
+
return url(name);
|
|
56
|
+
}
|
|
57
|
+
if (isContiguous(weights)) {
|
|
58
|
+
const range = `${weights[0]}..${weights[weights.length - 1]}`;
|
|
59
|
+
return url(name, italics ? `ital,wght@0,${range};1,${range}` : `wght@${range}`);
|
|
60
|
+
}
|
|
61
|
+
const spec = italics
|
|
62
|
+
? `ital,wght@${[...weights.map((w) => `0,${w}`), ...weights.map((w) => `1,${w}`)].join(';')}`
|
|
63
|
+
: `wght@${weights.join(';')}`;
|
|
64
|
+
return url(name, spec);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function censusFrom(css: string, requested: string): { name: string; weights: number[]; italics: boolean } {
|
|
68
|
+
const families = parseFontFaceText(css);
|
|
69
|
+
if (families.length === 0) {
|
|
70
|
+
throw new Error(`Google Fonts returned no @font-face rules for "${requested}".`);
|
|
71
|
+
}
|
|
72
|
+
const family = families[0];
|
|
73
|
+
return {
|
|
74
|
+
name: family.name,
|
|
75
|
+
weights: family.weights ?? [],
|
|
76
|
+
italics: family.italics === true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Verify a family exists on Google Fonts and settle on the URL to persist.
|
|
82
|
+
* Two requests in the common case: one to take the census, one to confirm the
|
|
83
|
+
* narrower URL built from it. A range that the family's axis cannot serve
|
|
84
|
+
* answers 400, so the enumerated form is the fallback rather than the guess.
|
|
85
|
+
*/
|
|
86
|
+
export async function resolveGoogleFont(name: string, fetcher: CssFetcher): Promise<ResolvedGoogleFont> {
|
|
87
|
+
const probeUrl = discoveryUrl(name);
|
|
88
|
+
const probe = await fetcher(probeUrl);
|
|
89
|
+
if (!probe.ok) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`"${name}" is not on Google Fonts (the API answered ${probe.status}). ` +
|
|
92
|
+
`Check the spelling against fonts.google.com.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const census = censusFrom(await probe.text(), name);
|
|
96
|
+
|
|
97
|
+
const candidates = [persistUrlFor(census.name, census.weights, census.italics)];
|
|
98
|
+
const enumerated = census.weights.length > 0
|
|
99
|
+
? url(
|
|
100
|
+
census.name,
|
|
101
|
+
census.italics
|
|
102
|
+
? `ital,wght@${[...census.weights.map((w) => `0,${w}`), ...census.weights.map((w) => `1,${w}`)].join(';')}`
|
|
103
|
+
: `wght@${census.weights.join(';')}`,
|
|
104
|
+
)
|
|
105
|
+
: null;
|
|
106
|
+
if (enumerated && !candidates.includes(enumerated)) candidates.push(enumerated);
|
|
107
|
+
const bare = url(census.name);
|
|
108
|
+
if (!candidates.includes(bare)) candidates.push(bare);
|
|
109
|
+
|
|
110
|
+
for (const candidate of candidates) {
|
|
111
|
+
const res = await fetcher(candidate);
|
|
112
|
+
if (!res.ok) continue;
|
|
113
|
+
const served = censusFrom(await res.text(), census.name);
|
|
114
|
+
return {
|
|
115
|
+
name: census.name,
|
|
116
|
+
url: candidate,
|
|
117
|
+
weights: served.weights,
|
|
118
|
+
italics: served.italics,
|
|
119
|
+
probeUrl,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`Could not build a working Google Fonts URL for "${census.name}".`);
|
|
123
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { FontStackVariable } from '../themes/themeTypes';
|
|
2
|
+
import { inferFontFamilyVariable } from './fontWeightAvailability';
|
|
3
|
+
|
|
4
|
+
export interface WeightRequirement {
|
|
5
|
+
variable: string;
|
|
6
|
+
stack: FontStackVariable;
|
|
7
|
+
weight: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FaceCoverage {
|
|
11
|
+
stack: FontStackVariable;
|
|
12
|
+
family: string;
|
|
13
|
+
required: number[];
|
|
14
|
+
available: number[];
|
|
15
|
+
missing: number[];
|
|
16
|
+
italics: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const STACK_REF = /var\(\s*(--font-(?:display|sans|serif|mono))\s*\)/;
|
|
20
|
+
const VAR_REF = /var\(\s*(--[a-z0-9-]+)/i;
|
|
21
|
+
|
|
22
|
+
function declarations(css: string): Map<string, string> {
|
|
23
|
+
const decls = new Map<string, string>();
|
|
24
|
+
for (const [, name, value] of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;{}]+);/gi)) {
|
|
25
|
+
decls.set(name, value.trim());
|
|
26
|
+
}
|
|
27
|
+
return decls;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every weight the token contract asks of each font stack, read off the
|
|
32
|
+
* `-font-family` / `-font-weight` pairing that typography tokens follow.
|
|
33
|
+
* `overrides` takes a theme's own `cssVariables`, so component tokens count
|
|
34
|
+
* the same as the shipped text styles.
|
|
35
|
+
*/
|
|
36
|
+
export function requiredWeights(
|
|
37
|
+
tokensCss: string,
|
|
38
|
+
overrides: Record<string, string> = {},
|
|
39
|
+
): WeightRequirement[] {
|
|
40
|
+
const decls = declarations(tokensCss);
|
|
41
|
+
for (const [name, value] of Object.entries(overrides)) decls.set(name, String(value).trim());
|
|
42
|
+
|
|
43
|
+
const scale = new Map<string, number>();
|
|
44
|
+
for (const [name, value] of decls) {
|
|
45
|
+
if (name.startsWith('--font-weight-') && /^\d+$/.test(value)) scale.set(name, Number(value));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
const out: WeightRequirement[] = [];
|
|
50
|
+
for (const [name, value] of decls) {
|
|
51
|
+
const familyVariable = inferFontFamilyVariable(name);
|
|
52
|
+
if (!familyVariable) continue;
|
|
53
|
+
const stack = decls.get(familyVariable)?.match(STACK_REF)?.[1] as FontStackVariable | undefined;
|
|
54
|
+
if (!stack) continue;
|
|
55
|
+
|
|
56
|
+
const weight = /^\d+$/.test(value) ? Number(value) : scale.get(value.match(VAR_REF)?.[1] ?? '');
|
|
57
|
+
if (weight === undefined) continue;
|
|
58
|
+
|
|
59
|
+
const key = `${stack}:${weight}`;
|
|
60
|
+
if (seen.has(key)) continue;
|
|
61
|
+
seen.add(key);
|
|
62
|
+
out.push({ variable: name, stack, weight });
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CoveredFace {
|
|
68
|
+
name: string;
|
|
69
|
+
weights: number[];
|
|
70
|
+
italics: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function weightCoverage(
|
|
74
|
+
requirements: WeightRequirement[],
|
|
75
|
+
faces: Partial<Record<FontStackVariable, CoveredFace>>,
|
|
76
|
+
): FaceCoverage[] {
|
|
77
|
+
const out: FaceCoverage[] = [];
|
|
78
|
+
for (const [stack, face] of Object.entries(faces) as [FontStackVariable, CoveredFace][]) {
|
|
79
|
+
const required = [...new Set(requirements.filter((r) => r.stack === stack).map((r) => r.weight))].sort(
|
|
80
|
+
(a, b) => a - b,
|
|
81
|
+
);
|
|
82
|
+
out.push({
|
|
83
|
+
stack,
|
|
84
|
+
family: face.name,
|
|
85
|
+
required,
|
|
86
|
+
available: face.weights,
|
|
87
|
+
missing: required.filter((w) => !face.weights.includes(w)),
|
|
88
|
+
italics: face.italics,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* derives the same CSS vars from the persisted config.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { cssColorToOklch,
|
|
16
|
+
import { cssColorToOklch, oklchToCss, gamutClamp, type Oklch } from './oklch';
|
|
17
17
|
import { type CurveAnchor, sampleCurve, makeAnchor, tangentAnchor, resmoothAutoCurve } from '../../ui/curveEngine';
|
|
18
18
|
import type { PaletteConfig } from '../themes/themeTypes';
|
|
19
19
|
|
|
@@ -204,7 +204,14 @@ export type DerivedValue =
|
|
|
204
204
|
* then serialized as `oklch()`. Idempotent on the already-in-gamut per-step
|
|
205
205
|
* derivation output. `raw` passes through untouched. */
|
|
206
206
|
export function serializeDerivedValue(value: DerivedValue): string {
|
|
207
|
-
|
|
207
|
+
// Emitted unclamped: the stored basis is OKLCH, and clamping into sRGB threw
|
|
208
|
+
// away chroma a wide-gamut display can actually show. Values already inside
|
|
209
|
+
// sRGB serialize identically (`gamutClamp` returns them untouched), so this
|
|
210
|
+
// moves nothing in an existing theme; only intent authored beyond sRGB — a
|
|
211
|
+
// Tailwind v4 ramp, say — now survives to the browser, which does its own
|
|
212
|
+
// gamut mapping at paint. `oklchToCssClamped` still backs the hex readouts,
|
|
213
|
+
// where a clamped projection is the whole point.
|
|
214
|
+
return value.kind === 'raw' ? value.css : oklchToCss(value.l, value.c, value.h);
|
|
208
215
|
}
|
|
209
216
|
|
|
210
217
|
export function computePaletteOklch(
|