@nebutra/fonts 0.1.1 → 3.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # @nebutra/fonts
2
+
3
+ ## 3.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies []:
8
+ - @nebutra/brand@3.0.0
9
+
10
+ ## 2.0.0
@@ -0,0 +1,27 @@
1
+ # Font redistribution notice
2
+
3
+ This package's MIT licence covers **first-party code only**.
4
+
5
+ ## MiSans
6
+
7
+ 本软件使用了 **MiSans** 字体(小米科技有限责任公司)。
8
+ This software uses the **MiSans** typeface by Xiaomi.
9
+
10
+ MiSans is free for commercial use and may be embedded in software on the
11
+ condition that the software states it uses MiSans (this notice, and the credit
12
+ line on the product surfaces). The font may not be distributed on its own or
13
+ have its appearance altered. Licence text: `vendor/misans/LICENSE.txt`.
14
+
15
+ ## DM Sans
16
+
17
+ This software uses the **DM Sans** typeface, licensed under the SIL Open Font
18
+ License 1.1. Licence text: `vendor/dm-sans/OFL.txt`.
19
+
20
+ ## Distribution
21
+
22
+ The DM Sans subset (`generated/dm-sans.woff2`) is committed so
23
+ `next/font/local` can load it offline; it is **not** in the npm `files` list.
24
+ MiSans subsets are never committed at all: the licence forbids distributing
25
+ the font on its own, so they are uploaded to the deployment's asset CDN and
26
+ `<CjkFontFace />` points at them. Do not add `generated/*.woff2` or other font
27
+ binaries to a publishable `files` glob.
package/README.md CHANGED
@@ -54,18 +54,14 @@ const stack = withRegistryFont("Space Grotesk, sans-serif");
54
54
  // "var(--font-space-grotesk), Space Grotesk, sans-serif"
55
55
  ```
56
56
 
57
- ## Simplified Chinese — self-hosted vivo Sans SC
57
+ ## Brand faces — DM Sans (headings) and MiSans (Chinese)
58
58
 
59
- Geist has no CJK coverage at all, so without a CJK face every Chinese character
60
- falls back to whatever the OS supplies: PingFang on macOS, Microsoft YaHei on
61
- Windows, something else again on Android. Chinese copy is a first-class surface
62
- in this product (see `docs/microcopy/`), so the CJK face is self-hosted and
63
- subset here.
59
+ Chosen 2026-09-25 by measuring what competitors ship: MiniMax and Moonshot set
60
+ Chinese in MiSans, DeepSeek and Databricks set Latin in DM Sans. Geist stays the
61
+ body/UI face for its tabular figures.
64
62
 
65
63
  ### Wiring an app
66
64
 
67
- Two lines in the root layout, beside the Geist loaders:
68
-
69
65
  ```tsx
70
66
  import { cjkFontClassName } from "@nebutra/fonts/next/cjk";
71
67
  import { GeistMono } from "geist/font/mono";
@@ -74,112 +70,75 @@ import { GeistSans } from "geist/font/sans";
74
70
  <html className={`${GeistSans.variable} ${GeistMono.variable} ${cjkFontClassName}`}>
75
71
  ```
76
72
 
77
- That defines `--font-vivo-sans-sc`. Nothing else is needed: the token stacks in
78
- `@nebutra/tokens` (`--font-sans`, `--font-cn`, `--font-display`, `--font-heading`)
79
- already reference the variable in the right position. The app also needs
80
- `"@nebutra/fonts"` in `dependencies` and in `transpilePackages` (the package ships
81
- TypeScript source).
82
-
83
- Do **not** re-declare `--font-sans` and friends downstream. `@nebutra/ui`'s
84
- `typography/fonts.css` used to, and being the later import its Geist-only copies
85
- won, so the CJK half of the token stack never reached `--font-sans` at all. That
86
- duplication is gone; the file now only carries derived aliases.
73
+ `cjkFontClassName` defines `--font-dm-sans` (self-hosted variable subset, SIL
74
+ OFL). MiSans arrives through `<CjkFontFace />` from the same entry — render it
75
+ once in the root layout. It builds the `@font-face` rules at render time from
76
+ the committed keys and `publicAssetUrl()` (`NEXT_PUBLIC_R2_PUBLIC_URL`, then
77
+ `R2_PUBLIC_URL`, then the brand's `cdn` origin), so no host is hardcoded and a
78
+ scaffold resolves to its own CDN. The token stacks in `@nebutra/tokens` already
79
+ reference both. Non-Next hosts (Storybook) import `@nebutra/fonts/font-face` and
80
+ pass `origin` explicitly.
81
+
82
+ ### Why MiSans is on the CDN, not in this package
83
+
84
+ The MiSans licence allows free commercial use and embedding **with attribution**,
85
+ but forbids distributing the font software on its own. This repository is public
86
+ and mirrored as a template, so committing the subsets would distribute them —
87
+ the reason vivo Sans was removed (b5e73db35). `pnpm subset:cjk --upload` writes
88
+ content-hashed subsets to the bucket behind your public asset origin
89
+ (`MISANS_R2_BUCKET`, under `fonts/misans/`) and commits only their keys. Until
90
+ they are uploaded, or offline, the requests fail and the stack falls back to
91
+ PingFang / YaHei. Credit MiSans on product surfaces (see NOTICE-FONTS.md).
87
92
 
88
93
  ### Stack order is the design decision
89
94
 
90
95
  ```css
91
- font-family: var(--font-geist-sans), "Geist", var(--font-vivo-sans-sc), "Noto Sans SC", …;
96
+ font-family: var(--font-geist-sans), "Geist", var(--font-misans, "MiSans"), …;
92
97
  ```
93
98
 
94
- Geist comes **first** and keeps Latin and the numerals — its tabular figures and
95
- tighter x-height are what dense dashboard tables need, and it is the locked UI
96
- face. vivo Sans SC takes CJK. Both faces cover Latin, so the *order* is what
97
- decides: reversed, vivo Sans would take the Latin too, and its Latin is not as
98
- good as Geist's for UI.
99
+ The Latin face comes **first** and keeps Latin and the numerals; MiSans takes
100
+ CJK. Both cover Latin, so the order decides: reversed, MiSans would take the
101
+ Latin too.
99
102
 
100
103
  Belt and braces: the generated `@font-face` rules carry a `unicode-range` with
101
104
  no Latin, no ASCII and no general-punctuation codepoints in it, so a Latin-only
102
105
  page can never trigger a CJK download even if a stack somewhere is written the
103
106
  wrong way round. Geist Mono remains the code face.
104
107
 
105
- ### Verified, not assumed
106
-
107
- Measured in Chromium against a running app (`apps/typelens`, dev and production
108
- build), reading the fonts the engine actually used per text run via
109
- `CSS.getPlatformFontsForNode` — not by eye and not from the declared stack:
110
-
111
- | Probe | Face actually used |
112
- |---|---|
113
- | `Handgloves ABC` | Geist |
114
- | `1234567890` | Geist |
115
- | `开始设置的一二三` @400 | vivo Sans |
116
- | `开始设置的一二三` @500 | vivo Sans Medium |
117
- | `开始设置的一二三` @600 | vivo Sans Demibold |
118
- | `开始设置的一二三` @700 | vivo Sans Demibold (no synthesis — `font-synthesis: none`) |
119
- | `。、!?()` | vivo Sans |
120
- | `Nebutra 云毓 2026` | Geist for Latin + digits, vivo Sans for 云, PingFang SC for 毓 |
121
-
122
- The last row is the design working as intended: 毓 is a GB2312 **level-2**
123
- character, outside the subset, so it falls through the stack instead of bloating
124
- every page. A Latin-only route (`/this-route-does-not-exist`) requested
125
- `Geist_Variable` and **nothing else** — zero CJK bytes. The production build emits
126
- exactly three woff2 files (498,084 / 504,228 / 504,332 B, byte-identical to the
127
- package) and preloads only the two Geist files.
128
-
129
- Note that `document.fonts.check("16px vivoSansCn", "A")` returns `true`. That is
130
- the vacuous-true case in the spec — no face in the family matches U+0041's
131
- `unicode-range`, so "all matching faces are loaded" is trivially satisfied. It is
132
- not evidence of Latin coverage; the platform-font table above is.
133
-
134
108
  ### Building the subsets
135
109
 
136
110
  ```bash
137
- pnpm --filter @nebutra/fonts subset:cjk # idempotent; reports every byte size
138
- pnpm --filter @nebutra/fonts subset:cjk -- --force
111
+ FONTTOOLS_PYTHON=/path/to/python MISANS_ZIP=/path/to/MiSans.zip \
112
+ pnpm --filter @nebutra/fonts subset:cjk -- --force --upload
139
113
  ```
140
114
 
141
- Requires `python3` with `fontTools` and `brotli` (for `--flavor=woff2`).
142
- `woff2_compress` is not needed. Outputs land in `generated/`:
143
-
144
- | File | Weight | Size |
145
- |---|---|---|
146
- | `vivo-sans-sc-400.woff2` | 400 body | 498,084 B (486.4 KB) |
147
- | `vivo-sans-sc-500.woff2` | 500 `--font-weight-medium` | 504,228 B (492.4 KB) |
148
- | `vivo-sans-sc-600.woff2` | 600 `--font-weight-heading` | 504,332 B (492.5 KB) |
149
- | `vivo-sans-cn.css` | `@font-face` rules | 1,813 B |
150
- | `generated/index.ts` | face metadata for `next/font/local` | 1,398 B |
151
-
152
- Sizes move by a few hundred bytes as Chinese copy lands — the catalog character
153
- count is an input, not a constant. `generated/subset-manifest.json` records the
154
- exact character count and byte size of the build actually on disk.
155
-
156
- ### Why three weights
157
-
158
- The design system's numeric slots are `--font-weight-medium: 500` and
159
- `--font-weight-heading: 600` (`packages/design/tokens/recipe.css`), and the token
160
- CSS writes literal `font-weight` in only four values: 500 (43×), 600 (35×),
161
- 400 (27×), 700 (10×). So 400 / 500 / 600 ship. 700 resolves to the 600 face by
162
- normal CSS font matching, and because that matched face is itself ≥ 600 no
163
- browser applies synthetic bold — which is precisely why the third face is
164
- DemiBold 600 rather than Bold 700. Choosing 700 instead would leave the *default*
165
- heading weight, the most common heading value in the system, a step out of place.
166
- Skin-declared fractional weights (300 / 450 / 510) resolve into the same set.
167
- Each weight costs ~490 KB, so shipping all nine static faces would be ~4.4 MB.
115
+ Requires Python with `fontTools` and `brotli` (for `--flavor=woff2`) and a
116
+ logged-in `wrangler`. Without `MISANS_ZIP` the script downloads Xiaomi's official
117
+ package (`MISANS_ZIP_URL`). It subsets the static Regular / Medium / Semibold /
118
+ Bold faces, names each output by content hash, and with `--upload` puts them in
119
+ R2 (`MISANS_R2_BUCKET`) with an immutable cache header. Only `index.ts` (the
120
+ keys) and the manifest are committed; the woff2 files are gitignored. A hash change means a new URL, so
121
+ the CDN never serves a stale face. The bucket's CORS allowlist lives in
122
+ `infra/iac/cloudflare/r2/cors.json`: an app on a new origin gets no MiSans (the
123
+ stack silently falls back to PingFang) until its origin is added there.
124
+
125
+ ### Why four weights
126
+
127
+ `--font-weight-heading` is 500 (`packages/design/tokens/recipe.css`), body is
128
+ 400, and the token CSS writes literal 600 and 700 as well. Each static face
129
+ costs ~525 KB, and `unicode-range` plus per-weight `@font-face` means a page
130
+ only downloads the weights it renders.
168
131
 
169
132
  ### Why the static faces, not the variable one
170
133
 
171
- `vivoSansSCVF.ttf` is 42 MB. Subset to this exact character set it is still
172
- 1,070,240 B, because a variable CJK font carries per-weight deltas for every
173
- glyph it keeps — one variable file costs more than all three static subsets on
174
- the pages that only use one weight. `vivo Sans SC L3` is *not* usable as a body
175
- face: 60,339 characters but zero in the CJK basic block, it is a rare-plane
176
- supplement.
134
+ A variable CJK font carries per-weight deltas for every glyph it keeps, so one
135
+ variable file costs more than the static weights a page actually uses.
177
136
 
178
137
  ### Character set
179
138
 
180
- Three inputs, unioned — 4,282 characters in the current build:
139
+ Three inputs, unioned — 4,330 characters in the current build:
181
140
 
182
- 1. **The zh catalogs, by glob** (1,586 chars) — every `zh*.json` under any
141
+ 1. **The zh catalogs, by glob** (1,662 chars) — every `zh*.json` under any
183
142
  `messages/` or `locales/` directory, walked at build time, so new Chinese copy
184
143
  is covered on the next run instead of drifting away from a hardcoded list.
185
144
  2. **CJK punctuation and fullwidth forms, wholesale** (197 chars) — U+3000–303F,
@@ -199,36 +158,22 @@ Three inputs, unioned — 4,282 characters in the current build:
199
158
  characters that appear in a fraction of a percent of text, which is exactly
200
159
  what OS fallback is for.
201
160
 
202
- The floor is what costs the bytes: the catalog set alone subsets to 191,108 B per
203
- weight, the full set to ~498,000 B.
204
-
205
- > Possible follow-up, measured but **not** implemented here: splitting each
206
- > weight into a hot tier (catalogs + punctuation, 1,777 chars, 197,028 B) and an
207
- > extended tier (the GB2312 remainder, 2,509 chars, 312,644 B) with complementary
208
- > `unicode-range`s would drop the common path from ~1.47 MB to ~591 KB across
209
- > three weights, and only fetch the extended tier when user data actually renders
210
- > a character outside our own copy. It costs two files per weight and (on the
211
- > `next/font/local` path) a per-tier `declarations` entry to carry the range.
212
-
213
161
  ### Vendored sources
214
162
 
215
- `vendor/vivo-sans/` holds only the three static TTFs the pipeline consumes —
216
- `vivoSans-Regular.ttf`, `vivoSans-Medium.ttf`, `vivoSans-DemiBold.ttf` (7.4 MB
217
- each) — not all 33 faces from the licensed set, and not the 42 MB variable font.
218
- The licence agreement is committed beside them as
219
- `vendor/vivo-sans/LICENCE-vivo-Sans.txt`. `VIVO_SANS_SOURCE_DIR` points the
220
- script at the licensed originals when re-vendoring.
163
+ `vendor/misans/LICENSE.txt` (licence text and the FAQ answers on embedding) and
164
+ `vendor/dm-sans/OFL.txt` are committed. The MiSans zip and TTFs are gitignored.
165
+ Never commit a MiSans binary: the licence forbids distributing it on its own.
221
166
 
222
167
  ## Third-party font attribution
223
168
 
224
- 本软件使用了 **vivo Sans** 字体。
225
- This software uses the **vivo Sans** typeface.
169
+ 本产品使用了小米 **MiSans** 字体。
170
+ This product uses the **MiSans** typeface by Xiaomi.
226
171
 
227
- Per clause 2.1 of the vivo Sans 字体知识产权许可协议 (committed at
228
- `vendor/vivo-sans/LICENCE-vivo-Sans.txt`): 您应在软件中特别注明使用了 vivo Sans 字体.
229
- vivo Sans is licensed from vivo Mobile Communication Co., Ltd. and is **not**
230
- covered by this package's MIT licence, which applies to the code only. Any
231
- redistribution of the generated `.woff2` files carries the same obligation.
172
+ MiSans is free for commercial use under the MiSans Font Intellectual Property
173
+ License Agreement, which requires this attribution
174
+ (`vendor/misans/LICENSE.txt`). DM Sans is SIL OFL 1.1 (`vendor/dm-sans/OFL.txt`).
175
+ Both licences are separate from this package's MIT licence. See
176
+ `NOTICE-FONTS.md`.
232
177
 
233
178
  ## Registered Families
234
179
 
@@ -244,4 +189,5 @@ uses the corresponding CSS variable.
244
189
 
245
190
  ## License
246
191
 
247
- MIT
192
+ MIT for first-party code. DM Sans is SIL OFL 1.1. MiSans binaries are never
193
+ committed or published to npm; they are served from the deployment's asset CDN.
@@ -0,0 +1,20 @@
1
+ // generated/index.ts
2
+ var MISANS_VARIABLE = "--font-misans";
3
+ var MISANS_FAMILY = "MiSans";
4
+ var MISANS_CHAR_COUNT = 4330;
5
+ var MISANS_UNICODE_RANGE = "U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF";
6
+ var MISANS_FILES = [
7
+ { key: "fonts/misans/misans-400.6bf25dfdf3.woff2", weight: "400", bytes: 535408 },
8
+ { key: "fonts/misans/misans-500.442041bb9d.woff2", weight: "500", bytes: 537060 },
9
+ { key: "fonts/misans/misans-600.2590c17c53.woff2", weight: "600", bytes: 540540 },
10
+ { key: "fonts/misans/misans-700.ddd8bf8017.woff2", weight: "700", bytes: 545728 }
11
+ ];
12
+
13
+ export {
14
+ MISANS_VARIABLE,
15
+ MISANS_FAMILY,
16
+ MISANS_CHAR_COUNT,
17
+ MISANS_UNICODE_RANGE,
18
+ MISANS_FILES
19
+ };
20
+ //# sourceMappingURL=chunk-FUPJK5RT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../generated/index.ts"],"sourcesContent":["/**\n * GENERATED FILE, DO NOT EDIT.\n * Written by packages/design/fonts/scripts/subset-cjk.mjs.\n *\n * Metadata for the CDN-hosted MiSans subsets. <CjkFontFace /> in\n * ../src/cjk-font-face.tsx turns it into @font-face rules at render time.\n *\n * The registry key is \"misans\" (see FONT_REGISTRY in ../src/index.ts);\n * the CSS variable is \"--font-misans\".\n */\n\nexport const MISANS_VARIABLE = \"--font-misans\" as const;\n\nexport const MISANS_FAMILY = \"MiSans\" as const;\n\n/** Characters covered per face (catalogs ∪ CJK punctuation ∪ GB2312 level-1). */\nexport const MISANS_CHAR_COUNT = 4330 as const;\n\n/** `unicode-range` of every generated @font-face — CJK only, no Latin. */\nexport const MISANS_UNICODE_RANGE = \"U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF\" as const;\n\n/** Public-asset keys, one per weight (content-hashed names). No host: see publicAssetUrl(). */\nexport const MISANS_FILES = [\n { key: \"fonts/misans/misans-400.6bf25dfdf3.woff2\", weight: \"400\", bytes: 535408 },\n { key: \"fonts/misans/misans-500.442041bb9d.woff2\", weight: \"500\", bytes: 537060 },\n { key: \"fonts/misans/misans-600.2590c17c53.woff2\", weight: \"600\", bytes: 540540 },\n { key: \"fonts/misans/misans-700.ddd8bf8017.woff2\", weight: \"700\", bytes: 545728 },\n] as const;\n"],"mappings":";AAWO,IAAM,kBAAkB;AAExB,IAAM,gBAAgB;AAGtB,IAAM,oBAAoB;AAG1B,IAAM,uBAAuB;AAG7B,IAAM,eAAe;AAAA,EAC1B,EAAE,KAAK,4CAA4C,QAAQ,OAAO,OAAO,OAAO;AAAA,EAChF,EAAE,KAAK,4CAA4C,QAAQ,OAAO,OAAO,OAAO;AAAA,EAChF,EAAE,KAAK,4CAA4C,QAAQ,OAAO,OAAO,OAAO;AAAA,EAChF,EAAE,KAAK,4CAA4C,QAAQ,OAAO,OAAO,OAAO;AAClF;","names":[]}
@@ -0,0 +1,38 @@
1
+ import {
2
+ MISANS_FAMILY,
3
+ MISANS_FILES,
4
+ MISANS_UNICODE_RANGE
5
+ } from "./chunk-FUPJK5RT.js";
6
+
7
+ // src/next-cjk.ts
8
+ import localFont from "next/font/local";
9
+
10
+ // src/cjk-font-face.tsx
11
+ import { publicAssetUrl } from "@nebutra/brand/metadata-helpers";
12
+ import { jsx } from "react/jsx-runtime";
13
+ function misansFontFaceCss(origin) {
14
+ return MISANS_FILES.map(
15
+ (face) => `@font-face{font-family:"${MISANS_FAMILY}";font-style:normal;font-weight:${face.weight};font-display:swap;src:url("${publicAssetUrl(face.key, origin)}") format("woff2");unicode-range:${MISANS_UNICODE_RANGE}}`
16
+ ).join("\n");
17
+ }
18
+ function CjkFontFace({ origin, nonce }) {
19
+ return /* @__PURE__ */ jsx("style", { href: "nebutra-misans", precedence: "default", nonce: nonce || void 0, children: misansFontFaceCss(origin) });
20
+ }
21
+
22
+ // src/next-cjk.ts
23
+ var dmSans = localFont({
24
+ src: [{ path: "../generated/dm-sans.woff2", weight: "100 1000", style: "normal" }],
25
+ display: "swap",
26
+ variable: "--font-dm-sans"
27
+ });
28
+ var brandFontClassName = dmSans.variable;
29
+ var cjkFontClassName = brandFontClassName;
30
+
31
+ export {
32
+ misansFontFaceCss,
33
+ CjkFontFace,
34
+ dmSans,
35
+ brandFontClassName,
36
+ cjkFontClassName
37
+ };
38
+ //# sourceMappingURL=chunk-JHYTIKKY.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/next-cjk.ts","../src/cjk-font-face.tsx"],"sourcesContent":["/**\n * @nebutra/fonts/next/cjk — the self-hosted brand faces (server-only).\n *\n * The Latin brand face lives here through `next/font/local` so first-party apps work\n * offline, in CI and in the network sandbox (`next/font/google` fails there):\n *\n * - MiSans — the Simplified-Chinese face — is CDN-hosted; see <CjkFontFace /> below.\n * - DM Sans — the Latin display/heading face (SIL OFL). Chosen the same day\n * from the same measurement (DeepSeek, Databricks). Body/UI Latin stays\n * Geist: its tabular figures are what dense dashboard tables need.\n *\n * WHY A SEPARATE ENTRY FROM `./next`: that module declares ~16\n * `next/font/google` faces for the theme / DESIGN.md registry; importing it for\n * the brand faces would drag those build-time downloads into every app. `./next`\n * re-exports this file, so an app already applying `fontRegistryClassName`\n * still needs one import.\n *\n * next/font is a compile-time transform: SWC statically analyses the call, so\n * the options object is spelled out as a literal.\n */\n\nimport localFont from \"next/font/local\";\n\n/**\n * MiSans is NOT loaded here: its licence forbids distributing the font on its\n * own and this repository is public (vivo Sans was removed for the same reason,\n * b5e73db35). The subsets are served from the deployment's asset CDN and\n * declared by <CjkFontFace />, re-exported below — render it in the root\n * layout next to this class name. The token stacks reference\n * var(--font-misans, \"MiSans\"): with no variable set, the literal family name\n * resolves to that @font-face.\n */\nexport { CjkFontFace, misansFontFaceCss } from \"./cjk-font-face\";\n\n/**\n * DM Sans — one variable file (opsz 9–40, wght 100–1000), Latin subset, 67KB.\n * Preloaded: headings render above the fold on most pages.\n */\nexport const dmSans = localFont({\n src: [{ path: \"../generated/dm-sans.woff2\", weight: \"100 1000\", style: \"normal\" }],\n display: \"swap\",\n variable: \"--font-dm-sans\",\n});\n\n/**\n * Apply to <html> next to the Geist loaders so `--font-dm-sans` exists:\n *\n * className={`${GeistSans.variable} ${GeistMono.variable} ${cjkFontClassName}`}\n *\n * The name predates DM Sans; it now carries both brand faces so every app that\n * already applies it picks them up without a layout change.\n */\nexport const brandFontClassName = dmSans.variable;\nexport const cjkFontClassName = brandFontClassName;\n","/**\n * MiSans @font-face, rendered at request time rather than shipped as CSS.\n *\n * The MiSans licence forbids distributing the font on its own and this\n * repository is public, so the subsets live in the deployment's public asset\n * bucket and only their keys are committed (../generated/index.ts). A static\n * stylesheet would have to spell out a host, and the host differs per\n * deployment: Nebutra's CDN for Nebutra, the scaffold's own for a template\n * user. publicAssetUrl() resolves it the way it resolves every other public\n * asset — NEXT_PUBLIC_R2_PUBLIC_URL, then R2_PUBLIC_URL, then the brand's cdn\n * origin.\n *\n * Before the subsets have been uploaded (a fresh scaffold, `pnpm subset:cjk\n * --upload` not yet run) the requests 404 and the token stacks fall through to\n * PingFang / YaHei. That is the intended degraded state, not an error.\n */\n\nimport { publicAssetUrl } from \"@nebutra/brand/metadata-helpers\";\nimport { MISANS_FAMILY, MISANS_FILES, MISANS_UNICODE_RANGE } from \"../generated/index\";\n\n/** One @font-face per weight, CJK-only unicode-range, `swap` so text is never invisible. */\nexport function misansFontFaceCss(origin?: string): string {\n return MISANS_FILES.map(\n (face) =>\n `@font-face{font-family:\"${MISANS_FAMILY}\";font-style:normal;font-weight:${face.weight};` +\n `font-display:swap;src:url(\"${publicAssetUrl(face.key, origin)}\") format(\"woff2\");` +\n `unicode-range:${MISANS_UNICODE_RANGE}}`,\n ).join(\"\\n\");\n}\n\nexport interface CjkFontFaceProps {\n /** Asset origin override; defaults to the publicAssetUrl() resolution. */\n origin?: string;\n /**\n * CSP nonce, for apps whose style-src allows inline styles only by nonce\n * (apps/web). Without it the whole @font-face block is refused. The page's\n * font-src must also allow publicAssetOrigin().\n */\n nonce?: string;\n}\n\n/**\n * Render once in each root layout, next to `cjkFontClassName` on <html>.\n * React 19 hoists a `<style>` carrying `href` + `precedence` into <head> and\n * dedupes it by `href`, so rendering it twice costs nothing.\n */\nexport function CjkFontFace({ origin, nonce }: CjkFontFaceProps) {\n return (\n <style href=\"nebutra-misans\" precedence=\"default\" nonce={nonce || undefined}>\n {misansFontFaceCss(origin)}\n </style>\n );\n}\n"],"mappings":";;;;;;;AAqBA,OAAO,eAAe;;;ACJtB,SAAS,sBAAsB;AA+B3B;AA3BG,SAAS,kBAAkB,QAAyB;AACzD,SAAO,aAAa;AAAA,IAClB,CAAC,SACC,2BAA2B,aAAa,mCAAmC,KAAK,MAAM,+BACxD,eAAe,KAAK,KAAK,MAAM,CAAC,oCAC7C,oBAAoB;AAAA,EACzC,EAAE,KAAK,IAAI;AACb;AAkBO,SAAS,YAAY,EAAE,QAAQ,MAAM,GAAqB;AAC/D,SACE,oBAAC,WAAM,MAAK,kBAAiB,YAAW,WAAU,OAAO,SAAS,QAC/D,4BAAkB,MAAM,GAC3B;AAEJ;;;ADdO,IAAM,SAAS,UAAU;AAAA,EAC9B,KAAK,CAAC,EAAE,MAAM,8BAA8B,QAAQ,YAAY,OAAO,SAAS,CAAC;AAAA,EACjF,SAAS;AAAA,EACT,UAAU;AACZ,CAAC;AAUM,IAAM,qBAAqB,OAAO;AAClC,IAAM,mBAAmB;","names":[]}
@@ -2,41 +2,35 @@
2
2
  * GENERATED FILE, DO NOT EDIT.
3
3
  * Written by packages/design/fonts/scripts/subset-cjk.mjs.
4
4
  *
5
- * Metadata for the self-hosted Simplified-Chinese faces, in the shape
6
- * `next/font/local` expects, so the server entry (`@nebutra/fonts/next`) can
7
- * declare the face without re-stating weights or file names. The plain
8
- * `vivo-sans-cn.css` next to this file is the non-Next consumer path.
5
+ * Metadata for the CDN-hosted MiSans subsets. <CjkFontFace /> in
6
+ * ../src/cjk-font-face.tsx turns it into @font-face rules at render time.
9
7
  *
10
- * The registry key is "vivo sans sc" (see FONT_REGISTRY in ../src/index.ts);
11
- * the CSS variable is "--font-vivo-sans-sc".
8
+ * The registry key is "misans" (see FONT_REGISTRY in ../src/index.ts);
9
+ * the CSS variable is "--font-misans".
12
10
  */
13
- declare const VIVO_SANS_CN_VARIABLE: "--font-vivo-sans-sc";
14
- declare const VIVO_SANS_CN_FAMILY: "vivo Sans SC";
11
+ declare const MISANS_VARIABLE: "--font-misans";
12
+ declare const MISANS_FAMILY: "MiSans";
15
13
  /** Characters covered per face (catalogs ∪ CJK punctuation ∪ GB2312 level-1). */
16
- declare const VIVO_SANS_CN_CHAR_COUNT: 4282;
14
+ declare const MISANS_CHAR_COUNT: 4330;
17
15
  /** `unicode-range` of every generated @font-face — CJK only, no Latin. */
18
- declare const VIVO_SANS_CN_UNICODE_RANGE: "U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF";
19
- /** Sources for `next/font/local({ src: [...] })`, paths relative to this file. */
20
- declare const VIVO_SANS_CN_SOURCES: readonly [{
21
- readonly path: "./vivo-sans-sc-400.woff2";
16
+ declare const MISANS_UNICODE_RANGE: "U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF";
17
+ /** Public-asset keys, one per weight (content-hashed names). No host: see publicAssetUrl(). */
18
+ declare const MISANS_FILES: readonly [{
19
+ readonly key: "fonts/misans/misans-400.6bf25dfdf3.woff2";
22
20
  readonly weight: "400";
23
- readonly style: "normal";
24
- readonly bytes: 498084;
21
+ readonly bytes: 535408;
25
22
  }, {
26
- readonly path: "./vivo-sans-sc-500.woff2";
23
+ readonly key: "fonts/misans/misans-500.442041bb9d.woff2";
27
24
  readonly weight: "500";
28
- readonly style: "normal";
29
- readonly bytes: 504228;
25
+ readonly bytes: 537060;
30
26
  }, {
31
- readonly path: "./vivo-sans-sc-600.woff2";
27
+ readonly key: "fonts/misans/misans-600.2590c17c53.woff2";
32
28
  readonly weight: "600";
33
- readonly style: "normal";
34
- readonly bytes: 504332;
29
+ readonly bytes: 540540;
35
30
  }, {
36
- readonly path: "./vivo-sans-sc-700.woff2";
31
+ readonly key: "fonts/misans/misans-700.ddd8bf8017.woff2";
37
32
  readonly weight: "700";
38
- readonly style: "normal";
39
- readonly bytes: 506808;
33
+ readonly bytes: 545728;
40
34
  }];
41
35
 
42
- export { VIVO_SANS_CN_CHAR_COUNT, VIVO_SANS_CN_FAMILY, VIVO_SANS_CN_SOURCES, VIVO_SANS_CN_UNICODE_RANGE, VIVO_SANS_CN_VARIABLE };
36
+ export { MISANS_CHAR_COUNT, MISANS_FAMILY, MISANS_FILES, MISANS_UNICODE_RANGE, MISANS_VARIABLE };
@@ -1,19 +1,15 @@
1
- // generated/index.ts
2
- var VIVO_SANS_CN_VARIABLE = "--font-vivo-sans-sc";
3
- var VIVO_SANS_CN_FAMILY = "vivo Sans SC";
4
- var VIVO_SANS_CN_CHAR_COUNT = 4282;
5
- var VIVO_SANS_CN_UNICODE_RANGE = "U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF";
6
- var VIVO_SANS_CN_SOURCES = [
7
- { path: "./vivo-sans-sc-400.woff2", weight: "400", style: "normal", bytes: 498084 },
8
- { path: "./vivo-sans-sc-500.woff2", weight: "500", style: "normal", bytes: 504228 },
9
- { path: "./vivo-sans-sc-600.woff2", weight: "600", style: "normal", bytes: 504332 },
10
- { path: "./vivo-sans-sc-700.woff2", weight: "700", style: "normal", bytes: 506808 }
11
- ];
1
+ import {
2
+ MISANS_CHAR_COUNT,
3
+ MISANS_FAMILY,
4
+ MISANS_FILES,
5
+ MISANS_UNICODE_RANGE,
6
+ MISANS_VARIABLE
7
+ } from "../chunk-FUPJK5RT.js";
12
8
  export {
13
- VIVO_SANS_CN_CHAR_COUNT,
14
- VIVO_SANS_CN_FAMILY,
15
- VIVO_SANS_CN_SOURCES,
16
- VIVO_SANS_CN_UNICODE_RANGE,
17
- VIVO_SANS_CN_VARIABLE
9
+ MISANS_CHAR_COUNT,
10
+ MISANS_FAMILY,
11
+ MISANS_FILES,
12
+ MISANS_UNICODE_RANGE,
13
+ MISANS_VARIABLE
18
14
  };
19
15
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../generated/index.ts"],"sourcesContent":["/**\n * GENERATED FILE, DO NOT EDIT.\n * Written by packages/design/fonts/scripts/subset-cjk.mjs.\n *\n * Metadata for the self-hosted Simplified-Chinese faces, in the shape\n * `next/font/local` expects, so the server entry (`@nebutra/fonts/next`) can\n * declare the face without re-stating weights or file names. The plain\n * `vivo-sans-cn.css` next to this file is the non-Next consumer path.\n *\n * The registry key is \"vivo sans sc\" (see FONT_REGISTRY in ../src/index.ts);\n * the CSS variable is \"--font-vivo-sans-sc\".\n */\n\nexport const VIVO_SANS_CN_VARIABLE = \"--font-vivo-sans-sc\" as const;\n\nexport const VIVO_SANS_CN_FAMILY = \"vivo Sans SC\" as const;\n\n/** Characters covered per face (catalogs ∪ CJK punctuation ∪ GB2312 level-1). */\nexport const VIVO_SANS_CN_CHAR_COUNT = 4282 as const;\n\n/** `unicode-range` of every generated @font-face — CJK only, no Latin. */\nexport const VIVO_SANS_CN_UNICODE_RANGE = \"U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF\" as const;\n\n/** Sources for `next/font/local({ src: [...] })`, paths relative to this file. */\nexport const VIVO_SANS_CN_SOURCES = [\n { path: \"./vivo-sans-sc-400.woff2\", weight: \"400\", style: \"normal\", bytes: 498084 },\n { path: \"./vivo-sans-sc-500.woff2\", weight: \"500\", style: \"normal\", bytes: 504228 },\n { path: \"./vivo-sans-sc-600.woff2\", weight: \"600\", style: \"normal\", bytes: 504332 },\n { path: \"./vivo-sans-sc-700.woff2\", weight: \"700\", style: \"normal\", bytes: 506808 },\n] as const;\n"],"mappings":";AAaO,IAAM,wBAAwB;AAE9B,IAAM,sBAAsB;AAG5B,IAAM,0BAA0B;AAGhC,IAAM,6BAA6B;AAGnC,IAAM,uBAAuB;AAAA,EAClC,EAAE,MAAM,4BAA4B,QAAQ,OAAO,OAAO,UAAU,OAAO,OAAO;AAAA,EAClF,EAAE,MAAM,4BAA4B,QAAQ,OAAO,OAAO,UAAU,OAAO,OAAO;AAAA,EAClF,EAAE,MAAM,4BAA4B,QAAQ,OAAO,OAAO,UAAU,OAAO,OAAO;AAAA,EAClF,EAAE,MAAM,4BAA4B,QAAQ,OAAO,OAAO,UAAU,OAAO,OAAO;AACpF;","names":[]}
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/dist/index.d.ts CHANGED
@@ -28,5 +28,22 @@ declare function resolveRegistryVar(stack: string): string | undefined;
28
28
  * e.g. "Space Grotesk, sans-serif" → "var(--font-space-grotesk), Space Grotesk, sans-serif"
29
29
  */
30
30
  declare function withRegistryFont(stack: string | undefined): string | undefined;
31
+ /**
32
+ * Like `withRegistryFont`, but matches the first registered family ANYWHERE in
33
+ * the stack rather than only in first position.
34
+ *
35
+ * A brand package names the typeface the design language actually uses, and
36
+ * those are frequently licensed faces we have no right to serve — Söhne, Mori,
37
+ * Lyon Text, Reckless. The declared stack already says what to do when they are
38
+ * absent: fall to the next family. But "the next family" is usually a bare name
39
+ * like `Inter`, which does NOT reach next/font's hashed face, so the stack
40
+ * skidded past every self-hosted option and landed on `ui-sans-serif`. All
41
+ * seven built-in design languages rendered in the system font until 2026-08-18.
42
+ *
43
+ * Prepending the nearest registered family produces exactly the outcome the
44
+ * declared chain intended, and leaves the licensed names in place so a customer
45
+ * who does own the font still gets it by shipping the face themselves.
46
+ */
47
+ declare function withNearestRegistryFont(stack: string | undefined): string | undefined;
31
48
 
32
- export { FONT_REGISTRY, primaryFamily, resolveRegistryVar, withRegistryFont };
49
+ export { FONT_REGISTRY, primaryFamily, resolveRegistryVar, withNearestRegistryFont, withRegistryFont };
package/dist/index.js CHANGED
@@ -4,14 +4,18 @@ var FONT_REGISTRY = {
4
4
  geist: "--font-geist-sans",
5
5
  "geist sans": "--font-geist-sans",
6
6
  "geist mono": "--font-geist-mono",
7
- // Self-hosted via next/font/local from the subset built in ./generated (see
8
- // ./next-cjk). The Simplified-Chinese face — CJK only, it carries no Latin or
9
- // digit glyphs, so it can never take Latin away from Geist.
10
- "vivo sans sc": "--font-vivo-sans-sc",
7
+ // Self-hosted via next/font/local (see ./next-cjk). MiSans is the
8
+ // Simplified-Chinese face — CJK only via unicode-range, so it cannot take
9
+ // Latin away from Geist. DM Sans is the Latin display/heading face.
10
+ misans: "--font-misans",
11
+ "dm sans display": "--font-dm-sans",
11
12
  // Self-hosted via next/font/google (see ./next)
12
13
  inter: "--font-inter",
14
+ "inter tight": "--font-reg-inter-tight",
13
15
  "space grotesk": "--font-space-grotesk",
14
16
  "playfair display": "--font-playfair-display",
17
+ fraunces: "--font-reg-fraunces",
18
+ "source serif 4": "--font-reg-source-serif-4",
15
19
  "jetbrains mono": "--font-jetbrains-mono",
16
20
  manrope: "--font-reg-manrope",
17
21
  sora: "--font-reg-sora",
@@ -40,10 +44,19 @@ function withRegistryFont(stack) {
40
44
  const variable = resolveRegistryVar(stack);
41
45
  return variable ? `var(${variable}), ${stack}` : stack;
42
46
  }
47
+ function withNearestRegistryFont(stack) {
48
+ if (!stack) return stack;
49
+ for (const token of stack.split(",")) {
50
+ const variable = FONT_REGISTRY[normalizeFamily(token)];
51
+ if (variable) return `var(${variable}), ${stack}`;
52
+ }
53
+ return stack;
54
+ }
43
55
  export {
44
56
  FONT_REGISTRY,
45
57
  primaryFamily,
46
58
  resolveRegistryVar,
59
+ withNearestRegistryFont,
47
60
  withRegistryFont
48
61
  };
49
62
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @nebutra/fonts — self-hosted OSS font registry (client-safe entry).\n *\n * Maps a normalized theme / DESIGN.md font-family name to the CSS variable that\n * the build-time self-hosted face defines (declared with next/font in\n * `@nebutra/fonts/next`, applied to <html> via `fontRegistryClassName`).\n *\n * WHY: next/font registers each face under a HASHED family name reachable ONLY\n * via its CSS variable — `font-family: 'Inter'` does NOT use the self-hosted\n * Inter. So when a theme / imported DESIGN.md font's primary family matches an\n * entry here, callers prepend `var(--font-…)` to the stack, making the\n * self-hosted font actually render — with ZERO runtime external requests\n * (next/font self-hosts at build time) and next/font's automatic metric-matched\n * fallback (no layout shift). Unmatched families keep their declared stack.\n *\n * This entry is FREE of `next/font` imports so client modules can use it.\n * The `./next` subpath holds the (server-only) next/font declarations and MUST\n * keep its CSS-variable names in sync with FONT_REGISTRY below.\n */\n\nexport const FONT_REGISTRY: Record<string, string> = {\n // Self-hosted via geist/font (default brand faces, loaded by the app shell)\n geist: \"--font-geist-sans\",\n \"geist sans\": \"--font-geist-sans\",\n \"geist mono\": \"--font-geist-mono\",\n // Self-hosted via next/font/local from the subset built in ./generated (see\n // ./next-cjk). The Simplified-Chinese face — CJK only, it carries no Latin or\n // digit glyphs, so it can never take Latin away from Geist.\n \"vivo sans sc\": \"--font-vivo-sans-sc\",\n // Self-hosted via next/font/google (see ./next)\n inter: \"--font-inter\",\n \"space grotesk\": \"--font-space-grotesk\",\n \"playfair display\": \"--font-playfair-display\",\n \"jetbrains mono\": \"--font-jetbrains-mono\",\n manrope: \"--font-reg-manrope\",\n sora: \"--font-reg-sora\",\n \"work sans\": \"--font-reg-work-sans\",\n \"dm sans\": \"--font-reg-dm-sans\",\n \"plus jakarta sans\": \"--font-reg-plus-jakarta-sans\",\n outfit: \"--font-reg-outfit\",\n figtree: \"--font-reg-figtree\",\n montserrat: \"--font-reg-montserrat\",\n lexend: \"--font-reg-lexend\",\n \"fira code\": \"--font-reg-fira-code\",\n \"roboto mono\": \"--font-reg-roboto-mono\",\n \"source code pro\": \"--font-reg-source-code-pro\",\n};\n\n/** Normalize a single font-family token: strip quotes/whitespace, lowercase. */\nfunction normalizeFamily(name: string): string {\n // Strip ALL quotes with a quantifier-free global replace (quotes only appear\n // at token boundaries in a font-family value). Avoids the end-anchored /['\"]+$/\n // form, which CodeQL flags as polynomial ReDoS (scanned from every position).\n return name.replace(/['\"]/g, \"\").trim().toLowerCase();\n}\n\n/** The first (primary) family in a CSS font-family list, normalized. */\nexport function primaryFamily(stack: string): string {\n return normalizeFamily(stack.split(\",\")[0] ?? \"\");\n}\n\n/** Registry CSS variable for a stack's primary family, or undefined. */\nexport function resolveRegistryVar(stack: string): string | undefined {\n return FONT_REGISTRY[primaryFamily(stack)];\n}\n\n/**\n * Return `stack` with the self-hosted registry font prepended when its primary\n * family is registered; otherwise return it unchanged.\n * e.g. \"Space Grotesk, sans-serif\" → \"var(--font-space-grotesk), Space Grotesk, sans-serif\"\n */\nexport function withRegistryFont(stack: string | undefined): string | undefined {\n if (!stack) return stack;\n const variable = resolveRegistryVar(stack);\n return variable ? `var(${variable}), ${stack}` : stack;\n}\n"],"mappings":";AAoBO,IAAM,gBAAwC;AAAA;AAAA,EAEnD,OAAO;AAAA,EACP,cAAc;AAAA,EACd,cAAc;AAAA;AAAA;AAAA;AAAA,EAId,gBAAgB;AAAA;AAAA,EAEhB,OAAO;AAAA,EACP,iBAAiB;AAAA,EACjB,oBAAoB;AAAA,EACpB,kBAAkB;AAAA,EAClB,SAAS;AAAA,EACT,MAAM;AAAA,EACN,aAAa;AAAA,EACb,WAAW;AAAA,EACX,qBAAqB;AAAA,EACrB,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,eAAe;AAAA,EACf,mBAAmB;AACrB;AAGA,SAAS,gBAAgB,MAAsB;AAI7C,SAAO,KAAK,QAAQ,SAAS,EAAE,EAAE,KAAK,EAAE,YAAY;AACtD;AAGO,SAAS,cAAc,OAAuB;AACnD,SAAO,gBAAgB,MAAM,MAAM,GAAG,EAAE,CAAC,KAAK,EAAE;AAClD;AAGO,SAAS,mBAAmB,OAAmC;AACpE,SAAO,cAAc,cAAc,KAAK,CAAC;AAC3C;AAOO,SAAS,iBAAiB,OAA+C;AAC9E,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,WAAW,mBAAmB,KAAK;AACzC,SAAO,WAAW,OAAO,QAAQ,MAAM,KAAK,KAAK;AACnD;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @nebutra/fonts — self-hosted OSS font registry (client-safe entry).\n *\n * Maps a normalized theme / DESIGN.md font-family name to the CSS variable that\n * the build-time self-hosted face defines (declared with next/font in\n * `@nebutra/fonts/next`, applied to <html> via `fontRegistryClassName`).\n *\n * WHY: next/font registers each face under a HASHED family name reachable ONLY\n * via its CSS variable — `font-family: 'Inter'` does NOT use the self-hosted\n * Inter. So when a theme / imported DESIGN.md font's primary family matches an\n * entry here, callers prepend `var(--font-…)` to the stack, making the\n * self-hosted font actually render — with ZERO runtime external requests\n * (next/font self-hosts at build time) and next/font's automatic metric-matched\n * fallback (no layout shift). Unmatched families keep their declared stack.\n *\n * This entry is FREE of `next/font` imports so client modules can use it.\n * The `./next` subpath holds the (server-only) next/font declarations and MUST\n * keep its CSS-variable names in sync with FONT_REGISTRY below.\n */\n\nexport const FONT_REGISTRY: Record<string, string> = {\n // Self-hosted via geist/font (default brand faces, loaded by the app shell)\n geist: \"--font-geist-sans\",\n \"geist sans\": \"--font-geist-sans\",\n \"geist mono\": \"--font-geist-mono\",\n // Self-hosted via next/font/local (see ./next-cjk). MiSans is the\n // Simplified-Chinese face — CJK only via unicode-range, so it cannot take\n // Latin away from Geist. DM Sans is the Latin display/heading face.\n misans: \"--font-misans\",\n \"dm sans display\": \"--font-dm-sans\",\n // Self-hosted via next/font/google (see ./next)\n inter: \"--font-inter\",\n \"inter tight\": \"--font-reg-inter-tight\",\n \"space grotesk\": \"--font-space-grotesk\",\n \"playfair display\": \"--font-playfair-display\",\n fraunces: \"--font-reg-fraunces\",\n \"source serif 4\": \"--font-reg-source-serif-4\",\n \"jetbrains mono\": \"--font-jetbrains-mono\",\n manrope: \"--font-reg-manrope\",\n sora: \"--font-reg-sora\",\n \"work sans\": \"--font-reg-work-sans\",\n \"dm sans\": \"--font-reg-dm-sans\",\n \"plus jakarta sans\": \"--font-reg-plus-jakarta-sans\",\n outfit: \"--font-reg-outfit\",\n figtree: \"--font-reg-figtree\",\n montserrat: \"--font-reg-montserrat\",\n lexend: \"--font-reg-lexend\",\n \"fira code\": \"--font-reg-fira-code\",\n \"roboto mono\": \"--font-reg-roboto-mono\",\n \"source code pro\": \"--font-reg-source-code-pro\",\n};\n\n/** Normalize a single font-family token: strip quotes/whitespace, lowercase. */\nfunction normalizeFamily(name: string): string {\n // Strip ALL quotes with a quantifier-free global replace (quotes only appear\n // at token boundaries in a font-family value). Avoids the end-anchored /['\"]+$/\n // form, which CodeQL flags as polynomial ReDoS (scanned from every position).\n return name.replace(/['\"]/g, \"\").trim().toLowerCase();\n}\n\n/** The first (primary) family in a CSS font-family list, normalized. */\nexport function primaryFamily(stack: string): string {\n return normalizeFamily(stack.split(\",\")[0] ?? \"\");\n}\n\n/** Registry CSS variable for a stack's primary family, or undefined. */\nexport function resolveRegistryVar(stack: string): string | undefined {\n return FONT_REGISTRY[primaryFamily(stack)];\n}\n\n/**\n * Return `stack` with the self-hosted registry font prepended when its primary\n * family is registered; otherwise return it unchanged.\n * e.g. \"Space Grotesk, sans-serif\" → \"var(--font-space-grotesk), Space Grotesk, sans-serif\"\n */\nexport function withRegistryFont(stack: string | undefined): string | undefined {\n if (!stack) return stack;\n const variable = resolveRegistryVar(stack);\n return variable ? `var(${variable}), ${stack}` : stack;\n}\n\n/**\n * Like `withRegistryFont`, but matches the first registered family ANYWHERE in\n * the stack rather than only in first position.\n *\n * A brand package names the typeface the design language actually uses, and\n * those are frequently licensed faces we have no right to serve — Söhne, Mori,\n * Lyon Text, Reckless. The declared stack already says what to do when they are\n * absent: fall to the next family. But \"the next family\" is usually a bare name\n * like `Inter`, which does NOT reach next/font's hashed face, so the stack\n * skidded past every self-hosted option and landed on `ui-sans-serif`. All\n * seven built-in design languages rendered in the system font until 2026-08-18.\n *\n * Prepending the nearest registered family produces exactly the outcome the\n * declared chain intended, and leaves the licensed names in place so a customer\n * who does own the font still gets it by shipping the face themselves.\n */\nexport function withNearestRegistryFont(stack: string | undefined): string | undefined {\n if (!stack) return stack;\n for (const token of stack.split(\",\")) {\n const variable = FONT_REGISTRY[normalizeFamily(token)];\n if (variable) return `var(${variable}), ${stack}`;\n }\n return stack;\n}\n"],"mappings":";AAoBO,IAAM,gBAAwC;AAAA;AAAA,EAEnD,OAAO;AAAA,EACP,cAAc;AAAA,EACd,cAAc;AAAA;AAAA;AAAA;AAAA,EAId,QAAQ;AAAA,EACR,mBAAmB;AAAA;AAAA,EAEnB,OAAO;AAAA,EACP,eAAe;AAAA,EACf,iBAAiB;AAAA,EACjB,oBAAoB;AAAA,EACpB,UAAU;AAAA,EACV,kBAAkB;AAAA,EAClB,kBAAkB;AAAA,EAClB,SAAS;AAAA,EACT,MAAM;AAAA,EACN,aAAa;AAAA,EACb,WAAW;AAAA,EACX,qBAAqB;AAAA,EACrB,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,eAAe;AAAA,EACf,mBAAmB;AACrB;AAGA,SAAS,gBAAgB,MAAsB;AAI7C,SAAO,KAAK,QAAQ,SAAS,EAAE,EAAE,KAAK,EAAE,YAAY;AACtD;AAGO,SAAS,cAAc,OAAuB;AACnD,SAAO,gBAAgB,MAAM,MAAM,GAAG,EAAE,CAAC,KAAK,EAAE;AAClD;AAGO,SAAS,mBAAmB,OAAmC;AACpE,SAAO,cAAc,cAAc,KAAK,CAAC;AAC3C;AAOO,SAAS,iBAAiB,OAA+C;AAC9E,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,WAAW,mBAAmB,KAAK;AACzC,SAAO,WAAW,OAAO,QAAQ,MAAM,KAAK,KAAK;AACnD;AAkBO,SAAS,wBAAwB,OAA+C;AACrF,MAAI,CAAC,MAAO,QAAO;AACnB,aAAW,SAAS,MAAM,MAAM,GAAG,GAAG;AACpC,UAAM,WAAW,cAAc,gBAAgB,KAAK,CAAC;AACrD,QAAI,SAAU,QAAO,OAAO,QAAQ,MAAM,KAAK;AAAA,EACjD;AACA,SAAO;AACT;","names":[]}