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/init.mjs ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // `font-lab init` — make a real project previewable: scaffold the dev panel + parity bundles
3
+ // and mount the panel (dev-only) in the layout. Reversible with `--undo`. Thin CLI over
4
+ // engine.init / engine.uninit (the same code the MCP server's font_lab_init tool uses).
5
+ //
6
+ // node cli/init.mjs --project <dir> [--vibe <v>] [--count <n>] [--no-fetch]
7
+ // node cli/init.mjs --project <dir> --undo
8
+
9
+ import path from "node:path";
10
+ import * as engine from "./engine.mjs";
11
+
12
+ const arg = (f, d) => { const i = process.argv.indexOf(f); return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : d; };
13
+ const PROJECT = path.resolve(arg("--project", process.cwd()));
14
+ const rel = (p) => path.relative(process.cwd(), p) || ".";
15
+
16
+ try {
17
+ if (process.argv.includes("--undo")) {
18
+ const r = engine.uninit(PROJECT);
19
+ console.log(`Font Lab — uninstalled (restored ${r.restored}, removed ${r.removed.join(" + ")})`);
20
+ } else {
21
+ const r = await engine.init(PROJECT, {
22
+ vibe: arg("--vibe", undefined),
23
+ count: Number(arg("--count", "5")),
24
+ fetch: !process.argv.includes("--no-fetch"),
25
+ log: (m) => console.log(m),
26
+ });
27
+ console.log(`\nFont Lab — initialized ${rel(PROJECT)}`);
28
+ console.log(` directions ${r.directions.map((d) => d.name).join(", ")}`);
29
+ console.log(` wiring ${["display", "body", "mono"].map((role) => `${role}:${r.wiring[role] ? r.wiring[role].var + "@" + r.wiring[role].el : "—"}`).join(" ")}`);
30
+ if (r.deadRoles.length) console.log(` note ${r.deadRoles.join(", ")} won't preview (dead on this site — \`font-lab rewire\` fixes it)`);
31
+ console.log(` panel ${r.mounted ? "mounted in" : "already in"} ${r.layout} (dev only)`);
32
+ console.log(`\n next: run your dev server, then \`node cli/font-lab.mjs --project ${rel(PROJECT)}\` — flip, Pick, then \`node cli/apply.mjs\`.`);
33
+ console.log(` undo: \`node cli/init.mjs --project ${rel(PROJECT)} --undo\``);
34
+ }
35
+ } catch (e) {
36
+ console.error("init failed:", e.message);
37
+ process.exit(1);
38
+ }
package/install.mjs ADDED
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ // `font-lab install` — the one-command setup that makes Font Lab self-installing, the way
3
+ // `npx impeccable install` works. Two wiring actions, mirroring impeccable's pattern:
4
+ //
5
+ // 1. Copy the `font-lab` SKILL into the global skills dir (~/.claude/skills/font-lab) so the
6
+ // agent DISCOVERS it in every session — you just say "pick new fonts" and it reaches for it.
7
+ // 2. Register the `font-lab` MCP server into the target project's `.mcp.json` so the agent has
8
+ // the font_lab_* tools to actually drive the loop.
9
+ //
10
+ // Both steps are idempotent (re-running is a no-op) and reversible (`font-lab uninstall`).
11
+ //
12
+ // npx font-lab install [--project <dir>] [--no-mcp] [--no-skill] [--local] [--dry-run]
13
+ // npx font-lab uninstall [--project <dir>]
14
+ //
15
+ // Flags:
16
+ // --project <dir> project to wire the MCP server into (default: cwd)
17
+ // --no-mcp skip the .mcp.json registration (skill only)
18
+ // --no-skill skip the global skill copy (MCP only)
19
+ // --local register the MCP server as `node <this-checkout>/mcp.mjs` instead of the
20
+ // published `npx` form — use this to test from a git clone before publishing
21
+ // --skills-dir <d> override the skills dir (default: $CLAUDE_CONFIG_DIR/skills or ~/.claude/skills)
22
+ // --dry-run print what would change, write nothing
23
+
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { cpSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
28
+
29
+ const PKG_NAME = "font-lab"; // single source of truth for the published name
30
+ const SKILL_NAME = "font-lab";
31
+ const MCP_KEY = "font-lab";
32
+
33
+ const HERE = path.dirname(fileURLToPath(import.meta.url)); // the `cli/` dir
34
+ const arg = (flag, def) => {
35
+ const i = process.argv.indexOf(flag);
36
+ return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : def;
37
+ };
38
+ const has = (flag) => process.argv.includes(flag);
39
+ const rel = (p) => path.relative(process.cwd(), p) || ".";
40
+
41
+ // ---- shared resolution -----------------------------------------------------
42
+
43
+ // The SKILL source: bundled inside the package (cli/skill/font-lab, created by `prepack`)
44
+ // when installed via npm, or the repo's top-level skill/font-lab when run from a git checkout.
45
+ function resolveSkillSource() {
46
+ const candidates = [
47
+ path.join(HERE, "skill", SKILL_NAME),
48
+ path.join(HERE, "..", "skill", SKILL_NAME),
49
+ ];
50
+ for (const c of candidates) {
51
+ if (existsSync(path.join(c, "SKILL.md"))) return c;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function skillsDir() {
57
+ const override = arg("--skills-dir", null);
58
+ if (override) return path.resolve(override);
59
+ const base = process.env.CLAUDE_CONFIG_DIR
60
+ ? path.resolve(process.env.CLAUDE_CONFIG_DIR)
61
+ : path.join(os.homedir(), ".claude");
62
+ return path.join(base, "skills");
63
+ }
64
+
65
+ // The command the agent's host will run to launch the MCP server.
66
+ // published (default): npx -y font-lab mcp
67
+ // --local (dev/test): node <abs>/cli/mcp.mjs
68
+ function mcpServerEntry() {
69
+ if (has("--local")) {
70
+ return { command: "node", args: [path.join(HERE, "mcp.mjs")] };
71
+ }
72
+ return { command: "npx", args: ["-y", PKG_NAME, "mcp"] };
73
+ }
74
+
75
+ function readJson(file) {
76
+ try {
77
+ return JSON.parse(readFileSync(file, "utf8"));
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ // ---- install ---------------------------------------------------------------
84
+
85
+ export function runInstall() {
86
+ const dry = has("--dry-run");
87
+ const doSkill = !has("--no-skill");
88
+ const doMcp = !has("--no-mcp");
89
+ const project = path.resolve(arg("--project", process.cwd()));
90
+ const tag = dry ? " (dry-run, nothing written)" : "";
91
+ const steps = [];
92
+
93
+ console.log(`Font Lab — install${tag}`);
94
+
95
+ if (doSkill) {
96
+ const src = resolveSkillSource();
97
+ if (!src) {
98
+ console.error(
99
+ ` ✗ skill source not found (looked in cli/skill/${SKILL_NAME} and ../skill/${SKILL_NAME}).`,
100
+ );
101
+ console.error(` If you're running from a git clone, run from the repo root; if from npm,`);
102
+ console.error(` this is a packaging bug (the skill wasn't bundled).`);
103
+ process.exit(1);
104
+ }
105
+ const dest = path.join(skillsDir(), SKILL_NAME);
106
+ if (!dry) {
107
+ mkdirSync(path.dirname(dest), { recursive: true });
108
+ rmSync(dest, { recursive: true, force: true }); // clean copy so updates fully replace
109
+ cpSync(src, dest, { recursive: true });
110
+ }
111
+ steps.push(`skill → ${dest}`);
112
+ }
113
+
114
+ if (doMcp) {
115
+ const mcpFile = path.join(project, ".mcp.json");
116
+ const entry = mcpServerEntry();
117
+ const existing = existsSync(mcpFile) ? readJson(mcpFile) || {} : {};
118
+ const servers = existing.mcpServers && typeof existing.mcpServers === "object" ? existing.mcpServers : {};
119
+ const before = JSON.stringify(servers[MCP_KEY] || null);
120
+ const after = JSON.stringify(entry);
121
+ const changed = before !== after;
122
+ if (!dry && changed) {
123
+ const next = { ...existing, mcpServers: { ...servers, [MCP_KEY]: entry } };
124
+ writeFileSync(mcpFile, JSON.stringify(next, null, 2) + "\n");
125
+ }
126
+ steps.push(
127
+ `mcp → ${rel(mcpFile)} ["${MCP_KEY}"] = ${entry.command} ${entry.args.join(" ")}` +
128
+ (changed ? "" : " (already set)"),
129
+ );
130
+ }
131
+
132
+ for (const s of steps) console.log(` ${s}`);
133
+ console.log();
134
+ if (doMcp) {
135
+ console.log(` Note: a newly registered MCP server is picked up when the agent/session reloads`);
136
+ console.log(` its config — restart the session (or reconnect MCP) if the tools aren't live yet.`);
137
+ }
138
+ console.log(` Then just ask: "use Font Lab to pick new fonts". Undo with \`font-lab uninstall\`.`);
139
+ }
140
+
141
+ // ---- uninstall -------------------------------------------------------------
142
+
143
+ export function runUninstall() {
144
+ const dry = has("--dry-run");
145
+ const project = path.resolve(arg("--project", process.cwd()));
146
+ const tag = dry ? " (dry-run, nothing written)" : "";
147
+ console.log(`Font Lab — uninstall${tag}`);
148
+
149
+ // 1. remove the global skill
150
+ const dest = path.join(skillsDir(), SKILL_NAME);
151
+ if (existsSync(dest)) {
152
+ if (!dry) rmSync(dest, { recursive: true, force: true });
153
+ console.log(` removed skill ${dest}`);
154
+ } else {
155
+ console.log(` skill not installed (${dest})`);
156
+ }
157
+
158
+ // 2. drop the MCP server entry (leave any others untouched)
159
+ const mcpFile = path.join(project, ".mcp.json");
160
+ if (existsSync(mcpFile) && statSync(mcpFile).isFile()) {
161
+ const json = readJson(mcpFile);
162
+ if (json && json.mcpServers && json.mcpServers[MCP_KEY]) {
163
+ if (!dry) {
164
+ delete json.mcpServers[MCP_KEY];
165
+ // If we left the file empty (no other servers and no other top-level keys), remove
166
+ // it — install likely created it, so a bare `{"mcpServers":{}}` shouldn't linger.
167
+ const otherKeys = Object.keys(json).filter((k) => k !== "mcpServers");
168
+ const emptied = Object.keys(json.mcpServers).length === 0 && otherKeys.length === 0;
169
+ if (emptied) rmSync(mcpFile, { force: true });
170
+ else writeFileSync(mcpFile, JSON.stringify(json, null, 2) + "\n");
171
+ console.log(` removed mcp ${rel(mcpFile)} ["${MCP_KEY}"]${emptied ? " (removed empty .mcp.json)" : ""}`);
172
+ } else {
173
+ console.log(` removed mcp ${rel(mcpFile)} ["${MCP_KEY}"]`);
174
+ }
175
+ } else {
176
+ console.log(` mcp entry not present in ${rel(mcpFile)}`);
177
+ }
178
+ } else {
179
+ console.log(` mcp no .mcp.json in ${rel(project)}`);
180
+ }
181
+ }
182
+
183
+ // Allow running directly as a bin (`font-lab-install`) in addition to the
184
+ // `font-lab install` subcommand dispatched from font-lab.mjs.
185
+ if (path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1] || "")) {
186
+ if (process.argv.includes("uninstall")) runUninstall();
187
+ else runInstall();
188
+ }
package/loop-test.mjs ADDED
@@ -0,0 +1,119 @@
1
+ // M1 verification — drive the whole loop in a real browser and prove the pick lands on
2
+ // disk: flip directions (arrow keys + click), swap fonts live, Pick (button + Enter), and
3
+ // assert .font-lab/selection.json + picks.log.jsonl. Spawns the CLI endpoint itself;
4
+ // expects the fixture's dev server already running at BASE_URL.
5
+
6
+ import { chromium } from "playwright";
7
+ import { spawn } from "node:child_process";
8
+ import { readFileSync, existsSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
9
+ import { fileURLToPath } from "node:url";
10
+ import path from "node:path";
11
+
12
+ const BASE = process.env.BASE_URL || process.argv[2] || "http://localhost:4331";
13
+ const HERE = fileURLToPath(new URL("./", import.meta.url));
14
+ const APP = fileURLToPath(new URL("../examples/sample-next-site/", import.meta.url));
15
+ const FLDIR = path.join(APP, ".font-lab");
16
+ const SEL = path.join(FLDIR, "selection.json");
17
+ const LOG = path.join(FLDIR, "picks.log.jsonl");
18
+ mkdirSync(HERE + "out", { recursive: true });
19
+
20
+ rmSync(FLDIR, { recursive: true, force: true });
21
+
22
+ const cli = spawn("node", [HERE + "font-lab.mjs", "--project", APP, "--port", "7777"], { stdio: "inherit" });
23
+ const waitHealth = async () => {
24
+ for (let i = 0; i < 40; i++) {
25
+ try {
26
+ if ((await fetch("http://localhost:7777/health")).ok) return;
27
+ } catch {}
28
+ await new Promise((r) => setTimeout(r, 250));
29
+ }
30
+ throw new Error("CLI endpoint never became healthy");
31
+ };
32
+ await waitHealth();
33
+
34
+ const results = [];
35
+ const assert = (name, cond, extra = "") => {
36
+ results.push({ name, pass: !!cond, extra });
37
+ console.log((cond ? "PASS" : "FAIL").padEnd(5), name, extra ? `(${extra})` : "");
38
+ };
39
+
40
+ const browser = await chromium.launch();
41
+ const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } });
42
+ const bodyFont = () => page.evaluate(() => getComputedStyle(document.body).fontFamily);
43
+ const displayFont = () => page.evaluate(() => getComputedStyle(document.querySelector("h1")).fontFamily);
44
+ const activeId = () => page.evaluate(() => document.documentElement.getAttribute("data-fontlab-active"));
45
+ const clickDir = (id) =>
46
+ page.evaluate((id) => document.getElementById("fontlab-panel-host").shadowRoot.querySelector(`button[data-fl-id="${id}"]`).click(), id);
47
+ const clickPick = () =>
48
+ page.evaluate(() => document.getElementById("fontlab-panel-host").shadowRoot.querySelector('[data-fl-action="pick"]').click());
49
+ const readSel = () => (existsSync(SEL) ? JSON.parse(readFileSync(SEL, "utf8")) : null);
50
+
51
+ await page.goto(BASE + "/", { waitUntil: "domcontentloaded" });
52
+ await page.evaluate(async () => {
53
+ await document.fonts.ready;
54
+ return true;
55
+ });
56
+ await page.waitForTimeout(600);
57
+ await page.mouse.click(2, 2); // focus the document for keyboard nav
58
+
59
+ assert("starts on current state", (await activeId()) === "current");
60
+ assert("current body is Inter", /Inter/i.test(await bodyFont()), await bodyFont());
61
+
62
+ // ← → flips to the first direction and swaps the body live.
63
+ await page.keyboard.press("ArrowRight");
64
+ await page.waitForTimeout(250);
65
+ assert("ArrowRight selects editorial-serif", (await activeId()) === "editorial-serif", await activeId());
66
+ assert("body swapped to Libre Franklin", /FL Libre Franklin/i.test(await bodyFont()), await bodyFont());
67
+
68
+ // Click the second direction; display + body both swap.
69
+ await clickDir("modern-grotesque");
70
+ await page.evaluate(async () => {
71
+ await document.fonts.ready;
72
+ return true;
73
+ });
74
+ await page.waitForTimeout(300);
75
+ assert("click selects modern-grotesque", (await activeId()) === "modern-grotesque");
76
+ assert("body swapped to Figtree", /FL Figtree/i.test(await bodyFont()), await bodyFont());
77
+ assert("display swapped to Bricolage", /FL Bricolage/i.test(await displayFont()), await displayFont());
78
+
79
+ // Pick via button -> selection.json written.
80
+ await clickPick();
81
+ let sel = null;
82
+ for (let i = 0; i < 40; i++) {
83
+ sel = readSel();
84
+ if (sel) break;
85
+ await page.waitForTimeout(150);
86
+ }
87
+ assert("selection.json written", !!sel);
88
+ assert("direction id = modern-grotesque", sel?.direction?.id === "modern-grotesque");
89
+ assert("body family = Figtree", sel?.roles?.body?.family === "Figtree");
90
+ assert("display family = Bricolage Grotesque", sel?.roles?.display?.family === "Bricolage Grotesque");
91
+ assert("mono family = JetBrains Mono", sel?.roles?.mono?.family === "JetBrains Mono");
92
+ assert("target tailwind v4", sel?.target?.tailwindVersion === 4);
93
+ assert("records replaces (Inter)", sel?.replaces?.body === "Inter");
94
+
95
+ // Re-pick a different direction via Enter -> overwrites selection.json, appends to log.
96
+ await clickDir("editorial-serif");
97
+ await page.waitForTimeout(200);
98
+ await page.keyboard.press("Enter");
99
+ let sel2 = null;
100
+ for (let i = 0; i < 40; i++) {
101
+ sel2 = readSel();
102
+ if (sel2?.direction?.id === "editorial-serif") break;
103
+ await page.waitForTimeout(150);
104
+ }
105
+ assert("Enter re-picks editorial-serif", sel2?.direction?.id === "editorial-serif");
106
+ const logLines = existsSync(LOG) ? readFileSync(LOG, "utf8").trim().split("\n").filter(Boolean) : [];
107
+ assert("picks.log appended (2 entries)", logLines.length === 2, `lines=${logLines.length}`);
108
+
109
+ await browser.close();
110
+ cli.kill();
111
+
112
+ const failed = results.filter((r) => !r.pass);
113
+ writeFileSync(HERE + "out/m1-report.json", JSON.stringify({ results, finalSelection: readSel() }, null, 2));
114
+ console.log(`\nM1 loop: ${results.length - failed.length}/${results.length} assertions passed`);
115
+ if (failed.length) {
116
+ console.error("FAILED:", failed.map((f) => f.name).join(", "));
117
+ process.exit(5);
118
+ }
119
+ console.log("M1 PASS");
package/m3-test.mjs ADDED
@@ -0,0 +1,255 @@
1
+ // M3 verification — the analyzer reads real projects correctly, and codegen consumes that
2
+ // analysis to ship BOTH wiring shapes: the role-var path (our fixture) and the adopt path
3
+ // (the real jack-mcgovern.com site, whose fonts ride project-named variables on <body>).
4
+ //
5
+ // Structural only — no build. It applies into throwaway copies so it never touches the
6
+ // source repos, and asserts the produced code, idempotency, and byte-exact reversibility.
7
+
8
+ import { analyzeProject } from "./analyzer.mjs";
9
+ import { applySelection, undo } from "./codegen.mjs";
10
+ import {
11
+ readFileSync,
12
+ writeFileSync,
13
+ mkdirSync,
14
+ rmSync,
15
+ existsSync,
16
+ cpSync,
17
+ } from "node:fs";
18
+ import { fileURLToPath } from "node:url";
19
+ import path from "node:path";
20
+
21
+ const HERE = fileURLToPath(new URL("./", import.meta.url));
22
+ const ROOT = path.resolve(HERE, "..");
23
+ const OUT = HERE + "out/";
24
+ const TMP = HERE + ".m3-tmp/";
25
+ mkdirSync(OUT, { recursive: true });
26
+
27
+ const SAMPLE = path.join(ROOT, "examples/sample-next-site");
28
+ const CLEAN = path.join(ROOT, "examples/clean-next-site");
29
+ const JACK = path.resolve(ROOT, "../jack-mcgovern-site");
30
+
31
+ const results = [];
32
+ const assert = (name, cond, extra = "") => {
33
+ results.push({ name, pass: !!cond });
34
+ console.log((cond ? "PASS" : "FAIL").padEnd(5), name, extra && !cond ? `(got: ${extra})` : "");
35
+ };
36
+
37
+ // Build a throwaway copy carrying just what the analyzer/codegen read.
38
+ function stage(srcDir, label) {
39
+ const dst = path.join(TMP, label);
40
+ rmSync(dst, { recursive: true, force: true });
41
+ mkdirSync(path.join(dst, "app"), { recursive: true });
42
+ for (const f of ["package.json", "app/layout.tsx", "app/globals.css"]) {
43
+ const s = path.join(srcDir, f);
44
+ if (existsSync(s)) cpSync(s, path.join(dst, f));
45
+ }
46
+ for (const cfg of ["tailwind.config.ts", "tailwind.config.js"]) {
47
+ const s = path.join(srcDir, cfg);
48
+ if (existsSync(s)) cpSync(s, path.join(dst, cfg));
49
+ }
50
+ return dst;
51
+ }
52
+
53
+ const SELECTION = {
54
+ version: 1,
55
+ pickedAt: "2026-06-25T00:00:00.000Z",
56
+ direction: { id: "editorial-serif", name: "Editorial", vibe: "editorial", rationale: "Warm serif headlines over a clean grotesque body." },
57
+ roles: {
58
+ display: { family: "Fraunces", source: "google", weights: [400, 700] },
59
+ body: { family: "Libre Franklin", source: "google", weights: [400, 600] },
60
+ mono: { family: "JetBrains Mono", source: "google", weights: [400, 700] },
61
+ },
62
+ };
63
+ function writeSelection(dir, replaces, target) {
64
+ mkdirSync(path.join(dir, ".font-lab"), { recursive: true });
65
+ writeFileSync(path.join(dir, ".font-lab/selection.json"), JSON.stringify({ ...SELECTION, replaces, target }, null, 2));
66
+ }
67
+
68
+ try {
69
+ rmSync(TMP, { recursive: true, force: true });
70
+
71
+ // ===================================================================== //
72
+ // Part 1 — the analyzer reads each project correctly //
73
+ // ===================================================================== //
74
+
75
+ const aSample = analyzeProject(SAMPLE);
76
+ assert("sample: framework next", aSample.framework === "next");
77
+ assert("sample: App Router", aSample.router === "app");
78
+ assert("sample: Tailwind v4", aSample.tailwindVersion === 4, String(aSample.tailwindVersion));
79
+ assert("sample: css-variable wiring", aSample.fontWiring === "css-variables", aSample.fontWiring);
80
+ assert("sample: class target <html>", aSample.classNameTarget === "html", String(aSample.classNameTarget));
81
+ assert("sample: current display Inter", aSample.replaces.display === "Inter", String(aSample.replaces.display));
82
+ assert("sample: current mono JetBrains Mono", aSample.replaces.mono === "JetBrains Mono", String(aSample.replaces.mono));
83
+ assert("sample: supported", aSample.supported === true, aSample.reasons.join("; "));
84
+
85
+ const aClean = analyzeProject(CLEAN);
86
+ assert("clean: no display font wired", aClean.replaces.display === null, String(aClean.replaces.display));
87
+ assert("clean: body Inter via --font-sans", aClean.replaces.body === "Inter", String(aClean.replaces.body));
88
+ assert("clean: supported", aClean.supported === true, aClean.reasons.join("; "));
89
+ assert("clean: no dead roles (no false positives)", aClean.coverage.deadRoles.length === 0, aClean.coverage.deadRoles.join(","));
90
+ assert("sample: no dead roles (no false positives)", aSample.coverage.deadRoles.length === 0, aSample.coverage.deadRoles.join(","));
91
+
92
+ const haveJack = existsSync(JACK);
93
+ let aJack = null;
94
+ if (haveJack) {
95
+ aJack = analyzeProject(JACK);
96
+ assert("jack: framework next", aJack.framework === "next");
97
+ assert("jack: App Router", aJack.router === "app");
98
+ assert("jack: Tailwind v4", aJack.tailwindVersion === 4, String(aJack.tailwindVersion));
99
+ assert("jack: css-variable wiring", aJack.fontWiring === "css-variables", aJack.fontWiring);
100
+ assert("jack: class target <body>", aJack.classNameTarget === "body", String(aJack.classNameTarget));
101
+ assert("jack: display = Bricolage Grotesque", aJack.replaces.display === "Bricolage Grotesque", String(aJack.replaces.display));
102
+ assert("jack: body = Hanken Grotesk", aJack.replaces.body === "Hanken Grotesk", String(aJack.replaces.body));
103
+ assert("jack: no mono font wired", aJack.replaces.mono === null, String(aJack.replaces.mono));
104
+ assert("jack: display rides --font-bricolage", aJack.roles.display?.nextFontVar === "--font-bricolage", String(aJack.roles.display?.nextFontVar));
105
+ assert("jack: body rides --font-hanken", aJack.roles.body?.nextFontVar === "--font-hanken", String(aJack.roles.body?.nextFontVar));
106
+ assert("jack: supported", aJack.supported === true, aJack.reasons.join("; "));
107
+ // Coverage diagnostics — the foolproofing: detect a swap that won't be visible.
108
+ assert("jack: flags display as a dead role", aJack.coverage.deadRoles.includes("display"), aJack.coverage.deadRoles.join(","));
109
+ assert("jack: does NOT flag body as dead", !aJack.coverage.deadRoles.includes("body"));
110
+ assert("jack: reports other font subsystems (/gus, /fonts)", aJack.coverage.otherSubsystems.length >= 2, String(aJack.coverage.otherSubsystems.length));
111
+ } else {
112
+ console.log("note: jack-mcgovern-site not found alongside Font-Lab — skipping its assertions");
113
+ }
114
+
115
+ // ===================================================================== //
116
+ // Part 2 — codegen ROLE-VAR path on the clean fixture //
117
+ // ===================================================================== //
118
+
119
+ const cleanTarget = { framework: "next", router: "app", styling: "tailwind", tailwindVersion: 4, fontWiring: "css-variables" };
120
+ {
121
+ const dir = stage(CLEAN, "clean");
122
+ const origLayout = readFileSync(path.join(dir, "app/layout.tsx"), "utf8");
123
+ const origCss = readFileSync(path.join(dir, "app/globals.css"), "utf8");
124
+ writeSelection(dir, aClean.replaces, cleanTarget);
125
+ const r = applySelection(dir);
126
+ const layout = readFileSync(path.join(dir, "app/layout.tsx"), "utf8");
127
+ const css = readFileSync(path.join(dir, "app/globals.css"), "utf8");
128
+
129
+ assert("clean: imports Fraunces + Libre_Franklin", /\bFraunces\b/.test(layout) && /\bLibre_Franklin\b/.test(layout));
130
+ assert("clean: fontLabDisplay on --font-display", /const fontLabDisplay = Fraunces\([^)]*--font-display/.test(layout));
131
+ assert("clean: fontLabBody on --font-sans", /const fontLabBody = Libre_Franklin\([^)]*--font-sans/.test(layout));
132
+ assert("clean: fontLabMono on --font-mono", /const fontLabMono = JetBrains_Mono\([^)]*--font-mono/.test(layout));
133
+ assert("clean: removed old `const inter`", !/const inter =/.test(layout));
134
+ assert("clean: html has all 3 role vars", ["fontLabDisplay", "fontLabBody", "fontLabMono"].every((c) => layout.includes(`${c}.variable`)));
135
+ assert("clean: css fenced @theme has 3 role vars", /\/\* font-lab:start \*\/[\s\S]*--font-display[\s\S]*--font-sans[\s\S]*--font-mono[\s\S]*\/\* font-lab:end \*\//.test(css));
136
+
137
+ const layout2 = (applySelection(dir), readFileSync(path.join(dir, "app/layout.tsx"), "utf8"));
138
+ assert("clean: idempotent re-apply", layout === layout2);
139
+ assert("clean: replaced reported (inter)", r.replaced.some((x) => /Inter/.test(x.font)));
140
+
141
+ // Reversibility on a fresh copy (single apply, like M2).
142
+ const rev = stage(CLEAN, "clean-rev");
143
+ writeSelection(rev, aClean.replaces, cleanTarget);
144
+ applySelection(rev);
145
+ undo(rev);
146
+ assert("clean: undo restores layout byte-identical", readFileSync(path.join(rev, "app/layout.tsx"), "utf8") === origLayout);
147
+ assert("clean: undo restores globals byte-identical", readFileSync(path.join(rev, "app/globals.css"), "utf8") === origCss);
148
+ }
149
+
150
+ // ===================================================================== //
151
+ // Part 3 — codegen ADOPT path on the real jack-mcgovern.com site //
152
+ // ===================================================================== //
153
+
154
+ if (haveJack) {
155
+ const jackTarget = { framework: "next", router: "app", styling: "tailwind", tailwindVersion: 4, fontWiring: "css-variables" };
156
+ const dir = stage(JACK, "jack");
157
+ const origLayout = readFileSync(path.join(dir, "app/layout.tsx"), "utf8");
158
+ const origCss = readFileSync(path.join(dir, "app/globals.css"), "utf8");
159
+ writeSelection(dir, aJack.replaces, jackTarget);
160
+
161
+ const r = applySelection(dir);
162
+ const layout = readFileSync(path.join(dir, "app/layout.tsx"), "utf8");
163
+ const css = readFileSync(path.join(dir, "app/globals.css"), "utf8");
164
+ writeFileSync(OUT + "jack-applied.layout.tsx", layout);
165
+ writeFileSync(OUT + "jack-applied.globals.css", css);
166
+ const fenced = (css.match(/\/\* font-lab:start \*\/[\s\S]*?\/\* font-lab:end \*\//) || [""])[0];
167
+
168
+ assert("jack: adopts display const (bricolage = Fraunces)", /const bricolage = Fraunces\(/.test(layout));
169
+ assert("jack: display keeps --font-bricolage", /Fraunces\(\{[^}]*--font-bricolage/.test(layout.replace(/\n/g, " ")));
170
+ assert("jack: adopts body const (hanken = Libre_Franklin)", /const hanken = Libre_Franklin\(/.test(layout));
171
+ assert("jack: body keeps --font-hanken", /Libre_Franklin\(\{[^}]*--font-hanken/.test(layout.replace(/\n/g, " ")));
172
+ assert("jack: dropped Bricolage_Grotesque import", !/Bricolage_Grotesque/.test(layout));
173
+ assert("jack: dropped Hanken_Grotesk import", !/Hanken_Grotesk/.test(layout));
174
+ assert("jack: creates fontLabMono on --font-mono", /const fontLabMono = JetBrains_Mono\([^)]*--font-mono/.test(layout));
175
+ assert("jack: <body> keeps bricolage.variable + hanken.variable", /bricolage\.variable/.test(layout) && /hanken\.variable/.test(layout));
176
+ assert("jack: <body> gains fontLabMono.variable", /fontLabMono\.variable/.test(layout));
177
+ assert("jack: fenced @theme maps only --font-mono", /--font-mono/.test(fenced) && !/--font-display/.test(fenced) && !/--font-sans/.test(fenced), fenced.replace(/\n/g, " "));
178
+ assert("jack: project's own @theme (--font-display: var(--font-bricolage)) intact", /--font-display:\s*var\(--font-bricolage\)/.test(css));
179
+ assert("jack: replaces reports Bricolage Grotesque", r.replaced.some((x) => /Bricolage_Grotesque/.test(x.font)));
180
+ assert("jack: class target reported as body", r.classTarget === "body");
181
+
182
+ const layout2 = (applySelection(dir), readFileSync(path.join(dir, "app/layout.tsx"), "utf8"));
183
+ assert("jack: idempotent re-apply", layout === layout2);
184
+
185
+ // Reversibility on a fresh copy (single apply, like M2).
186
+ const rev = stage(JACK, "jack-rev");
187
+ writeSelection(rev, aJack.replaces, jackTarget);
188
+ applySelection(rev);
189
+ undo(rev);
190
+ assert("jack: undo restores layout byte-identical", readFileSync(path.join(rev, "app/layout.tsx"), "utf8") === origLayout);
191
+ assert("jack: undo restores globals byte-identical", readFileSync(path.join(rev, "app/globals.css"), "utf8") === origCss);
192
+ }
193
+ // ===================================================================== //
194
+ // Part 4 — branch selection: the analyzer gates what codegen ships //
195
+ // ===================================================================== //
196
+
197
+ // Synthesize minimal projects the analyzer should flag as out-of-branch.
198
+ function synth(label, { pkg, layoutPath, layout, css }) {
199
+ const dir = path.join(TMP, label);
200
+ rmSync(dir, { recursive: true, force: true });
201
+ mkdirSync(path.dirname(path.join(dir, layoutPath)), { recursive: true });
202
+ mkdirSync(path.join(dir, "app"), { recursive: true });
203
+ writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkg, null, 2));
204
+ writeFileSync(path.join(dir, layoutPath), layout);
205
+ writeFileSync(path.join(dir, "app/globals.css"), css);
206
+ return dir;
207
+ }
208
+ const refuses = (dir, needle) => {
209
+ writeSelection(dir, { display: null, body: null, mono: null }, {});
210
+ try {
211
+ applySelection(dir);
212
+ return false;
213
+ } catch (e) {
214
+ return new RegExp(needle, "i").test(e.message);
215
+ }
216
+ };
217
+
218
+ const v3 = synth("v3", {
219
+ pkg: { dependencies: { next: "^15", react: "^19" }, devDependencies: { tailwindcss: "^3.4.0" } },
220
+ layoutPath: "app/layout.tsx",
221
+ layout: `import { Inter } from "next/font/google";\nconst inter = Inter({ subsets: ["latin"], variable: "--font-sans" });\nexport default function RootLayout({ children }) { return (<html className={inter.variable}><body>{children}</body></html>); }`,
222
+ css: `@tailwind base;\n@tailwind components;\n@tailwind utilities;\n@theme inline { --font-sans: var(--font-sans); }`,
223
+ });
224
+ assert("v3: analyzer reports Tailwind v3", analyzeProject(v3).tailwindVersion === 3, String(analyzeProject(v3).tailwindVersion));
225
+ assert("v3: codegen refuses (need v4)", refuses(v3, "tailwind v3"));
226
+
227
+ const pages = synth("pages", {
228
+ pkg: { dependencies: { next: "^15", react: "^19", tailwindcss: "^4.2.0" }, devDependencies: { "@tailwindcss/postcss": "^4" } },
229
+ layoutPath: "pages/_app.tsx",
230
+ layout: `import { Inter } from "next/font/google";\nconst inter = Inter({ subsets: ["latin"], variable: "--font-sans" });\nexport default function App({ Component, pageProps }) { return (<div className={inter.variable}><Component {...pageProps} /></div>); }`,
231
+ css: `@import "tailwindcss";\n@theme inline { --font-sans: var(--font-sans); }`,
232
+ });
233
+ assert("pages: analyzer reports Pages Router", analyzeProject(pages).router === "pages", String(analyzeProject(pages).router));
234
+ assert("pages: codegen refuses (need app)", refuses(pages, "router is pages"));
235
+
236
+ const hard = synth("hardcoded", {
237
+ pkg: { dependencies: { next: "^15", react: "^19", tailwindcss: "^4.2.0" }, devDependencies: { "@tailwindcss/postcss": "^4" } },
238
+ layoutPath: "app/layout.tsx",
239
+ layout: `export default function RootLayout({ children }) { return (<html><body>{children}</body></html>); }`,
240
+ css: `@import "tailwindcss";\nbody { font-family: "Times New Roman", serif; }`,
241
+ });
242
+ assert("hardcoded: analyzer reports hardcoded wiring", analyzeProject(hard).fontWiring === "hardcoded", analyzeProject(hard).fontWiring);
243
+ assert("hardcoded: codegen refuses (need css-variables)", refuses(hard, "hardcoded"));
244
+ } finally {
245
+ rmSync(TMP, { recursive: true, force: true });
246
+ }
247
+
248
+ const failed = results.filter((r) => !r.pass);
249
+ writeFileSync(OUT + "m3-report.json", JSON.stringify({ results }, null, 2));
250
+ console.log(`\nM3: ${results.length - failed.length}/${results.length} assertions passed`);
251
+ if (failed.length) {
252
+ console.error("FAILED:", failed.map((f) => f.name).join(", "));
253
+ process.exit(5);
254
+ }
255
+ console.log("M3 PASS");