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.
@@ -0,0 +1,79 @@
1
+ // rewire verification — a Jack-shaped project (Tailwind v4 @theme inline + a hand-written
2
+ // `h1 { font-family: var(--font-display) }`) has a DEAD display role; `rewire` points that raw
3
+ // usage at the published leaf var so it renders, leaves @theme alone, clears the dead flag,
4
+ // and is byte-for-byte reversible. Offline.
5
+
6
+ import { analyzeProject } from "./analyzer.mjs";
7
+ import { rewireCoverage, undo } from "./codegen.mjs";
8
+ import { writeFileSync, mkdirSync, rmSync, readFileSync } 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 OUT = HERE + "out/";
14
+ const TMP = HERE + ".rewire-tmp/";
15
+ mkdirSync(OUT, { recursive: true });
16
+
17
+ const results = [];
18
+ const assert = (n, c, e = "") => { results.push({ name: n, pass: !!c }); console.log((c ? "PASS" : "FAIL").padEnd(5), n, e && !c ? `(${e})` : ""); };
19
+
20
+ const LAYOUT = `import { Bricolage_Grotesque, Hanken_Grotesk } from "next/font/google";
21
+ const bricolage = Bricolage_Grotesque({ subsets: ["latin"], variable: "--font-bricolage" });
22
+ const hanken = Hanken_Grotesk({ subsets: ["latin"], variable: "--font-hanken" });
23
+ export default function RootLayout({ children }) {
24
+ return (<html lang="en"><body className={\`\${bricolage.variable} \${hanken.variable} font-sans\`}>{children}</body></html>);
25
+ }
26
+ `;
27
+ const GLOBALS = `@import "tailwindcss";
28
+
29
+ @theme inline {
30
+ --font-display: var(--font-bricolage), ui-sans-serif, system-ui, sans-serif;
31
+ --font-sans: var(--font-hanken), ui-sans-serif, system-ui, sans-serif;
32
+ }
33
+
34
+ @layer base {
35
+ h1, h2, h3 { font-family: var(--font-display); }
36
+ body { @apply bg-white; }
37
+ }
38
+ `;
39
+ const PKG = JSON.stringify({ dependencies: { next: "^15", react: "^19", tailwindcss: "^4.2.0" }, devDependencies: { "@tailwindcss/postcss": "^4" } }, null, 2);
40
+
41
+ try {
42
+ rmSync(TMP, { recursive: true, force: true });
43
+ const dir = path.join(TMP, "proj");
44
+ mkdirSync(path.join(dir, "app"), { recursive: true });
45
+ writeFileSync(path.join(dir, "package.json"), PKG);
46
+ writeFileSync(path.join(dir, "app/layout.tsx"), LAYOUT);
47
+ writeFileSync(path.join(dir, "app/globals.css"), GLOBALS);
48
+ const cssPath = path.join(dir, "app/globals.css");
49
+ const orig = readFileSync(cssPath, "utf8");
50
+
51
+ const before = analyzeProject(dir);
52
+ assert("analyzer flags display as dead", before.coverage.deadRoles.includes("display"), before.coverage.deadRoles.join(","));
53
+ assert("analyzer does NOT flag body (uses utility)", !before.coverage.deadRoles.includes("body"));
54
+
55
+ const r = rewireCoverage(dir);
56
+ const css = readFileSync(cssPath, "utf8");
57
+ assert("rewire reports display fixed", r.rewired.some((x) => x.role === "display" && x.to === "--font-bricolage"), JSON.stringify(r.rewired));
58
+ assert("base rule now uses the leaf var", /h1, h2, h3 \{ font-family: var\(--font-bricolage\); \}/.test(css), css.match(/h1[^}]*}/)?.[0]);
59
+ assert("@theme definition left intact", /--font-display: var\(--font-bricolage\)/.test(css));
60
+ assert("did not touch body / --font-sans", /--font-sans: var\(--font-hanken\)/.test(css));
61
+
62
+ const after = analyzeProject(dir);
63
+ assert("dead flag cleared after rewire", !after.coverage.deadRoles.includes("display"), after.coverage.deadRoles.join(","));
64
+
65
+ undo(dir);
66
+ assert("undo restores globals byte-identical", readFileSync(cssPath, "utf8") === orig);
67
+
68
+ // no-op when there's nothing dead
69
+ const r2 = rewireCoverage(dir);
70
+ assert("re-rewire after undo finds the dead role again", r2.rewired.length >= 1);
71
+ } finally {
72
+ rmSync(TMP, { recursive: true, force: true });
73
+ }
74
+
75
+ const failed = results.filter((r) => !r.pass);
76
+ writeFileSync(OUT + "rewire-report.json", JSON.stringify({ results }, null, 2));
77
+ console.log(`\nrewire: ${results.length - failed.length}/${results.length} assertions passed`);
78
+ if (failed.length) { console.error("FAILED:", failed.map((f) => f.name).join(", ")); process.exit(5); }
79
+ console.log("rewire PASS");
package/rewire.mjs ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // `font-lab rewire` — fix dead roles (a font declared but not actually rendered, e.g. a
3
+ // heading rule that reads var(--font-display) under @theme inline). Points those raw usages
4
+ // at the published leaf var so the font renders. Reversible via `font-lab undo`.
5
+ // node cli/rewire.mjs --project <dir>
6
+ import path from "node:path";
7
+ import { rewireCoverage } from "./codegen.mjs";
8
+
9
+ const arg = (f, d) => { const i = process.argv.indexOf(f); return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : d; };
10
+ const project = path.resolve(arg("--project", process.cwd()));
11
+
12
+ try {
13
+ const r = rewireCoverage(project);
14
+ if (!r.rewired.length) {
15
+ console.log(`Font Lab — nothing to rewire (${r.note}).`);
16
+ } else {
17
+ console.log("Font Lab — rewired dead roles");
18
+ for (const x of r.rewired) console.log(` ${x.role.padEnd(8)} var(${x.from}) → var(${x.to}) (${x.count}×)`);
19
+ console.log(` edited ${r.edited.join(", ")}`);
20
+ console.log(` backup ${r.backupDir} → \`font-lab undo\` to revert`);
21
+ }
22
+ } catch (e) {
23
+ console.error("rewire failed:", e.message);
24
+ process.exit(1);
25
+ }
@@ -0,0 +1,42 @@
1
+ // Capture the loop visually: the current state vs a chosen direction, panel in view.
2
+ // Usage: BASE_URL=http://localhost:4331 node cli/screenshot-demo.mjs
3
+ import { chromium } from "playwright";
4
+ import { mkdirSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const BASE = process.env.BASE_URL || process.argv[2] || "http://localhost:4331";
8
+ const OUT = fileURLToPath(new URL("./out/", import.meta.url));
9
+ mkdirSync(OUT, { recursive: true });
10
+
11
+ const browser = await chromium.launch();
12
+ const page = await browser.newPage({ viewport: { width: 1200, height: 1000 }, deviceScaleFactor: 2 });
13
+ await page.goto(BASE + "/", { waitUntil: "domcontentloaded" });
14
+ await page.evaluate(async () => {
15
+ await document.fonts.ready;
16
+ return true;
17
+ });
18
+ await page.waitForTimeout(600);
19
+ await page.screenshot({ path: OUT + "loop-current.png" });
20
+
21
+ await page.evaluate(() =>
22
+ document.getElementById("fontlab-panel-host").shadowRoot.querySelector('button[data-fl-id="modern-grotesque"]').click(),
23
+ );
24
+ await page.evaluate(async () => {
25
+ await document.fonts.ready;
26
+ return true;
27
+ });
28
+ await page.waitForTimeout(500);
29
+ await page.screenshot({ path: OUT + "loop-modern-grotesque.png" });
30
+
31
+ await page.evaluate(() =>
32
+ document.getElementById("fontlab-panel-host").shadowRoot.querySelector('button[data-fl-id="editorial-serif"]').click(),
33
+ );
34
+ await page.evaluate(async () => {
35
+ await document.fonts.ready;
36
+ return true;
37
+ });
38
+ await page.waitForTimeout(500);
39
+ await page.screenshot({ path: OUT + "loop-editorial.png" });
40
+
41
+ await browser.close();
42
+ console.log("wrote loop-current / loop-editorial / loop-modern-grotesque .png to cli/out/");
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // `font-lab-screenshots` — HEADLESS pick mode. Drive the live panel through each curated
3
+ // direction and screenshot the running site, so a human with no live browser (web/cloud/phone)
4
+ // can pick from images. Requires `font-lab init` done + your dev server running.
5
+ //
6
+ // node screenshots.mjs --project <dir> --base http://localhost:3000 [--route /] [--out <dir>]
7
+
8
+ import path from "node:path";
9
+ import * as engine from "./engine.mjs";
10
+
11
+ const arg = (f, d) => { const i = process.argv.indexOf(f); return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : d; };
12
+ const project = path.resolve(arg("--project", process.cwd()));
13
+ const baseUrl = arg("--base", arg("--base-url", "http://localhost:3000"));
14
+ const out = arg("--out", undefined);
15
+ const routes = arg("--route", "/").split(",");
16
+ const rel = (p) => path.relative(process.cwd(), p) || ".";
17
+
18
+ try {
19
+ const r = await engine.captureDirections(project, { baseUrl, outDir: out, routes });
20
+ console.log(`Font Lab — captured ${r.shots.length} preview(s) from ${r.baseUrl}${r.route} → ${rel(r.outDir)}`);
21
+ for (const s of r.shots) {
22
+ console.log(` ${s.error ? "✗" : "✓"} ${s.id.padEnd(22)} ${s.error || rel(s.screenshot)}`);
23
+ }
24
+ console.log(`\n Show these to the human, let them pick an id, then:`);
25
+ console.log(` node select.mjs --project ${rel(project)} --direction <id> && node apply.mjs --project ${rel(project)}`);
26
+ console.log(`\n Prefer to flip live instead? ${r.live.note}`);
27
+ } catch (e) {
28
+ console.error("screenshots failed:", e.message);
29
+ process.exit(1);
30
+ }
package/select.mjs ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // `font-lab-select` — record the human's pick by direction id (the HEADLESS path: no panel
3
+ // click needed). Writes the same .font-lab/selection.json the live panel writes, so `apply`
4
+ // ships it identically. Supports a mixed pick via per-role flags.
5
+ //
6
+ // node select.mjs --project <dir> --direction <id>
7
+ // node select.mjs --project <dir> --direction <id> --display <id> --body <id> --mono <id>
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 directionId = arg("--direction", arg("--id", undefined));
15
+ const rel = (p) => path.relative(process.cwd(), p) || ".";
16
+
17
+ if (!directionId) {
18
+ console.error("usage: font-lab-select --project <dir> --direction <id> [--display <id> --body <id> --mono <id>]");
19
+ process.exit(1);
20
+ }
21
+
22
+ const roles = {};
23
+ for (const r of ["display", "body", "mono"]) {
24
+ const v = arg(`--${r}`, undefined);
25
+ if (v) roles[r] = v;
26
+ }
27
+
28
+ try {
29
+ const sel = engine.selectDirection(project, { directionId, roles: Object.keys(roles).length ? roles : undefined });
30
+ console.log(`Font Lab — recorded pick: ${sel.direction.name} (${sel.direction.vibe})`);
31
+ console.log(` display ${sel.roles.display.family} body ${sel.roles.body.family} mono ${sel.roles.mono.family}`);
32
+ console.log(` → ship it: node apply.mjs --project ${rel(project)} (reversible: node undo.mjs --project ${rel(project)})`);
33
+ } catch (e) {
34
+ console.error("select failed:", e.message);
35
+ process.exit(1);
36
+ }
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: font-lab
3
+ description: >-
4
+ Use when a user wants to choose, change, compare, or improve the FONTS / typography of
5
+ their Next.js + Tailwind app ("pick a font", "these fonts look generic/AI-generated",
6
+ "make the headings nicer", "what typeface should I use", "change the font"). Font Lab shows
7
+ the user tasteful, ready-to-ship font directions rendered live on their OWN running site,
8
+ lets the human pick, then ships the exact next/font + Tailwind code — reversibly. The human
9
+ keeps the taste decision; you do the typing.
10
+ ---
11
+
12
+ # Font Lab
13
+
14
+ A decision surface for typography. AI removed the labor of implementing fonts but deleted the
15
+ **moment of choice** — and taste only happens at the moment of choice. Font Lab re-inserts it:
16
+ the human picks from a curated set rendered on their real site, and you ship what they chose,
17
+ byte-for-byte. **You never auto-pick a font for the user.** Your job is to curate the menu and
18
+ ship the order.
19
+
20
+ ## The loop
21
+
22
+ Use the `font-lab` MCP tools (or the CLIs in `cli/`) in this order:
23
+
24
+ 1. **Analyze** — `font_lab_analyze({ projectDir })`. Learn the current fonts, wiring, and any
25
+ coverage warnings. Do this first. If it reports the project is out-of-branch (not App
26
+ Router + Tailwind v4 + CSS-variable wiring), tell the user what's missing instead of
27
+ pushing ahead.
28
+ 2. **Decide the menu** — two ways, your call:
29
+ - **Default (free):** `font_lab_curate({ projectDir, vibe? })` → ~5 tasteful directions.
30
+ - **Take the wheel:** when the user asked for something specific, `font_lab_list_catalog({ role, tag })`
31
+ to browse, then `font_lab_compose_directions({ directions: [...] })` to build your own.
32
+ Every family must be a catalog member — that's what keeps preview == ship.
33
+ Mix freely: start from `curate`, swap a direction or two with composed ones.
34
+ 3. **Set up the preview** — `font_lab_init({ projectDir, vibe? })`. This self-hosts the
35
+ fonts, installs the dev panel, and mounts it (dev-only). If `analyze` flagged a dead role and
36
+ the user wants it to change, also call `font_lab_rewire_dead_roles({ projectDir })`.
37
+ (Already initialized and just changing the options? `font_lab_prepare_preview` rebuilds the
38
+ bundles without re-mounting.)
39
+ 4. **The choosing moment** — pick the path that fits where you're running. Start the dev server
40
+ in the background first (`<dev command>`); note its URL (e.g. `http://localhost:3000`).
41
+
42
+ - **Live (best — when the human has a real browser on this machine):** you're in a local
43
+ terminal / IDE (Mac or Linux terminal, VS Code, Cursor, the Claude Code IDE extension).
44
+ Also start the pick endpoint (`node cli/font-lab.mjs --project <dir>`), then tell the human
45
+ to open their site and flip the panel (← →, `↑↓`+`[ ]` to mix, `B` for before/after) and
46
+ **pick one**. Read the pick with `font_lab_read_pick` (poll until it returns a selection).
47
+
48
+ - **Headless (when there's NO live browser for the human — a web/cloud session, or they're on
49
+ a phone):** call `font_lab_screenshot_directions({ projectDir, baseUrl })`. It drives the
50
+ real panel and screenshots the site in each direction (faithful to what ships). **Show those
51
+ images to the human** and ask them to pick an id. Record it with
52
+ `font_lab_select({ projectDir, directionId })` (supports a mixed pick via `roles`). You are
53
+ still only preparing the menu — **the human makes the call.**
54
+
55
+ Always offer the live escape hatch: if the screenshots aren't enough and the human wants to
56
+ flip/mix/compare themselves, give them `font_lab_live_instructions({ projectDir })` —
57
+ ready-to-run commands to launch the full editor locally (works in any terminal / IDE / Cursor).
58
+ 5. **Ship it** — once a selection exists (from either path), `font_lab_apply({ projectDir })`.
59
+ Reversible via `font_lab_undo`; remove the panel scaffolding with `font_lab_uninit`.
60
+
61
+ ## Rules
62
+
63
+ - **The human picks.** Never choose the final font yourself. Prepare options; let them decide.
64
+ - **Catalog-only.** Compose freely, but only from catalog fonts — preview fidelity and the
65
+ CLS-safe ship both depend on it. `compose_directions` enforces this and suggests alternates.
66
+ - **Be honest about coverage.** If `analyze` flags a dead role (a font declared but not actually
67
+ rendered, common with Tailwind v4 `@theme inline` + raw `var(--font-*)`), tell the user a
68
+ swap there won't be visible until it's rewired — don't pretend it worked. Offer
69
+ `font_lab_rewire_dead_roles` to fix it (points the raw usage at the published leaf var so the
70
+ font renders); it's reversible via `font_lab_undo`.
71
+ - **Reversible.** Every apply backs up first; offer `undo` if they don't love it.
72
+ - **Headless needs Chromium.** `font_lab_screenshot_directions` uses Playwright (present in Claude
73
+ Code on the web; elsewhere `npm i -D playwright && npx playwright install chromium`). If it isn't
74
+ available, don't fake a pick — hand the human `font_lab_live_instructions` and let them choose in
75
+ a real browser. The live, local path is always the highest-fidelity option.
@@ -0,0 +1,293 @@
1
+ "use client";
2
+
3
+ // Font Lab dev panel — portable build, installed by `font-lab init` into a real project.
4
+ // Identical UX to the fixture panel (presets, mixed picks, before/after, pin, multi-route),
5
+ // but it applies the swap through the analyzer's `wiring`: for each role it overrides the
6
+ // project's OWN leaf next/font variable (e.g. --font-bricolage) on the element next/font uses
7
+ // (<html> or <body>). That's what makes the live preview honest on any site — it moves the
8
+ // exact variable that ship rewrites. A role with no wiring (a font the site doesn't route
9
+ // through a variable) is shown as not-previewable rather than faked.
10
+ //
11
+ // Dev-only (mounted behind a NODE_ENV guard in layout). Shadow-DOM isolated.
12
+
13
+ import { useEffect } from "react";
14
+ import { catalogFontFaceCss, directions, replaces, target, wiring, type Direction } from "./catalog.generated";
15
+
16
+ const ENDPOINT = "http://localhost:7777";
17
+ const STORE_KEY = "fontlab.working.v1";
18
+ const ROLES = ["display", "body", "mono"] as const;
19
+ type Role = (typeof ROLES)[number];
20
+ const LABEL: Record<Role, string> = { display: "Display", body: "Body", mono: "Mono" };
21
+
22
+ type Cand = { family: string; stack: string; weights: number[] };
23
+ const wir = (wiring || {}) as Partial<Record<Role, { var: string; el: string } | null>>;
24
+
25
+ function candidatesFor(role: Role): Cand[] {
26
+ const seen = new Set<string>();
27
+ const out: Cand[] = [];
28
+ for (const d of directions) {
29
+ const r = d.roles[role];
30
+ if (!seen.has(r.family)) {
31
+ seen.add(r.family);
32
+ out.push({ family: r.family, stack: r.stack, weights: r.weights });
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+ function currentLabel(): string {
38
+ const fams = [replaces?.display, replaces?.body].filter(Boolean) as string[];
39
+ const uniq = [...new Set(fams)];
40
+ return uniq.length ? `Current — ${uniq.join(" / ")}` : "Current";
41
+ }
42
+
43
+ export function FontLabDevPanel() {
44
+ useEffect(() => {
45
+ const root = document.documentElement;
46
+ const CANDS: Record<Role, Cand[]> = { display: candidatesFor("display"), body: candidatesFor("body"), mono: candidatesFor("mono") };
47
+ const elFor = (role: Role) => (wir[role]?.el === "body" ? document.body : document.documentElement);
48
+ const canSwap = (role: Role) => !!wir[role];
49
+
50
+ const FACE_ID = "fontlab-catalog-faces";
51
+ if (!document.getElementById(FACE_ID)) {
52
+ const styleEl = document.createElement("style");
53
+ styleEl.id = FACE_ID;
54
+ styleEl.textContent = catalogFontFaceCss;
55
+ document.head.appendChild(styleEl);
56
+ }
57
+
58
+ const entries = [{ id: "current", dir: null as Direction | null }, ...directions.map((d) => ({ id: d.id, dir: d }))];
59
+ const roleSel: Record<Role, number> = { display: -1, body: -1, mono: -1 };
60
+ let cursor = 0;
61
+ let focus: Role = "display";
62
+ let comparing = false;
63
+ const pins: (Record<Role, number> | null)[] = [null, null];
64
+ let showingPin: 0 | 1 | null = null;
65
+
66
+ const setRolesFromEntry = (i: number) => {
67
+ const e = entries[i];
68
+ for (const role of ROLES) {
69
+ if (!e.dir) roleSel[role] = -1;
70
+ else roleSel[role] = Math.max(0, CANDS[role].findIndex((c) => c.family === e.dir!.roles[role].family));
71
+ }
72
+ };
73
+
74
+ let restored = false;
75
+ try {
76
+ const saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || "null");
77
+ if (saved && saved.roles && ROLES.some((role) => saved.roles[role])) {
78
+ for (const role of ROLES) {
79
+ const idx = saved.roles[role] ? CANDS[role].findIndex((c) => c.family === saved.roles[role]) : -1;
80
+ roleSel[role] = idx;
81
+ }
82
+ cursor = Math.max(0, entries.findIndex((e) => e.id === saved.cursorId));
83
+ restored = true;
84
+ }
85
+ } catch {}
86
+
87
+ const host = document.createElement("div");
88
+ host.id = "fontlab-panel-host";
89
+ host.style.cssText = "position:fixed;right:16px;bottom:16px;z-index:2147483647;";
90
+ document.body.appendChild(host);
91
+ const shadow = host.attachShadow({ mode: "open" });
92
+ shadow.innerHTML = `
93
+ <style>
94
+ :host { all: initial; }
95
+ .panel { font-family: ui-sans-serif, system-ui, sans-serif; background:#111114; color:#fff; border-radius:14px; padding:14px; width:288px; box-shadow:0 12px 40px rgba(0,0,0,.45); }
96
+ .title { font-size:11px; letter-spacing:.12em; text-transform:uppercase; opacity:.55; margin-bottom:9px; }
97
+ .chips { display:flex; flex-wrap:wrap; gap:4px; margin-bottom:10px; }
98
+ .chip { padding:5px 8px; border:0; border-radius:7px; background:#27272a; color:#fff; font-size:11.5px; cursor:pointer; }
99
+ .chip[aria-pressed="true"] { background:#2563eb; }
100
+ .chip.cur[aria-pressed="true"] { background:#3f3f46; }
101
+ .roles { display:flex; flex-direction:column; gap:4px; margin-bottom:9px; }
102
+ .role { display:flex; align-items:center; gap:6px; background:#1c1c20; border-radius:8px; padding:4px 6px; }
103
+ .role[data-focus="true"] { outline:2px solid #2563eb; }
104
+ .role[data-off="true"] { opacity:.45; }
105
+ .role .lab { font-size:10px; text-transform:uppercase; letter-spacing:.08em; opacity:.5; width:48px; }
106
+ .role .fam { flex:1; font-size:12.5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
107
+ .role button { border:0; background:#3f3f46; color:#fff; border-radius:6px; width:22px; height:22px; cursor:pointer; font-size:13px; line-height:1; }
108
+ .role button:disabled { opacity:.3; cursor:not-allowed; }
109
+ .rationale { font-size:11px; line-height:1.4; opacity:.7; min-height:30px; margin:2px 2px 9px; }
110
+ .row { display:flex; gap:6px; align-items:center; }
111
+ .pick { flex:1; padding:9px 11px; border:0; border-radius:9px; background:#16a34a; color:#fff; font-size:13px; font-weight:600; cursor:pointer; }
112
+ .pick[disabled] { background:#3f3f46; color:#a1a1aa; cursor:not-allowed; }
113
+ .mini { padding:9px 9px; border:0; border-radius:9px; background:#27272a; color:#fff; font-size:12px; cursor:pointer; white-space:nowrap; }
114
+ .mini[aria-pressed="true"] { background:#a16207; }
115
+ .status { font-size:11px; opacity:.75; margin-top:8px; min-height:14px; }
116
+ .hint { font-size:9.5px; opacity:.4; margin-top:6px; line-height:1.5; }
117
+ kbd { background:#27272a; border-radius:3px; padding:0 3px; font-size:9px; }
118
+ </style>
119
+ <div class="panel" role="group" aria-label="Font Lab">
120
+ <div class="title">Font Lab · build your type</div>
121
+ <div class="chips" id="chips"></div>
122
+ <div class="roles" id="roles"></div>
123
+ <div class="rationale" id="rationale"></div>
124
+ <div class="row">
125
+ <button class="pick" data-fl-action="pick">Pick</button>
126
+ <button class="mini" data-fl-action="compare" title="Before / after (B)">⇄</button>
127
+ <button class="mini" data-fl-action="pin" title="Pin to compare (P)">📌</button>
128
+ </div>
129
+ <div class="status" id="status"></div>
130
+ <div class="hint"><kbd>← →</kbd> direction · <kbd>↑↓</kbd> role · <kbd>[ ]</kbd> swap · <kbd>B</kbd> before/after · <kbd>P</kbd>/<kbd>Space</kbd> pin · <kbd>↵</kbd> pick</div>
131
+ </div>`;
132
+
133
+ const chipsEl = shadow.getElementById("chips")!;
134
+ const rolesEl = shadow.getElementById("roles")!;
135
+ const rationaleEl = shadow.getElementById("rationale")!;
136
+ const statusEl = shadow.getElementById("status")!;
137
+ const pickBtn = shadow.querySelector<HTMLButtonElement>('[data-fl-action="pick"]')!;
138
+ const cmpBtn = shadow.querySelector<HTMLButtonElement>('[data-fl-action="compare"]')!;
139
+ const pinBtn = shadow.querySelector<HTMLButtonElement>('[data-fl-action="pin"]')!;
140
+
141
+ entries.forEach((e, i) => {
142
+ const b = document.createElement("button");
143
+ b.className = "chip" + (e.dir ? "" : " cur");
144
+ b.dataset.flId = e.id;
145
+ b.textContent = e.dir ? e.dir.name : "Current";
146
+ b.addEventListener("click", () => selectPreset(i));
147
+ chipsEl.appendChild(b);
148
+ });
149
+
150
+ const roleFamEls: Record<Role, HTMLElement> = {} as any;
151
+ const roleRowEls: Record<Role, HTMLElement> = {} as any;
152
+ for (const role of ROLES) {
153
+ const row = document.createElement("div");
154
+ row.className = "role";
155
+ row.dataset.role = role;
156
+ row.dataset.off = String(!canSwap(role));
157
+ row.innerHTML = `<span class="lab">${LABEL[role]}</span><span class="fam" data-fl-fam="${role}"></span>
158
+ <button data-fl-dec="${role}">‹</button><button data-fl-inc="${role}">›</button>`;
159
+ row.addEventListener("click", () => { focus = role; render(); });
160
+ row.querySelector(`[data-fl-dec="${role}"]`)!.addEventListener("click", (ev) => { ev.stopPropagation(); cycleRole(role, -1); });
161
+ row.querySelector(`[data-fl-inc="${role}"]`)!.addEventListener("click", (ev) => { ev.stopPropagation(); cycleRole(role, +1); });
162
+ rolesEl.appendChild(row);
163
+ roleFamEls[role] = row.querySelector(`[data-fl-fam="${role}"]`)!;
164
+ roleRowEls[role] = row;
165
+ }
166
+
167
+ const effRoles = (): Record<Role, number> => (showingPin !== null && pins[showingPin] ? pins[showingPin]! : roleSel);
168
+
169
+ function applyToPage() {
170
+ const er = effRoles();
171
+ for (const role of ROLES) {
172
+ if (!canSwap(role)) continue;
173
+ const idx = comparing ? -1 : er[role];
174
+ if (idx < 0) elFor(role).style.removeProperty(wir[role]!.var);
175
+ else elFor(role).style.setProperty(wir[role]!.var, CANDS[role][idx].stack);
176
+ }
177
+ }
178
+ const trioFamilies = (er = roleSel) => ROLES.map((role) => (er[role] < 0 ? null : CANDS[role][er[role]].family));
179
+ function matchedDirection(er = roleSel): Direction | null {
180
+ const fams = trioFamilies(er);
181
+ if (fams.some((f) => f === null)) return null;
182
+ return directions.find((d) => ROLES.every((role, i) => d.roles[role].family === fams[i])) || null;
183
+ }
184
+ function activeId(): string {
185
+ if (comparing) return "current";
186
+ const er = effRoles();
187
+ if (ROLES.every((role) => er[role] < 0)) return "current";
188
+ return matchedDirection(er)?.id ?? "mixed";
189
+ }
190
+ function persist() {
191
+ try {
192
+ const fams = trioFamilies(roleSel);
193
+ sessionStorage.setItem(STORE_KEY, JSON.stringify({ cursorId: entries[cursor]?.id, roles: { display: fams[0], body: fams[1], mono: fams[2] } }));
194
+ } catch {}
195
+ }
196
+
197
+ function render() {
198
+ applyToPage();
199
+ const id = activeId();
200
+ root.setAttribute("data-fontlab-active", id);
201
+ const onCurrent = ROLES.every((role) => roleSel[role] < 0);
202
+ chipsEl.querySelectorAll(".chip").forEach((c) => c.setAttribute("aria-pressed", String((c as HTMLElement).dataset.flId === id)));
203
+ for (const role of ROLES) {
204
+ const idx = roleSel[role];
205
+ roleFamEls[role].textContent = !canSwap(role) ? "— not wired —" : idx < 0 ? "— current —" : CANDS[role][idx].family;
206
+ roleRowEls[role].dataset.focus = String(role === focus);
207
+ roleRowEls[role].querySelectorAll("button").forEach((b) => ((b as HTMLButtonElement).disabled = onCurrent || !canSwap(role)));
208
+ }
209
+ const md = matchedDirection();
210
+ rationaleEl.textContent = comparing
211
+ ? `Before: ${currentLabel().replace(/^Current — /, "")}. Press B to flip back.`
212
+ : onCurrent
213
+ ? "Flip to a direction (→), then cycle any role to mix. Renders on your real site."
214
+ : md
215
+ ? md.rationale
216
+ : `Mixed — ${trioFamilies().filter(Boolean).join(" / ")}.`;
217
+ cmpBtn.setAttribute("aria-pressed", String(comparing));
218
+ const pinned = pins.filter(Boolean).length;
219
+ pinBtn.textContent = pinned ? `📌${pinned}` : "📌";
220
+ pinBtn.setAttribute("aria-pressed", String(showingPin !== null));
221
+ pickBtn.disabled = onCurrent && showingPin === null;
222
+ persist();
223
+ }
224
+
225
+ function selectPreset(i: number) { cursor = Math.max(0, Math.min(entries.length - 1, i)); comparing = false; showingPin = null; setRolesFromEntry(cursor); statusEl.textContent = ""; render(); }
226
+ function cycleRole(role: Role, dir: number) {
227
+ if (!canSwap(role) || ROLES.every((r) => roleSel[r] < 0)) return;
228
+ focus = role; comparing = false; showingPin = null;
229
+ const n = CANDS[role].length;
230
+ roleSel[role] = ((roleSel[role] < 0 ? 0 : roleSel[role]) + dir + n) % n;
231
+ statusEl.textContent = ""; render();
232
+ }
233
+ function moveFocus(dir: number) { focus = ROLES[(ROLES.indexOf(focus) + dir + ROLES.length) % ROLES.length]; render(); }
234
+ function toggleCompare() { if (ROLES.every((r) => roleSel[r] < 0)) return; comparing = !comparing; render(); }
235
+ function pin() {
236
+ if (ROLES.every((r) => roleSel[r] < 0)) return;
237
+ const slot = pins[0] === null ? 0 : pins[1] === null ? 1 : 0;
238
+ pins[slot] = { ...roleSel };
239
+ statusEl.textContent = `Pinned ${slot === 0 ? "A" : "B"}${pins[0] && pins[1] ? " — Space to compare" : ""}`; render();
240
+ }
241
+ function togglePins() { if (!(pins[0] && pins[1])) return; showingPin = showingPin === 0 ? 1 : 0; comparing = false; statusEl.textContent = `Showing ${showingPin === 0 ? "A" : "B"}`; render(); }
242
+
243
+ async function pick() {
244
+ const er = effRoles();
245
+ if (ROLES.every((role) => er[role] < 0)) { statusEl.textContent = "Flip to a direction first."; return; }
246
+ const roleObj = (role: Role) => {
247
+ const idx = er[role];
248
+ const c = idx < 0 ? null : CANDS[role][idx];
249
+ return c ? { family: c.family, source: "google", weights: c.weights } : { family: replaces?.[role] ?? null, source: "current", weights: [] };
250
+ };
251
+ const md = matchedDirection(er);
252
+ const fams = ROLES.map((r) => roleObj(r).family);
253
+ const direction = md
254
+ ? { id: md.id, name: md.name, vibe: md.vibe, rationale: md.rationale }
255
+ : { id: "mixed", name: "Mixed", vibe: "mixed", rationale: `Custom pairing — ${fams.filter(Boolean).join(" / ")}.` };
256
+ const selection = { version: 1, pickedAt: new Date().toISOString(), direction, roles: { display: roleObj("display"), body: roleObj("body"), mono: roleObj("mono") }, replaces, target };
257
+ statusEl.textContent = "Saving…";
258
+ try {
259
+ const res = await fetch(ENDPOINT + "/select", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(selection) });
260
+ statusEl.textContent = res.ok ? `Picked ✓ ${direction.name}` : `Error ${res.status}`;
261
+ root.setAttribute("data-fontlab-picked", direction.id);
262
+ } catch {
263
+ statusEl.textContent = "No endpoint on :7777 — run `font-lab`";
264
+ }
265
+ }
266
+
267
+ pickBtn.addEventListener("click", pick);
268
+ cmpBtn.addEventListener("click", toggleCompare);
269
+ pinBtn.addEventListener("click", pin);
270
+ const onKey = (e: KeyboardEvent) => {
271
+ const t = e.target as HTMLElement | null;
272
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
273
+ const k = e.key;
274
+ if (k === "ArrowRight") { e.preventDefault(); selectPreset(cursor + 1); }
275
+ else if (k === "ArrowLeft") { e.preventDefault(); selectPreset(cursor - 1); }
276
+ else if (k === "ArrowDown") { e.preventDefault(); moveFocus(1); }
277
+ else if (k === "ArrowUp") { e.preventDefault(); moveFocus(-1); }
278
+ else if (k === "]") { e.preventDefault(); cycleRole(focus, 1); }
279
+ else if (k === "[") { e.preventDefault(); cycleRole(focus, -1); }
280
+ else if (k === "b" || k === "B") { e.preventDefault(); toggleCompare(); }
281
+ else if (k === "p" || k === "P") { e.preventDefault(); pin(); }
282
+ else if (k === " ") { e.preventDefault(); togglePins(); }
283
+ else if (k === "Enter") { e.preventDefault(); void pick(); }
284
+ };
285
+ document.addEventListener("keydown", onKey);
286
+
287
+ if (restored) render();
288
+ else selectPreset(0);
289
+ return () => { document.removeEventListener("keydown", onKey); host.remove(); };
290
+ }, []);
291
+
292
+ return null;
293
+ }
package/undo.mjs ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // `font-lab undo` — restore the files Font Lab last edited, from the backup-first snapshot.
3
+ import path from "node:path";
4
+ import { undo } from "./codegen.mjs";
5
+
6
+ const arg = (f, d) => {
7
+ const i = process.argv.indexOf(f);
8
+ return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : d;
9
+ };
10
+ const project = path.resolve(arg("--project", process.cwd()));
11
+
12
+ try {
13
+ const r = undo(project);
14
+ for (const w of r.warnings) console.warn(` ! ${w}`);
15
+ console.log(`Font Lab — reverted ${r.runId}; restored ${r.restored.join(", ")}`);
16
+ } catch (e) {
17
+ console.error("undo failed:", e.message);
18
+ process.exit(1);
19
+ }