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/m4-test.mjs ADDED
@@ -0,0 +1,92 @@
1
+ // M4 verification — the parity catalog and the curator. Offline (no network): the gate that
2
+ // matters most, capsize coverage, is checked by actually importing each font's metrics; the
3
+ // curator's determinism and selection rules are pure logic.
4
+
5
+ import { catalog, families, get } from "./catalog.mjs";
6
+ import { directions, curate, fontsForDirections } from "./curator.mjs";
7
+ import { writeFileSync, mkdirSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const OUT = fileURLToPath(new URL("./out/", import.meta.url));
11
+ mkdirSync(OUT, { recursive: true });
12
+
13
+ const results = [];
14
+ const assert = (name, cond, extra = "") => {
15
+ results.push({ name, pass: !!cond });
16
+ console.log((cond ? "PASS" : "FAIL").padEnd(5), name, extra && !cond ? `(${extra})` : "");
17
+ };
18
+ const ROLES = ["display", "body", "mono"];
19
+ const deep = (a, b) => JSON.stringify(a) === JSON.stringify(b);
20
+
21
+ // ===================================================================== //
22
+ // Part 1 — the catalog: every member is a verified parity bundle //
23
+ // ===================================================================== //
24
+
25
+ assert("catalog is ~40 fonts", families.length >= 36, String(families.length));
26
+
27
+ let coverageOk = 0;
28
+ for (const family of families) {
29
+ const e = catalog[family];
30
+ let metrics = null;
31
+ try {
32
+ metrics = (await import("@capsizecss/metrics/" + e.capsize)).default;
33
+ } catch {}
34
+ const covered = !!(metrics && metrics.familyName);
35
+ if (covered) coverageOk++;
36
+ else assert(`capsize coverage: ${family}`, false, `no metrics for slug "${e.capsize}"`);
37
+ const validRoles = Array.isArray(e.roles) && e.roles.length && e.roles.every((r) => ROLES.includes(r));
38
+ if (!validRoles) assert(`roles valid: ${family}`, false, JSON.stringify(e.roles));
39
+ if (!(e.css2 && /wght@/.test(e.css2))) assert(`css2 weight-range: ${family}`, false, e.css2);
40
+ if (!(Array.isArray(e.tags) && e.tags.length)) assert(`has tags: ${family}`, false, JSON.stringify(e.tags));
41
+ }
42
+ assert("every catalog font has verified capsize coverage", coverageOk === families.length, `${coverageOk}/${families.length}`);
43
+ assert("every catalog font: valid roles / css2 / tags", results.every((r) => r.pass));
44
+ assert("mono fonts exist for the mono role", families.some((f) => catalog[f].roles.includes("mono")));
45
+
46
+ // ===================================================================== //
47
+ // Part 2 — the curator: deterministic, valid, moves off the baseline //
48
+ // ===================================================================== //
49
+
50
+ // every authored direction references only catalog fonts, with role-appropriate fonts
51
+ for (const d of directions) {
52
+ for (const r of ROLES) {
53
+ const fam = d.roles[r].family;
54
+ assert(`${d.id}: ${r} "${fam}" in catalog`, (() => { try { get(fam); return true; } catch { return false; } })());
55
+ assert(`${d.id}: ${fam} suits role ${r}`, catalog[fam].roles.includes(r), catalog[fam].roles.join(","));
56
+ }
57
+ assert(`${d.id}: has name/vibe/rationale`, !!(d.name && d.vibe && d.rationale));
58
+ }
59
+
60
+ const fresh = { replaces: { display: "Inter", body: "Inter", mono: "JetBrains Mono" } };
61
+
62
+ const a = curate(fresh);
63
+ const b = curate(fresh);
64
+ assert("curate is deterministic (same input → same output)", deep(a, b));
65
+ assert("curate returns 5 by default", a.length === 5, String(a.length));
66
+ assert("curate respects count", curate(fresh, { count: 3 }).length === 3);
67
+
68
+ // excludes no-op directions (display+body already current)
69
+ const onGeist = { replaces: { display: "Geist", body: "Geist", mono: "Geist Mono" } };
70
+ assert("curate drops a direction that wouldn't change the site", !curate(onGeist, { count: 12 }).some((d) => d.id === "clean-geometric"));
71
+
72
+ // vibe ranking puts the matching vibe first
73
+ const ed = curate(fresh, { vibe: "editorial" });
74
+ assert("vibe=editorial ranks an editorial direction first", ed[0].vibe === "editorial", ed[0].vibe);
75
+ const minimal = curate(fresh, { vibe: "minimal" });
76
+ assert("vibe=minimal ranks a minimal direction first", minimal[0].vibe === "minimal", minimal[0].vibe);
77
+
78
+ // rationale becomes concrete about what it replaces
79
+ assert("rationale names the replaced font when known", a.some((d) => /replaces Inter/.test(d.rationale)));
80
+
81
+ // fontsForDirections returns catalog members only
82
+ const used = fontsForDirections(a);
83
+ assert("fontsForDirections returns catalog members", used.length > 0 && used.every((f) => !!catalog[f]));
84
+
85
+ const failed = results.filter((r) => !r.pass);
86
+ writeFileSync(OUT + "m4-report.json", JSON.stringify({ catalogSize: families.length, directions: directions.length, results }, null, 2));
87
+ console.log(`\nM4: ${results.length - failed.length}/${results.length} assertions passed (catalog ${families.length} fonts, ${directions.length} authored directions)`);
88
+ if (failed.length) {
89
+ console.error("FAILED:", failed.map((f) => f.name).join(", "));
90
+ process.exit(5);
91
+ }
92
+ console.log("M4 PASS");
package/m5-test.mjs ADDED
@@ -0,0 +1,142 @@
1
+ // M5 verification — the engine facade and the MCP server. The engine logic is offline; the
2
+ // MCP server is exercised over real stdio (spawn → initialize → tools/list → tools/call).
3
+ // Option 3 is the heart of it: the agent can compose its own directions, but only from the
4
+ // catalog, and the human is always the one who picks (we only ever prepare a preview).
5
+
6
+ import * as engine from "./engine.mjs";
7
+ import { spawn } from "node:child_process";
8
+ import { writeFileSync, mkdirSync, rmSync, cpSync, existsSync } from "node:fs";
9
+ import { fileURLToPath } from "node:url";
10
+ import path from "node:path";
11
+
12
+ const HERE = fileURLToPath(new URL("./", import.meta.url));
13
+ const ROOT = path.resolve(HERE, "..");
14
+ const OUT = HERE + "out/";
15
+ const TMP = HERE + ".m5-tmp/";
16
+ mkdirSync(OUT, { recursive: true });
17
+ const CLEAN = path.join(ROOT, "examples/clean-next-site");
18
+
19
+ const results = [];
20
+ const assert = (name, cond, extra = "") => {
21
+ results.push({ name, pass: !!cond });
22
+ console.log((cond ? "PASS" : "FAIL").padEnd(5), name, extra && !cond ? `(${extra})` : "");
23
+ };
24
+
25
+ try {
26
+ rmSync(TMP, { recursive: true, force: true });
27
+
28
+ // ===================================================================== //
29
+ // Part 1 — the engine facade //
30
+ // ===================================================================== //
31
+
32
+ const a = engine.analyze(CLEAN);
33
+ assert("analyze returns target + current", a.framework === "next" && "replaces" in a);
34
+
35
+ const cat = engine.listCatalog({ role: "mono" });
36
+ assert("listCatalog filters by role (mono)", cat.length > 0 && cat.every((f) => f.roles.includes("mono")));
37
+ assert("listCatalog filters by tag (serif)", engine.listCatalog({ tag: "serif" }).every((f) => f.tags.includes("serif")));
38
+
39
+ const { directions: curated } = engine.curate(CLEAN);
40
+ assert("curate returns the default menu (~5)", curated.length === 5);
41
+
42
+ // option 3: agent composes its own directions from the catalog
43
+ const composed = engine.composeDirections([
44
+ { name: "My Pick", vibe: "editorial", display: "Playfair Display", body: "Inter", mono: "Geist Mono" },
45
+ ]);
46
+ assert("composeDirections accepts catalog fonts", composed.directions.length === 1 && composed.directions[0].roles.display.family === "Playfair Display");
47
+ assert("composeDirections normalizes id + weights", !!composed.directions[0].id && Array.isArray(composed.directions[0].roles.body.weights));
48
+
49
+ // option 3 guard: a non-catalog font is refused with a helpful error
50
+ let refused = false, msg = "";
51
+ try {
52
+ engine.composeDirections([{ display: "Comic Sans MS", body: "Inter", mono: "Geist Mono" }]);
53
+ } catch (e) {
54
+ refused = true;
55
+ msg = e.message;
56
+ }
57
+ assert("composeDirections refuses non-catalog fonts", refused && /not in the Font Lab catalog/.test(msg), msg);
58
+
59
+ // preparePreview without network (fetch:false) builds the generated module from composed dirs
60
+ const proj = path.join(TMP, "clean");
61
+ mkdirSync(path.join(proj, "app/_fontlab"), { recursive: true });
62
+ for (const f of ["package.json", "app/layout.tsx", "app/globals.css"]) cpSync(path.join(CLEAN, f), path.join(proj, f));
63
+ const prep = await engine.preparePreview(proj, { directions: composed.directions, fetch: false });
64
+ assert("preparePreview writes catalog.generated.ts", existsSync(prep.outPath));
65
+ assert("preparePreview reports prepared fonts", prep.prepared.includes("Playfair Display"));
66
+
67
+ // readSelection: null before a pick, the object after
68
+ assert("readSelection is null before a pick", engine.readSelection(proj) === null);
69
+ mkdirSync(path.join(proj, ".font-lab"), { recursive: true });
70
+ writeFileSync(path.join(proj, ".font-lab/selection.json"), JSON.stringify({ version: 1, direction: { id: "x" } }));
71
+ assert("readSelection returns the pick after one exists", engine.readSelection(proj)?.direction?.id === "x");
72
+
73
+ // ===================================================================== //
74
+ // Part 2 — the MCP server over real stdio //
75
+ // ===================================================================== //
76
+
77
+ const server = spawn("node", [HERE + "mcp.mjs"], { stdio: ["pipe", "pipe", "inherit"] });
78
+ const pending = new Map();
79
+ let sbuf = "";
80
+ server.stdout.setEncoding("utf8");
81
+ server.stdout.on("data", (chunk) => {
82
+ sbuf += chunk;
83
+ let nl;
84
+ while ((nl = sbuf.indexOf("\n")) !== -1) {
85
+ const line = sbuf.slice(0, nl).trim();
86
+ sbuf = sbuf.slice(nl + 1);
87
+ if (!line) continue;
88
+ const msg = JSON.parse(line);
89
+ if (msg.id && pending.has(msg.id)) {
90
+ pending.get(msg.id)(msg);
91
+ pending.delete(msg.id);
92
+ }
93
+ }
94
+ });
95
+ let nextId = 1;
96
+ const rpc = (method, params) =>
97
+ new Promise((resolve, reject) => {
98
+ const id = nextId++;
99
+ pending.set(id, resolve);
100
+ server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
101
+ setTimeout(() => reject(new Error(`timeout: ${method}`)), 15000);
102
+ });
103
+
104
+ try {
105
+ const init = await rpc("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "test", version: "0" } });
106
+ assert("MCP initialize returns serverInfo", init.result?.serverInfo?.name === "font-lab");
107
+ assert("MCP initialize advertises tools capability", !!init.result?.capabilities?.tools);
108
+ server.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
109
+
110
+ const list = await rpc("tools/list", {});
111
+ const names = list.result.tools.map((t) => t.name);
112
+ for (const t of ["font_lab_analyze", "font_lab_list_catalog", "font_lab_curate", "font_lab_compose_directions", "font_lab_init", "font_lab_uninit", "font_lab_prepare_preview", "font_lab_read_pick", "font_lab_apply", "font_lab_rewire_dead_roles", "font_lab_undo"])
113
+ assert(`MCP exposes ${t}`, names.includes(t));
114
+ assert("MCP tool descriptions mention the human keeps the pick", list.result.tools.some((t) => /human/i.test(t.description)));
115
+
116
+ const an = await rpc("tools/call", { name: "font_lab_analyze", arguments: { projectDir: CLEAN } });
117
+ const anObj = JSON.parse(an.result.content[0].text);
118
+ assert("MCP analyze call returns analysis", anObj.framework === "next");
119
+
120
+ const cu = await rpc("tools/call", { name: "font_lab_curate", arguments: { projectDir: CLEAN, count: 3 } });
121
+ assert("MCP curate call returns 3 directions", JSON.parse(cu.result.content[0].text).length === 3);
122
+
123
+ const bad = await rpc("tools/call", { name: "font_lab_compose_directions", arguments: { directions: [{ display: "Nope", body: "Inter", mono: "Geist Mono" }] } });
124
+ assert("MCP surfaces tool errors in-band (isError)", bad.result.isError === true && /not in the Font Lab catalog/.test(bad.result.content[0].text));
125
+
126
+ const unknown = await rpc("tools/call", { name: "font_lab_nope", arguments: {} });
127
+ assert("MCP rejects unknown tool", !!unknown.error);
128
+ } finally {
129
+ server.kill();
130
+ }
131
+ } finally {
132
+ rmSync(TMP, { recursive: true, force: true });
133
+ }
134
+
135
+ const failed = results.filter((r) => !r.pass);
136
+ writeFileSync(OUT + "m5-report.json", JSON.stringify({ results }, null, 2));
137
+ console.log(`\nM5: ${results.length - failed.length}/${results.length} assertions passed`);
138
+ if (failed.length) {
139
+ console.error("FAILED:", failed.map((f) => f.name).join(", "));
140
+ process.exit(5);
141
+ }
142
+ console.log("M5 PASS");
package/m6-test.mjs ADDED
@@ -0,0 +1,119 @@
1
+ // M6 verification — drive the polished choosing moment in a real browser and prove the new
2
+ // powers actually work on the running fixture: mixed picks (display from A, body from B),
3
+ // before/after, pin-two-to-compare, and multi-route persistence. Spawns the CLI endpoint;
4
+ // expects the fixture 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:4332";
13
+ const HERE = fileURLToPath(new URL("./", import.meta.url));
14
+ const APP = fileURLToPath(new URL("../examples/sample-next-site/", import.meta.url));
15
+ const SEL = path.join(APP, ".font-lab", "selection.json");
16
+ mkdirSync(HERE + "out", { recursive: true });
17
+ rmSync(path.join(APP, ".font-lab"), { recursive: true, force: true });
18
+
19
+ const cli = spawn("node", [HERE + "font-lab.mjs", "--project", APP, "--port", "7777"], { stdio: "inherit" });
20
+ for (let i = 0; i < 40; i++) {
21
+ try { if ((await fetch("http://localhost:7777/health")).ok) break; } catch {}
22
+ await new Promise((r) => setTimeout(r, 250));
23
+ }
24
+
25
+ const results = [];
26
+ const assert = (name, cond, extra = "") => {
27
+ results.push({ name, pass: !!cond });
28
+ console.log((cond ? "PASS" : "FAIL").padEnd(5), name, extra && !cond ? `(${extra})` : "");
29
+ };
30
+
31
+ const browser = await chromium.launch();
32
+ const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } });
33
+
34
+ const SHADOW = `document.getElementById("fontlab-panel-host").shadowRoot`;
35
+ const click = (sel) => page.evaluate((s) => eval(s.host).querySelector(s.sel).click(), { host: SHADOW, sel });
36
+ const famRow = (role) => page.evaluate((s) => eval(s.host).querySelector(`[data-fl-fam="${s.role}"]`).textContent, { host: SHADOW, role });
37
+ const activeId = () => page.evaluate(() => document.documentElement.getAttribute("data-fontlab-active"));
38
+ const bodyFont = () => page.evaluate(() => getComputedStyle(document.body).fontFamily);
39
+ const dispFont = () => page.evaluate(() => getComputedStyle(document.querySelector("h1")).fontFamily);
40
+ const settle = async () => { await page.evaluate(async () => { await document.fonts.ready; }); await page.waitForTimeout(180); };
41
+ const readSel = () => (existsSync(SEL) ? JSON.parse(readFileSync(SEL, "utf8")) : null);
42
+ const blur = () => page.mouse.click(3, 3);
43
+
44
+ try {
45
+ await page.goto(BASE + "/", { waitUntil: "domcontentloaded" });
46
+ await settle();
47
+ await blur();
48
+ assert("starts on current (Inter)", (await activeId()) === "current" && /Inter/i.test(await bodyFont()), await bodyFont());
49
+
50
+ // ---- Mixed pick: display from Editorial, body swapped to another direction's font ----
51
+ await click('button[data-fl-id="editorial-serif"]');
52
+ await settle();
53
+ assert("editorial: display Fraunces", /FL Fraunces/i.test(await dispFont()), await dispFont());
54
+ assert("editorial: body Libre Franklin", /FL Libre Franklin/i.test(await bodyFont()), await bodyFont());
55
+
56
+ await click('[data-fl-inc="body"]'); // cycle just the body role
57
+ await settle();
58
+ const mixedBody = await bodyFont();
59
+ assert("mixed: body changed independently", !/Libre Franklin/i.test(mixedBody) && /FL /.test(mixedBody), mixedBody);
60
+ assert("mixed: display still Fraunces", /FL Fraunces/i.test(await dispFont()), await dispFont());
61
+ assert("mixed: active flips to 'mixed'", (await activeId()) === "mixed", await activeId());
62
+ const bodyFam = await famRow("body");
63
+
64
+ await click('[data-fl-action="pick"]');
65
+ let sel = null;
66
+ for (let i = 0; i < 40; i++) { sel = readSel(); if (sel) break; await page.waitForTimeout(150); }
67
+ assert("mixed pick written", !!sel);
68
+ assert("mixed pick: direction id = mixed", sel?.direction?.id === "mixed", sel?.direction?.id);
69
+ assert("mixed pick: display = Fraunces", sel?.roles?.display?.family === "Fraunces");
70
+ assert("mixed pick: body = the swapped font", sel?.roles?.body?.family === bodyFam, `${sel?.roles?.body?.family} vs ${bodyFam}`);
71
+ assert("mixed pick: display ≠ body (it's a real mix)", sel?.roles?.display?.family !== sel?.roles?.body?.family);
72
+
73
+ // ---- Before / after ----
74
+ await click('[data-fl-action="compare"]');
75
+ await settle();
76
+ assert("before/after shows current", (await activeId()) === "current" && /Inter/i.test(await bodyFont()), await bodyFont());
77
+ await click('[data-fl-action="compare"]');
78
+ await settle();
79
+ assert("before/after restores the build", (await activeId()) === "mixed");
80
+
81
+ // ---- Pin two to compare ----
82
+ await click('[data-fl-action="pin"]'); // pin A (the mix)
83
+ await click('button[data-fl-id="clean-geometric"]'); // build B
84
+ await settle();
85
+ await click('[data-fl-action="pin"]'); // pin B
86
+ await blur();
87
+ await page.keyboard.press(" ");
88
+ await settle();
89
+ const firstShown = await bodyFont();
90
+ await blur();
91
+ await page.keyboard.press(" ");
92
+ await settle();
93
+ const secondShown = await bodyFont();
94
+ assert("pin compare toggles between A and B", firstShown !== secondShown, `${firstShown} / ${secondShown}`);
95
+ assert("one of the pinned views is Geist (clean-geometric)", /FL Geist/i.test(firstShown) || /FL Geist/i.test(secondShown));
96
+
97
+ // ---- Multi-route: re-establish a mix, navigate, confirm it persists ----
98
+ await click('button[data-fl-id="editorial-serif"]');
99
+ await click('[data-fl-inc="body"]');
100
+ await settle();
101
+ const homeBody = await bodyFont();
102
+ await page.goto(BASE + "/dense", { waitUntil: "domcontentloaded" });
103
+ await settle();
104
+ assert("panel persists across route nav (multi-route)", (await bodyFont()) === homeBody, `${await bodyFont()} vs ${homeBody}`);
105
+ assert("dense route renders the mixed display too", /FL Fraunces/i.test(await dispFont()), await dispFont());
106
+ await page.screenshot({ path: HERE + "out/m6-dense.png" });
107
+ } finally {
108
+ await browser.close();
109
+ cli.kill();
110
+ }
111
+
112
+ const failed = results.filter((r) => !r.pass);
113
+ writeFileSync(HERE + "out/m6-report.json", JSON.stringify({ results, finalSelection: readSel() }, null, 2));
114
+ console.log(`\nM6: ${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("M6 PASS");
package/mcp.mjs ADDED
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env node
2
+ // Font Lab MCP server (M5) — wraps the engine so an agent can drive the whole loop:
3
+ // analyze → (curate OR list_catalog + compose_directions) → prepare_preview → read_pick → apply
4
+ //
5
+ // Minimal, dependency-free JSON-RPC 2.0 over stdio (newline-delimited messages, per the MCP
6
+ // stdio transport). Protocol on stdout; all logging on stderr.
7
+ //
8
+ // Discoverability ("SEO for agents") lives in the tool descriptions below: they're written so
9
+ // an agent reaches for Font Lab the moment a user wants to choose, change, or improve fonts —
10
+ // and so it understands the contract (the HUMAN picks; the agent curates and ships).
11
+
12
+ import * as engine from "./engine.mjs";
13
+
14
+ const PROTOCOL_VERSION = "2024-11-05";
15
+ const SERVER = { name: "font-lab", version: "0.7.0" };
16
+ const log = (...a) => process.stderr.write("[font-lab mcp] " + a.join(" ") + "\n");
17
+
18
+ const proj = { type: "string", description: "Absolute path to the user's Next.js + Tailwind project root." };
19
+
20
+ const TOOLS = [
21
+ {
22
+ name: "font_lab_analyze",
23
+ description:
24
+ "Audit a Next.js + Tailwind project's CURRENT typography before changing it: framework, App/Pages router, Tailwind version, the current display/body/mono fonts, how they're wired, and coverage warnings (e.g. a font that's declared but not actually rendered). ALWAYS run this first when a user wants to pick, change, or improve fonts.",
25
+ inputSchema: { type: "object", properties: { projectDir: proj }, required: ["projectDir"] },
26
+ handler: (a) => engine.analyze(a.projectDir),
27
+ },
28
+ {
29
+ name: "font_lab_list_catalog",
30
+ description:
31
+ "Browse Font Lab's curated catalog of ship-ready variable Google fonts (each verified for capsize/CLS-safe parity). Filter by role ('display'|'body'|'mono') or vibe tag (e.g. 'editorial','geometric','serif','technical'). Use this to compose your OWN font directions when the default curation isn't what the user asked for.",
32
+ inputSchema: {
33
+ type: "object",
34
+ properties: { role: { type: "string", enum: ["display", "body", "mono"] }, tag: { type: "string" } },
35
+ },
36
+ handler: (a) => engine.listCatalog({ role: a.role, tag: a.tag }),
37
+ },
38
+ {
39
+ name: "font_lab_curate",
40
+ description:
41
+ "Get ~5 tasteful, ready-to-preview font directions for a project (display+body+mono pairings with a name, vibe, and rationale). Deterministic, no LLM — a strong DEFAULT menu that moves off the project's current fonts. Pass an optional 'vibe' to steer it. You can also ignore this and compose your own with list_catalog + compose_directions.",
42
+ inputSchema: {
43
+ type: "object",
44
+ properties: { projectDir: proj, vibe: { type: "string" }, count: { type: "number" } },
45
+ required: ["projectDir"],
46
+ },
47
+ handler: (a) => engine.curate(a.projectDir, { vibe: a.vibe, count: a.count }).directions,
48
+ },
49
+ {
50
+ name: "font_lab_compose_directions",
51
+ description:
52
+ "Assemble your OWN font directions from catalog fonts when you want to tailor the options to the user's request (this is how the agent takes the wheel on the menu). Each direction needs display, body, and mono families. Every family MUST be a catalog member (run list_catalog) — that's what guarantees the preview matches what ships. Returns validated, preview-ready directions (and warnings for unusual role choices).",
53
+ inputSchema: {
54
+ type: "object",
55
+ properties: {
56
+ directions: {
57
+ type: "array",
58
+ items: {
59
+ type: "object",
60
+ properties: {
61
+ name: { type: "string" },
62
+ vibe: { type: "string" },
63
+ rationale: { type: "string" },
64
+ display: { type: "string" },
65
+ body: { type: "string" },
66
+ mono: { type: "string" },
67
+ },
68
+ required: ["display", "body", "mono"],
69
+ },
70
+ },
71
+ },
72
+ required: ["directions"],
73
+ },
74
+ handler: (a) => engine.composeDirections(a.directions),
75
+ },
76
+ {
77
+ name: "font_lab_init",
78
+ description:
79
+ "SET UP Font Lab in the project so the human can preview live — self-hosts the parity bundles, installs the dev panel, and mounts it (dev-only) in the layout. This is the one-shot setup: run it after font_lab_analyze, then start the dev server and tell the human to flip fonts in the panel (bottom-right) and Pick. Idempotent and reversible (font_lab_uninit). Reports any dead roles (offer font_lab_rewire_dead_roles).",
80
+ inputSchema: {
81
+ type: "object",
82
+ properties: { projectDir: proj, vibe: { type: "string" }, count: { type: "number" } },
83
+ required: ["projectDir"],
84
+ },
85
+ handler: (a) => engine.init(a.projectDir, { vibe: a.vibe, count: a.count, log }),
86
+ },
87
+ {
88
+ name: "font_lab_uninit",
89
+ description: "Remove Font Lab's panel scaffolding from the project (restores the layout, removes the generated panel + self-hosted fonts). Use to clean up if the user doesn't want to keep previewing.",
90
+ inputSchema: { type: "object", properties: { projectDir: proj }, required: ["projectDir"] },
91
+ handler: (a) => engine.uninit(a.projectDir),
92
+ },
93
+ {
94
+ name: "font_lab_prepare_preview",
95
+ description:
96
+ "Build the LIVE preview bundle (self-hosted woff2 + exact next/font fallbacks) into the user's project so the HUMAN can flip through the directions on their real running site and pick one. Pass either curated/composed 'directions' or a 'vibe'. Font Lab keeps the taste decision with the human — this never auto-selects. (Fetches fonts from Google.)",
97
+ inputSchema: {
98
+ type: "object",
99
+ properties: { projectDir: proj, directions: { type: "array", items: { type: "object" } }, vibe: { type: "string" }, count: { type: "number" } },
100
+ required: ["projectDir"],
101
+ },
102
+ handler: (a) => engine.preparePreview(a.projectDir, { directions: a.directions, vibe: a.vibe, count: a.count, log }),
103
+ },
104
+ {
105
+ name: "font_lab_read_pick",
106
+ description:
107
+ "Read the human's pick (.font-lab/selection.json). Returns null until they've chosen in the panel. Poll this after prepare_preview; ship it with font_lab_apply once present.",
108
+ inputSchema: { type: "object", properties: { projectDir: proj }, required: ["projectDir"] },
109
+ handler: (a) => engine.readSelection(a.projectDir),
110
+ },
111
+ {
112
+ name: "font_lab_apply",
113
+ description:
114
+ "Ship the human's pick: apply the exact next/font + Tailwind edits to the project, idempotently and reversibly (backup-first). Refuses out-of-branch projects with a clear reason. Run after read_pick returns a selection.",
115
+ inputSchema: { type: "object", properties: { projectDir: proj }, required: ["projectDir"] },
116
+ handler: (a) => engine.apply(a.projectDir),
117
+ },
118
+ {
119
+ name: "font_lab_rewire_dead_roles",
120
+ description:
121
+ "Fix a role that font_lab_analyze flags as DEAD — declared but not actually rendered (common with Tailwind v4 @theme inline + a hand-written `font-family: var(--font-display)`, which resolves to nothing). Points those raw usages at the published leaf variable so the font renders, making the swap visible. Reversible via font_lab_undo. Offer this when analyze reports dead roles and the user wants that role to actually change.",
122
+ inputSchema: { type: "object", properties: { projectDir: proj }, required: ["projectDir"] },
123
+ handler: (a) => engine.rewire(a.projectDir),
124
+ },
125
+ {
126
+ name: "font_lab_undo",
127
+ description: "Revert Font Lab's last apply or rewire, restoring the edited files byte-for-byte from the backup.",
128
+ inputSchema: { type: "object", properties: { projectDir: proj }, required: ["projectDir"] },
129
+ handler: (a) => engine.undo(a.projectDir),
130
+ },
131
+ {
132
+ name: "font_lab_screenshot_directions",
133
+ description:
134
+ "HEADLESS pick mode — when the human has no live browser to flip in (a web/cloud session, or they're on a phone), screenshot the running site in each curated direction so they can pick from IMAGES instead of a live panel. Requires font_lab_init done and the project's dev server running; pass its baseUrl (e.g. http://localhost:3000). Returns a manifest of {id, name, vibe, rationale, fonts, screenshot path} per direction (plus a 'current' before-shot) — SHOW these images to the human and ask them to pick an id. The screenshots are driven through the real preview panel, so they are faithful to what ships. Makes no edits.",
135
+ inputSchema: {
136
+ type: "object",
137
+ properties: {
138
+ projectDir: proj,
139
+ baseUrl: { type: "string", description: "The running dev server URL, e.g. http://localhost:3000." },
140
+ routes: { type: "array", items: { type: "string" }, description: "Route(s) to capture; defaults to ['/']." },
141
+ outDir: { type: "string", description: "Where to write PNGs; defaults to <project>/.font-lab/previews." },
142
+ },
143
+ required: ["projectDir", "baseUrl"],
144
+ },
145
+ handler: (a) => engine.captureDirections(a.projectDir, { baseUrl: a.baseUrl, routes: a.routes, outDir: a.outDir }),
146
+ },
147
+ {
148
+ name: "font_lab_select",
149
+ description:
150
+ "Record the human's pick by direction id — the HEADLESS counterpart to clicking Pick in the panel. Use AFTER the human has chosen from the screenshots (you must still let the HUMAN make the call — never auto-select). Writes the same selection.json the panel writes, so font_lab_apply ships it identically. Supports a mixed pick: pass roles {display, body, mono} as direction ids to take each role from a different direction.",
151
+ inputSchema: {
152
+ type: "object",
153
+ properties: {
154
+ projectDir: proj,
155
+ directionId: { type: "string", description: "The id the human picked (from curate/screenshots)." },
156
+ roles: {
157
+ type: "object",
158
+ description: "Optional mixed pick — per-role direction ids, e.g. {display:'editorial-serif', body:'modern-grotesque'}.",
159
+ properties: { display: { type: "string" }, body: { type: "string" }, mono: { type: "string" } },
160
+ },
161
+ },
162
+ required: ["projectDir", "directionId"],
163
+ },
164
+ handler: (a) => engine.selectDirection(a.projectDir, { directionId: a.directionId, roles: a.roles }),
165
+ },
166
+ {
167
+ name: "font_lab_live_instructions",
168
+ description:
169
+ "Get ready-to-run commands to launch the FULL live editor (flip / mix / compare directions in a real browser) — for when the headless screenshots aren't enough and the human wants to drive it themselves. Detects the project's dev command. These run in a local terminal: a Mac/Linux terminal or the integrated terminal in VS Code / Cursor / the Claude Code IDE extension.",
170
+ inputSchema: { type: "object", properties: { projectDir: proj }, required: ["projectDir"] },
171
+ handler: (a) => engine.liveInstructions(a.projectDir),
172
+ },
173
+ ];
174
+
175
+ const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
176
+ const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
177
+ const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
178
+
179
+ async function handle(msg) {
180
+ const { id, method, params } = msg;
181
+ const isNotification = id === undefined || id === null;
182
+ try {
183
+ if (method === "initialize") {
184
+ return reply(id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER });
185
+ }
186
+ if (method === "notifications/initialized" || method === "notifications/cancelled") return; // notifications
187
+ if (method === "ping") return reply(id, {});
188
+ if (method === "tools/list") {
189
+ return reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
190
+ }
191
+ if (method === "tools/call") {
192
+ const tool = TOOLS.find((t) => t.name === params?.name);
193
+ if (!tool) return fail(id, -32602, `unknown tool: ${params?.name}`);
194
+ try {
195
+ const out = await tool.handler(params.arguments || {});
196
+ return reply(id, { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] });
197
+ } catch (e) {
198
+ // tool-level errors are reported in-band so the agent can react
199
+ return reply(id, { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true });
200
+ }
201
+ }
202
+ if (!isNotification) fail(id, -32601, `method not found: ${method}`);
203
+ } catch (e) {
204
+ if (!isNotification) fail(id, -32603, e.message);
205
+ }
206
+ }
207
+
208
+ let buf = "";
209
+ process.stdin.setEncoding("utf8");
210
+ process.stdin.on("data", (chunk) => {
211
+ buf += chunk;
212
+ let nl;
213
+ while ((nl = buf.indexOf("\n")) !== -1) {
214
+ const line = buf.slice(0, nl).trim();
215
+ buf = buf.slice(nl + 1);
216
+ if (!line) continue;
217
+ let msg;
218
+ try {
219
+ msg = JSON.parse(line);
220
+ } catch {
221
+ log("parse error:", line.slice(0, 120));
222
+ continue;
223
+ }
224
+ handle(msg);
225
+ }
226
+ });
227
+ process.stdin.on("end", () => process.exit(0));
228
+ log(`ready — ${TOOLS.length} tools, protocol ${PROTOCOL_VERSION}`);
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "font-lab",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Font Lab — a decision surface for typography in Next.js + Tailwind apps. Curate tasteful font directions on your real running site, let a human pick, and ship the exact next/font + Tailwind code. Installs as an agent skill + MCP server.",
6
+ "keywords": [
7
+ "fonts",
8
+ "typography",
9
+ "nextjs",
10
+ "next-font",
11
+ "tailwindcss",
12
+ "claude",
13
+ "claude-code",
14
+ "mcp",
15
+ "agent",
16
+ "skill"
17
+ ],
18
+ "license": "MIT",
19
+ "author": "jmg698",
20
+ "homepage": "https://github.com/jmg698/Font-Lab#readme",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/jmg698/Font-Lab.git",
24
+ "directory": "cli"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/jmg698/Font-Lab/issues"
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "bin": {
33
+ "font-lab": "./font-lab.mjs",
34
+ "font-lab-install": "./install.mjs",
35
+ "font-lab-apply": "./apply.mjs",
36
+ "font-lab-undo": "./undo.mjs",
37
+ "font-lab-rewire": "./rewire.mjs",
38
+ "font-lab-analyze": "./analyze.mjs",
39
+ "font-lab-curate": "./curate.mjs",
40
+ "font-lab-init": "./init.mjs",
41
+ "font-lab-screenshots": "./screenshots.mjs",
42
+ "font-lab-select": "./select.mjs",
43
+ "font-lab-mcp": "./mcp.mjs"
44
+ },
45
+ "files": [
46
+ "*.mjs",
47
+ "templates/",
48
+ "skill/",
49
+ "README.md"
50
+ ],
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "scripts": {
55
+ "gen": "node gen-catalog.mjs",
56
+ "start": "node font-lab.mjs",
57
+ "setup": "node install.mjs",
58
+ "analyze": "node analyze.mjs",
59
+ "curate": "node curate.mjs",
60
+ "apply": "node apply.mjs",
61
+ "undo": "node undo.mjs",
62
+ "rewire": "node rewire.mjs",
63
+ "mcp": "node mcp.mjs",
64
+ "prepack": "node bundle-skill.mjs",
65
+ "m1": "bash run-m1.sh",
66
+ "m2": "bash run-m2.sh",
67
+ "m3": "bash run-m3.sh",
68
+ "m4": "bash run-m4.sh",
69
+ "m5": "bash run-m5.sh",
70
+ "m6": "bash run-m6.sh"
71
+ },
72
+ "dependencies": {
73
+ "@capsizecss/metrics": "^3.4.0",
74
+ "ts-morph": "^28.0.0"
75
+ },
76
+ "devDependencies": {
77
+ "playwright": "1.56.1"
78
+ }
79
+ }