font-lab 0.1.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/analyzer.mjs ADDED
@@ -0,0 +1,431 @@
1
+ // Font Lab analyzer (M3) — a static, read-only audit of a target Next.js project.
2
+ //
3
+ // Detects the four things codegen and the preview both need to stop guessing:
4
+ // • framework + App vs Pages Router
5
+ // • Tailwind v3 vs v4
6
+ // • the current fonts, per role (display / body / mono)
7
+ // • how those fonts are wired (CSS variables vs hardcoded)
8
+ //
9
+ // Pure functions, no writes. ts-morph parses the declaration file (the same engine codegen
10
+ // edits with, so the two agree on what a font const is); the CSS entry is read as text and
11
+ // its custom-property graph resolved so a role var like `--font-display` can be traced —
12
+ // through any number of indirections — back to the next/font const that ultimately feeds it.
13
+ // That chain is what lets the analyzer name "Bricolage Grotesque" on a real site that maps
14
+ // `--font-display: var(--font-bricolage)`, and "Inter" on our fixture that hops
15
+ // `--font-sans → --fl-sans → --font-inter`.
16
+ //
17
+ // Output feeds two consumers: codegen's branch selection (target) and the panel's
18
+ // before/after toggle (replaces — the real "current" the preview compares against).
19
+
20
+ import { Project, Node, SyntaxKind } from "ts-morph";
21
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
22
+ import path from "node:path";
23
+
24
+ const ROLE_VARS = { display: "--font-display", body: "--font-sans", mono: "--font-mono" };
25
+ const ROLES = ["display", "body", "mono"];
26
+
27
+ // next/font import specifier (e.g. `Bricolage_Grotesque`) -> display family name. This is
28
+ // the inverse of codegen's family->importName, so a round-trip is lossless for the families
29
+ // Google actually ships.
30
+ const familyFromImport = (name) => name.replace(/_/g, " ");
31
+
32
+ const rel = (projectDir, p) => (p ? path.relative(projectDir, p) : null);
33
+
34
+ function readJson(p) {
35
+ try {
36
+ return JSON.parse(readFileSync(p, "utf8"));
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ // ---- locate the moving parts -----------------------------------------------
43
+
44
+ function locate(projectDir) {
45
+ const exists = (p) => existsSync(p) && p;
46
+ const firstFile = (...cands) => cands.map((c) => path.join(projectDir, c)).find(existsSync) || null;
47
+
48
+ // App Router declares fonts in app/layout.{tsx,jsx}; Pages Router in pages/_app.{tsx,jsx}.
49
+ const appLayout = firstFile("app/layout.tsx", "app/layout.jsx", "src/app/layout.tsx", "src/app/layout.jsx");
50
+ const pagesApp = firstFile("pages/_app.tsx", "pages/_app.jsx", "src/pages/_app.tsx", "src/pages/_app.jsx");
51
+
52
+ const router = appLayout ? "app" : pagesApp ? "pages" : "unknown";
53
+ const declarationFile = appLayout || pagesApp || null;
54
+
55
+ // CSS entry — the file carrying `@import "tailwindcss"` / `@tailwind` directives.
56
+ const css = firstFile(
57
+ "app/globals.css",
58
+ "app/global.css",
59
+ "src/app/globals.css",
60
+ "styles/globals.css",
61
+ "src/styles/globals.css",
62
+ );
63
+
64
+ const twConfig = firstFile(
65
+ "tailwind.config.ts",
66
+ "tailwind.config.js",
67
+ "tailwind.config.mjs",
68
+ "tailwind.config.cjs",
69
+ );
70
+
71
+ return { declarationFile, router, css, twConfig };
72
+ }
73
+
74
+ // ---- package.json: framework + tailwind version hints ----------------------
75
+
76
+ const majorOf = (range) => {
77
+ const m = String(range || "").match(/(\d+)/);
78
+ return m ? Number(m[1]) : null;
79
+ };
80
+
81
+ function readDeps(projectDir) {
82
+ const pkg = readJson(path.join(projectDir, "package.json")) || {};
83
+ return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
84
+ }
85
+
86
+ // ---- the declaration file: next/font consts + which element wears them ------
87
+
88
+ function parseDeclaration(declPath) {
89
+ const result = { nextFonts: [], localFonts: [], classNameTarget: null, importModules: [] };
90
+ if (!declPath) return result;
91
+
92
+ const project = new Project({ useInMemoryFileSystem: false, skipAddingFilesFromTsConfig: true });
93
+ const sf = project.addSourceFileAtPath(declPath);
94
+
95
+ // Which next/font specifiers are imported (google vs local matters for the ship path).
96
+ const googleNames = new Set();
97
+ const localNames = new Set();
98
+ for (const imp of sf.getImportDeclarations()) {
99
+ const mod = imp.getModuleSpecifierValue();
100
+ if (mod === "next/font/google" || mod === "next/font/local") {
101
+ result.importModules.push(mod);
102
+ const target = mod === "next/font/local" ? localNames : googleNames;
103
+ for (const n of imp.getNamedImports()) target.add(n.getName());
104
+ const def = imp.getDefaultImport();
105
+ if (def) target.add(def.getText());
106
+ }
107
+ }
108
+
109
+ // Font consts: `const x = Family({ variable: "--font-x", ... })`.
110
+ const constByName = new Map();
111
+ for (const vd of sf.getVariableDeclarations()) {
112
+ const init = vd.getInitializer();
113
+ if (!init || !Node.isCallExpression(init)) continue;
114
+ const callee = init.getExpression().getText();
115
+ const isGoogle = googleNames.has(callee);
116
+ const isLocal = localNames.has(callee);
117
+ if (!isGoogle && !isLocal) continue;
118
+
119
+ const obj = init.getArguments()[0];
120
+ let variable = null;
121
+ if (obj && Node.isObjectLiteralExpression(obj)) {
122
+ const p = obj.getProperty("variable");
123
+ if (p && Node.isPropertyAssignment(p)) {
124
+ const v = p.getInitializer();
125
+ if (v && (Node.isStringLiteral(v) || Node.isNoSubstitutionTemplateLiteral(v))) variable = v.getLiteralValue();
126
+ }
127
+ }
128
+ const entry = {
129
+ constName: vd.getName(),
130
+ importName: callee,
131
+ family: isGoogle ? familyFromImport(callee) : callee,
132
+ variable,
133
+ source: isGoogle ? "google" : "local",
134
+ };
135
+ constByName.set(vd.getName(), entry);
136
+ (isLocal ? result.localFonts : result.nextFonts).push(entry);
137
+ }
138
+
139
+ // Which element (html/body) carries the font consts' `.variable` classes.
140
+ const allConsts = [...result.nextFonts, ...result.localFonts];
141
+ let best = { tag: null, hits: 0 };
142
+ for (const kind of [SyntaxKind.JsxOpeningElement, SyntaxKind.JsxSelfClosingElement]) {
143
+ for (const el of sf.getDescendantsOfKind(kind)) {
144
+ const tag = el.getTagNameNode().getText();
145
+ if (tag !== "html" && tag !== "body") continue;
146
+ const attr = el.getAttribute?.("className");
147
+ const text = attr ? attr.getText() : "";
148
+ const hits = allConsts.filter((c) => text.includes(`${c.constName}.variable`)).length;
149
+ if (hits > best.hits) best = { tag, hits };
150
+ }
151
+ }
152
+ result.classNameTarget = best.tag;
153
+ return result;
154
+ }
155
+
156
+ // ---- CSS: tailwind version + the custom-property graph ----------------------
157
+
158
+ // Collect every `--name: value;` declaration in the file (across :root, @theme, @theme
159
+ // inline). Last write wins, which roughly matches the cascade for our purposes.
160
+ function collectCssVars(css) {
161
+ const vars = new Map();
162
+ const re = /(--[A-Za-z0-9-]+)\s*:\s*([^;}]+)\s*;/g;
163
+ let m;
164
+ while ((m = re.exec(css))) vars.set(m[1], m[2].trim());
165
+ return vars;
166
+ }
167
+
168
+ // Follow `var(--x)` references from a role var until we land on one of the next/font
169
+ // variables (resolved) or run out of indirection (unresolved). Cycle-guarded.
170
+ function resolveToFontVar(startVar, cssVars, fontVarSet) {
171
+ const seen = new Set();
172
+ let cur = startVar;
173
+ while (cur && !seen.has(cur)) {
174
+ if (fontVarSet.has(cur)) return cur;
175
+ seen.add(cur);
176
+ const val = cssVars.get(cur);
177
+ if (!val) return null;
178
+ const next = val.match(/var\(\s*(--[A-Za-z0-9-]+)/);
179
+ if (!next) return null;
180
+ cur = next[1];
181
+ }
182
+ return null;
183
+ }
184
+
185
+ function detectTailwind(css, deps, twConfig) {
186
+ const cssV4 = /@import\s+["']tailwindcss["']/.test(css);
187
+ const cssV3 = /@tailwind\s+(base|components|utilities)/.test(css);
188
+ const pkgMajor = majorOf(deps.tailwindcss);
189
+ const hasV4Postcss = !!(deps["@tailwindcss/postcss"] || deps["@tailwindcss/vite"]);
190
+
191
+ let version = null;
192
+ if (cssV4 && (pkgMajor === 4 || hasV4Postcss || pkgMajor === null)) version = 4;
193
+ else if (cssV3 && (pkgMajor === 3 || twConfig)) version = 3;
194
+ else if (pkgMajor) version = pkgMajor;
195
+ else if (cssV4) version = 4;
196
+ else if (cssV3) version = 3;
197
+
198
+ return { version, signals: { cssV4, cssV3, pkgMajor, hasV4Postcss, hasConfig: !!twConfig } };
199
+ }
200
+
201
+ // ---- coverage diagnostics (will a swap actually be visible? at scale) -------
202
+ //
203
+ // Two ways a swap silently does nothing on a real site, both of which we'd rather REPORT
204
+ // than be surprised by:
205
+ //
206
+ // 1. Dead role — a role var is declared in `@theme inline { … }`, but the site consumes it
207
+ // via a *raw* `var(--font-display)` somewhere (e.g. `@layer base { h1 { font-family:
208
+ // var(--font-display) } }`). Under `@theme inline`, Tailwind v4 does NOT publish the
209
+ // theme var as a `:root` custom property — only the generated `font-*` utilities deref
210
+ // it. So that raw reference resolves to nothing and the element silently inherits its
211
+ // parent's font. Swapping that role is invisible until it's rewired through the utility.
212
+ // (This is exactly what jack-mcgovern.com does with its headings.)
213
+ //
214
+ // 2. Other subsystems — fonts declared with their own next/font + variables in a different
215
+ // route/component (jack's `/gus` uses `--font-fraunces`/`--font-dm-sans` via inline
216
+ // styles). A global swap of the layout fonts won't reach them; the agent/user should
217
+ // know the swap's true scope (full per-route flipping is M6).
218
+
219
+ const THEME_BLOCK_RE = /@theme(\s+inline)?\s*\{([^}]*)\}/g;
220
+ const reVar = (v) => new RegExp(`var\\(\\s*${v}\\s*\\)`);
221
+
222
+ function deadRoles(css) {
223
+ // role vars declared inside an `@theme inline` block
224
+ const inlineVars = new Set();
225
+ let m;
226
+ THEME_BLOCK_RE.lastIndex = 0;
227
+ while ((m = THEME_BLOCK_RE.exec(css))) {
228
+ if (!m[1]) continue; // plain @theme (not inline) DOES publish the var — not dead
229
+ for (const v of Object.values(ROLE_VARS)) if (new RegExp(`${v}\\s*:`).test(m[2])) inlineVars.add(v);
230
+ }
231
+ const cssNoTheme = css.replace(THEME_BLOCK_RE, "");
232
+ const dead = [];
233
+ for (const role of ROLES) {
234
+ const rv = ROLE_VARS[role];
235
+ if (inlineVars.has(rv) && reVar(rv).test(cssNoTheme)) dead.push(role);
236
+ }
237
+ return dead;
238
+ }
239
+
240
+ function walkSourceFiles(dir, acc, depth = 0) {
241
+ if (depth > 6) return acc;
242
+ let entries = [];
243
+ try {
244
+ entries = readdirSync(dir, { withFileTypes: true });
245
+ } catch {
246
+ return acc;
247
+ }
248
+ for (const e of entries) {
249
+ if (e.name === "node_modules" || e.name === ".next" || e.name === ".git" || e.name.startsWith(".")) continue;
250
+ const full = path.join(dir, e.name);
251
+ if (e.isDirectory()) walkSourceFiles(full, acc, depth + 1);
252
+ else if (/\.(tsx|ts|jsx|js)$/.test(e.name)) acc.push(full);
253
+ }
254
+ return acc;
255
+ }
256
+
257
+ function otherFontSubsystems(projectDir, declarationFile) {
258
+ const declAbs = declarationFile ? path.join(projectDir, declarationFile) : null;
259
+ const roots = ["app", "src/app", "components", "src/components"].map((d) => path.join(projectDir, d));
260
+ const files = [];
261
+ for (const r of roots) walkSourceFiles(r, files);
262
+ const out = [];
263
+ for (const f of [...new Set(files)]) {
264
+ if (f === declAbs) continue;
265
+ let text = "";
266
+ try {
267
+ text = readFileSync(f, "utf8");
268
+ } catch {
269
+ continue;
270
+ }
271
+ if (!/from\s+["']next\/font\/(google|local)["']/.test(text)) continue;
272
+ const families = [...new Set([...text.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']next\/font\/(?:google|local)["']/g)].flatMap((m) => m[1].split(",").map((s) => s.trim()).filter(Boolean)))];
273
+ const variables = [...new Set([...text.matchAll(/variable\s*:\s*["'](--[A-Za-z0-9-]+)["']/g)].map((m) => m[1]))];
274
+ out.push({ file: path.relative(projectDir, f), families, variables });
275
+ }
276
+ return out;
277
+ }
278
+
279
+ // ---- the public surface -----------------------------------------------------
280
+
281
+ export function analyzeProject(projectDir) {
282
+ projectDir = path.resolve(projectDir);
283
+ const deps = readDeps(projectDir);
284
+ const { declarationFile, router, css: cssPath, twConfig } = locate(projectDir);
285
+
286
+ const framework = deps.next ? "next" : "unknown";
287
+ const styling = deps.tailwindcss ? "tailwind" : "unknown";
288
+
289
+ const decl = parseDeclaration(declarationFile);
290
+ const css = cssPath ? readFileSync(cssPath, "utf8") : "";
291
+ const cssVars = collectCssVars(css);
292
+ const tw = detectTailwind(css, deps, twConfig);
293
+
294
+ const fontByVar = new Map();
295
+ for (const f of [...decl.nextFonts, ...decl.localFonts]) if (f.variable) fontByVar.set(f.variable, f);
296
+ const fontVarSet = new Set(fontByVar.keys());
297
+
298
+ // Resolve each role's font by tracing the role var through the CSS graph.
299
+ const roles = {};
300
+ let resolvedAny = false;
301
+ for (const role of ROLES) {
302
+ const hit = resolveToFontVar(ROLE_VARS[role], cssVars, fontVarSet);
303
+ const font = hit ? fontByVar.get(hit) : null;
304
+ if (font) {
305
+ resolvedAny = true;
306
+ roles[role] = {
307
+ family: font.family,
308
+ source: font.source,
309
+ constName: font.constName,
310
+ importName: font.importName,
311
+ nextFontVar: font.variable,
312
+ roleVar: ROLE_VARS[role],
313
+ };
314
+ } else {
315
+ roles[role] = null;
316
+ }
317
+ }
318
+
319
+ // Wiring: role vars resolving to next/font consts is the high-fidelity, swap-friendly
320
+ // path. next/font present but unreachable through vars (or literal font-family) is the
321
+ // lower-fidelity hardcoded path. Nothing at all is "none".
322
+ const hasNextFont = decl.nextFonts.length + decl.localFonts.length > 0;
323
+ const hardcodedFamily = /font-family\s*:\s*(['"][A-Za-z][^;}]*|[A-Za-z][\w -]*,)/.test(
324
+ css.replace(/font-family\s*:\s*var\([^)]*\)[^;}]*/g, ""),
325
+ );
326
+ let fontWiring = "none";
327
+ if (resolvedAny) fontWiring = "css-variables";
328
+ else if (hasNextFont || hardcodedFamily) fontWiring = "hardcoded";
329
+
330
+ const replaces = {};
331
+ for (const role of ROLES) replaces[role] = roles[role]?.family ?? null;
332
+
333
+ const target = {
334
+ framework,
335
+ router,
336
+ styling,
337
+ tailwindVersion: tw.version,
338
+ fontWiring,
339
+ };
340
+
341
+ // Is this the branch codegen actually ships today (App + Tailwind v4 + CSS-variable
342
+ // wiring)? The analyzer decides; codegen never re-guesses.
343
+ const reasons = [];
344
+ if (framework !== "next") reasons.push(`framework is ${framework} (need next)`);
345
+ if (router !== "app") reasons.push(`router is ${router} (need app)`);
346
+ if (styling !== "tailwind") reasons.push(`styling is ${styling} (need tailwind)`);
347
+ if (tw.version !== 4) reasons.push(`tailwind v${tw.version ?? "?"} (need v4)`);
348
+ if (fontWiring === "hardcoded") reasons.push("fonts are hardcoded (need css-variables)");
349
+ if (fontWiring === "none") reasons.push("no fonts detected to replace");
350
+ const supported = reasons.length === 0;
351
+
352
+ // Coverage: will a swap actually be visible, and is this the only font subsystem?
353
+ const dead = deadRoles(css).filter((role) => roles[role]); // only roles we'd actually swap
354
+ const otherSubsystems = otherFontSubsystems(projectDir, rel(projectDir, declarationFile));
355
+ const coverage = { deadRoles: dead, otherSubsystems };
356
+
357
+ const notes = [];
358
+ if (decl.localFonts.length)
359
+ notes.push(`next/font/local in use (${decl.localFonts.map((f) => f.constName).join(", ")}) — repointed, not deleted`);
360
+ if (decl.classNameTarget === "body") notes.push("font variables applied on <body> (not <html>)");
361
+ for (const role of ROLES) if (!roles[role]) notes.push(`no ${role} font wired (codegen will add one)`);
362
+ for (const role of dead)
363
+ notes.push(
364
+ `${role}: consumed via raw var(${ROLE_VARS[role]}) under @theme inline — Tailwind v4 doesn't expose it as a :root var, so it's dead on the live site; swapping ${role} is invisible until rewired through the font-${role === "body" ? "sans" : role} utility`,
365
+ );
366
+ for (const s of otherSubsystems)
367
+ notes.push(`other font subsystem in ${s.file} (${[...s.families].join(", ") || s.variables.join(", ")}) — a global swap won't reach it (M6: multi-route)`);
368
+
369
+ return {
370
+ projectDir,
371
+ ...target,
372
+ declarationFile: rel(projectDir, declarationFile),
373
+ cssFile: rel(projectDir, cssPath),
374
+ tailwindConfig: rel(projectDir, twConfig),
375
+ classNameTarget: decl.classNameTarget,
376
+ roles,
377
+ replaces,
378
+ nextFonts: decl.nextFonts,
379
+ localFonts: decl.localFonts,
380
+ tailwindSignals: tw.signals,
381
+ coverage,
382
+ supported,
383
+ reasons,
384
+ notes,
385
+ };
386
+ }
387
+
388
+ // The preview swap target, per role: which leaf next/font variable to override and on which
389
+ // element next/font set it. This is what makes the live panel honest on ANY site — override
390
+ // the same variable that ship rewrites, at the same element, so preview == ship by
391
+ // construction. Roles with no next/font variable (e.g. a system-mono `--font-mono`) are null:
392
+ // the panel can't preview a swap the site doesn't wire (we say so instead of faking it).
393
+ export function wiringFor(a) {
394
+ const el = a.classNameTarget || "html";
395
+ const w = {};
396
+ for (const role of ROLES) {
397
+ const r = a.roles[role];
398
+ w[role] = r && r.nextFontVar ? { var: r.nextFontVar, el } : null;
399
+ }
400
+ return w;
401
+ }
402
+
403
+ // The subset codegen and selection.json care about.
404
+ export function toTarget(a) {
405
+ return {
406
+ framework: a.framework,
407
+ router: a.router,
408
+ styling: a.styling,
409
+ tailwindVersion: a.tailwindVersion,
410
+ fontWiring: a.fontWiring,
411
+ };
412
+ }
413
+
414
+ // A compact, human-readable summary for the CLI.
415
+ export function summarize(a) {
416
+ const fam = (r) => a.replaces[r] ?? "—";
417
+ const lines = [
418
+ ` framework ${a.framework}`,
419
+ ` router ${a.router}`,
420
+ ` styling ${a.styling}${a.tailwindVersion ? ` v${a.tailwindVersion}` : ""}`,
421
+ ` wiring ${a.fontWiring}${a.classNameTarget ? ` (on <${a.classNameTarget}>)` : ""}`,
422
+ ` current display ${fam("display")} body ${fam("body")} mono ${fam("mono")}`,
423
+ ` files ${[a.declarationFile, a.cssFile].filter(Boolean).join(", ") || "—"}`,
424
+ ` ships now ${a.supported ? "yes (App + Tailwind v4 + CSS variables)" : "no — " + a.reasons.join("; ")}`,
425
+ ];
426
+ if (a.coverage.deadRoles.length) lines.push(` ⚠ dead ${a.coverage.deadRoles.join(", ")} — declared but not actually rendered (swap invisible until rewired)`);
427
+ if (a.coverage.otherSubsystems.length)
428
+ lines.push(` ⚠ scope other font subsystems: ${a.coverage.otherSubsystems.map((s) => s.file).join(", ")} (global swap won't reach them)`);
429
+ if (a.notes.length) lines.push(` notes ${a.notes.join("\n ")}`);
430
+ return lines.join("\n");
431
+ }
package/apply-test.mjs ADDED
@@ -0,0 +1,131 @@
1
+ // M2 verification — apply a real selection into the clean fixture and prove the four
2
+ // things that matter: it produces correct code, it BUILDS, it RENDERS the picked fonts,
3
+ // it is idempotent, and it is reversible. Leaves the fixture pristine when done.
4
+
5
+ import { chromium } from "playwright";
6
+ import { execFileSync, spawn } from "node:child_process";
7
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import path from "node:path";
10
+ import { applySelection, undo } from "./codegen.mjs";
11
+
12
+ const HERE = fileURLToPath(new URL("./", import.meta.url));
13
+ const APP = fileURLToPath(new URL("../examples/clean-next-site/", import.meta.url));
14
+ const OUT = HERE + "out/";
15
+ mkdirSync(OUT, { recursive: true });
16
+
17
+ const FILES = { layout: APP + "app/layout.tsx", css: APP + "app/globals.css", page: APP + "app/page.tsx" };
18
+ const read = (p) => readFileSync(p, "utf8");
19
+ const originals = Object.fromEntries(Object.entries(FILES).map(([k, p]) => [k, read(p)]));
20
+ const reset = () => {
21
+ for (const [k, p] of Object.entries(FILES)) writeFileSync(p, originals[k]);
22
+ rmSync(APP + ".font-lab", { recursive: true, force: true });
23
+ };
24
+
25
+ const selection = {
26
+ version: 1,
27
+ pickedAt: "2026-06-25T00:00:00.000Z",
28
+ direction: { id: "editorial-serif", name: "Editorial", vibe: "editorial", rationale: "Warm serif headlines over a clean grotesque body." },
29
+ roles: {
30
+ display: { family: "Fraunces", source: "google", weights: [400, 700] },
31
+ body: { family: "Libre Franklin", source: "google", weights: [400, 600] },
32
+ mono: { family: "JetBrains Mono", source: "google", weights: [400, 700] },
33
+ },
34
+ replaces: { display: "Inter", body: "Inter", mono: "JetBrains Mono" },
35
+ target: { framework: "next", router: "app", styling: "tailwind", tailwindVersion: 4, fontWiring: "css-variables" },
36
+ };
37
+ const writeSelection = () => {
38
+ mkdirSync(APP + ".font-lab", { recursive: true });
39
+ writeFileSync(APP + ".font-lab/selection.json", JSON.stringify(selection, null, 2));
40
+ };
41
+
42
+ const results = [];
43
+ const assert = (name, cond, extra = "") => {
44
+ results.push({ name, pass: !!cond });
45
+ console.log((cond ? "PASS" : "FAIL").padEnd(5), name, extra && !cond ? `(${extra})` : "");
46
+ };
47
+
48
+ try {
49
+ // ---- Phase 1: apply + structural correctness --------------------------------
50
+ reset();
51
+ writeSelection();
52
+ applySelection(APP);
53
+ const layout = read(FILES.layout);
54
+ const css = read(FILES.css);
55
+
56
+ assert("imports Fraunces", /import\s*\{[^}]*\bFraunces\b/.test(layout));
57
+ assert("imports Libre_Franklin", /\bLibre_Franklin\b/.test(layout));
58
+ assert("imports JetBrains_Mono", /\bJetBrains_Mono\b/.test(layout));
59
+ assert("declares fontLabDisplay on --font-display", /const fontLabDisplay = Fraunces\([^)]*--font-display/.test(layout));
60
+ assert("declares fontLabBody on --font-sans", /const fontLabBody = Libre_Franklin\([^)]*--font-sans/.test(layout));
61
+ assert("declares fontLabMono on --font-mono", /const fontLabMono = JetBrains_Mono\([^)]*--font-mono/.test(layout));
62
+ assert("removed the replaced Inter import", !/\bInter\b/.test(layout), "Inter still present");
63
+ assert("removed the old `const inter`", !/const inter =/.test(layout));
64
+ assert("html className has all 3 role variables", ["fontLabDisplay", "fontLabBody", "fontLabMono"].every((c) => layout.includes(`${c}.variable`)));
65
+ assert("html className dropped old inter.variable", !/inter\.variable/.test(layout));
66
+ assert("css has fenced @theme block", /\/\* font-lab:start \*\/[\s\S]*--font-display[\s\S]*\/\* font-lab:end \*\//.test(css));
67
+
68
+ // ---- Phase 2: idempotency ---------------------------------------------------
69
+ applySelection(APP);
70
+ assert("layout.tsx unchanged on re-apply (idempotent)", read(FILES.layout) === layout);
71
+ assert("globals.css unchanged on re-apply (idempotent)", read(FILES.css) === css);
72
+
73
+ // ---- Phase 3: it BUILDS and RENDERS the picked fonts ------------------------
74
+ let built = false;
75
+ try {
76
+ execFileSync("pnpm", ["build"], { cwd: APP, stdio: "pipe" });
77
+ built = true;
78
+ } catch (e) {
79
+ console.log(String(e.stdout || e).slice(-800));
80
+ }
81
+ assert("project builds after apply", built);
82
+
83
+ if (built) {
84
+ const srv = spawn("pnpm", ["exec", "next", "start", "-p", "4342"], { cwd: APP, stdio: "ignore" });
85
+ try {
86
+ for (let i = 0; i < 80; i++) {
87
+ try {
88
+ if ((await fetch("http://localhost:4342/")).ok) break;
89
+ } catch {}
90
+ await new Promise((r) => setTimeout(r, 500));
91
+ }
92
+ const browser = await chromium.launch();
93
+ const page = await browser.newPage({ viewport: { width: 1100, height: 1000 }, deviceScaleFactor: 2 });
94
+ await page.goto("http://localhost:4342/", { waitUntil: "load" });
95
+ await page.evaluate(async () => {
96
+ await document.fonts.ready;
97
+ return true;
98
+ });
99
+ await page.waitForTimeout(500);
100
+ const h1 = await page.evaluate(() => getComputedStyle(document.querySelector("h1")).fontFamily);
101
+ const body = await page.evaluate(() => getComputedStyle(document.body).fontFamily);
102
+ const code = await page.evaluate(() => getComputedStyle(document.querySelector("pre")).fontFamily);
103
+ await page.screenshot({ path: OUT + "clean-applied.png" });
104
+ await browser.close();
105
+ assert("h1 renders Fraunces", /Fraunces/i.test(h1), h1);
106
+ assert("body renders Libre Franklin", /Libre[ _]Franklin/i.test(body), body);
107
+ assert("code renders JetBrains Mono", /JetBrains[ _]Mono/i.test(code), code);
108
+ } finally {
109
+ srv.kill();
110
+ }
111
+ }
112
+
113
+ // ---- Phase 4: reversibility (single apply -> undo == original) --------------
114
+ reset();
115
+ writeSelection();
116
+ applySelection(APP);
117
+ undo(APP);
118
+ assert("undo restores layout.tsx byte-identical", read(FILES.layout) === originals.layout);
119
+ assert("undo restores globals.css byte-identical", read(FILES.css) === originals.css);
120
+ } finally {
121
+ reset(); // leave the fixture pristine
122
+ }
123
+
124
+ const failed = results.filter((r) => !r.pass);
125
+ writeFileSync(OUT + "m2-report.json", JSON.stringify({ results }, null, 2));
126
+ console.log(`\nM2: ${results.length - failed.length}/${results.length} assertions passed`);
127
+ if (failed.length) {
128
+ console.error("FAILED:", failed.map((f) => f.name).join(", "));
129
+ process.exit(5);
130
+ }
131
+ console.log("M2 PASS");
package/apply.mjs ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // `font-lab apply` — apply .font-lab/selection.json into the project (next/font + Tailwind).
3
+ import path from "node:path";
4
+ import { applySelection } from "./codegen.mjs";
5
+
6
+ const arg = (f, d) => {
7
+ const i = process.argv.indexOf(f);
8
+ return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : d;
9
+ };
10
+ const project = path.resolve(arg("--project", process.cwd()));
11
+
12
+ try {
13
+ const r = applySelection(project);
14
+ console.log(`Font Lab — applied "${r.direction?.name ?? "?"}"`);
15
+ for (const x of r.roles) console.log(` ${x.role.padEnd(8)} ${x.family}`);
16
+ if (r.replaced.length) console.log(` replaced: ${r.replaced.map((x) => `${x.font} @ ${x.variable}`).join(", ")}`);
17
+ console.log(` edited ${r.edited.join(", ")}`);
18
+ console.log(` backup ${r.backupDir} → \`font-lab undo\` to revert`);
19
+ } catch (e) {
20
+ console.error("apply failed:", e.message);
21
+ process.exit(1);
22
+ }
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // prepack hook: copy the repo's top-level `skill/` into `cli/skill/` so the published npm
3
+ // tarball carries the SKILL. After `npx @jmg698/font-lab install`, install.mjs finds it at
4
+ // `cli/skill/font-lab` and copies it into ~/.claude/skills. (cli/skill/ is gitignored — it's a
5
+ // build artifact, the source of truth stays at the repo root `skill/`.)
6
+
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { cpSync, existsSync } from "node:fs";
10
+
11
+ const here = path.dirname(fileURLToPath(import.meta.url));
12
+ const src = path.join(here, "..", "skill");
13
+ const dest = path.join(here, "skill");
14
+
15
+ if (!existsSync(src)) {
16
+ console.error("bundle-skill: no ../skill to bundle — skipping");
17
+ process.exit(0);
18
+ }
19
+ cpSync(src, dest, { recursive: true });
20
+ console.error(`bundle-skill: copied ${src} -> ${dest}`);