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/curator.mjs ADDED
@@ -0,0 +1,92 @@
1
+ // Font Lab curator (M4) — turns `analysis + vibe` into ~5 concrete directions via a
2
+ // deterministic lookup over the catalog. **No runtime LLM**: the agent driving Font Lab is
3
+ // the LLM; this package stays dumb, instant, free, and reproducible (same inputs → same
4
+ // directions, every time). Each direction is a hand-authored pairing with a pre-written
5
+ // rationale; `curate()` selects among them.
6
+ //
7
+ // Selection rules (all deterministic):
8
+ // • drop any direction that wouldn't change the current site (display+body == current),
9
+ // so we always move *away* from the baseline;
10
+ // • if a vibe is given, rank exact-vibe and tag matches first;
11
+ // • otherwise return a fixed, diverse spread in authored order;
12
+ // • always validate every referenced family is a catalog member (authoring guard).
13
+
14
+ import { get, catalog } from "./catalog.mjs";
15
+
16
+ const D = (id, name, vibe, rationale, display, body, mono, weights = {}) => ({
17
+ id,
18
+ name,
19
+ vibe,
20
+ rationale,
21
+ roles: {
22
+ display: { family: display, weights: weights.display || [400, 700] },
23
+ body: { family: body, weights: weights.body || [400, 600] },
24
+ mono: { family: mono, weights: weights.mono || [400, 700] },
25
+ },
26
+ });
27
+
28
+ // The curated set — diverse across vibe, each a real pairing of catalog fonts.
29
+ export const directions = [
30
+ D("editorial-serif", "Editorial", "editorial", "Warm high-contrast serif headlines over a clean grotesque body.", "Fraunces", "Libre Franklin", "JetBrains Mono"),
31
+ D("modern-grotesque", "Modern Grotesque", "technical", "A characterful display grotesque over a quiet geometric body.", "Bricolage Grotesque", "Figtree", "JetBrains Mono"),
32
+ D("clean-geometric", "Clean Geometric", "minimal", "One geometric family throughout — calm, precise, contemporary.", "Geist", "Geist", "Geist Mono"),
33
+ D("warm-humanist", "Warm Humanist", "warm", "Friendly humanist shapes, soft and highly readable end to end.", "Bricolage Grotesque", "Hanken Grotesk", "Spline Sans Mono"),
34
+ D("classic-editorial", "Classic Editorial", "classic", "High-drama display serif over a steady text serif — magazine feel.", "Playfair Display", "Source Serif 4", "Roboto Mono"),
35
+ D("technical", "Technical", "technical", "Engineered grotesque headings, a neutral geometric body, code-first mono.", "Space Grotesk", "Sora", "Fira Code"),
36
+ D("elegant-contrast", "Elegant Contrast", "elegant", "Delicate high-contrast serif display against a plain grotesque body.", "Cormorant", "Work Sans", "JetBrains Mono"),
37
+ D("expressive", "Expressive", "bold", "A loud, rounded display voice balanced by a minimal geometric body.", "Unbounded", "Manrope", "Geist Mono"),
38
+ D("modern-serif", "Modern Serif", "editorial", "A contemporary text serif headline over a neutral UI sans.", "Newsreader", "Inter", "Geist Mono"),
39
+ D("bold-editorial", "Bold Editorial", "bold", "An expressive editorial display over a crisp modern sans.", "Syne", "Albert Sans", "Spline Sans Mono"),
40
+ D("friendly-rounded", "Friendly Rounded", "friendly", "Rounded, approachable display with a soft geometric body.", "Gabarito", "Plus Jakarta Sans", "Roboto Mono"),
41
+ D("quiet-minimal", "Quiet Minimal", "minimal", "Understated geometric sans throughout — gets out of the way.", "Manrope", "Manrope", "Geist Mono"),
42
+ ];
43
+
44
+ // Validate authoring once at import: every referenced family must be a catalog member.
45
+ for (const d of directions) for (const r of ["display", "body", "mono"]) get(d.roles[r].family);
46
+
47
+ const norm = (s) => (s || "").toLowerCase().trim();
48
+
49
+ function scoreForVibe(d, vibe) {
50
+ const v = norm(vibe);
51
+ if (!v) return 0;
52
+ if (norm(d.vibe) === v) return 3;
53
+ // tag match across the direction's fonts
54
+ const tags = new Set(["display", "body", "mono"].flatMap((r) => catalog[d.roles[r].family].tags));
55
+ return tags.has(v) ? 1 : 0;
56
+ }
57
+
58
+ /**
59
+ * Deterministically pick ~`count` directions for a project.
60
+ * @param analysis output of analyzeProject (or null) — used to skip no-op directions.
61
+ * @param opts { vibe?: string, count?: number }
62
+ */
63
+ export function curate(analysis, opts = {}) {
64
+ const count = opts.count ?? 5;
65
+ const cur = analysis?.replaces || {};
66
+ const isNoop = (d) => norm(d.roles.display.family) === norm(cur.display) && norm(d.roles.body.family) === norm(cur.body);
67
+
68
+ const pool = directions.filter((d) => !isNoop(d));
69
+ // stable sort by vibe score (desc), preserving authored order for ties.
70
+ const ranked = pool
71
+ .map((d, i) => ({ d, i, s: scoreForVibe(d, opts.vibe) }))
72
+ .sort((a, b) => b.s - a.s || a.i - b.i)
73
+ .map((x) => x.d);
74
+
75
+ const picked = ranked.slice(0, count);
76
+ return picked.map((d) => ({ ...d, rationale: rationaleFor(d, cur) }));
77
+ }
78
+
79
+ // Make the rationale concrete about what it replaces, when we know the current fonts.
80
+ function rationaleFor(d, cur) {
81
+ if (cur.display && norm(cur.display) !== norm(d.roles.display.family)) {
82
+ return `${d.rationale} (replaces ${cur.display}${cur.body && cur.body !== cur.display ? " / " + cur.body : ""}.)`;
83
+ }
84
+ return d.rationale;
85
+ }
86
+
87
+ // Unique catalog families needed to render a set of directions (for self-hosting).
88
+ export function fontsForDirections(dirs) {
89
+ const set = new Set();
90
+ for (const d of dirs) for (const r of ["display", "body", "mono"]) set.add(d.roles[r].family);
91
+ return [...set];
92
+ }
package/engine.mjs ADDED
@@ -0,0 +1,343 @@
1
+ // Font Lab engine (M5) — the stable programmatic facade the MCP server and CLIs wrap. One
2
+ // import surface over the whole pipeline so the agent can drive the loop:
3
+ //
4
+ // analyze → (curate OR listCatalog + composeDirections) → preparePreview → readSelection → apply
5
+ //
6
+ // The taste split is enforced here, not just documented:
7
+ // • the HUMAN makes the final pick (we only ever prepare a preview; we never auto-select);
8
+ // • the AGENT may take the wheel on the *menu* — compose its own directions — but every
9
+ // font it chooses must be a catalog member, so the parity / ship guarantee always holds.
10
+ // • the curator is the strong default the agent gets for free.
11
+
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { readFileSync, writeFileSync, copyFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
15
+ import { catalog, get as catalogGet, inCatalog } from "./catalog.mjs";
16
+ import { curate as curateDirections } from "./curator.mjs";
17
+ import { analyzeProject, toTarget, wiringFor } from "./analyzer.mjs";
18
+ import { generateCatalog } from "./catalog-build.mjs";
19
+ import { applySelection, undo as undoApply, rewireCoverage } from "./codegen.mjs";
20
+
21
+ const PANEL_TEMPLATE = fileURLToPath(new URL("./templates/font-lab-panel.tsx", import.meta.url));
22
+
23
+ const ROLES = ["display", "body", "mono"];
24
+ const slugId = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
25
+
26
+ // ── read ────────────────────────────────────────────────────────────────────
27
+
28
+ export function analyze(projectDir) {
29
+ return analyzeProject(path.resolve(projectDir));
30
+ }
31
+
32
+ // Browse the catalog so the agent can compose its own directions. Filter by role/tag.
33
+ export function listCatalog({ role, tag } = {}) {
34
+ return Object.entries(catalog)
35
+ .filter(([, e]) => (!role || e.roles.includes(role)) && (!tag || e.tags.includes(tag)))
36
+ .map(([family, e]) => ({ family, roles: e.roles, tags: e.tags }));
37
+ }
38
+
39
+ // The default menu: ~5 deterministic directions for this project.
40
+ export function curate(projectDir, opts = {}) {
41
+ const analysis = analyzeProject(path.resolve(projectDir));
42
+ return { analysis, directions: curateDirections(analysis, opts) };
43
+ }
44
+
45
+ // ── option 3: the agent composes its own directions ──────────────────────────
46
+ // specs: [{ name, vibe?, rationale?, display, body, mono, weights? }]
47
+ // Parity guard: every family must be a catalog member; otherwise throw with suggestions.
48
+ export function composeDirections(specs) {
49
+ if (!Array.isArray(specs) || !specs.length) throw new Error("composeDirections: provide a non-empty array of directions");
50
+ const warnings = [];
51
+ const directions = specs.map((s, i) => {
52
+ if (!s || !s.display || !s.body || !s.mono) throw new Error(`direction[${i}]: needs display, body, and mono families`);
53
+ for (const role of ROLES) {
54
+ const fam = s[role];
55
+ if (!inCatalog(fam)) {
56
+ const near = suggest(fam, role);
57
+ throw new Error(`direction[${i}].${role}: "${fam}" is not in the Font Lab catalog${near ? ` — did you mean ${near}?` : ""}`);
58
+ }
59
+ if (!catalogGet(fam).roles.includes(role)) warnings.push(`"${fam}" isn't a typical ${role} font (allowed, but check it reads well)`);
60
+ }
61
+ const name = s.name || `${s.display} / ${s.body}`;
62
+ return {
63
+ id: s.id || slugId(name),
64
+ name,
65
+ vibe: s.vibe || "custom",
66
+ rationale: s.rationale || `${s.display} headings over ${s.body}.`,
67
+ roles: {
68
+ display: { family: s.display, weights: s.weights?.display || [400, 700] },
69
+ body: { family: s.body, weights: s.weights?.body || [400, 600] },
70
+ mono: { family: s.mono, weights: s.weights?.mono || [400, 700] },
71
+ },
72
+ };
73
+ });
74
+ return { directions, warnings };
75
+ }
76
+
77
+ function suggest(fam, role) {
78
+ const f = (fam || "").toLowerCase();
79
+ const hit = Object.keys(catalog).find((k) => k.toLowerCase().includes(f) || f.includes(k.toLowerCase()));
80
+ if (hit) return `"${hit}"`;
81
+ const someInRole = Object.entries(catalog).filter(([, e]) => e.roles.includes(role)).slice(0, 3).map(([k]) => `"${k}"`);
82
+ return someInRole.length ? someInRole.join(", ") : null;
83
+ }
84
+
85
+ // ── prepare the live preview (build parity bundles into the project) ──────────
86
+ // directions: optional agent-composed/curated directions; if omitted, curate for the project.
87
+ export async function preparePreview(projectDir, { directions, vibe, count, fetch = true, log } = {}) {
88
+ const dir = path.resolve(projectDir);
89
+ const analysis = analyzeProject(dir);
90
+ const dirs = directions && directions.length ? directions : curateDirections(analysis, { vibe, count });
91
+ const meta = { target: toTarget(analysis), replaces: analysis.replaces };
92
+ const result = await generateCatalog(dir, dirs, meta, { fetch, log });
93
+ return { analysis, prepared: result.fonts, directions: result.directions, outPath: result.outPath };
94
+ }
95
+
96
+ // ── the human's pick, and shipping it ────────────────────────────────────────
97
+
98
+ export function readSelection(projectDir) {
99
+ const p = path.join(path.resolve(projectDir), ".font-lab", "selection.json");
100
+ if (!existsSync(p)) return null;
101
+ try {
102
+ return JSON.parse(readFileSync(p, "utf8"));
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ export function apply(projectDir) {
109
+ return applySelection(path.resolve(projectDir));
110
+ }
111
+
112
+ // Fix a role the analyzer flags as dead (declared but not actually rendered). Reversible.
113
+ export function rewire(projectDir) {
114
+ return rewireCoverage(path.resolve(projectDir));
115
+ }
116
+
117
+ // ── install / uninstall the live panel (the agent's "setup" step) ────────────
118
+
119
+ const INIT_START = "// font-lab:init:start";
120
+ const INIT_END = "// font-lab:init:end";
121
+
122
+ function resolveAppDir(projectDir) {
123
+ const d = ["app", "src/app"].map((x) => path.join(projectDir, x)).find((x) => existsSync(path.join(x, "layout.tsx")));
124
+ if (!d) throw new Error("could not find app/layout.tsx (App Router only)");
125
+ return d;
126
+ }
127
+ function insertAfterImports(text, snippet) {
128
+ const re = /^import\s[^\n]*$/gm;
129
+ let last = 0, m;
130
+ while ((m = re.exec(text))) last = m.index + m[0].length;
131
+ return `${text.slice(0, last).replace(/\s*$/, "")}\n\n${snippet}\n\n${text.slice(last).replace(/^\s*/, "")}`;
132
+ }
133
+ function mountPanel(layoutPath) {
134
+ let src = readFileSync(layoutPath, "utf8");
135
+ if (src.includes(INIT_START)) return false;
136
+ if (!/from\s+["']next\/dynamic["']/.test(src)) src = insertAfterImports(src, `import dynamic from "next/dynamic"`);
137
+ const block = [
138
+ INIT_START,
139
+ `const FontLabDevPanel =`,
140
+ ` process.env.NODE_ENV === "development"`,
141
+ ` ? dynamic(() => import("./_fontlab/FontLabDevPanel").then((m) => m.FontLabDevPanel))`,
142
+ ` : () => null;`,
143
+ INIT_END,
144
+ ].join("\n");
145
+ src = insertAfterImports(src, block);
146
+ if (!/<FontLabDevPanel\s*\/>/.test(src)) src = src.replace(/<\/body>/, ` {process.env.NODE_ENV === "development" && <FontLabDevPanel />}\n </body>`);
147
+ writeFileSync(layoutPath, src);
148
+ return true;
149
+ }
150
+
151
+ // Set the project up so the human can preview live: self-host the parity bundles, drop in the
152
+ // portable dev panel, and mount it (dev-only) in the layout. Idempotent + reversible (uninit).
153
+ export async function init(projectDir, { vibe, count, fetch = true, log } = {}) {
154
+ const dir = path.resolve(projectDir);
155
+ const analysis = analyzeProject(dir);
156
+ if (!analysis.supported) throw new Error(`project not supported yet: ${analysis.reasons.join("; ")}`);
157
+ const appDir = resolveAppDir(dir);
158
+ const layout = path.join(appDir, "layout.tsx");
159
+
160
+ const backupDir = path.join(dir, ".font-lab", "init-backup");
161
+ mkdirSync(backupDir, { recursive: true });
162
+ const backupLayout = path.join(backupDir, "layout.tsx");
163
+ if (!existsSync(backupLayout)) copyFileSync(layout, backupLayout); // never clobber the original
164
+
165
+ const directions = curateDirections(analysis, { vibe, count });
166
+ const meta = { target: toTarget(analysis), replaces: analysis.replaces, wiring: wiringFor(analysis) };
167
+ const built = await generateCatalog(dir, directions, meta, { fetch, log });
168
+
169
+ mkdirSync(path.join(appDir, "_fontlab"), { recursive: true });
170
+ copyFileSync(PANEL_TEMPLATE, path.join(appDir, "_fontlab", "FontLabDevPanel.tsx"));
171
+ const mounted = mountPanel(layout);
172
+
173
+ return {
174
+ analysis,
175
+ directions: directions.map((d) => ({ id: d.id, name: d.name, vibe: d.vibe })),
176
+ wiring: meta.wiring,
177
+ deadRoles: analysis.coverage?.deadRoles || [],
178
+ otherSubsystems: analysis.coverage?.otherSubsystems || [],
179
+ prepared: built.fonts,
180
+ mounted,
181
+ layout: path.relative(dir, layout),
182
+ nextStep: "Start your dev server, then have the human flip fonts in the panel (bottom-right) and Pick. Then read_pick → apply.",
183
+ };
184
+ }
185
+
186
+ export function uninit(projectDir) {
187
+ const dir = path.resolve(projectDir);
188
+ const appDir = resolveAppDir(dir);
189
+ const layout = path.join(appDir, "layout.tsx");
190
+ const backupLayout = path.join(dir, ".font-lab", "init-backup", "layout.tsx");
191
+ if (existsSync(backupLayout)) copyFileSync(backupLayout, layout);
192
+ rmSync(path.join(appDir, "_fontlab"), { recursive: true, force: true });
193
+ rmSync(path.join(dir, "public", "fontlab"), { recursive: true, force: true });
194
+ rmSync(path.join(dir, ".font-lab", "init-backup"), { recursive: true, force: true });
195
+ return { restored: path.relative(dir, layout), removed: ["app/_fontlab", "public/fontlab"] };
196
+ }
197
+
198
+ export function undo(projectDir) {
199
+ return undoApply(path.resolve(projectDir));
200
+ }
201
+
202
+ // ── headless pick mode ────────────────────────────────────────────────────────
203
+ // When there's no live browser for the human to flip in (web/cloud sessions, phones), the
204
+ // agent screenshots the site in each direction, shows the images, the human picks by id, and
205
+ // we record that pick — the SAME selection.json the panel writes, so `apply` ships it
206
+ // identically. The taste decision still belongs to the human; only the surface changes.
207
+
208
+ // Record the human's pick from a chosen direction id — no panel click needed. Supports a mixed
209
+ // pick: each role can be sourced from a different direction (heading from one, body from another).
210
+ export function selectDirection(projectDir, { directionId, directions, vibe, count, roles: roleSrc } = {}) {
211
+ const dir = path.resolve(projectDir);
212
+ const analysis = analyzeProject(dir);
213
+ const dirs = directions && directions.length ? directions : curateDirections(analysis, { vibe, count });
214
+ const byId = (id) => dirs.find((d) => d.id === id || slugId(d.name) === id);
215
+ const chosen = byId(directionId);
216
+ if (!chosen) throw new Error(`no direction "${directionId}" — available: ${dirs.map((d) => d.id).join(", ")}`);
217
+ const roles = {};
218
+ for (const role of ROLES) {
219
+ const src = roleSrc?.[role] ? byId(roleSrc[role]) : chosen;
220
+ if (!src) throw new Error(`no direction "${roleSrc[role]}" for role ${role}`);
221
+ roles[role] = src.roles[role];
222
+ }
223
+ const selection = {
224
+ direction: { id: chosen.id, name: chosen.name, vibe: chosen.vibe },
225
+ roles,
226
+ pickedAt: new Date().toISOString(),
227
+ via: "headless",
228
+ };
229
+ const flDir = path.join(dir, ".font-lab");
230
+ mkdirSync(flDir, { recursive: true });
231
+ writeFileSync(path.join(flDir, "selection.json"), JSON.stringify(selection, null, 2) + "\n");
232
+ return selection;
233
+ }
234
+
235
+ // Ready-to-run commands to launch the FULL live editor (flip / mix / compare in a real browser),
236
+ // for when the screenshots aren't enough. Detects the project's dev command + package manager.
237
+ export function liveInstructions(projectDir) {
238
+ const dir = path.resolve(projectDir);
239
+ let devCmd = "npm run dev";
240
+ try {
241
+ const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"));
242
+ if (pkg.scripts?.dev) {
243
+ const pm = existsSync(path.join(dir, "pnpm-lock.yaml"))
244
+ ? "pnpm"
245
+ : existsSync(path.join(dir, "yarn.lock"))
246
+ ? "yarn"
247
+ : existsSync(path.join(dir, "bun.lockb"))
248
+ ? "bun"
249
+ : "npm run";
250
+ devCmd = `${pm} dev`;
251
+ }
252
+ } catch {}
253
+ return {
254
+ note: "Run these in a local terminal — your Mac/Linux terminal, or the integrated terminal in VS Code / Cursor / the Claude Code IDE extension — to flip, mix, and compare the directions live on your real site.",
255
+ steps: [
256
+ "npx font-lab init --project . # scaffold the live panel + parity bundles (reversible)",
257
+ `${devCmd} # start your dev server`,
258
+ "npx font-lab --project . & # the pick endpoint on :7777 (records your choice)",
259
+ "# open your site (e.g. http://localhost:3000): ← → flip · [ ] mix a role · B before/after · Pick",
260
+ "npx font-lab-apply --project . # the agent ships exactly what you picked",
261
+ ],
262
+ teardown: "npx font-lab init --project . --undo # remove the panel scaffolding when done",
263
+ };
264
+ }
265
+
266
+ // Headless capture: drive the REAL live panel through each direction and screenshot the site, so
267
+ // the images are faithful to what ships. Requires init() done and a dev server running at baseUrl.
268
+ // Makes no project edits — it only reads the running site. Returns a manifest the agent shows.
269
+ export async function captureDirections(projectDir, { baseUrl, routes = ["/"], outDir, directions, viewport, fullPage = true } = {}) {
270
+ if (!baseUrl) throw new Error("captureDirections: baseUrl is required (your running dev server, e.g. http://localhost:3000)");
271
+ let chromium;
272
+ try {
273
+ ({ chromium } = await import("playwright"));
274
+ } catch {
275
+ throw new Error(
276
+ "Playwright/Chromium isn't available for screenshots. Install it (`npm i -D playwright && npx playwright install chromium`), or use the live editor instead — see liveInstructions().",
277
+ );
278
+ }
279
+ const dir = path.resolve(projectDir);
280
+ const analysis = analyzeProject(dir);
281
+ const dirs = directions && directions.length ? directions : curateDirections(analysis, {});
282
+ const out = outDir ? path.resolve(outDir) : path.join(dir, ".font-lab", "previews");
283
+ mkdirSync(out, { recursive: true });
284
+ const base = baseUrl.replace(/\/+$/, "");
285
+ const route = routes[0] || "/";
286
+
287
+ const browser = await chromium.launch();
288
+ try {
289
+ const page = await browser.newPage({ viewport: viewport || { width: 1280, height: 900 }, deviceScaleFactor: 2 });
290
+ await page.goto(base + route, { waitUntil: "networkidle" });
291
+ await page.waitForSelector("#fontlab-panel-host", { timeout: 20000 });
292
+ await page.evaluate(async () => {
293
+ await document.fonts.ready;
294
+ });
295
+
296
+ const setPanel = (v) =>
297
+ page.evaluate((vis) => {
298
+ const h = document.getElementById("fontlab-panel-host");
299
+ if (h) h.style.visibility = vis;
300
+ }, v);
301
+
302
+ const shots = [];
303
+ // current / before
304
+ await setPanel("hidden");
305
+ const curPath = path.join(out, "current.png");
306
+ await page.screenshot({ path: curPath, fullPage });
307
+ await setPanel("visible");
308
+ shots.push({ id: "current", name: "Current (before)", vibe: "—", rationale: "the site as it is today", screenshot: curPath });
309
+
310
+ for (const d of dirs) {
311
+ const clicked = await page.evaluate((id) => {
312
+ const host = document.getElementById("fontlab-panel-host");
313
+ const btn = host?.shadowRoot?.querySelector(`button[data-fl-id="${id}"]`);
314
+ if (!btn) return false;
315
+ btn.click();
316
+ return true;
317
+ }, d.id);
318
+ if (!clicked) {
319
+ shots.push({ id: d.id, name: d.name, vibe: d.vibe, rationale: d.rationale, error: "no panel chip — direction not in the preview build (re-run init/preparePreview with these directions)" });
320
+ continue;
321
+ }
322
+ await page.evaluate(async () => {
323
+ await document.fonts.ready;
324
+ });
325
+ await page.waitForTimeout(350);
326
+ await setPanel("hidden");
327
+ const file = path.join(out, `${d.id}.png`);
328
+ await page.screenshot({ path: file, fullPage });
329
+ await setPanel("visible");
330
+ shots.push({
331
+ id: d.id,
332
+ name: d.name,
333
+ vibe: d.vibe,
334
+ rationale: d.rationale,
335
+ fonts: { display: d.roles.display.family, body: d.roles.body.family, mono: d.roles.mono.family },
336
+ screenshot: file,
337
+ });
338
+ }
339
+ return { baseUrl: base, route, outDir: out, shots, live: liveInstructions(dir) };
340
+ } finally {
341
+ await browser.close();
342
+ }
343
+ }
package/font-lab.mjs ADDED
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ // Font Lab CLI — entry point.
3
+ //
4
+ // Subcommands:
5
+ // install wire Font Lab into your machine + project (skill + MCP server)
6
+ // uninstall undo the above
7
+ // mcp run the MCP server (stdio) — what `.mcp.json` launches
8
+ // serve (default) run the localhost write-back endpoint the dev panel POSTs a pick to,
9
+ // persist it to `.font-lab/selection.json` (+ append picks.log.jsonl).
10
+ // This is the seam where codegen hooks in: pick lands here -> agent ships it.
11
+ //
12
+ // Usage:
13
+ // npx font-lab install [--project <dir>] [--no-mcp] [--local] [--dry-run]
14
+ // npx font-lab uninstall [--project <dir>]
15
+ // node cli/font-lab.mjs [serve] [--project <dir>] [--port <n>] [--apply]
16
+
17
+ import http from "node:http";
18
+ import path from "node:path";
19
+ import { writeFileSync, appendFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
20
+
21
+ // ---- subcommand dispatch ---------------------------------------------------
22
+ // The first non-flag arg selects a subcommand. Anything else (or a leading flag)
23
+ // falls through to `serve`, preserving the original `--project/--port` invocation.
24
+ const SUB = process.argv[2] && !process.argv[2].startsWith("-") ? process.argv[2] : "serve";
25
+ if (SUB === "install" || SUB === "uninstall") {
26
+ const { runInstall, runUninstall } = await import("./install.mjs");
27
+ if (SUB === "install") runInstall();
28
+ else runUninstall();
29
+ } else if (SUB === "mcp") {
30
+ await import("./mcp.mjs"); // self-runs the stdio server on import
31
+ } else if (SUB === "help" || SUB === "--help" || SUB === "-h") {
32
+ console.log(
33
+ [
34
+ "Font Lab",
35
+ " font-lab install [--project <dir>] [--no-mcp] [--no-skill] [--local] [--dry-run]",
36
+ " font-lab uninstall [--project <dir>]",
37
+ " font-lab mcp run the MCP server (stdio)",
38
+ " font-lab serve [--project <dir>] [--port <n>] [--apply] pick write-back endpoint",
39
+ ].join("\n"),
40
+ );
41
+ } else {
42
+ runServe();
43
+ }
44
+
45
+ function runServe() {
46
+
47
+ const arg = (flag, def) => {
48
+ const i = process.argv.indexOf(flag);
49
+ return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : def;
50
+ };
51
+ const PORT = Number(arg("--port", "7777"));
52
+ const PROJECT = path.resolve(arg("--project", process.cwd()));
53
+ const AUTO_APPLY = process.argv.includes("--apply"); // pick -> ship, in one step
54
+ const FLDIR = path.join(PROJECT, ".font-lab");
55
+ const SELECTION = path.join(FLDIR, "selection.json");
56
+ const PICKLOG = path.join(FLDIR, "picks.log.jsonl");
57
+
58
+ const cors = (res) => {
59
+ res.setHeader("Access-Control-Allow-Origin", "*");
60
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
61
+ res.setHeader("Access-Control-Allow-Headers", "content-type");
62
+ };
63
+
64
+ function printPick(sel) {
65
+ const r = sel.roles || {};
66
+ const fam = (x) => (x && x.family) || "?";
67
+ console.log(`\n ✓ picked "${sel.direction?.name ?? "?"}" (${sel.direction?.vibe ?? "?"})`);
68
+ console.log(` display ${fam(r.display)} body ${fam(r.body)} mono ${fam(r.mono)}`);
69
+ console.log(` wrote ${path.relative(process.cwd(), SELECTION)}`);
70
+ console.log(` → next milestone (M4) turns this into next/font + Tailwind edits.\n`);
71
+ }
72
+
73
+ const server = http.createServer((req, res) => {
74
+ cors(res);
75
+ if (req.method === "OPTIONS") {
76
+ res.writeHead(204);
77
+ return res.end();
78
+ }
79
+ if (req.method === "GET" && req.url === "/health") {
80
+ res.writeHead(200, { "content-type": "application/json" });
81
+ return res.end('{"ok":true}');
82
+ }
83
+ if (req.method === "GET" && req.url === "/selection") {
84
+ const cur = existsSync(SELECTION) ? readFileSync(SELECTION, "utf8") : "{}";
85
+ res.writeHead(200, { "content-type": "application/json" });
86
+ return res.end(cur);
87
+ }
88
+ if (req.method === "POST" && req.url === "/select") {
89
+ let body = "";
90
+ req.on("data", (c) => (body += c));
91
+ req.on("end", () => {
92
+ try {
93
+ const sel = JSON.parse(body);
94
+ mkdirSync(FLDIR, { recursive: true });
95
+ writeFileSync(SELECTION, JSON.stringify(sel, null, 2) + "\n");
96
+ appendFileSync(
97
+ PICKLOG,
98
+ JSON.stringify({ at: sel.pickedAt, direction: sel.direction?.id, roles: sel.roles }) + "\n",
99
+ );
100
+ printPick(sel);
101
+ if (AUTO_APPLY) {
102
+ import("./codegen.mjs")
103
+ .then(({ applySelection }) => {
104
+ const r = applySelection(PROJECT);
105
+ console.log(` → applied to project: ${r.edited.join(", ")} (\`font-lab undo\` to revert)\n`);
106
+ })
107
+ .catch((e) => console.error(` apply failed: ${e.message}\n`));
108
+ }
109
+ res.writeHead(200, { "content-type": "application/json" });
110
+ res.end('{"ok":true}');
111
+ } catch (e) {
112
+ res.writeHead(400, { "content-type": "application/json" });
113
+ res.end(JSON.stringify({ ok: false, error: String(e) }));
114
+ }
115
+ });
116
+ return;
117
+ }
118
+ res.writeHead(404);
119
+ res.end();
120
+ });
121
+
122
+ server.listen(PORT, () => {
123
+ console.log(`Font Lab — walking skeleton`);
124
+ console.log(` endpoint http://localhost:${PORT} (POST /select, GET /selection)`);
125
+ console.log(` project ${PROJECT}`);
126
+ console.log(` Open your dev site, flip directions in the panel (← →), and hit Pick.`);
127
+ console.log(` Waiting for a pick…`);
128
+ });
129
+ }
@@ -0,0 +1,32 @@
1
+ // `font-lab gen` — build the parity catalog the dev panel previews from. Thin CLI over the
2
+ // full analyzer → curator → generateCatalog pipeline:
3
+ // 1. analyze the project (M3) → target + the real current fonts;
4
+ // 2. curate ~5 directions for it (M4, deterministic, no LLM);
5
+ // 3. self-host each font's variable woff2 + compute next/font's exact adjusted fallback
6
+ // (M0-proven parity) and write app/_fontlab/catalog.generated.ts.
7
+ //
8
+ // Uses curl (which honors the sandbox HTTPS proxy) to fetch from Google.
9
+
10
+ import { fileURLToPath } from "node:url";
11
+ import { curate } from "./curator.mjs";
12
+ import { analyzeProject, toTarget, wiringFor } from "./analyzer.mjs";
13
+ import { generateCatalog } from "./catalog-build.mjs";
14
+
15
+ const arg = (flag, def) => {
16
+ const i = process.argv.indexOf(flag);
17
+ return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : def;
18
+ };
19
+ const APP = arg("--project", fileURLToPath(new URL("../examples/sample-next-site/", import.meta.url))).replace(/\/+$/, "");
20
+
21
+ const analysis = analyzeProject(APP);
22
+ const target = toTarget(analysis);
23
+ const replaces = analysis.replaces;
24
+ const directions = curate(analysis, { vibe: arg("--vibe", undefined), count: Number(arg("--count", "5")) });
25
+ console.log(
26
+ ` analyzed ${analysis.router}/${target.framework} · tailwind v${target.tailwindVersion} · ${target.fontWiring}` +
27
+ ` · current: ${replaces.display ?? "—"} / ${replaces.body ?? "—"} / ${replaces.mono ?? "—"}`,
28
+ );
29
+ console.log(` curated ${directions.length} directions: ${directions.map((d) => d.name).join(", ")}`);
30
+
31
+ const r = await generateCatalog(APP, directions, { target, replaces, wiring: wiringFor(analysis) }, { log: (m) => console.log(m) });
32
+ console.log(`\nwrote app/_fontlab/catalog.generated.ts (${r.fonts.length} fonts, ${r.directions.length} directions)`);
package/init-test.mjs ADDED
@@ -0,0 +1,52 @@
1
+ // init verification — `font-lab init` scaffolds a real project (panel + parity module +
2
+ // dev-only mount) and `--undo` restores it byte-for-byte. Offline (--no-fetch).
3
+
4
+ import { execFileSync } from "node:child_process";
5
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, cpSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import path from "node:path";
8
+
9
+ const HERE = fileURLToPath(new URL("./", import.meta.url));
10
+ const ROOT = path.resolve(HERE, "..");
11
+ const OUT = HERE + "out/";
12
+ const TMP = HERE + ".init-tmp/";
13
+ mkdirSync(OUT, { recursive: true });
14
+ const CLEAN = path.join(ROOT, "examples/clean-next-site");
15
+
16
+ const results = [];
17
+ const assert = (n, c, e = "") => { results.push({ name: n, pass: !!c }); console.log((c ? "PASS" : "FAIL").padEnd(5), n, e && !c ? `(${e})` : ""); };
18
+
19
+ try {
20
+ rmSync(TMP, { recursive: true, force: true });
21
+ const dir = path.join(TMP, "proj");
22
+ mkdirSync(path.join(dir, "app"), { recursive: true });
23
+ for (const f of ["package.json", "app/layout.tsx", "app/globals.css"]) cpSync(path.join(CLEAN, f), path.join(dir, f));
24
+ const layoutPath = path.join(dir, "app/layout.tsx");
25
+ const orig = readFileSync(layoutPath, "utf8");
26
+
27
+ execFileSync("node", [HERE + "init.mjs", "--project", dir, "--no-fetch"], { stdio: "pipe" });
28
+ const layout = readFileSync(layoutPath, "utf8");
29
+ assert("init: catalog.generated.ts written", existsSync(path.join(dir, "app/_fontlab/catalog.generated.ts")));
30
+ assert("init: generated module carries wiring", /export const wiring/.test(readFileSync(path.join(dir, "app/_fontlab/catalog.generated.ts"), "utf8")));
31
+ assert("init: portable panel copied", existsSync(path.join(dir, "app/_fontlab/FontLabDevPanel.tsx")));
32
+ assert("init: layout imports next/dynamic", /from\s+["']next\/dynamic["']/.test(layout));
33
+ assert("init: layout declares the dev-only panel", /font-lab:init:start/.test(layout) && /NODE_ENV === "development"/.test(layout));
34
+ assert("init: panel mounted in <body>", /<FontLabDevPanel \/>/.test(layout));
35
+
36
+ // idempotent: re-init doesn't double-mount
37
+ execFileSync("node", [HERE + "init.mjs", "--project", dir, "--no-fetch"], { stdio: "pipe" });
38
+ const layout2 = readFileSync(layoutPath, "utf8");
39
+ assert("init: re-init doesn't duplicate the mount", (layout2.match(/font-lab:init:start/g) || []).length === 1);
40
+
41
+ execFileSync("node", [HERE + "init.mjs", "--project", dir, "--undo"], { stdio: "pipe" });
42
+ assert("undo: layout restored byte-identical", readFileSync(layoutPath, "utf8") === orig);
43
+ assert("undo: _fontlab removed", !existsSync(path.join(dir, "app/_fontlab")));
44
+ } finally {
45
+ rmSync(TMP, { recursive: true, force: true });
46
+ }
47
+
48
+ const failed = results.filter((r) => !r.pass);
49
+ writeFileSync(OUT + "init-report.json", JSON.stringify({ results }, null, 2));
50
+ console.log(`\ninit: ${results.length - failed.length}/${results.length} assertions passed`);
51
+ if (failed.length) { console.error("FAILED:", failed.map((f) => f.name).join(", ")); process.exit(5); }
52
+ console.log("init PASS");