@caelo-cms/shared 0.10.50 → 0.10.52

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 (42) hide show
  1. package/dist/ai-tools.d.ts +9 -0
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +6 -0
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/font-axis-policy.d.ts +174 -0
  6. package/dist/font-axis-policy.d.ts.map +1 -0
  7. package/dist/font-axis-policy.js +282 -0
  8. package/dist/font-axis-policy.js.map +1 -0
  9. package/dist/font-usage.d.ts +23 -0
  10. package/dist/font-usage.d.ts.map +1 -0
  11. package/dist/font-usage.js +381 -0
  12. package/dist/font-usage.js.map +1 -0
  13. package/dist/index.d.ts +3 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/media.d.ts +1 -1
  18. package/dist/static-cache-policy.d.ts +21 -7
  19. package/dist/static-cache-policy.d.ts.map +1 -1
  20. package/dist/static-cache-policy.js +24 -7
  21. package/dist/static-cache-policy.js.map +1 -1
  22. package/dist/themes.d.ts.map +1 -1
  23. package/dist/themes.js +6 -1
  24. package/dist/themes.js.map +1 -1
  25. package/dist/untrusted-tool-result.d.ts +28 -0
  26. package/dist/untrusted-tool-result.d.ts.map +1 -0
  27. package/dist/untrusted-tool-result.js +49 -0
  28. package/dist/untrusted-tool-result.js.map +1 -0
  29. package/dist/version.d.ts +2 -2
  30. package/dist/version.js +1 -1
  31. package/package.json +1 -1
  32. package/src/ai-tools.ts +8 -0
  33. package/src/font-axis-policy.test.ts +182 -0
  34. package/src/font-axis-policy.ts +372 -0
  35. package/src/font-usage.test.ts +100 -0
  36. package/src/font-usage.ts +395 -0
  37. package/src/index.ts +3 -0
  38. package/src/static-cache-policy.test.ts +33 -3
  39. package/src/static-cache-policy.ts +28 -8
  40. package/src/themes.ts +6 -1
  41. package/src/untrusted-tool-result.ts +55 -0
  42. package/src/version.ts +1 -1
@@ -0,0 +1,372 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Variation-axis policy for pinned registry faces: which part of a
5
+ * variable font's design space visitors download.
6
+ *
7
+ * A variable face carries its whole design space (Inter: `wght` 100-900
8
+ * and `opsz` 14-32). Most sites use a fraction of it, and every axis kept
9
+ * costs bytes in every slice: Inter's latin slice is 103 KB with all
10
+ * axes, ~50 KB with `wght` clamped to the weights in use and `opsz`
11
+ * pinned. So delivery instantiates the face (harfbuzz) to an
12
+ * {@link EffectiveAxisSpec} per axis:
13
+ *
14
+ * - `full` — keep the source range;
15
+ * - `range` — clamp to `[min, max]` (a sub-range of the source);
16
+ * - `pin` — fix the axis at one value; the axis disappears from the
17
+ * delivered file.
18
+ *
19
+ * Where it comes from: the operator may store an override per face (the
20
+ * `caelo.fontAxes` extension next to `caelo.font` on each typography role
21
+ * bound to the face — see {@link FONT_AXES_EXTENSION}). Axes the override
22
+ * does not name follow the derived default ({@link deriveDefaultAxisSpec}):
23
+ * `wght` clamped to the weights the site can actually render (see
24
+ * `font-usage.ts` for how that set is derived and when it is unknown),
25
+ * every other axis pinned to the font's default — the same choice Google
26
+ * Fonts makes. The one visible consequence of that default is `opsz`:
27
+ * with `font-optical-sizing: auto` the browser would pick the optical
28
+ * size from the font size; pinned, large headings render with the text
29
+ * optical size. That is the trade-off the operator can reverse per face
30
+ * with `{ opsz: "full" }`.
31
+ *
32
+ * Validation is strict (CLAUDE.md §2): an override naming an axis the
33
+ * face does not have, or a value outside the face's source range, is a
34
+ * {@link FontAxisPolicyError} — never silently clamped.
35
+ */
36
+
37
+ import { z } from "zod";
38
+ import { type FontRef, fontRef } from "./font-assets.js";
39
+
40
+ /** Theme-token extension key holding a role's axis override (beside `caelo.font`). */
41
+ export const FONT_AXES_EXTENSION = "caelo.fontAxes";
42
+
43
+ /** OpenType axis tag: four printable characters (`wght`, `opsz`, `GRAD`). */
44
+ export const fontAxisTag = z.string().regex(/^[A-Za-z0-9]{4}$/, "axis tag must be 4 characters");
45
+
46
+ /** One axis in an operator override. */
47
+ export const fontAxisSetting = z.union([
48
+ z.literal("full"),
49
+ z.object({ min: z.number().finite(), max: z.number().finite() }).strict(),
50
+ z.number().finite(),
51
+ ]);
52
+
53
+ /** Operator override for one face: axis tag → setting. Unnamed axes use the default. */
54
+ export const fontAxisPolicy = z
55
+ .record(fontAxisTag, fontAxisSetting)
56
+ .refine(
57
+ (p) => Object.keys(p).length > 0 && Object.keys(p).length <= 16,
58
+ "name between 1 and 16 axes; pass null to reset the face to the default policy",
59
+ );
60
+
61
+ export type FontAxisSetting = z.infer<typeof fontAxisSetting>;
62
+ export type FontAxisPolicy = z.infer<typeof fontAxisPolicy>;
63
+
64
+ /** The source design space of one axis, as the registry metadata records it. */
65
+ export interface SourceAxis {
66
+ readonly min: number;
67
+ readonly max: number;
68
+ readonly default: number;
69
+ }
70
+
71
+ /** What delivery does with one axis. */
72
+ export type EffectiveAxis =
73
+ | { readonly mode: "full"; readonly min: number; readonly max: number }
74
+ | { readonly mode: "range"; readonly min: number; readonly max: number }
75
+ | { readonly mode: "pin"; readonly value: number };
76
+
77
+ /** Axis tag → effective setting, one entry per source axis, keys sorted. */
78
+ export type EffectiveAxisSpec = Readonly<Record<string, EffectiveAxis>>;
79
+
80
+ /** Where an axis' effective setting came from (for the AI read surface). */
81
+ export type AxisOrigin = "override" | "default";
82
+
83
+ /** Structured, AI-actionable policy failure. */
84
+ export class FontAxisPolicyError extends Error {
85
+ readonly code = "FontAxisPolicyInvalid";
86
+ constructor(message: string) {
87
+ super(`FontAxisPolicyInvalid: ${message}`);
88
+ this.name = "FontAxisPolicyError";
89
+ }
90
+ }
91
+
92
+ /** Weight usage as far as the default needs it; `null` = not determinable. */
93
+ export interface WeightUsage {
94
+ readonly weights: readonly number[] | null;
95
+ /** Axis tags named in `font-variation-settings` anywhere; `"all"` when unparsable. */
96
+ readonly variationSettingsAxes: readonly string[] | "all";
97
+ }
98
+
99
+ function normalize(axis: SourceAxis, min: number, max: number): EffectiveAxis {
100
+ if (min === max) return { mode: "pin", value: min };
101
+ if (min === axis.min && max === axis.max) return { mode: "full", min, max };
102
+ return { mode: "range", min, max };
103
+ }
104
+
105
+ function sortedSpec(entries: [string, EffectiveAxis][]): EffectiveAxisSpec {
106
+ return Object.fromEntries(entries.sort(([a], [z]) => (a < z ? -1 : a > z ? 1 : 0)));
107
+ }
108
+
109
+ /**
110
+ * Check an override against the face's real axes.
111
+ *
112
+ * @param subsettable false when OS/2 fsType forbids subsetting: the face
113
+ * ships byte-complete, so no axis can be instanced either
114
+ * @throws FontAxisPolicyError naming the offending axis and the valid range
115
+ */
116
+ export function validateAxisPolicy(
117
+ policy: FontAxisPolicy,
118
+ sourceAxes: Readonly<Record<string, SourceAxis>>,
119
+ subsettable: boolean,
120
+ ): void {
121
+ const known = Object.keys(sourceAxes).sort();
122
+ for (const [tag, setting] of Object.entries(policy)) {
123
+ const axis = sourceAxes[tag];
124
+ if (axis === undefined) {
125
+ throw new FontAxisPolicyError(
126
+ known.length === 0
127
+ ? `this face is not a variable font (no variation axes), so '${tag}' cannot be set — remove the override`
128
+ : `this face has no '${tag}' axis; its axes are ${known.map((t) => `${t} ${sourceAxes[t]?.min}-${sourceAxes[t]?.max}`).join(", ")}`,
129
+ );
130
+ }
131
+ if (setting === "full") continue;
132
+ if (!subsettable) {
133
+ throw new FontAxisPolicyError(
134
+ `the foundry forbids modifying this face (OS/2 fsType: no subsetting), so it always ships with its full design space — use "full" or reset the override`,
135
+ );
136
+ }
137
+ const [min, max] =
138
+ typeof setting === "number" ? [setting, setting] : [setting.min, setting.max];
139
+ if (min > max) {
140
+ throw new FontAxisPolicyError(`'${tag}' range min ${min} is greater than max ${max}`);
141
+ }
142
+ if (min < axis.min || max > axis.max) {
143
+ throw new FontAxisPolicyError(
144
+ `'${tag}' ${typeof setting === "number" ? `value ${min}` : `range ${min}-${max}`} is outside the face's ${tag} range ${axis.min}-${axis.max}`,
145
+ );
146
+ }
147
+ }
148
+ }
149
+
150
+ /**
151
+ * The policy used when the operator set nothing for an axis.
152
+ *
153
+ * - `wght`: clamped to `[min, max]` of the weights the site can render,
154
+ * intersected with the source range — or kept full when that set is
155
+ * not determinable (keeping the full range is the safe outcome, never a
156
+ * degraded one: the browser simply has every weight available).
157
+ * - every other axis: pinned to the font's default, as Google Fonts
158
+ * does. Registered axes other than `opsz` are already held at one value
159
+ * by the single-valued `font-style`/`font-stretch` descriptors the
160
+ * resolver emits, so pinning them changes nothing visible; `opsz` is
161
+ * the accepted trade-off described in the file header.
162
+ * - any axis named in `font-variation-settings` somewhere on the site is
163
+ * kept full: that declaration addresses the axis directly and would
164
+ * silently stop working on an instanced file.
165
+ * - a face whose fsType forbids subsetting keeps every axis full.
166
+ */
167
+ export function deriveDefaultAxisSpec(
168
+ sourceAxes: Readonly<Record<string, SourceAxis>>,
169
+ usage: WeightUsage,
170
+ subsettable: boolean,
171
+ ): EffectiveAxisSpec {
172
+ const entries: [string, EffectiveAxis][] = [];
173
+ for (const [tag, axis] of Object.entries(sourceAxes)) {
174
+ const addressedDirectly =
175
+ usage.variationSettingsAxes === "all" || usage.variationSettingsAxes.includes(tag);
176
+ if (!subsettable || addressedDirectly) {
177
+ entries.push([tag, { mode: "full", min: axis.min, max: axis.max }]);
178
+ } else if (tag === "wght") {
179
+ if (usage.weights === null || usage.weights.length === 0) {
180
+ entries.push([tag, { mode: "full", min: axis.min, max: axis.max }]);
181
+ } else {
182
+ const lo = Math.min(Math.max(Math.min(...usage.weights), axis.min), axis.max);
183
+ const hi = Math.max(Math.min(Math.max(...usage.weights), axis.max), axis.min);
184
+ entries.push([tag, normalize(axis, lo, hi)]);
185
+ }
186
+ } else {
187
+ entries.push([tag, { mode: "pin", value: axis.default }]);
188
+ }
189
+ }
190
+ return sortedSpec(entries);
191
+ }
192
+
193
+ /**
194
+ * Effective spec for a face: the override's axes win, every other axis
195
+ * follows {@link deriveDefaultAxisSpec}. Also reports where each axis
196
+ * came from.
197
+ *
198
+ * @throws FontAxisPolicyError when the override does not fit the face
199
+ */
200
+ export function effectiveAxisSpec(
201
+ sourceAxes: Readonly<Record<string, SourceAxis>>,
202
+ override: FontAxisPolicy | null,
203
+ usage: WeightUsage,
204
+ subsettable: boolean,
205
+ ): { readonly spec: EffectiveAxisSpec; readonly origin: Readonly<Record<string, AxisOrigin>> } {
206
+ if (override !== null) validateAxisPolicy(override, sourceAxes, subsettable);
207
+ const defaults = deriveDefaultAxisSpec(sourceAxes, usage, subsettable);
208
+ const entries: [string, EffectiveAxis][] = [];
209
+ const origin: Record<string, AxisOrigin> = {};
210
+ for (const [tag, axis] of Object.entries(sourceAxes)) {
211
+ const setting = override?.[tag];
212
+ if (setting === undefined) {
213
+ const derived = defaults[tag];
214
+ if (derived === undefined) throw new Error(`default spec lacks source axis ${tag}`);
215
+ entries.push([tag, derived]);
216
+ origin[tag] = "default";
217
+ } else {
218
+ const [min, max] =
219
+ setting === "full"
220
+ ? [axis.min, axis.max]
221
+ : typeof setting === "number"
222
+ ? [setting, setting]
223
+ : [setting.min, setting.max];
224
+ entries.push([tag, normalize(axis, min, max)]);
225
+ origin[tag] = "override";
226
+ }
227
+ }
228
+ return { spec: sortedSpec(entries), origin };
229
+ }
230
+
231
+ /** True when delivery would ship the face's design space untouched. */
232
+ export function isFullAxisSpec(spec: EffectiveAxisSpec): boolean {
233
+ return Object.values(spec).every((a) => a.mode === "full");
234
+ }
235
+
236
+ /**
237
+ * Canonical text form of a spec (`opsz=14;wght=400..900`), stable across
238
+ * key order. Delivery hashes it into cache keys and file names, so a
239
+ * policy change can never be answered with slices cut for another one.
240
+ */
241
+ export function axisSpecKey(spec: EffectiveAxisSpec): string {
242
+ return Object.keys(spec)
243
+ .sort()
244
+ .map((tag) => {
245
+ const a = spec[tag] as EffectiveAxis;
246
+ return a.mode === "pin" ? `${tag}=${a.value}` : `${tag}=${a.min}..${a.max}`;
247
+ })
248
+ .join(";");
249
+ }
250
+
251
+ /**
252
+ * Axes a delivered file must report after instancing: pinned axes are
253
+ * gone, the rest carry their (possibly clamped) range.
254
+ */
255
+ export function expectedOutputAxes(
256
+ spec: EffectiveAxisSpec,
257
+ ): Record<string, { min: number; max: number }> {
258
+ const out: Record<string, { min: number; max: number }> = {};
259
+ for (const tag of Object.keys(spec).sort()) {
260
+ const a = spec[tag] as EffectiveAxis;
261
+ if (a.mode !== "pin") out[tag] = { min: a.min, max: a.max };
262
+ }
263
+ return out;
264
+ }
265
+
266
+ /**
267
+ * The `@font-face` `font-weight` descriptor matching the delivered file,
268
+ * so the browser neither requests nor synthesizes outside it.
269
+ *
270
+ * @param staticWeight the face's OS/2 weight, used when it has no `wght` axis
271
+ */
272
+ export function fontWeightDescriptor(spec: EffectiveAxisSpec, staticWeight: number): string {
273
+ const wght = spec.wght;
274
+ if (wght === undefined) return String(staticWeight);
275
+ return wght.mode === "pin" ? String(wght.value) : `${wght.min} ${wght.max}`;
276
+ }
277
+
278
+ /** The weights a face actually serves, as `[min, max]`. */
279
+ export function servedWeightRange(
280
+ spec: EffectiveAxisSpec,
281
+ staticWeight: number,
282
+ ): readonly [number, number] {
283
+ const wght = spec.wght;
284
+ if (wght === undefined) return [staticWeight, staticWeight];
285
+ return wght.mode === "pin" ? [wght.value, wght.value] : [wght.min, wght.max];
286
+ }
287
+
288
+ /** One typography role bound to a pinned face. */
289
+ export interface PinnedRole {
290
+ readonly role: string;
291
+ readonly fontFamily: string | undefined;
292
+ readonly fontWeight: number | string | undefined;
293
+ readonly fontStyle: string | undefined;
294
+ }
295
+
296
+ /** A pinned face with every role that binds it and its stored override. */
297
+ export interface PinnedFaceBinding {
298
+ readonly ref: FontRef;
299
+ readonly roles: readonly PinnedRole[];
300
+ /** The `caelo.fontAxes` override the face's roles carry; null = default policy. */
301
+ readonly override: FontAxisPolicy | null;
302
+ }
303
+
304
+ function canonicalPolicy(policy: FontAxisPolicy | null): string {
305
+ if (policy === null) return "null";
306
+ return JSON.stringify(
307
+ Object.keys(policy)
308
+ .sort()
309
+ .map((tag) => [tag, policy[tag]]),
310
+ );
311
+ }
312
+
313
+ /**
314
+ * Group the theme's `caelo.font` bindings by face (sha256), in role
315
+ * order. The override belongs to the FACE — one file, one `@font-face`
316
+ * set — so every role bound to the same face must carry the same
317
+ * `caelo.fontAxes` (themes.update_tokens keeps them in step). Roles
318
+ * whose binding does not parse are skipped here; the resolver reports
319
+ * those itself.
320
+ *
321
+ * @throws FontAxisPolicyError when a stored override is malformed or two
322
+ * roles of one face disagree
323
+ */
324
+ export function collectPinnedFaces(tokens: unknown): PinnedFaceBinding[] {
325
+ const typography = (tokens as { typography?: unknown } | null)?.typography;
326
+ if (typography === null || typeof typography !== "object") return [];
327
+ const byFace = new Map<
328
+ string,
329
+ { ref: FontRef; roles: PinnedRole[]; policies: Set<string>; override: FontAxisPolicy | null }
330
+ >();
331
+ for (const [role, raw] of Object.entries(typography as Record<string, unknown>)) {
332
+ const entry = raw as {
333
+ $extensions?: Record<string, unknown>;
334
+ $value?: { fontFamily?: string; fontWeight?: number | string; fontStyle?: string };
335
+ };
336
+ const ref = fontRef.safeParse(entry?.$extensions?.["caelo.font"]);
337
+ if (!ref.success) continue;
338
+ const stored = entry.$extensions?.[FONT_AXES_EXTENSION];
339
+ let override: FontAxisPolicy | null = null;
340
+ if (stored !== undefined) {
341
+ const parsed = fontAxisPolicy.safeParse(stored);
342
+ if (!parsed.success) {
343
+ throw new FontAxisPolicyError(
344
+ `typography.${role} carries a malformed ${FONT_AXES_EXTENSION} (${parsed.error.issues[0]?.message ?? "invalid"}) — reset it with set_theme_tokens fontAxes: { ${role}: null }`,
345
+ );
346
+ }
347
+ override = parsed.data;
348
+ }
349
+ const face = byFace.get(ref.data.sha256) ?? {
350
+ ref: ref.data,
351
+ roles: [],
352
+ policies: new Set<string>(),
353
+ override,
354
+ };
355
+ face.roles.push({
356
+ role,
357
+ fontFamily: entry.$value?.fontFamily,
358
+ fontWeight: entry.$value?.fontWeight,
359
+ fontStyle: entry.$value?.fontStyle,
360
+ });
361
+ face.policies.add(canonicalPolicy(override));
362
+ byFace.set(ref.data.sha256, face);
363
+ }
364
+ return [...byFace.values()].map((face) => {
365
+ if (face.policies.size > 1) {
366
+ throw new FontAxisPolicyError(
367
+ `roles ${face.roles.map((r) => r.role).join(", ")} bind the same font file but carry different axis overrides — set one override for the face with set_theme_tokens fontAxes (it applies to every role using that file)`,
368
+ );
369
+ }
370
+ return { ref: face.ref, roles: face.roles, override: face.override };
371
+ });
372
+ }
@@ -0,0 +1,100 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ import { describe, expect, it } from "bun:test";
3
+ import { scanSiteFontUsage } from "./font-usage.js";
4
+ import type { ThemeDocument } from "./themes.js";
5
+
6
+ const THEME = {
7
+ typography: {
8
+ body: { $type: "typography", $value: { fontFamily: "Inter", fontWeight: 400 } },
9
+ heading: { $type: "typography", $value: { fontFamily: "Inter", fontWeight: 600 } },
10
+ },
11
+ } as unknown as ThemeDocument;
12
+
13
+ function scan(...texts: string[]) {
14
+ return scanSiteFontUsage({
15
+ tokens: THEME,
16
+ sources: texts.map((text, i) => ({ label: `module:m${i}`, text })),
17
+ });
18
+ }
19
+
20
+ describe("scanSiteFontUsage", () => {
21
+ it("always includes the initial 400, UA bold 700 and the bolder closure", () => {
22
+ // Theme 600 → bolder 900; UA 700 → 900.
23
+ expect(scan().weights).toEqual([400, 600, 700, 900]);
24
+ });
25
+
26
+ it("collects weights from CSS, inline styles, JSON-escaped content and the font shorthand", () => {
27
+ const usage = scan(
28
+ ".a{font-weight:300}.b{font-weight: bold !important}",
29
+ '<p style="font-weight:500">x</p>',
30
+ '{"body":"<span style=\\"font-weight:800\\">x</span>"}',
31
+ ".c{font:italic 200 1rem/1.4 var(--font-body)}",
32
+ );
33
+ expect(usage.uncertain).toEqual([]);
34
+ expect(usage.weights).toEqual([200, 300, 400, 500, 600, 700, 800, 900]);
35
+ });
36
+
37
+ it("resolves var() against every declaration of the property, including theme vars", () => {
38
+ const usage = scan(
39
+ ".h{font-weight:var(--font-weight-heading)}",
40
+ ".x{--w:350}.y{font-weight:var(--w, 450)}",
41
+ );
42
+ expect(usage.uncertain).toEqual([]);
43
+ expect(usage.weights).toEqual([350, 400, 450, 600, 700, 900]);
44
+ });
45
+
46
+ it("applies the lighter closure only when lighter is used", () => {
47
+ expect(scan(".a{font-weight:lighter}").weights).toEqual([100, 400, 600, 700, 900]);
48
+ });
49
+
50
+ it("reports uncertainty instead of guessing", () => {
51
+ const cases = [
52
+ ".a{font-weight:calc(400 + 100)}",
53
+ ".a{font-weight:{{weight}}}",
54
+ ".a{font-weight:var(--w)} el.style.setProperty('--w', 800)",
55
+ "el.style.fontWeight = w;",
56
+ ".a{font:menu}",
57
+ ".a{transition:font-weight .2s}",
58
+ "ctx.font = size + 'px Inter';",
59
+ ];
60
+ for (const text of cases) {
61
+ const usage = scan(text);
62
+ expect({ text, weights: usage.weights }).toEqual({ text, weights: null });
63
+ expect(usage.uncertain[0]).toStartWith("module:m0: ");
64
+ }
65
+ });
66
+
67
+ it("accepts literal camelCase weights in script", () => {
68
+ expect(scan("const s = { fontWeight: 650 };").weights).toEqual([400, 600, 650, 700, 900]);
69
+ });
70
+
71
+ it("reports axes named by font-variation-settings, all of them when unparsable", () => {
72
+ expect(scan(`.a{font-variation-settings:"opsz" 32, 'GRAD' -50}`).variationSettingsAxes).toEqual(
73
+ ["GRAD", "opsz"],
74
+ );
75
+ expect(scan(".a{font-variation-settings:normal}").variationSettingsAxes).toEqual([]);
76
+ expect(scan(".a{font-variation-settings:var(--v)}").variationSettingsAxes).toBe("all");
77
+ expect(scan("el.style.fontVariationSettings = v").variationSettingsAxes).toBe("all");
78
+ expect(scan().variationSettingsAxes).toEqual([]);
79
+ });
80
+
81
+ it("reads theme weights in linear time even for role names repeating --font-weight-", () => {
82
+ // Regression (CodeQL js/polynomial-redos): the theme-weight pattern used
83
+ // to backtrack quadratically on runs of `--font-weight-`.
84
+ const role = `x${"--font-weight-".repeat(20_000)}`;
85
+ const tokens = {
86
+ typography: {
87
+ [role]: { $type: "typography", $value: { fontFamily: "Inter", fontWeight: 550 } },
88
+ },
89
+ } as unknown as ThemeDocument;
90
+ const started = performance.now();
91
+ const usage = scanSiteFontUsage({ tokens, sources: [] });
92
+ expect(performance.now() - started).toBeLessThan(2_000);
93
+ expect(usage.uncertain).toEqual([]);
94
+ expect(usage.weights).toContain(550);
95
+ });
96
+
97
+ it("does not mistake custom property names for font-weight declarations", () => {
98
+ expect(scan(".a{--font-weight-x:500}").weights).toEqual([400, 600, 700, 900]);
99
+ });
100
+ });