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.
- package/dist/claude-md.js +27 -5
- package/dist/commands/add.js +2 -0
- package/dist/commands/connect.js +7 -3
- package/dist/commands/hook.js +197 -5
- package/dist/commands/init.js +18 -5
- package/dist/commands/read.js +355 -0
- package/dist/door.js +176 -0
- package/dist/index.js +25 -2
- package/dist/install-marks.js +25 -2
- package/dist/reading/classify.js +181 -0
- package/dist/reading/page-script.js +64 -0
- package/dist/reading/png.js +66 -0
- package/dist/role-rules.js +237 -0
- package/package.json +1 -1
|
@@ -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]
|
|
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
|
|
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;
|
package/dist/install-marks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
*
|