rainbowindex 0.4.0 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,98 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.1] - 2026-08-25
9
+
10
+ ### Added
11
+
12
+ - **Automatic zero-CLS font fallbacks.** Any `@font` slot whose family is in
13
+ the built-in metrics table (~100 common Google + system families, generated
14
+ from `@capsizecss/metrics` at development time — no new runtime dependency)
15
+ now gets a metrics-adjusted local fallback `@font-face` automatically. The
16
+ fallback font is picked from the slot's stack, or by the web font's
17
+ category (Arial / Times New Roman / Courier New). Opt out with
18
+ `metrics: none;`, pick the matched local font with `metrics: "Segoe UI";`,
19
+ or keep full manual control with
20
+ `metrics: "Arial" <size-adjust> <ascent> <descent> <line-gap>;`.
21
+ - **`face:` entries** — local font files are now declared as repeatable
22
+ `face: <src> [{ overrides }]` entries, one grammar for every face:
23
+ `display: "Satoshi" { face: /Satoshi.woff2; face: /Satoshi-Italic.woff2 { style: italic; } }`.
24
+ - **Fallback stacks work with `from google`** —
25
+ `sans: "Inter", ui-sans-serif, sans-serif from google;` now parses as a
26
+ google slot with fallbacks (previously it silently became a manual stack).
27
+ - **@font now warns instead of ignoring silently**: unknown option keys
28
+ (RI-1217), `preload` on non-local slots where it can have no effect
29
+ (RI-1219), and partial or invalid `metrics` values (RI-1220).
30
+
31
+ ### Changed
32
+
33
+ - The `@font` parser was rebuilt on the shared entry scanner: one pass, no
34
+ regex preambles, no re-serialization. Public API (`parseFontBody`,
35
+ `parseNestedFontBlock`) is unchanged.
36
+ - **Incremental rebuilds only re-do changed work** — the scanner keeps a
37
+ per-file cache keyed on mtime + size, so a CLI watch or Vite HMR rebuild
38
+ re-reads and re-extracts only the files that actually changed instead of
39
+ the whole project. The CSS entry analysis is memoized on the entry text
40
+ (stable theme identity across rebuilds), Google-font resolution keeps its
41
+ identity when metadata is unchanged, and per-class compilation results
42
+ (rule, warnings, token usage) are replayed across rebuilds from a
43
+ theme-keyed cache. A one-file edit in a large project now costs
44
+ O(changed files) instead of O(project).
45
+ - **`ri()` conditional args hit the cache fast path** — falsy arguments
46
+ (`ri("flex", isActive && "bg-blue-500")`) no longer force re-tokenization
47
+ on every call: the raw-key cache skips falsy primitives, making cached
48
+ conditional-pattern calls ~4x faster.
49
+ - **Cheaper compile passes** — duplicate class names across `@apply` rules
50
+ resolve once per pass, the `@slot`/`@apply` walks no longer traverse the
51
+ generated utility CSS, custom `@utility` bodies parse once per body text
52
+ instead of once per variant form, and the editor candidate collector's
53
+ context assignment went from quadratic to a sorted sweep. As part of the
54
+ compile-result replay, engine-level compilation warnings are now
55
+ deduplicated (final project output already was).
56
+ - **Internal restructuring (behavior-preserving)** — the directive body
57
+ parsers were split by grammar family: `@color` and `@font` now own their
58
+ own modules, and the generic key-value/entry grammars sit together in the
59
+ directives foundation. The `parsers` import surface is unchanged.
60
+
61
+ ### Deprecated
62
+
63
+ - The older `@font` forms still parse and desugar into the new model, but
64
+ warn (RI-1218) and will be removed in a future release: `@face { src: …; }`
65
+ blocks and the `italic:` shorthand (use `face:`), `from "<path>"` (use
66
+ `face:`), `from system` (use bare `system`), the `fallback:` key (list
67
+ fallbacks in the slot preamble), the five-key metrics cluster
68
+ `metricsFallback`/`sizeAdjust`/`ascent`/`descent`/`lineGap` (use
69
+ `metrics:`), and the `unicodeRange` spelling (use `unicode-range`).
70
+
71
+ ### Removed
72
+
73
+ - **`@font` `subset:`** — the Google css2 API takes no subset hint, so the
74
+ key never affected the emitted URL. It now warns (RI-1218) and is ignored.
75
+ - **`FontSlot` / `FontFace` fields** — the five slot-level metrics fields
76
+ (`metricsFallback`, `sizeAdjust`, `ascent`, `descent`, `lineGap`) are
77
+ replaced by a single `metrics?: FontMetricsConfig | null`; slot-level
78
+ `preload` moved onto the faces; `FontFace.subset` is gone. Stylesheets are
79
+ unaffected — this only touches code importing those types directly.
80
+
81
+ ### Fixed
82
+
83
+ - **`ri()` stroke-width vs stroke-color conflict — for real this time.** The
84
+ v0.4.0 changelog described this fix, but the implementation did not ship in
85
+ that release: `ri("stroke-2 stroke-red-500")` still dropped `stroke-2`. The
86
+ `stroke` prefix is now actually width-vs-color dual-mode (mirroring
87
+ `border`/`outline`): decimal and non-color arbitrary values claim
88
+ `stroke-width`, color values claim `stroke`. Same-property conflicts
89
+ (`stroke-2` vs `stroke-4`, `stroke-red-500` vs `stroke-blue-500`) still
90
+ merge as before. The emission↔claim parity suite no longer carries a
91
+ stroke exception, and regression tests pin the merged output.
92
+ - **Directive rewriter no longer duplicates unclosed blocks** — an
93
+ unterminated `{` inside a `@color` body made the Vite-plugin pre-pass
94
+ re-emit the text before it (`@color { accent: 0.18 330 { inline` came out
95
+ with the entry doubled). The rewriter's two hand-rolled brace walks were
96
+ replaced by one shared walker built on the same brace matcher the directive
97
+ scan already uses, which also makes quotes and comments inside blocks
98
+ behave consistently across the rewrite passes.
99
+
8
100
  ## [0.4.0] - 2026-08-20
9
101
 
10
102
  ### Added
package/README.md CHANGED
@@ -211,7 +211,7 @@ Customization happens in your CSS input, not a JS config. The engine recognizes:
211
211
  | `@color` | Define color tokens. Supports generative (`chroma hue`), explicit (`oklch(...)`, `#rrggbb`), light/dark pairs, and aliases. |
212
212
  | `@spacing` | Set the spacing base unit. |
213
213
  | `@text` | Define text size tokens (`size, line-height`). |
214
- | `@font`, `@font-face` | Register font families inside a single `@font { … }` block. One slot can own multiple `@face` faces (e.g. upright + italic) or use the `italic:` shorthand. |
214
+ | `@font`, `@font-face` | Register font families inside a single `@font { … }` block. Local files are repeatable `face:` entries (e.g. upright + italic). Known families get an automatic zero-CLS metrics fallback (`metrics: none` opts out). |
215
215
  | `@rounded` | Border-radius tokens; modifier sets corner shape (`round`, `squircle`, `superellipse(N)`, etc). |
216
216
  | `@fluid` | Configure fluid type/spacing range. |
217
217
  | `@animate` | Register named animations with inline `@keyframes`. |
@@ -242,10 +242,11 @@ Example:
242
242
  }
243
243
 
244
244
  @font {
245
- sans: "Inter" from google { weight: 400 700; }
246
- display: "Satoshi" from "/fonts/Satoshi.woff2" {
245
+ sans: "Inter", ui-sans-serif, sans-serif from google { weight: 400 700; }
246
+ display: "Satoshi" {
247
247
  weight: 300 900;
248
- italic: "/fonts/Satoshi-Italic.woff2"; /* second face, font-style: italic */
248
+ face: /fonts/Satoshi.woff2;
249
+ face: /fonts/Satoshi-Italic.woff2 { style: italic; }
249
250
  }
250
251
  }
251
252
 
package/dist/browser.mjs CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  registerCustomTextSizes,
14
14
  registerCustomUtility,
15
15
  ri
16
- } from "./chunk-5Y7EXXLS.mjs";
16
+ } from "./chunk-KRZL4IDK.mjs";
17
17
 
18
18
  // src/entries/browser.ts
19
19
  function browserEntryUnavailable() {
@@ -19,12 +19,12 @@ import {
19
19
  renderCSS,
20
20
  scanCSSForTokenUsage,
21
21
  withTimeout
22
- } from "./chunk-6DAHUFNU.mjs";
22
+ } from "./chunk-6GTG5SZJ.mjs";
23
23
  import {
24
24
  checkPaletteContrast,
25
25
  generateAllColorVariables,
26
26
  generateThemeOverrides
27
- } from "./chunk-5Y7EXXLS.mjs";
27
+ } from "./chunk-KRZL4IDK.mjs";
28
28
 
29
29
  // src/integrations/font-providers/google/state.ts
30
30
  var googleFontInternals = {
@@ -318,8 +318,13 @@ async function fetchGoogleFontList() {
318
318
  googleFontInternals.googleFontListPromise = localPromise;
319
319
  return localPromise;
320
320
  }
321
+ var refreshMemo = /* @__PURE__ */ new WeakMap();
321
322
  function refreshFontWeightDefaults(fonts) {
322
- return fonts.map((slot) => {
323
+ const state = googleFontInternals.googleFontState;
324
+ const memo = refreshMemo.get(fonts);
325
+ if (memo && memo.state === state) return memo.result;
326
+ let anyChanged = false;
327
+ const refreshed = fonts.map((slot) => {
323
328
  if (slot.kind !== "google") return slot;
324
329
  const meta = googleFontInternals.googleFontState.cache.get(slot.family);
325
330
  if (!meta) return slot;
@@ -339,8 +344,12 @@ function refreshFontWeightDefaults(fonts) {
339
344
  if (next !== f) changed = true;
340
345
  return next;
341
346
  });
347
+ if (changed) anyChanged = true;
342
348
  return changed ? { ...slot, faces } : slot;
343
349
  });
350
+ const result = anyChanged ? refreshed : fonts;
351
+ refreshMemo.set(fonts, { state, result });
352
+ return result;
344
353
  }
345
354
  var FONT_FETCH_TIMEOUT_MS = 1e4;
346
355
  async function resolveGoogleFonts(fonts) {
@@ -359,6 +368,145 @@ async function resolveGoogleFonts(fonts) {
359
368
  return refreshFontWeightDefaults(fonts);
360
369
  }
361
370
 
371
+ // src/integrations/font-providers/metrics-data.ts
372
+ var FONT_METRICS_TABLE = {
373
+ abel: [2006, -604, 0, 2048, 783, "sans-serif"],
374
+ alegreya: [1016, -345, 0, 1e3, 410, "serif"],
375
+ "anonymous pro": [1675, -373, 0, 2048, 1118, "monospace"],
376
+ archivo: [878, -210, 0, 1e3, 440, "sans-serif"],
377
+ arial: [1854, -434, 67, 2048, 913, "sans-serif"],
378
+ asap: [934, -212, 0, 1e3, 442, "sans-serif"],
379
+ barlow: [1e3, -200, 0, 1e3, 431, "sans-serif"],
380
+ bitter: [935, -265, 0, 1e3, 465, "serif"],
381
+ "bricolage grotesque": [930, -270, 0, 1e3, 470, "sans-serif"],
382
+ cabin: [1930, -500, 0, 2e3, 844, "sans-serif"],
383
+ catamaran: [1100, -540, 0, 1e3, 411, "sans-serif"],
384
+ chivo: [940, -250, 0, 1e3, 478, "sans-serif"],
385
+ "cormorant garamond": [924, -287, 0, 1e3, 394, "serif"],
386
+ "courier new": [1705, -615, 0, 2048, 1229, "monospace"],
387
+ "crimson pro": [918, -220, 0, 1024, 410, "serif"],
388
+ "crimson text": [972, -359, 0, 1024, 405, "serif"],
389
+ "dm sans": [992, -310, 0, 1e3, 466, "sans-serif"],
390
+ "dm serif display": [1036, -335, 0, 1e3, 446, "serif"],
391
+ dosis: [1027, -237, 0, 1e3, 377, "sans-serif"],
392
+ "eb garamond": [1007, -298, 0, 1e3, 385, "serif"],
393
+ epilogue: [1580, -470, 0, 2e3, 990, "sans-serif"],
394
+ "exo 2": [999, -201, 0, 1e3, 455, "sans-serif"],
395
+ figtree: [950, -250, 0, 1e3, 449, "sans-serif"],
396
+ "fira code": [1980, -644, 0, 2e3, 1200, "monospace"],
397
+ "fira sans": [935, -265, 0, 1e3, 458, "sans-serif"],
398
+ fraunces: [1956, -510, 0, 2e3, 938, "serif"],
399
+ gabarito: [940, -260, 0, 1e3, 442, "display"],
400
+ "geist mono": [1005, -295, 0, 1e3, 600, "monospace"],
401
+ geist: [1005, -295, 0, 1e3, 467, "sans-serif"],
402
+ georgia: [1878, -449, 0, 2048, 913, "serif"],
403
+ heebo: [2146, -862, 0, 2048, 912, "sans-serif"],
404
+ "helvetica neue": [952, -213, 28, 1e3, 450, "sans-serif"],
405
+ helvetica: [1577, -471, 0, 2048, 913, "sans-serif"],
406
+ hind: [1055, -546, 0, 1e3, 429, "sans-serif"],
407
+ "ibm plex mono": [1025, -275, 0, 1e3, 600, "monospace"],
408
+ "ibm plex sans": [1025, -275, 0, 1e3, 451, "sans-serif"],
409
+ "ibm plex serif": [1025, -275, 0, 1e3, 473, "serif"],
410
+ inconsolata: [859, -190, 0, 1e3, 500, "monospace"],
411
+ "instrument sans": [970, -250, 0, 1e3, 458, "sans-serif"],
412
+ "instrument serif": [990, -310, 0, 1e3, 341, "serif"],
413
+ inter: [1984, -494, 0, 2048, 978, "sans-serif"],
414
+ "jetbrains mono": [1020, -300, 0, 1e3, 600, "monospace"],
415
+ "josefin sans": [750, -250, 0, 1e3, 456, "sans-serif"],
416
+ kanit: [1100, -395, 0, 1e3, 452, "sans-serif"],
417
+ karla: [1834, -504, 0, 2e3, 913, "sans-serif"],
418
+ lato: [1974, -426, 0, 2e3, 871, "sans-serif"],
419
+ lexend: [1e3, -250, 0, 1e3, 490, "sans-serif"],
420
+ "libre baskerville": [970, -270, 0, 1e3, 517, "serif"],
421
+ "libre franklin": [966, -246, 0, 1e3, 465, "sans-serif"],
422
+ lora: [1006, -274, 0, 1e3, 468, "serif"],
423
+ manrope: [2132, -600, 0, 2e3, 920, "sans-serif"],
424
+ "maven pro": [965, -210, 0, 1e3, 462, "sans-serif"],
425
+ merriweather: [1968, -546, 0, 2e3, 970, "serif"],
426
+ montserrat: [968, -251, 0, 1e3, 503, "sans-serif"],
427
+ mulish: [1005, -250, 0, 1e3, 464, "sans-serif"],
428
+ newsreader: [1470, -530, 0, 2e3, 857, "serif"],
429
+ "noto sans": [1069, -293, 0, 1e3, 474, "sans-serif"],
430
+ "noto serif": [1069, -293, 0, 1e3, 481, "serif"],
431
+ "nunito sans": [1011, -353, 0, 1e3, 452, "sans-serif"],
432
+ nunito: [1011, -353, 0, 1e3, 452, "sans-serif"],
433
+ onest: [970, -305, 0, 1e3, 469, "sans-serif"],
434
+ "open sans": [2189, -600, 0, 2048, 960, "sans-serif"],
435
+ oswald: [1193, -289, 0, 1e3, 363, "sans-serif"],
436
+ outfit: [1e3, -260, 0, 1e3, 445, "sans-serif"],
437
+ overpass: [1766, -766, 0, 2e3, 898, "sans-serif"],
438
+ oxygen: [2103, -483, 0, 2048, 923, "sans-serif"],
439
+ "playfair display": [1082, -251, 0, 1e3, 452, "serif"],
440
+ "plus jakarta sans": [1038, -222, 0, 1e3, 468, "sans-serif"],
441
+ poppins: [1050, -350, 100, 1e3, 500, "sans-serif"],
442
+ "pt sans": [1018, -276, 0, 1e3, 431, "sans-serif"],
443
+ "pt serif": [1039, -286, 0, 1e3, 448, "serif"],
444
+ "public sans": [1900, -450, 0, 2e3, 935, "sans-serif"],
445
+ quicksand: [1e3, -250, 0, 1e3, 465, "sans-serif"],
446
+ raleway: [940, -234, 0, 1e3, 463, "sans-serif"],
447
+ "red hat display": [1018, -305, 0, 1e3, 442, "sans-serif"],
448
+ "red hat text": [1018, -305, 0, 1e3, 447, "sans-serif"],
449
+ "roboto condensed": [1900, -500, 0, 2048, 811, "sans-serif"],
450
+ "roboto flex": [1900, -500, 0, 2048, 908, "sans-serif"],
451
+ "roboto mono": [2146, -555, 0, 2048, 1229, "monospace"],
452
+ "roboto slab": [2146, -555, 0, 2048, 972, "serif"],
453
+ roboto: [1900, -500, 0, 2048, 911, "sans-serif"],
454
+ rubik: [935, -250, 0, 1e3, 468, "sans-serif"],
455
+ "schibsted grotesk": [2e3, -528, 0, 2048, 954, "sans-serif"],
456
+ "segoe ui": [2210, -514, 0, 2048, 908, "sans-serif"],
457
+ sora: [970, -290, 0, 1e3, 507, "sans-serif"],
458
+ "source code pro": [984, -273, 0, 1e3, 600, "monospace"],
459
+ "source sans 3": [1024, -400, 0, 1e3, 418, "sans-serif"],
460
+ "source serif 4": [1036, -335, 0, 1e3, 479, "serif"],
461
+ "space grotesk": [984, -292, 0, 1e3, 489, "sans-serif"],
462
+ "space mono": [1120, -361, 0, 1e3, 612, "monospace"],
463
+ spectral: [1059, -463, 0, 1e3, 446, "serif"],
464
+ tahoma: [2049, -423, 0, 2048, 917, "sans-serif"],
465
+ "times new roman": [1825, -443, 87, 2048, 832, "serif"],
466
+ "titillium web": [1133, -388, 0, 1e3, 421, "sans-serif"],
467
+ "trebuchet ms": [1923, -455, 0, 2048, 934, "sans-serif"],
468
+ "ubuntu mono": [830, -170, 0, 1e3, 500, "monospace"],
469
+ ubuntu: [932, -189, 28, 1e3, 455, "sans-serif"],
470
+ urbanist: [1900, -500, 0, 2e3, 883, "sans-serif"],
471
+ "varela round": [918, -286, 0, 1e3, 478, "sans-serif"],
472
+ verdana: [2059, -430, 0, 2048, 1049, "sans-serif"],
473
+ "victor mono": [1100, -250, 0, 1e3, 600, "monospace"],
474
+ vollkorn: [952, -441, 0, 1e3, 438, "serif"],
475
+ "work sans": [930, -243, 0, 1e3, 499, "sans-serif"],
476
+ "zilla slab": [944, -256, 0, 1e3, 434, "serif"]
477
+ };
478
+
479
+ // src/integrations/font-providers/metrics.ts
480
+ var CATEGORY_FALLBACK = {
481
+ "sans-serif": "Arial",
482
+ serif: "Times New Roman",
483
+ monospace: "Courier New"
484
+ };
485
+ function lookupFontMetrics(family) {
486
+ return FONT_METRICS_TABLE[family.trim().toLowerCase()];
487
+ }
488
+ var round4 = (n) => Math.round(n * 1e4) / 1e4;
489
+ function computeFallbackMetrics(fallbackName, font, fallbackFont) {
490
+ const [ascent, descent, lineGap, unitsPerEm, xWidthAvg] = font;
491
+ const [, , , fbUnitsPerEm, fbXWidthAvg] = fallbackFont;
492
+ const sizeAdjust = xWidthAvg / unitsPerEm / (fbXWidthAvg / fbUnitsPerEm);
493
+ return {
494
+ fallback: fallbackName,
495
+ sizeAdjust: round4(sizeAdjust * 100),
496
+ ascent: round4(ascent / unitsPerEm / sizeAdjust * 100),
497
+ descent: round4(Math.abs(descent) / unitsPerEm / sizeAdjust * 100),
498
+ lineGap: round4(lineGap / unitsPerEm / sizeAdjust * 100)
499
+ };
500
+ }
501
+ function resolveAutoMetrics(family, fallbackStack, explicitFallback) {
502
+ const font = lookupFontMetrics(family);
503
+ if (!font) return null;
504
+ const fallbackName = explicitFallback ?? fallbackStack.find((f) => lookupFontMetrics(f)) ?? CATEGORY_FALLBACK[font[5]] ?? "Arial";
505
+ const fallbackFont = lookupFontMetrics(fallbackName);
506
+ if (!fallbackFont) return null;
507
+ return computeFallbackMetrics(fallbackName, font, fallbackFont);
508
+ }
509
+
362
510
  // src/integrations/font-providers/index.ts
363
511
  var SYSTEM_STACKS = Object.freeze({
364
512
  sans: 'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"',
@@ -390,8 +538,7 @@ function googleFontsUrl(family, face) {
390
538
  axisParam = face.style.includes("italic") ? `ital,wght@0,${w};1,${w}` : `wght@${w}`;
391
539
  }
392
540
  const display = face.display || "swap";
393
- const subset = face.subset && face.subset !== "latin" ? `&subset=${encodeURIComponent(face.subset)}` : "";
394
- return `https://fonts.googleapis.com/css2?family=${encodedFamily}:${axisParam}&display=${display}${subset}`;
541
+ return `https://fonts.googleapis.com/css2?family=${encodedFamily}:${axisParam}&display=${display}`;
395
542
  }
396
543
  function escapeFontFamily(name) {
397
544
  return name.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\0/g, "\\0").replace(/\n/g, "\\a ").replace(/\r/g, "\\d ");
@@ -400,7 +547,7 @@ function generateFallbackFontFace(family, metrics) {
400
547
  const safeFamily = escapeFontFamily(family);
401
548
  return `@font-face {
402
549
  font-family: "${safeFamily} Fallback";
403
- src: local("${metrics.fallback}");
550
+ src: local("${escapeFontFamily(metrics.fallback)}");
404
551
  size-adjust: ${metrics.sizeAdjust}%;
405
552
  ascent-override: ${metrics.ascent}%;
406
553
  descent-override: ${metrics.descent}%;
@@ -425,7 +572,7 @@ function generateWebFontFace(family, face) {
425
572
  if (face.provider === "google") {
426
573
  return { type: "import", css: `@import url("${googleFontsUrl(family, face)}");` };
427
574
  }
428
- const safeProvider = face.provider.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
575
+ const safeProvider = escapeFontFamily(face.provider);
429
576
  const format = inferFontFormat(face.provider);
430
577
  const src = `url("${safeProvider}") format("${format}")`;
431
578
  const declarations = [` font-family: "${escapeFontFamily(family)}";`, ` src: ${src};`];
@@ -447,6 +594,27 @@ function normalizeLocalStyle(style, family, warnings) {
447
594
  }
448
595
  return style;
449
596
  }
597
+ function resolveSlotMetrics(slot, warnings) {
598
+ const cfg = slot.metrics;
599
+ if (cfg === null) return null;
600
+ if (cfg?.sizeAdjust !== void 0) {
601
+ return {
602
+ fallback: cfg.fallback || slot.fallback[0] || "Arial",
603
+ sizeAdjust: cfg.sizeAdjust,
604
+ ascent: cfg.ascent,
605
+ descent: cfg.descent,
606
+ lineGap: cfg.lineGap
607
+ };
608
+ }
609
+ const resolved = resolveAutoMetrics(slot.family, slot.fallback, cfg?.fallback);
610
+ if (!resolved && cfg?.fallback) {
611
+ const missing = lookupFontMetrics(slot.family) ? cfg.fallback : slot.family;
612
+ warnings.push(
613
+ `[RI-1220] @font slot "${slot.slot}" requests metrics matching against "${cfg.fallback}", but "${missing}" is not in the built-in metrics table \u2014 no fallback @font-face was generated. Provide the four percentages explicitly (metrics: "${cfg.fallback}" <size-adjust> <ascent> <descent> <line-gap>) or use \`metrics: none\`.`
614
+ );
615
+ }
616
+ return resolved;
617
+ }
450
618
  function generateFontCSS(slot) {
451
619
  const imports = [];
452
620
  const fontFaces = [];
@@ -466,20 +634,8 @@ function generateFontCSS(slot) {
466
634
  pushFeatureVars();
467
635
  return { imports, fontFaces, variables, warnings };
468
636
  }
469
- const { sizeAdjust, ascent, descent, lineGap } = slot;
470
- const hasMetrics = sizeAdjust !== void 0 && ascent !== void 0 && descent !== void 0 && lineGap !== void 0;
471
- if (hasMetrics) {
472
- const metricsFallbackFont = slot.metricsFallback || slot.fallback[0] || "Arial";
473
- fontFaces.push(
474
- generateFallbackFontFace(slot.family, {
475
- fallback: metricsFallbackFont,
476
- sizeAdjust,
477
- ascent,
478
- descent,
479
- lineGap
480
- })
481
- );
482
- }
637
+ const metrics = resolveSlotMetrics(slot, warnings);
638
+ if (metrics) fontFaces.push(generateFallbackFontFace(slot.family, metrics));
483
639
  for (const face of slot.faces) {
484
640
  if (face.provider !== "google" && !face.provider.startsWith("/") && !face.provider.startsWith("http") && !face.provider.startsWith(".")) {
485
641
  warnings.push(
@@ -496,7 +652,7 @@ function generateFontCSS(slot) {
496
652
  const fallbackStack = slot.fallback.length > 0 ? slot.fallback.join(", ") : getFallbackStack(slot.slot);
497
653
  const safeFamily = escapeFontFamily(slot.family);
498
654
  const stackParts = [`"${safeFamily}"`];
499
- if (hasMetrics) stackParts.push(`"${safeFamily} Fallback"`);
655
+ if (metrics) stackParts.push(`"${safeFamily} Fallback"`);
500
656
  stackParts.push(fallbackStack);
501
657
  variables.push(`--font-${slot.slot}: ${stackParts.join(", ")};`);
502
658
  pushFeatureVars();
@@ -507,8 +663,7 @@ function getFontPreloadLinks(slots) {
507
663
  const seen = /* @__PURE__ */ new Set();
508
664
  for (const slot of slots) {
509
665
  for (const face of slot.faces) {
510
- const preload = face.preload ?? slot.preload;
511
- if (!preload) continue;
666
+ if (!face.preload) continue;
512
667
  if (face.provider === "system" || face.provider === "google" || !face.provider) continue;
513
668
  if (seen.has(face.provider)) continue;
514
669
  seen.add(face.provider);
@@ -775,31 +930,38 @@ function collectInlineClasses(sources) {
775
930
  return { classes, warnings };
776
931
  }
777
932
  var FILE_IO_TIMEOUT_MS = 1e4;
933
+ var SCAN_CACHE_MAX_ENTRIES = 2e4;
934
+ var scanCache = /* @__PURE__ */ new Map();
778
935
  async function scanOneFile(file) {
779
936
  const warnings = [];
780
937
  try {
781
- const fileSize = await withTimeout(
782
- stat(file).then((s) => s.size),
783
- FILE_IO_TIMEOUT_MS,
784
- "stat() timed out"
785
- );
786
- if (fileSize > MAX_FILE_SIZE) {
787
- return {
938
+ const stats = await withTimeout(stat(file), FILE_IO_TIMEOUT_MS, "stat() timed out");
939
+ const cached = scanCache.get(file);
940
+ if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) {
941
+ return cached.result;
942
+ }
943
+ let result;
944
+ if (stats.size > MAX_FILE_SIZE) {
945
+ result = {
788
946
  classes: null,
789
947
  warnings,
790
- failure: `[RI-1405] Skipping source file "${file}" (${fileSize} bytes) \u2014 exceeds ${MAX_FILE_SIZE} byte limit.`
948
+ failure: `[RI-1405] Skipping source file "${file}" (${stats.size} bytes) \u2014 exceeds ${MAX_FILE_SIZE} byte limit.`
949
+ };
950
+ } else {
951
+ const content = await withTimeout(
952
+ readFile(file, "utf-8"),
953
+ FILE_IO_TIMEOUT_MS,
954
+ "readFile() timed out"
955
+ );
956
+ result = {
957
+ classes: extractClassesFromSource({ path: file, content }, warnings),
958
+ warnings,
959
+ failure: null
791
960
  };
792
961
  }
793
- const content = await withTimeout(
794
- readFile(file, "utf-8"),
795
- FILE_IO_TIMEOUT_MS,
796
- "readFile() timed out"
797
- );
798
- return {
799
- classes: extractClassesFromSource({ path: file, content }, warnings),
800
- warnings,
801
- failure: null
802
- };
962
+ if (scanCache.size >= SCAN_CACHE_MAX_ENTRIES) scanCache.clear();
963
+ scanCache.set(file, { mtimeMs: stats.mtimeMs, size: stats.size, result });
964
+ return result;
803
965
  } catch (err) {
804
966
  return {
805
967
  classes: null,
@@ -1575,13 +1737,20 @@ function collectApplyClassNames(css, warnings) {
1575
1737
  }
1576
1738
  return classes;
1577
1739
  }
1740
+ var effectiveThemeMemo = /* @__PURE__ */ new WeakMap();
1578
1741
  async function finalizeProjectCompilation(options) {
1579
1742
  const { analysis } = options;
1580
1743
  let effectiveTheme = analysis.theme;
1581
1744
  if (options.resolveFonts) {
1582
1745
  const resolvedFonts = await options.resolveFonts(analysis.theme.fonts);
1583
1746
  if (resolvedFonts !== analysis.theme.fonts) {
1584
- effectiveTheme = { ...analysis.theme, fonts: [...resolvedFonts] };
1747
+ const memo = effectiveThemeMemo.get(analysis.theme);
1748
+ if (memo && memo.fonts === resolvedFonts) {
1749
+ effectiveTheme = memo.theme;
1750
+ } else {
1751
+ effectiveTheme = { ...analysis.theme, fonts: [...resolvedFonts] };
1752
+ effectiveThemeMemo.set(analysis.theme, { fonts: resolvedFonts, theme: effectiveTheme });
1753
+ }
1585
1754
  }
1586
1755
  }
1587
1756
  const expansionWarnings = [];
@@ -1625,8 +1794,21 @@ async function finalizeProjectCompilation(options) {
1625
1794
  }
1626
1795
 
1627
1796
  // src/project/scan.ts
1797
+ var lastAnalysis = null;
1798
+ function analyzeProjectCSSMemo(css) {
1799
+ if (lastAnalysis === null || lastAnalysis.css !== css) {
1800
+ lastAnalysis = { css, analysis: analyzeProjectCSS(css) };
1801
+ }
1802
+ const cached = lastAnalysis.analysis;
1803
+ return {
1804
+ ...cached,
1805
+ warnings: [...cached.warnings],
1806
+ warningSeen: new Set(cached.warningSeen),
1807
+ diagnostics: [...cached.diagnostics]
1808
+ };
1809
+ }
1628
1810
  async function compileScannedProject(options) {
1629
- const analysis = analyzeProjectCSS(options.css);
1811
+ const analysis = analyzeProjectCSSMemo(options.css);
1630
1812
  const resolveFonts = options.resolveFonts ?? resolveGoogleFonts;
1631
1813
  const fontsReady = Promise.resolve(resolveFonts(analysis.theme.fonts));
1632
1814
  fontsReady.catch(() => {