synthesisui 0.16.499 → 0.16.501

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,355 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { desligarCaptura, ligarCaptura } from "../transcript.js";
3
+ import { adopt } from "./adopt.js";
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
+ import { createRequire } from "node:module";
6
+ import { homedir } from "node:os";
7
+ import { join, relative } from "node:path";
8
+ import { classify, pixelRatio } from "../reading/classify.js";
9
+ import { PAGE_SCRIPT } from "../reading/page-script.js";
10
+ import { decodePng } from "../reading/png.js";
11
+ import { moduleClassName, pxOf } from "../role-rules.js";
12
+ import { agentsToMaintain } from "../agents-chosen.js";
13
+ import { syncClaudeMd } from "../claude-md.js";
14
+ import { projectRootFrom } from "./hook.js";
15
+ /**
16
+ * `synthesisui read` - O QUE A SUA PÁGINA JÁ DECIDIU (jornada "a leitura no agente", E02, 18/10).
17
+ *
18
+ * A fase 1 do mapa aprovado: depois da primeira tela que a camada de estética criou, a pessoa pede ao agente para ler a
19
+ * página, e recebe o sistema que ela já decidiu - os papéis de título com a classe e o lugar no código, as escalas, as
20
+ * quebras que a fazem ser dela, a deriva e o contraste que falha. Lê a página RENDERIZADA no dev server (o que o visitante
21
+ * vê) e liga cada achado ao componente pelo React, não pela forma de estilo.
22
+ *
23
+ * O NAVEGADOR É SOB DEMANDA (D9 do mapa): o CLI continua sem dependência nenhuma. No primeiro `read` ele DIZ o que vai
24
+ * baixar e baixa o leitor (playwright-core) numa pasta própria; usa o Chromium que já estiver na máquina, e só baixa um
25
+ * quando não há nenhum.
26
+ *
27
+ * Grava `_synthesisui/reading.json` - a proposta. O `rules.json` nasce da confirmação da pessoa (E03), nunca daqui.
28
+ */
29
+ const READER_VERSION = "1.58.2";
30
+ const READER_DIR = join(homedir(), ".synthesisui", "reader");
31
+ const VIEWPORTS = [
32
+ ["desktop", 1440, 900],
33
+ ["celular", 390, 844],
34
+ ];
35
+ function loadReader(say) {
36
+ const override = process.env.SYNTHESISUI_PLAYWRIGHT;
37
+ if (override)
38
+ return createRequire(join(override, "package.json"))(override);
39
+ const mod = join(READER_DIR, "node_modules", "playwright-core");
40
+ if (!existsSync(mod)) {
41
+ say(`First read on this machine: downloading the page reader (playwright-core ${READER_VERSION}, about 3 MB) into ${READER_DIR}. It is used only by \`read\`.`);
42
+ mkdirSync(READER_DIR, { recursive: true });
43
+ execFileSync("npm", ["install", "--prefix", READER_DIR, "--no-save", "--no-audit", "--no-fund", `playwright-core@${READER_VERSION}`], { stdio: "ignore" });
44
+ }
45
+ return createRequire(join(READER_DIR, "package.json"))("playwright-core");
46
+ }
47
+ async function launch(pw, say) {
48
+ for (const o of [{}, { channel: "chrome" }, { channel: "msedge" }]) {
49
+ try {
50
+ return await pw.chromium.launch(o);
51
+ }
52
+ catch {
53
+ /* o próximo */
54
+ }
55
+ }
56
+ say("No Chromium found on this machine: downloading one for the reader (about 150 MB, once).");
57
+ execFileSync(process.execPath, [join(READER_DIR, "node_modules", "playwright-core", "cli.js"), "install", "chromium"], { stdio: "ignore" });
58
+ return pw.chromium.launch();
59
+ }
60
+ /**
61
+ * O DEV SERVER DESTE PROJETO, E NÃO O PRIMEIRO QUE RESPONDER - a porta que o script `dev` do próprio projeto declara
62
+ * (`-p 3389`, `--port 3389`, `PORT=3389`) vem antes das portas de sempre. Sem isto, numa máquina com outro projeto na
63
+ * 3000, a leitura descreveria a página de outra pessoa.
64
+ */
65
+ function declaredPort(root) {
66
+ try {
67
+ const dev = (JSON.parse(readFileSync(join(root, "package.json"), "utf8")).scripts?.dev ?? "");
68
+ const m = dev.match(/(?:-p|--port)[ =](\d{2,5})|PORT=(\d{2,5})/);
69
+ return m ? Number(m[1] ?? m[2]) : null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ async function findServer(url, root) {
76
+ const port = declaredPort(root);
77
+ const cands = url ? [url] : [...(port ? [`http://localhost:${port}`] : []), "http://localhost:3000", "http://localhost:3001", "http://localhost:5173", "http://localhost:4321"];
78
+ for (const u of cands) {
79
+ try {
80
+ const r = await fetch(u, { signal: AbortSignal.timeout(4000) });
81
+ if (r.status < 500)
82
+ return u;
83
+ }
84
+ catch {
85
+ /* o próximo */
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+ /** O maior tamanho entre as classes de um texto (o do desktop), sem o prefixo de tela - a mesma escolha da checagem. */
91
+ function sizeClassOf(cls) {
92
+ const toks = cls.match(/(?:[a-z0-9]+:)?text-(?:\[(?:clamp\([^\]\s]+\)|[\d.]+(?:rem|px|em))\]|xs|sm|base|lg|[2-9]?xl)(?=\s|$|\|)/g) ?? [];
93
+ if (toks.length === 0)
94
+ return null;
95
+ const best = toks.sort((a, b) => (pxOf(b) ?? 0) - (pxOf(a) ?? 0))[0];
96
+ return best.replace(/^(?:[a-z0-9]+:)+/, "");
97
+ }
98
+ function sources(root) {
99
+ const out = [];
100
+ const walk = (d) => {
101
+ for (const n of readdirSync(d)) {
102
+ if (n === "node_modules" || n.startsWith(".") || n === "_synthesisui")
103
+ continue;
104
+ const p = join(d, n);
105
+ const s = statSync(p);
106
+ if (s.isDirectory())
107
+ walk(p);
108
+ else if (/\.(tsx|jsx)$/.test(n))
109
+ out.push(p);
110
+ }
111
+ };
112
+ const src = existsSync(join(root, "src")) ? join(root, "src") : root;
113
+ walk(src);
114
+ return out;
115
+ }
116
+ /** Onde o texto mora no código: o componente (React) -> o arquivo que o declara -> a linha da classe; senão, pela classe. */
117
+ /**
118
+ * A CLASSE DE UM CSS MODULE NO DOM - `page-module___8aEwW__h2` (o Turbopack: arquivo, hash, nome local). Achado na rodada
119
+ * real da porta (18/10): sem isto, a página que o impeccable escreveu com `styles.h2` saía com 5 papéis lidos e 0
120
+ * gravados. A extensão vem do import que o código escreve.
121
+ */
122
+ function moduleOf(cls, files) {
123
+ /* a do próprio elemento primeiro; sem ela, a do título e a do pai (a capa da porta-2: `<span>` sem classe dentro de
124
+ `<p className={styles.batch}>`, com o tamanho em `.batch span`) */
125
+ for (const tok of cls.split(" | ").flatMap((part) => part.split(/\s+/))) {
126
+ const m = tok.match(/^([A-Za-z0-9-]+?)-module__.+?__([A-Za-z0-9_-]+)$/);
127
+ if (!m)
128
+ continue;
129
+ const stem = m[1];
130
+ const local = m[2];
131
+ const imp = new RegExp(`["'][^"']*\\b${stem}\\.module\\.(s?css)["']`);
132
+ const ext = files.map((f) => readFileSync(f, "utf8").match(imp)?.[1]).find(Boolean) ?? "css";
133
+ return { name: moduleClassName(`${stem}.module.${ext}`, local), local };
134
+ }
135
+ return null;
136
+ }
137
+ function homeOf(root, files, t) {
138
+ const mod = sizeClassOf(t.cls) ? null : moduleOf(t.cls, files);
139
+ const cls = sizeClassOf(t.cls) ?? mod?.name ?? null;
140
+ const lineOf = (f) => {
141
+ if (!cls)
142
+ return null;
143
+ const lines = readFileSync(f, "utf8").split("\n");
144
+ const i = mod
145
+ ? lines.findIndex((l) => new RegExp(`\\b\\w+(?:\\.${mod.local}\\b|\\[["']${mod.local}["']\\])`).test(l))
146
+ : lines.findIndex((l) => l.includes(cls));
147
+ return i >= 0 ? i + 1 : null;
148
+ };
149
+ if (t.comp) {
150
+ const decl = new RegExp(`(function|const|class)\\s+${t.comp}\\b`);
151
+ const f = files.find((x) => decl.test(readFileSync(x, "utf8")));
152
+ if (f)
153
+ return { file: relative(root, f), line: lineOf(f), cls, via: "component", component: t.comp };
154
+ }
155
+ if (cls && !mod) {
156
+ const f = files.find((x) => readFileSync(x, "utf8").includes(cls));
157
+ if (f)
158
+ return { file: relative(root, f), line: lineOf(f), cls, via: "class", component: t.comp };
159
+ }
160
+ return null;
161
+ }
162
+ /**
163
+ * NO MÁXIMO OITO PERGUNTAS, UMA DECISÃO CADA - por quebra e por papel com deriva, nunca por elemento (seção 6 do estudo:
164
+ * "5-8 perguntas por projeto, não 300"). As quebras vêm primeiro: dizer sim à própria assinatura é o caminho feliz. A
165
+ * primeira opção é sempre a padrão, e é o que vale quando a pessoa não responde.
166
+ */
167
+ /** O nome que a pessoa entende - nunca o rótulo interno da classificação ("h3 de cartão", "número"). */
168
+ function plainRole(role) {
169
+ if (role === "número")
170
+ return "big number";
171
+ if (role === "decoração")
172
+ return "decoration";
173
+ if (role === "bloco")
174
+ return "block";
175
+ return nameOf(role);
176
+ }
177
+ function questionsFor(breaks, drift) {
178
+ const qs = breaks.map((b) => ({
179
+ id: `break:${b.rule}:${b.role}:${b.px}`,
180
+ text: `Is this on purpose? The ${plainRole(b.role)} ${b.rule === "Q2" ? "is tilted off the grid" : b.rule === "Q3" ? "is a big motif, outside the headings" : "goes above the heading scale"} (${b.what}).`,
181
+ options: ["Yes, it is part of my design", "No, make it follow the scale"],
182
+ }));
183
+ const groups = new Map();
184
+ for (const d of drift) {
185
+ const g = groups.get(`${d.role}|${d.surface}`) ?? { role: d.role, surface: d.surface, rulePx: d.rulePx, pxs: [] };
186
+ g.pxs.push(d.px);
187
+ groups.set(`${d.role}|${d.surface}`, g);
188
+ }
189
+ for (const g of groups.values())
190
+ qs.push({
191
+ id: `drift:${g.role}:${g.surface}`,
192
+ text: `Are these the same ${plainRole(g.role)}? Most places use ${g.rulePx}px; here ${g.pxs.join("px, ")}px.`,
193
+ options: [`Yes, use ${g.rulePx}px everywhere`, "No, they are different roles"],
194
+ });
195
+ return qs.slice(0, 8);
196
+ }
197
+ const contextOf = (role) => (role.includes("cartão") ? "card" : role.includes("texto corrido") ? "prose" : "section");
198
+ const NAME = { h1: "cover title", h2: "section title", h3: "subheading" };
199
+ const nameOf = (role) => {
200
+ const el = role.split(" ")[0];
201
+ return contextOf(role) === "card" ? "card title" : contextOf(role) === "prose" ? `heading inside running text (${el})` : (NAME[el] ?? role);
202
+ };
203
+ const at = (h) => (h ? `${h.file}${h.line ? `:${h.line}` : ""}` : "");
204
+ /**
205
+ * \`read --confirm\` - AS RESPOSTAS DELA VIRAM O SISTEMA (E03). Lê \`reading.json\` e as respostas (um JSON \`{ "<id da
206
+ * pergunta>": 0 | 1 }\`, o índice da opção; sem arquivo, a primeira opção de todas) e grava \`_synthesisui/ds/<slug>/rules.json\`
207
+ * no formato que a checagem lê (\`role-rules.ts\`); depois regenera o contrato. Um "não" numa quebra a tira do sistema; um
208
+ * "são papéis diferentes" vira papel próprio, com a classe que ele usa.
209
+ */
210
+ export async function confirm(opts) {
211
+ const say = (s) => process.stdout.write(`${s}\n`);
212
+ const root = opts.dir ?? projectRootFrom(process.cwd());
213
+ const rp = join(root, "_synthesisui", "reading.json");
214
+ if (!existsSync(rp)) {
215
+ say("No reading yet - run `synthesisui read` first.");
216
+ process.exitCode = 1;
217
+ return;
218
+ }
219
+ const R = JSON.parse(readFileSync(rp, "utf8"));
220
+ const answers = opts.answers ? JSON.parse(readFileSync(opts.answers, "utf8")) : {};
221
+ const pick = (id) => answers[id] ?? 0;
222
+ const dsDir = join(root, "_synthesisui", "ds");
223
+ const firstSystem = () => existsSync(dsDir) ? readdirSync(dsDir).find((d) => statSync(join(dsDir, d)).isDirectory()) : undefined;
224
+ /* O SISTEMA NASCE AQUI quando o projeto chegou pela porta (fase 2, 18/10): o `init` não adota, porque num projeto vazio
225
+ os únicos tokens são os de exemplo do create-next-app. Agora a página já decidiu - os tokens que ela tem são os dela. */
226
+ if (!firstSystem()) {
227
+ ligarCaptura();
228
+ try {
229
+ await adopt({ dir: root, write: true });
230
+ }
231
+ finally {
232
+ desligarCaptura();
233
+ }
234
+ }
235
+ const slug = firstSystem();
236
+ if (!slug) {
237
+ say("No tokens found in this project's CSS, so there is no system to write the answers into - check that the page's stylesheet declares them in :root or @theme.");
238
+ process.exitCode = 1;
239
+ return;
240
+ }
241
+ const roles = R.roles
242
+ .filter((r) => r.home?.cls)
243
+ .map((r) => ({
244
+ id: `${r.role.split(" ")[0]}-${contextOf(r.role)}-${r.surface}`, name: nameOf(r.role), element: r.role.split(" ")[0],
245
+ context: contextOf(r.role), surface: r.surface, class: r.home?.cls, px: r.px, home: at(r.home), places: r.places,
246
+ }));
247
+ for (const d of R.drift) {
248
+ if (pick(`drift:${d.role}:${d.surface}`) !== 1 || !d.home?.cls)
249
+ continue;
250
+ roles.push({ id: `${d.role.split(" ")[0]}-${contextOf(d.role)}-${d.surface}-${d.px}`, name: `${nameOf(d.role)} (${d.px}px)`, element: d.role.split(" ")[0],
251
+ context: contextOf(d.role), surface: d.surface, class: d.home.cls, px: d.px, home: at(d.home), places: 1 });
252
+ }
253
+ const breaks = R.breaks
254
+ .filter((b) => pick(`break:${b.rule}:${b.role}:${b.px}`) === 0 && b.home)
255
+ .map((b) => ({ id: `${b.rule}-${b.px}`, rule: b.name, what: b.what, element: b.role, class: b.home?.cls ?? null, px: b.px,
256
+ /* o ARQUIVO onde a quebra mora, como a checagem compara (role-rules.ts) - não o componente do React, que pode
257
+ ser um de vários no mesmo arquivo (o DogCard mora em Entries.tsx) */
258
+ allowedIn: (b.home?.file.split("/").pop()?.replace(/\.[jt]sx?$/, "") ?? ""), home: at(b.home) }));
259
+ const rules = { version: 1, enforce: true, source: `synthesisui read, ${R.read}`, confirmed: opts.answers ? "the person's answers" : "yes to every question (no answers file)", roles, breaks };
260
+ writeFileSync(join(dsDir, slug, "rules.json"), `${JSON.stringify(rules, null, 2)}\n`);
261
+ const contract = await syncClaudeMd(root, await agentsToMaintain(root));
262
+ say(`Your system: ${roles.length} roles and ${breaks.length} breaks, written to _synthesisui/ds/${slug}/rules.json.`);
263
+ say(contract ? "The contract your agent reads now lists them - every heading is checked against its role at the end of each turn." : "Run `synthesisui connect` so your agent reads and checks it.");
264
+ }
265
+ export async function read(opts) {
266
+ const say = (s) => process.stdout.write(`${s}\n`);
267
+ const root = opts.dir ?? projectRootFrom(process.cwd());
268
+ const started = Date.now();
269
+ const base = await findServer(opts.url, root);
270
+ if (!base) {
271
+ say("No dev server answered (tried :3000, :3001, :5173, :4321). Start it - `npm run dev` - and run `synthesisui read` again, or pass `--url`.");
272
+ process.exitCode = 1;
273
+ return;
274
+ }
275
+ const routes = opts.routes?.length ? opts.routes : ["/"];
276
+ const pw = loadReader(say);
277
+ const browser = await launch(pw, say);
278
+ const seen = [];
279
+ const pages = new Map();
280
+ try {
281
+ for (const rota of routes) {
282
+ for (const [disp, w, h] of VIEWPORTS) {
283
+ const ctx = await browser.newContext({ viewport: { width: w, height: h }, reducedMotion: "reduce" });
284
+ const page = await ctx.newPage();
285
+ await page.goto(new URL(rota, base).href, { waitUntil: "networkidle", timeout: 90_000 });
286
+ await page.waitForTimeout(800);
287
+ const textos = (await page.evaluate(PAGE_SCRIPT));
288
+ for (const t of textos)
289
+ seen.push({ ...t, rota, disp });
290
+ pages.set(`${rota}|${disp}`, { page, ctx });
291
+ }
292
+ }
293
+ const reading = classify(seen);
294
+ // cada contraste baixo conferido pelos pixels: sem tinta é texto não pintado; acima do alvo, o DOM errou o fundo
295
+ const confirmed = [];
296
+ for (const f of reading.contraste) {
297
+ const p = pages.get(`${f.sample.rota}|${f.sample.disp}`);
298
+ let px = null;
299
+ if (p && f.sample.w >= 2 && f.sample.h >= 2) {
300
+ const png = await p.page.screenshot({ fullPage: true, clip: { x: Math.max(0, f.sample.x), y: Math.max(0, f.sample.y), width: f.sample.w, height: f.sample.h } }).catch(() => null);
301
+ const rgba = png ? decodePng(png) : null;
302
+ px = rgba ? pixelRatio(rgba) : null;
303
+ }
304
+ if (px !== null && (px < 1.2 || px >= f.alvo))
305
+ continue;
306
+ confirmed.push({ ...f, pixels: px });
307
+ }
308
+ reading.contraste = confirmed;
309
+ const files = sources(root);
310
+ const home = (t) => homeOf(root, files, t);
311
+ const out = {
312
+ read: new Date().toISOString(),
313
+ url: base,
314
+ routes,
315
+ seconds: Math.round((Date.now() - started) / 1000),
316
+ sectionTitlePx: reading.tituloSecao,
317
+ typeSteps: reading.passos,
318
+ roles: reading.papeis.map((r) => ({ role: r.papel, surface: r.superficie, px: r.px, places: r.lugares, seen: r.onde, home: home(r.sample) })),
319
+ breaks: reading.quebras.map((q) => ({ rule: q.regra, name: q.nome, role: q.papel, px: q.px, what: q.medida, times: q.vezes, seen: q.onde, home: home(q.sample) })),
320
+ drift: reading.deriva.map((d) => ({ role: d.papel, surface: d.superficie, px: d.px, rulePx: d.regraPx, seen: d.onde, home: home(d.sample), ruleHome: home(d.ruleSample) })),
321
+ lowContrast: reading.contraste.map((c) => ({ ratio: c.razao, target: c.alvo, text: c.texto, background: c.fundo, px: c.px, seen: c.onde, home: home(c.sample) })),
322
+ overImages: reading.sobreImagem,
323
+ questions: [],
324
+ };
325
+ out.questions = questionsFor(out.breaks, out.drift);
326
+ mkdirSync(join(root, "_synthesisui"), { recursive: true });
327
+ writeFileSync(join(root, "_synthesisui", "reading.json"), `${JSON.stringify(out, null, 2)}\n`);
328
+ const at = (x) => (x ? `${x.file}${x.line ? `:${x.line}` : ""}` : "?");
329
+ say(`\nWhat your page already decided (${routes.join(", ")}, read in ${out.seconds}s):\n`);
330
+ say(` Type steps that repeat: ${out.typeSteps.join(" · ")} px`);
331
+ say(" Roles:");
332
+ for (const r of out.roles)
333
+ say(` ${r.role} (${r.surface}): ${r.px}px, ${r.home?.cls ?? "?"} - ${at(r.home)}`);
334
+ if (out.breaks.length)
335
+ say(" Breaks (each one rule, broken on purpose - confirm or not):");
336
+ for (const q of out.breaks)
337
+ say(` ${q.name}: ${q.role}, ${q.what} - ${at(q.home)}`);
338
+ if (out.drift.length)
339
+ say(" Drift (the same role at another size):");
340
+ for (const d of out.drift)
341
+ say(` ${d.role} at ${d.px}px instead of ${d.rulePx}px - ${at(d.home)}`);
342
+ if (out.lowContrast.length)
343
+ say(" Hard to read (checked against the pixels):");
344
+ for (const c of out.lowContrast)
345
+ say(` ${c.ratio}:1 (needs ${c.target}:1) ${c.text} on ${c.background} - ${at(c.home)}`);
346
+ if (out.questions.length)
347
+ say(`\n ${out.questions.length} question${out.questions.length === 1 ? "" : "s"} for the person in _synthesisui/reading.json - then \`synthesisui read --confirm <answers.json>\` (no file = yes to all).`);
348
+ say(`\n Written to _synthesisui/reading.json. Nothing in your code changed.`);
349
+ }
350
+ finally {
351
+ for (const { ctx } of pages.values())
352
+ await ctx.close().catch(() => { });
353
+ await browser.close().catch(() => { });
354
+ }
355
+ }
package/dist/door.js ADDED
@@ -0,0 +1,176 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { connect } from "./commands/connect.js";
6
+ import { MCP_TOOL_COUNT } from "./commands/mcp.js";
7
+ import { body, paint, section, snippet } from "./output.js";
8
+ import { SKILLS } from "./skills.js";
9
+ import { desligarCaptura, ligarCaptura } from "./transcript.js";
10
+ /**
11
+ * A CAMADA DE ESTÉTICA PADRÃO, na versão que a régua do conjunto provou (18/10: corte, linhas A-C, relógios). O impeccable
12
+ * muda rápido; subir este número pede a régua de novo (D10 do mapa), não só o publish dele.
13
+ */
14
+ export const IMPECCABLE_VERSION = "4.6.0";
15
+ /** O impeccable que já está no projeto, pela skill que o instalador DELE escreve - nunca por um arquivo nosso. */
16
+ export function impeccableIn(root) {
17
+ const skill = join(root, ".claude", "skills", "impeccable", "SKILL.md");
18
+ if (!existsSync(skill))
19
+ return null;
20
+ try {
21
+ const head = readFileSync(skill, "utf8").slice(0, 4000);
22
+ return /^version:\s*(\S+)/m.exec(head)?.[1] ?? "?";
23
+ }
24
+ catch {
25
+ return "?";
26
+ }
27
+ }
28
+ /** Uma pergunta, o sim como padrão - e só num terminal de verdade. */
29
+ async function askAesthetic() {
30
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
31
+ return null;
32
+ const { createInterface } = await import("node:readline/promises");
33
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
34
+ try {
35
+ const answer = await rl.question(` Add the aesthetic layer - impeccable ${IMPECCABLE_VERSION}, by its own installer? It creates the first look; SynthesisUI keeps every screen after it consistent. [Y/n] `);
36
+ return !/^n/i.test(answer.trim());
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ finally {
42
+ rl.close();
43
+ }
44
+ }
45
+ /**
46
+ * COMPOR, NUNCA FORKAR (decisão dele, 18/10 15:25): o SynthesisUI é o ecossistema e o impeccable é uma camada. Quem
47
+ * instala é o instalador DELE, chamado por nós; nenhum arquivo dele é escrito, copiado ou apagado aqui. Já instalado,
48
+ * fica como está.
49
+ */
50
+ async function aestheticLayer(root, wanted) {
51
+ const found = impeccableIn(root);
52
+ if (found)
53
+ return { kind: "found", version: found };
54
+ if (wanted === "none")
55
+ return { kind: "declined", asked: false };
56
+ if (wanted !== "impeccable") {
57
+ const yes = await askAesthetic();
58
+ if (yes === false)
59
+ return { kind: "declined", asked: true };
60
+ }
61
+ try {
62
+ execFileSync("npx", ["-y", `impeccable@${IMPECCABLE_VERSION}`, "install", "-y", "--providers=claude", "--project"], { cwd: root, stdio: "pipe" });
63
+ return { kind: "installed", version: impeccableIn(root) ?? IMPECCABLE_VERSION };
64
+ }
65
+ catch (e) {
66
+ const err = e;
67
+ return {
68
+ kind: "failed",
69
+ why: err.stderr?.toString().trim().split("\n").filter(Boolean).pop() ??
70
+ err.message ??
71
+ "unknown",
72
+ };
73
+ }
74
+ }
75
+ /** O gerenciador que o projeto já usa, pelo lockfile - instalar com outro deixaria dois lockfiles brigando. */
76
+ export function packageManagerOf(root) {
77
+ if (existsSync(join(root, "pnpm-lock.yaml")))
78
+ return "pnpm";
79
+ if (existsSync(join(root, "yarn.lock")))
80
+ return "yarn";
81
+ if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock")))
82
+ return "bun";
83
+ return "npm";
84
+ }
85
+ export function addDevArgs(pm, spec) {
86
+ if (pm === "npm")
87
+ return ["install", "--save-dev", "--no-audit", "--no-fund", spec];
88
+ if (pm === "bun")
89
+ return ["add", "--dev", spec];
90
+ return ["add", "--save-dev", spec];
91
+ }
92
+ /**
93
+ * O CLI COMO DEPENDÊNCIA DO PROJETO - é o que faz o fim do turno rodar a versão dele, e ~10x mais rápido que o `npx`
94
+ * (medido em 27/07, ver `hookCommand`). O `connect` escolhe sozinho o local quando ele está em `node_modules`, então
95
+ * esta instalação vem ANTES dele.
96
+ *
97
+ * `spec` existe para provar uma versão antes do publish (um tarball do `npm pack`); o padrão é a versão que roda.
98
+ */
99
+ async function installLocalCli(root, spec) {
100
+ const pm = packageManagerOf(root);
101
+ try {
102
+ execFileSync(pm, addDevArgs(pm, spec), { cwd: root, stdio: "pipe" });
103
+ return { ok: true, pm };
104
+ }
105
+ catch (e) {
106
+ const err = e;
107
+ const why = err.stderr?.toString().trim().split("\n").filter(Boolean).pop() ??
108
+ err.message ??
109
+ "unknown";
110
+ return { ok: false, pm, why };
111
+ }
112
+ }
113
+ export async function openTheDoor(opts) {
114
+ const { root, version } = opts;
115
+ const pkgPath = join(root, "package.json");
116
+ const hasPackage = existsSync(pkgPath);
117
+ const local = hasPackage
118
+ ? await installLocalCli(root, opts.pkg ?? `synthesisui@${version}`)
119
+ : null;
120
+ const layer = await aestheticLayer(root, opts.aesthetic);
121
+ ligarCaptura();
122
+ let failed = null;
123
+ try {
124
+ await connect({
125
+ dir: root,
126
+ version,
127
+ ...(opts.registry ? { registry: opts.registry } : {}),
128
+ hook: true,
129
+ mcp: true,
130
+ agents: "claude",
131
+ interactive: false,
132
+ });
133
+ }
134
+ catch (e) {
135
+ failed = e;
136
+ }
137
+ const connectLines = desligarCaptura();
138
+ if (failed) {
139
+ for (const line of [...opts.configLines, ...connectLines])
140
+ console.log(line);
141
+ throw failed;
142
+ }
143
+ const installed = hasPackage
144
+ ? (JSON.parse(await readFile(pkgPath, "utf8")).devDependencies?.synthesisui ?? null)
145
+ : null;
146
+ console.log(section("SynthesisUI is set up"));
147
+ console.log("");
148
+ console.log(body(local?.ok
149
+ ? `✓ the CLI synthesisui ${version} in your devDependencies (${local.pm}) - the check runs this one`
150
+ : local
151
+ ? `· the CLI not installed (${local.why}) - the check runs it through npx, slower`
152
+ : "· the CLI no package.json here - the check runs it through npx, slower"));
153
+ console.log(body(`✓ your agent Claude Code: the check at the end of each turn, ${MCP_TOOL_COUNT} tools, ${SKILLS.length} skills`));
154
+ console.log(body(layer.kind === "found"
155
+ ? `✓ aesthetic impeccable ${layer.version} was already here - left exactly as it is`
156
+ : layer.kind === "installed"
157
+ ? `✓ aesthetic impeccable ${layer.version}, by its own installer - it creates the first look`
158
+ : layer.kind === "failed"
159
+ ? `· aesthetic impeccable did not install (${layer.why}) - run init again to retry`
160
+ : "· aesthetic none - the first look comes from your agent alone, with no design direction behind it"));
161
+ if (layer.kind === "declined")
162
+ console.log(body(paint.faint(" add it later: npx synthesisui init --aesthetic impeccable")));
163
+ if (layer.kind === "installed" && opts.aesthetic !== "impeccable" && !process.stdin.isTTY)
164
+ console.log(body(paint.faint(" installed by default - `--aesthetic none` leaves it out")));
165
+ console.log(body("✓ no account nothing here needs one"));
166
+ if (installed && local?.ok === false)
167
+ console.log(body(paint.faint(` (package.json already lists synthesisui ${installed})`)));
168
+ console.log("");
169
+ console.log(section("Next"));
170
+ console.log("");
171
+ console.log(body("Open your agent here and ask for your first screen:"));
172
+ console.log("");
173
+ console.log(snippet(["claude", '"make a landing page for <what you are building>"']));
174
+ console.log("");
175
+ console.log(body("When the first screen is done, the agent offers to read it and turn what it decided into your system."));
176
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import "./copy/connect.pt-BR.js";
6
6
  import { absorb } from "./commands/absorb.js";
7
7
  import { add } from "./commands/add.js";
8
8
  import { adopt } from "./commands/adopt.js";
9
+ import { confirm, read } from "./commands/read.js";
9
10
  import { advise } from "./commands/advise.js";
10
11
  import { align } from "./commands/align.js";
11
12
  import { autopilot } from "./commands/autopilot.js";
@@ -49,7 +50,8 @@ const HELP = `synthesisui - bring SynthesisUI design systems into your project
49
50
  Usage - deterministic, FREE:
50
51
  synthesisui login [options] connect the CLI to your account (device-flow; --force to switch accounts)
51
52
  synthesisui logout sign out of this machine (removes the saved token)
52
- synthesisui init [options] write _synthesisui/config.json (target, dirs); --ds to bring one in
53
+ synthesisui init [options] start here: the CLI in your devDependencies, your agent wired, no account;
54
+ --ds <slug> brings a published system in instead
53
55
  synthesisui list [options] list the published design systems
54
56
  synthesisui list --mine your own systems, with their group
55
57
  synthesisui add <slug> [options] materialize a DS into _synthesisui/ds/<slug>/
@@ -70,6 +72,10 @@ Usage - governance (deterministic, FREE):
70
72
  their variant axes and options, their parts and shape, and
71
73
  where one is built from another - then the values you repeat,
72
74
  with file and line. No account, no network, nothing written
75
+ synthesisui read [--url <dev server>] [--routes /,/x]
76
+ synthesisui read --confirm [answers.json] the person's answers become the roles and breaks the check keeps
77
+ read the page you already built and say the system it decided:
78
+ roles, breaks, drift and contrast, with file and line
73
79
  synthesisui adopt [--write] turn the design system you ALREADY have into a contract
74
80
  synthesisui import [--scope <p>] [--usage <p>] read what you already have and make it a system
75
81
  your agent follows - without touching your CSS
@@ -120,6 +126,8 @@ Options:
120
126
  --dir <path> consumer project root (default: current directory)
121
127
  --version <n> install a specific version (default: latest)
122
128
  --ds <slug> init: bring this DS in right away · generate: target DS (default: installed)
129
+ --package <spec> init: what to add to devDependencies (default: synthesisui@<this version>)
130
+ --aesthetic <a> init: impeccable | none - the layer that creates the first look (default: impeccable, asked in a terminal)
123
131
  --name <name> import: the NAME of the system, which is what its install slug
124
132
  comes from - and the slug never changes. Also the preferred
125
133
  component name for generate.
@@ -160,7 +168,7 @@ Examples:
160
168
  synthesisui adopt # you already have a system: start here
161
169
  synthesisui import --dry # see the census before anything is sent
162
170
  synthesisui login
163
- synthesisui init --target next
171
+ synthesisui init # an empty project: one command, then ask your agent
164
172
  synthesisui init --target next --ds halogen bootstrap + bring a system in
165
173
  synthesisui doctor
166
174
  synthesisui doctor apps/web packages/ui # scope the read in a monorepo
@@ -309,6 +317,19 @@ async function main() {
309
317
  registry,
310
318
  });
311
319
  break;
320
+ case "read":
321
+ // What the page already decided - read from the running dev server; writes only _synthesisui/reading.json.
322
+ // `--confirm [answers.json]` turns the person's answers into rules.json and the contract.
323
+ if (flags.confirm !== undefined) {
324
+ await confirm({ dir, answers: typeof flags.confirm === "string" ? flags.confirm : undefined });
325
+ break;
326
+ }
327
+ await read({
328
+ dir,
329
+ url: typeof flags.url === "string" ? flags.url : undefined,
330
+ routes: typeof flags.routes === "string" ? flags.routes.split(",").map((r) => r.trim()).filter(Boolean) : undefined,
331
+ });
332
+ break;
312
333
  case "adopt":
313
334
  // Dry by default: `--write` is the only way anything lands on disk.
314
335
  await adopt({
@@ -556,6 +577,8 @@ async function main() {
556
577
  ...(typeof flags["theme-in"] === "string"
557
578
  ? { themeIn: flags["theme-in"] }
558
579
  : {}),
580
+ ...(typeof flags.package === "string" ? { pkg: flags.package } : {}),
581
+ ...(typeof flags.aesthetic === "string" ? { aesthetic: flags.aesthetic } : {}),
559
582
  cli: CLI_VERSION,
560
583
  });
561
584
  break;
@@ -344,7 +344,18 @@
344
344
  * `[font-size:var(--text-sm)]` em vez de `[font-size:0.875rem]`. Um componente escrito antes traz os tamanhos à mão. No
345
345
  * mesmo PR, o `init` declara a largura da página (`--container-page`) e a seção a escreve pelo nome, não como `1440px`.
346
346
  */
347
- export const MATERIALISER_SINCE = "0.16.499";
347
+ /**
348
+ * 0.16.499 -> 0.16.500 em 18/10, e o passo 1 dá **SIM** (jornada "a leitura no agente"): o bloco do `CLAUDE.md` de um
349
+ * sistema adotado ganha os papéis de título e as quebras que a pessoa confirmou (`_synthesisui/ds/<slug>/rules.json`) e,
350
+ * sem eles, a linha que ensina o agente a oferecer a leitura (`synthesisui read`) e a confirmar (`read --confirm`). Uma
351
+ * pasta escrita antes não fala de papéis nem da leitura.
352
+ */
353
+ /**
354
+ * 0.16.500 -> 0.16.501 em 18/10, e o passo 1 dá **SIM** (jornada "um instalador, uma porta"): num projeto sem sistema, o
355
+ * bloco do `CLAUDE.md` ensina a leitura da primeira tela em vez do `/sui-import-ds`; com o `rules.json` e o impeccable no
356
+ * projeto, diz que o revisor dele é só da criação. Uma pasta escrita antes manda o agente importar e não fala do revisor.
357
+ */
358
+ export const MATERIALISER_SINCE = "0.16.501";
348
359
  /**
349
360
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
350
361
  *
@@ -562,7 +573,19 @@ export const COUNTED_DIFFERENTLY = "this run counts a value as named only when Y
562
573
  * de texto escrito à mão (`font-size`, `[font-size:X]`, `text-[X]`), e a família de fonte no colchete do Tailwind deixa de
563
574
  * engolir o resto da linha. Um hook anterior diz "limpo" sobre um `[font-size:0.875rem]` com `--text-sm` declarado.
564
575
  */
565
- export const CHECKER_SINCE = "0.16.499";
576
+ /**
577
+ * 0.16.499 -> 0.16.500 em 18/10, e o passo 1 dá **SIM** (jornada "a leitura no agente"): o hook confere os papéis e as
578
+ * quebras do `rules.json` (o título fora do papel, a quebra fora do lugar em qualquer elemento, o papel novo dito uma
579
+ * vez) e acha a raiz pelo projeto - não pela pasta onde o terminal do agente parou, que deixava turnos inteiros sem
580
+ * conferência. Um hook pinado antes não confere papel nenhum.
581
+ */
582
+ /**
583
+ * 0.16.500 -> 0.16.501 em 18/10, e o passo 1 dá **SIM** (jornada "um instalador, uma porta"): o fim do turno pede ao agente,
584
+ * uma vez, que ofereça a leitura quando a primeira tela aparece num projeto sem sistema nenhum. Um hook pinado antes nunca
585
+ * oferece. E a checagem por papel lê CSS Modules (`className={styles.h2}`, o tamanho no `.module.css`): um hook anterior
586
+ * não confere papel nenhum numa página escrita assim.
587
+ */
588
+ export const CHECKER_SINCE = "0.16.501";
566
589
  /**
567
590
  * A ÚLTIMA VERSÃO EM QUE OS LEITORES PASSARAM A PRODUZIR UM CENSO DIFERENTE.
568
591
  *