synthesisui 0.16.499 → 0.16.500

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 CHANGED
@@ -1,4 +1,5 @@
1
1
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { loadRoleRules, rolesContract } from "./role-rules.js";
2
3
  import { dirname, join } from "node:path";
3
4
  import { hasHook } from "./agent-wiring.js";
4
5
  import { declaredReference } from "./group-role.js";
@@ -468,6 +469,12 @@ written now** - read the code before you edit it, never a remembered signature.`
468
469
  * livres passa pelo `landing` antes de qualquer linha - ele lista as seções do catálogo e devolve o prompt da pilha, com
469
470
  * as decisões tomadas. Sem servidor também: o `landing` fala com o registro direto.
470
471
  */
472
+ /** Os papéis e as quebras que a página dele já decidiu - ver `role-rules.ts` (18/10). Só existe com `rules.json`. */
473
+ const roleRules = await loadRoleRules(projectRoot).catch(() => null);
474
+ const rolesLine = roleRules
475
+ ? rolesContract(roleRules)
476
+ : /** Sem rules.json ainda: o agente oferece a leitura (jornada "a leitura no agente", E03, 18/10). */
477
+ "\n\n**Once the first screen exists, offer to read it.** When the person asks you to read their page - or right after you build the first screen - run `npx synthesisui read` with the dev server up, tell them in a few lines the system it found, then ask the questions in `_synthesisui/reading.json` ONE AT A TIME with your question tool (the first option is the default). Save their answers as `{ \"<question id>\": <option index> }` and run `npx synthesisui read --confirm <that file>`: their answers become the roles and breaks this project keeps.";
471
478
  const landingLine = '\n\n**A landing asked in free words:** run `npx synthesisui@latest landing <slug> "<their words>"` first, and follow it.';
472
479
  const preferencesLine = recall.available
473
480
  ? `
@@ -497,7 +504,7 @@ is \`remember_preference\`.`
497
504
  ? `**These are true without asking anyone:** use the project's OWN custom properties, exactly as
498
505
  this system declares them. No raw colours, spacings or radii that a token already covers, and never a
499
506
  new token invented in silence - say so instead, because a new token is a decision for a person. There
500
- is no component index for an adopted system: the tokens ARE the contract.${theirsLine}
507
+ is no component index for an adopted system: the tokens ARE the contract.${theirsLine}${rolesLine}
501
508
 
502
509
  **What the vocabulary IS is not written here.** Fetch only the piece the task requires:
503
510
  \`system_doctrine\` for the rules and the voice, \`find_token\` for a value you are about to write.${memoryLine}${preferencesLine}${selfCheck}`
@@ -529,7 +536,7 @@ If nothing covers it, say which indexed entry you considered and why it did not
529
536
  project's OWN custom properties, exactly as the guide lists them. Do not write raw colours,
530
537
  spacings or radii that a token already covers, and do not invent a new token silently - say so
531
538
  instead, because a new token is a decision for a person to make. There is no component
532
- manifest for an adopted system - the tokens ARE the contract.${theirsLine}${landingLine}${selfCheck}`
539
+ manifest for an adopted system - the tokens ARE the contract.${theirsLine}${rolesLine}${landingLine}${selfCheck}`
533
540
  : `**When creating or editing components, read the system's GUIDE.md and follow it:** use the
534
541
  vocabulary it lists - every name in it is a name THIS project already declares, written exactly as
535
542
  you would write it. Do not use raw values outside the system's scale, and never a name from a
@@ -1,6 +1,7 @@
1
1
  import { readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { ourSpellingLines } from "../our-spelling.js";
3
- import { join, relative, resolve } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ import { dirname, join, relative, resolve } from "node:path";
4
5
  import { readAutoSync, startSync } from "../auto-sync.js";
5
6
  import { changedSince } from "../changed-files.js";
6
7
  import { emptyTally, exportedNames, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
@@ -8,6 +9,7 @@ import { checkContracts } from "../doctor/contract-check.js";
8
9
  import { componentOf } from "../doctor/component-of.js";
9
10
  import { appendEvent, ledgerPath } from "../doctor/ledger.js";
10
11
  import { describeOffScale, diagnose, nameToWrite, scanSource } from "../doctor/scan.js";
12
+ import { checkRoles, loadRoleRules, onlyOnce } from "../role-rules.js";
11
13
  import { declarados, falaDaPrateleira, naPrateleira } from "../doctor/shelf.js";
12
14
  import { governs, ungovernedIn } from "../governed.js";
13
15
  import { readMode } from "../mode.js";
@@ -313,6 +315,9 @@ async function report(root, filePath, mode) {
313
315
  const offScale = describeOffScale(d.files);
314
316
  /** A nossa grafia no arquivo dele - ver `our-spelling.ts`. Entra nas mesmas portas que os `phantoms`. */
315
317
  const ours = d.tokenUses;
318
+ /** Os papéis e as quebras que a página dele já decidiu - ver `role-rules.ts` (18/10). */
319
+ const roleRules = await loadRoleRules(root).catch(() => null);
320
+ const roles = roleRules ? await onlyOnce(root, rel, checkRoles(rel, src, roleRules)) : [];
316
321
  // Every check leaves one line in the ledger - INCLUDING the clean ones,
317
322
  // because a fix is itself a write, so the clean re-check of a file that was
318
323
  // just flagged is the evidence the agent read the report and acted. Without
@@ -327,6 +332,7 @@ async function report(root, filePath, mode) {
327
332
  phantoms: phantoms.length,
328
333
  unnamed: unnamed.length,
329
334
  offScale: d.files.reduce((n, f) => n + (f.offScale?.length ?? 0), 0),
335
+ ...(roleRules ? { roles: roles.length } : {}),
330
336
  ...(component ? { component } : {}),
331
337
  });
332
338
  /** O bloqueio de CONSUMIR é a única saída que recusa, e ele acompanha o relatório seja qual for. */
@@ -338,6 +344,7 @@ async function report(root, filePath, mode) {
338
344
  phantoms.length === 0 &&
339
345
  offScale.length === 0 &&
340
346
  unnamed.length === 0 &&
347
+ roles.length === 0 &&
341
348
  ours === 0) {
342
349
  const hello = await greet(root, rel);
343
350
  return hello ? say(hello) : null;
@@ -376,6 +383,7 @@ async function report(root, filePath, mode) {
376
383
  named.length === 0 &&
377
384
  phantoms.length === 0 &&
378
385
  offScale.length === 0 &&
386
+ roles.length === 0 &&
379
387
  ours === 0)
380
388
  return say(broke.join("\n"));
381
389
  const sample = unnamed
@@ -387,6 +395,7 @@ async function report(root, filePath, mode) {
387
395
  named.length === 0 &&
388
396
  phantoms.length === 0 &&
389
397
  offScale.length === 0 &&
398
+ roles.length === 0 &&
390
399
  ours === 0)
391
400
  return say([
392
401
  `${rel} - ${unnamed.length} value${unnamed.length === 1 ? "" : "s"} here ${unnamed.length === 1 ? "has" : "have"} no name in this system, and nothing to replace ${unnamed.length === 1 ? "it" : "them"} with: ${sample}${unnamed.length > 3 ? `, +${unnamed.length - 3} more` : ""}.`,
@@ -413,6 +422,9 @@ async function report(root, filePath, mode) {
413
422
  ...(broke.length > 0 ? [...broke, ""] : []),
414
423
  `${rel} - checked against ${table.name ?? table.slug}.`,
415
424
  ];
425
+ if (roles.length > 0) {
426
+ lines.push("", "Headings that leave the role this project already decided (read from its own pages):", ...roles, "", "Fix these now, while you have the file open - one role, one class.");
427
+ }
416
428
  if (named.length > 0) {
417
429
  lines.push("", "Values written by hand that this system already has a name for:", ...named
418
430
  .slice(0, 20)
@@ -459,8 +471,28 @@ const namedInPayload = (input) => {
459
471
  input.tool_name === "MultiEdit";
460
472
  return writes ? (input.tool_input?.file_path ?? null) : null;
461
473
  };
474
+ /**
475
+ * A RAIZ É O PROJETO, NÃO A PASTA ONDE A SESSÃO PAROU - jornada "a leitura no agente", E01 (18/10).
476
+ *
477
+ * O Claude Code roda o gancho na pasta em que o terminal do agente está. Na rodada dos relógios, o agente terminou os
478
+ * turnos 3 e 7 com `cd .impeccable/review`; o fim do turno rodou ali, não achou `_synthesisui/` e passou calado - a
479
+ * página de cada relógio, o trabalho inteiro do turno 3, nunca foi conferida. A ordem: a pasta que o Claude Code
480
+ * declara (`CLAUDE_PROJECT_DIR`), quando ela é um projeto nosso; senão, a pasta mais próxima, subindo, que tenha
481
+ * `_synthesisui/`; senão, onde estamos (o comportamento de antes).
482
+ */
483
+ export function projectRootFrom(cwd, env = process.env) {
484
+ const declared = env.CLAUDE_PROJECT_DIR;
485
+ if (declared && existsSync(join(declared, "_synthesisui")))
486
+ return resolve(declared);
487
+ for (let dir = resolve(cwd);; dir = dirname(dir)) {
488
+ if (existsSync(join(dir, "_synthesisui")))
489
+ return dir;
490
+ if (dirname(dir) === dir)
491
+ return resolve(cwd);
492
+ }
493
+ }
462
494
  export async function hook(opts) {
463
- const root = resolve(opts.dir ?? process.cwd());
495
+ const root = resolve(opts.dir ?? projectRootFrom(process.cwd()));
464
496
  let raw = "";
465
497
  for await (const chunk of process.stdin)
466
498
  raw += chunk;
@@ -0,0 +1,317 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { homedir } from "node:os";
5
+ import { join, relative } from "node:path";
6
+ import { classify, pixelRatio } from "../reading/classify.js";
7
+ import { PAGE_SCRIPT } from "../reading/page-script.js";
8
+ import { decodePng } from "../reading/png.js";
9
+ import { pxOf } from "../role-rules.js";
10
+ import { agentsToMaintain } from "../agents-chosen.js";
11
+ import { syncClaudeMd } from "../claude-md.js";
12
+ import { projectRootFrom } from "./hook.js";
13
+ /**
14
+ * `synthesisui read` - O QUE A SUA PÁGINA JÁ DECIDIU (jornada "a leitura no agente", E02, 18/10).
15
+ *
16
+ * 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
17
+ * 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
18
+ * 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
19
+ * vê) e liga cada achado ao componente pelo React, não pela forma de estilo.
20
+ *
21
+ * O NAVEGADOR É SOB DEMANDA (D9 do mapa): o CLI continua sem dependência nenhuma. No primeiro `read` ele DIZ o que vai
22
+ * baixar e baixa o leitor (playwright-core) numa pasta própria; usa o Chromium que já estiver na máquina, e só baixa um
23
+ * quando não há nenhum.
24
+ *
25
+ * Grava `_synthesisui/reading.json` - a proposta. O `rules.json` nasce da confirmação da pessoa (E03), nunca daqui.
26
+ */
27
+ const READER_VERSION = "1.58.2";
28
+ const READER_DIR = join(homedir(), ".synthesisui", "reader");
29
+ const VIEWPORTS = [
30
+ ["desktop", 1440, 900],
31
+ ["celular", 390, 844],
32
+ ];
33
+ function loadReader(say) {
34
+ const override = process.env.SYNTHESISUI_PLAYWRIGHT;
35
+ if (override)
36
+ return createRequire(join(override, "package.json"))(override);
37
+ const mod = join(READER_DIR, "node_modules", "playwright-core");
38
+ if (!existsSync(mod)) {
39
+ 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\`.`);
40
+ mkdirSync(READER_DIR, { recursive: true });
41
+ execFileSync("npm", ["install", "--prefix", READER_DIR, "--no-save", "--no-audit", "--no-fund", `playwright-core@${READER_VERSION}`], { stdio: "ignore" });
42
+ }
43
+ return createRequire(join(READER_DIR, "package.json"))("playwright-core");
44
+ }
45
+ async function launch(pw, say) {
46
+ for (const o of [{}, { channel: "chrome" }, { channel: "msedge" }]) {
47
+ try {
48
+ return await pw.chromium.launch(o);
49
+ }
50
+ catch {
51
+ /* o próximo */
52
+ }
53
+ }
54
+ say("No Chromium found on this machine: downloading one for the reader (about 150 MB, once).");
55
+ execFileSync(process.execPath, [join(READER_DIR, "node_modules", "playwright-core", "cli.js"), "install", "chromium"], { stdio: "ignore" });
56
+ return pw.chromium.launch();
57
+ }
58
+ /**
59
+ * O DEV SERVER DESTE PROJETO, E NÃO O PRIMEIRO QUE RESPONDER - a porta que o script `dev` do próprio projeto declara
60
+ * (`-p 3389`, `--port 3389`, `PORT=3389`) vem antes das portas de sempre. Sem isto, numa máquina com outro projeto na
61
+ * 3000, a leitura descreveria a página de outra pessoa.
62
+ */
63
+ function declaredPort(root) {
64
+ try {
65
+ const dev = (JSON.parse(readFileSync(join(root, "package.json"), "utf8")).scripts?.dev ?? "");
66
+ const m = dev.match(/(?:-p|--port)[ =](\d{2,5})|PORT=(\d{2,5})/);
67
+ return m ? Number(m[1] ?? m[2]) : null;
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ async function findServer(url, root) {
74
+ const port = declaredPort(root);
75
+ const cands = url ? [url] : [...(port ? [`http://localhost:${port}`] : []), "http://localhost:3000", "http://localhost:3001", "http://localhost:5173", "http://localhost:4321"];
76
+ for (const u of cands) {
77
+ try {
78
+ const r = await fetch(u, { signal: AbortSignal.timeout(4000) });
79
+ if (r.status < 500)
80
+ return u;
81
+ }
82
+ catch {
83
+ /* o próximo */
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ /** O maior tamanho entre as classes de um texto (o do desktop), sem o prefixo de tela - a mesma escolha da checagem. */
89
+ function sizeClassOf(cls) {
90
+ const toks = cls.match(/(?:[a-z0-9]+:)?text-(?:\[(?:clamp\([^\]\s]+\)|[\d.]+(?:rem|px|em))\]|xs|sm|base|lg|[2-9]?xl)(?=\s|$|\|)/g) ?? [];
91
+ if (toks.length === 0)
92
+ return null;
93
+ const best = toks.sort((a, b) => (pxOf(b) ?? 0) - (pxOf(a) ?? 0))[0];
94
+ return best.replace(/^(?:[a-z0-9]+:)+/, "");
95
+ }
96
+ function sources(root) {
97
+ const out = [];
98
+ const walk = (d) => {
99
+ for (const n of readdirSync(d)) {
100
+ if (n === "node_modules" || n.startsWith(".") || n === "_synthesisui")
101
+ continue;
102
+ const p = join(d, n);
103
+ const s = statSync(p);
104
+ if (s.isDirectory())
105
+ walk(p);
106
+ else if (/\.(tsx|jsx)$/.test(n))
107
+ out.push(p);
108
+ }
109
+ };
110
+ const src = existsSync(join(root, "src")) ? join(root, "src") : root;
111
+ walk(src);
112
+ return out;
113
+ }
114
+ /** Onde o texto mora no código: o componente (React) -> o arquivo que o declara -> a linha da classe; senão, pela classe. */
115
+ function homeOf(root, files, t) {
116
+ const cls = sizeClassOf(t.cls);
117
+ const lineOf = (f) => {
118
+ if (!cls)
119
+ return null;
120
+ const i = readFileSync(f, "utf8").split("\n").findIndex((l) => l.includes(cls));
121
+ return i >= 0 ? i + 1 : null;
122
+ };
123
+ if (t.comp) {
124
+ const decl = new RegExp(`(function|const|class)\\s+${t.comp}\\b`);
125
+ const f = files.find((x) => decl.test(readFileSync(x, "utf8")));
126
+ if (f)
127
+ return { file: relative(root, f), line: lineOf(f), cls, via: "component", component: t.comp };
128
+ }
129
+ if (cls) {
130
+ const f = files.find((x) => readFileSync(x, "utf8").includes(cls));
131
+ if (f)
132
+ return { file: relative(root, f), line: lineOf(f), cls, via: "class", component: t.comp };
133
+ }
134
+ return null;
135
+ }
136
+ /**
137
+ * NO MÁXIMO OITO PERGUNTAS, UMA DECISÃO CADA - por quebra e por papel com deriva, nunca por elemento (seção 6 do estudo:
138
+ * "5-8 perguntas por projeto, não 300"). As quebras vêm primeiro: dizer sim à própria assinatura é o caminho feliz. A
139
+ * primeira opção é sempre a padrão, e é o que vale quando a pessoa não responde.
140
+ */
141
+ /** O nome que a pessoa entende - nunca o rótulo interno da classificação ("h3 de cartão", "número"). */
142
+ function plainRole(role) {
143
+ if (role === "número")
144
+ return "big number";
145
+ if (role === "decoração")
146
+ return "decoration";
147
+ if (role === "bloco")
148
+ return "block";
149
+ return nameOf(role);
150
+ }
151
+ function questionsFor(breaks, drift) {
152
+ const qs = breaks.map((b) => ({
153
+ id: `break:${b.rule}:${b.role}:${b.px}`,
154
+ 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}).`,
155
+ options: ["Yes, it is part of my design", "No, make it follow the scale"],
156
+ }));
157
+ const groups = new Map();
158
+ for (const d of drift) {
159
+ const g = groups.get(`${d.role}|${d.surface}`) ?? { role: d.role, surface: d.surface, rulePx: d.rulePx, pxs: [] };
160
+ g.pxs.push(d.px);
161
+ groups.set(`${d.role}|${d.surface}`, g);
162
+ }
163
+ for (const g of groups.values())
164
+ qs.push({
165
+ id: `drift:${g.role}:${g.surface}`,
166
+ text: `Are these the same ${plainRole(g.role)}? Most places use ${g.rulePx}px; here ${g.pxs.join("px, ")}px.`,
167
+ options: [`Yes, use ${g.rulePx}px everywhere`, "No, they are different roles"],
168
+ });
169
+ return qs.slice(0, 8);
170
+ }
171
+ const contextOf = (role) => (role.includes("cartão") ? "card" : role.includes("texto corrido") ? "prose" : "section");
172
+ const NAME = { h1: "cover title", h2: "section title", h3: "subheading" };
173
+ const nameOf = (role) => {
174
+ const el = role.split(" ")[0];
175
+ return contextOf(role) === "card" ? "card title" : contextOf(role) === "prose" ? `heading inside running text (${el})` : (NAME[el] ?? role);
176
+ };
177
+ const at = (h) => (h ? `${h.file}${h.line ? `:${h.line}` : ""}` : "");
178
+ /**
179
+ * \`read --confirm\` - AS RESPOSTAS DELA VIRAM O SISTEMA (E03). Lê \`reading.json\` e as respostas (um JSON \`{ "<id da
180
+ * pergunta>": 0 | 1 }\`, o índice da opção; sem arquivo, a primeira opção de todas) e grava \`_synthesisui/ds/<slug>/rules.json\`
181
+ * no formato que a checagem lê (\`role-rules.ts\`); depois regenera o contrato. Um "não" numa quebra a tira do sistema; um
182
+ * "são papéis diferentes" vira papel próprio, com a classe que ele usa.
183
+ */
184
+ export async function confirm(opts) {
185
+ const say = (s) => process.stdout.write(`${s}\n`);
186
+ const root = opts.dir ?? projectRootFrom(process.cwd());
187
+ const rp = join(root, "_synthesisui", "reading.json");
188
+ if (!existsSync(rp)) {
189
+ say("No reading yet - run `synthesisui read` first.");
190
+ process.exitCode = 1;
191
+ return;
192
+ }
193
+ const R = JSON.parse(readFileSync(rp, "utf8"));
194
+ const answers = opts.answers ? JSON.parse(readFileSync(opts.answers, "utf8")) : {};
195
+ const pick = (id) => answers[id] ?? 0;
196
+ const dsDir = join(root, "_synthesisui", "ds");
197
+ const slug = existsSync(dsDir) ? readdirSync(dsDir).find((d) => statSync(join(dsDir, d)).isDirectory()) : undefined;
198
+ if (!slug) {
199
+ say("This project has no adopted system yet - run `synthesisui adopt --write` first.");
200
+ process.exitCode = 1;
201
+ return;
202
+ }
203
+ const roles = R.roles
204
+ .filter((r) => r.home?.cls)
205
+ .map((r) => ({
206
+ id: `${r.role.split(" ")[0]}-${contextOf(r.role)}-${r.surface}`, name: nameOf(r.role), element: r.role.split(" ")[0],
207
+ context: contextOf(r.role), surface: r.surface, class: r.home?.cls, px: r.px, home: at(r.home), places: r.places,
208
+ }));
209
+ for (const d of R.drift) {
210
+ if (pick(`drift:${d.role}:${d.surface}`) !== 1 || !d.home?.cls)
211
+ continue;
212
+ 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],
213
+ context: contextOf(d.role), surface: d.surface, class: d.home.cls, px: d.px, home: at(d.home), places: 1 });
214
+ }
215
+ const breaks = R.breaks
216
+ .filter((b) => pick(`break:${b.rule}:${b.role}:${b.px}`) === 0 && b.home)
217
+ .map((b) => ({ id: `${b.rule}-${b.px}`, rule: b.name, what: b.what, element: b.role, class: b.home?.cls ?? null, px: b.px,
218
+ /* o ARQUIVO onde a quebra mora, como a checagem compara (role-rules.ts) - não o componente do React, que pode
219
+ ser um de vários no mesmo arquivo (o DogCard mora em Entries.tsx) */
220
+ allowedIn: (b.home?.file.split("/").pop()?.replace(/\.[jt]sx?$/, "") ?? ""), home: at(b.home) }));
221
+ 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 };
222
+ writeFileSync(join(dsDir, slug, "rules.json"), `${JSON.stringify(rules, null, 2)}\n`);
223
+ const contract = await syncClaudeMd(root, await agentsToMaintain(root));
224
+ say(`Your system: ${roles.length} roles and ${breaks.length} breaks, written to _synthesisui/ds/${slug}/rules.json.`);
225
+ 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.");
226
+ }
227
+ export async function read(opts) {
228
+ const say = (s) => process.stdout.write(`${s}\n`);
229
+ const root = opts.dir ?? projectRootFrom(process.cwd());
230
+ const started = Date.now();
231
+ const base = await findServer(opts.url, root);
232
+ if (!base) {
233
+ say("No dev server answered (tried :3000, :3001, :5173, :4321). Start it - `npm run dev` - and run `synthesisui read` again, or pass `--url`.");
234
+ process.exitCode = 1;
235
+ return;
236
+ }
237
+ const routes = opts.routes?.length ? opts.routes : ["/"];
238
+ const pw = loadReader(say);
239
+ const browser = await launch(pw, say);
240
+ const seen = [];
241
+ const pages = new Map();
242
+ try {
243
+ for (const rota of routes) {
244
+ for (const [disp, w, h] of VIEWPORTS) {
245
+ const ctx = await browser.newContext({ viewport: { width: w, height: h }, reducedMotion: "reduce" });
246
+ const page = await ctx.newPage();
247
+ await page.goto(new URL(rota, base).href, { waitUntil: "networkidle", timeout: 90_000 });
248
+ await page.waitForTimeout(800);
249
+ const textos = (await page.evaluate(PAGE_SCRIPT));
250
+ for (const t of textos)
251
+ seen.push({ ...t, rota, disp });
252
+ pages.set(`${rota}|${disp}`, { page, ctx });
253
+ }
254
+ }
255
+ const reading = classify(seen);
256
+ // cada contraste baixo conferido pelos pixels: sem tinta é texto não pintado; acima do alvo, o DOM errou o fundo
257
+ const confirmed = [];
258
+ for (const f of reading.contraste) {
259
+ const p = pages.get(`${f.sample.rota}|${f.sample.disp}`);
260
+ let px = null;
261
+ if (p && f.sample.w >= 2 && f.sample.h >= 2) {
262
+ 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);
263
+ const rgba = png ? decodePng(png) : null;
264
+ px = rgba ? pixelRatio(rgba) : null;
265
+ }
266
+ if (px !== null && (px < 1.2 || px >= f.alvo))
267
+ continue;
268
+ confirmed.push({ ...f, pixels: px });
269
+ }
270
+ reading.contraste = confirmed;
271
+ const files = sources(root);
272
+ const home = (t) => homeOf(root, files, t);
273
+ const out = {
274
+ read: new Date().toISOString(),
275
+ url: base,
276
+ routes,
277
+ seconds: Math.round((Date.now() - started) / 1000),
278
+ sectionTitlePx: reading.tituloSecao,
279
+ typeSteps: reading.passos,
280
+ roles: reading.papeis.map((r) => ({ role: r.papel, surface: r.superficie, px: r.px, places: r.lugares, seen: r.onde, home: home(r.sample) })),
281
+ 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) })),
282
+ 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) })),
283
+ 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) })),
284
+ overImages: reading.sobreImagem,
285
+ questions: [],
286
+ };
287
+ out.questions = questionsFor(out.breaks, out.drift);
288
+ mkdirSync(join(root, "_synthesisui"), { recursive: true });
289
+ writeFileSync(join(root, "_synthesisui", "reading.json"), `${JSON.stringify(out, null, 2)}\n`);
290
+ const at = (x) => (x ? `${x.file}${x.line ? `:${x.line}` : ""}` : "?");
291
+ say(`\nWhat your page already decided (${routes.join(", ")}, read in ${out.seconds}s):\n`);
292
+ say(` Type steps that repeat: ${out.typeSteps.join(" · ")} px`);
293
+ say(" Roles:");
294
+ for (const r of out.roles)
295
+ say(` ${r.role} (${r.surface}): ${r.px}px, ${r.home?.cls ?? "?"} - ${at(r.home)}`);
296
+ if (out.breaks.length)
297
+ say(" Breaks (each one rule, broken on purpose - confirm or not):");
298
+ for (const q of out.breaks)
299
+ say(` ${q.name}: ${q.role}, ${q.what} - ${at(q.home)}`);
300
+ if (out.drift.length)
301
+ say(" Drift (the same role at another size):");
302
+ for (const d of out.drift)
303
+ say(` ${d.role} at ${d.px}px instead of ${d.rulePx}px - ${at(d.home)}`);
304
+ if (out.lowContrast.length)
305
+ say(" Hard to read (checked against the pixels):");
306
+ for (const c of out.lowContrast)
307
+ say(` ${c.ratio}:1 (needs ${c.target}:1) ${c.text} on ${c.background} - ${at(c.home)}`);
308
+ if (out.questions.length)
309
+ 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).`);
310
+ say(`\n Written to _synthesisui/reading.json. Nothing in your code changed.`);
311
+ }
312
+ finally {
313
+ for (const { ctx } of pages.values())
314
+ await ctx.close().catch(() => { });
315
+ await browser.close().catch(() => { });
316
+ }
317
+ }
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";
@@ -70,6 +71,10 @@ Usage - governance (deterministic, FREE):
70
71
  their variant axes and options, their parts and shape, and
71
72
  where one is built from another - then the values you repeat,
72
73
  with file and line. No account, no network, nothing written
74
+ synthesisui read [--url <dev server>] [--routes /,/x]
75
+ synthesisui read --confirm [answers.json] the person's answers become the roles and breaks the check keeps
76
+ read the page you already built and say the system it decided:
77
+ roles, breaks, drift and contrast, with file and line
73
78
  synthesisui adopt [--write] turn the design system you ALREADY have into a contract
74
79
  synthesisui import [--scope <p>] [--usage <p>] read what you already have and make it a system
75
80
  your agent follows - without touching your CSS
@@ -309,6 +314,19 @@ async function main() {
309
314
  registry,
310
315
  });
311
316
  break;
317
+ case "read":
318
+ // What the page already decided - read from the running dev server; writes only _synthesisui/reading.json.
319
+ // `--confirm [answers.json]` turns the person's answers into rules.json and the contract.
320
+ if (flags.confirm !== undefined) {
321
+ await confirm({ dir, answers: typeof flags.confirm === "string" ? flags.confirm : undefined });
322
+ break;
323
+ }
324
+ await read({
325
+ dir,
326
+ url: typeof flags.url === "string" ? flags.url : undefined,
327
+ routes: typeof flags.routes === "string" ? flags.routes.split(",").map((r) => r.trim()).filter(Boolean) : undefined,
328
+ });
329
+ break;
312
330
  case "adopt":
313
331
  // Dry by default: `--write` is the only way anything lands on disk.
314
332
  await adopt({
@@ -344,7 +344,13 @@
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
+ export const MATERIALISER_SINCE = "0.16.500";
348
354
  /**
349
355
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
350
356
  *
@@ -562,7 +568,13 @@ export const COUNTED_DIFFERENTLY = "this run counts a value as named only when Y
562
568
  * 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
569
  * engolir o resto da linha. Um hook anterior diz "limpo" sobre um `[font-size:0.875rem]` com `--text-sm` declarado.
564
570
  */
565
- export const CHECKER_SINCE = "0.16.499";
571
+ /**
572
+ * 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
573
+ * quebras do `rules.json` (o título fora do papel, a quebra fora do lugar em qualquer elemento, o papel novo dito uma
574
+ * vez) e acha a raiz pelo projeto - não pela pasta onde o terminal do agente parou, que deixava turnos inteiros sem
575
+ * conferência. Um hook pinado antes não confere papel nenhum.
576
+ */
577
+ export const CHECKER_SINCE = "0.16.500";
566
578
  /**
567
579
  * A ÚLTIMA VERSÃO EM QUE OS LEITORES PASSARAM A PRODUZIR UM CENSO DIFERENTE.
568
580
  *
@@ -0,0 +1,181 @@
1
+ /**
2
+ * O QUE A PÁGINA JÁ DECIDIU - a classificação da leitura, pura (jornada "a leitura no agente", E02, 18/10).
3
+ *
4
+ * Porta, regra por regra, a régua do conjunto (`synthesisui-tests/regua/conjunto/analisar.py`), que provou em 7 lugares -
5
+ * impeccable, Claude livre, SynthesisUI livre, dois sites públicos - que a mesma conta separa, sem ajuste por projeto:
6
+ *
7
+ * REGRA o que se repete: os passos de tipo (3+ lugares), o papel de cada título
8
+ * QUEBRA uma regra nomeada quebrada num papel só, de propósito - o vocabulário das peças de Rule breakers:
9
+ * Q1 passa da escala dos títulos · Q2 sai do eixo (inclinado) · Q3 motivo grande fora dos títulos
10
+ * DERIVA o mesmo papel com tamanhos diferentes na mesma faixa
11
+ *
12
+ * Determinístico: os mesmos textos medidos dão a mesma classificação. E TUDO É CANDIDATO - a pessoa confirma (E03). Este
13
+ * módulo não lê a página nem o disco: recebe o que o navegador mediu e devolve a proposta.
14
+ */
15
+ const VIZ = 0.05;
16
+ const FAIXA = 1.4;
17
+ const pagina = (rota) => (rota === "/" ? "landing" : rota);
18
+ const superficie = (rota) => (/^\/(dashboard|admin|team)\b/.test(rota) ? "painel" : "site");
19
+ const r1 = (v) => Math.round(v * 10) / 10;
20
+ /** O papel de um texto: o título que ele é (ou está dentro), de cartão ou do texto corrido; decoração; número; texto. */
21
+ export function roleOf(t) {
22
+ const h = /^h[1-3]$/.test(t.tag) ? t.tag : t.papel && /^h[1-3]$/.test(t.papel) ? t.papel : null;
23
+ if (h)
24
+ return h + (h !== "h1" && t.cartao ? " de cartão" : h !== "h1" && t.corrido ? " do texto corrido" : "");
25
+ if (t.decorativo)
26
+ return "decoração";
27
+ if (/^[\d.,%$“”"#]+$/.test(t.texto))
28
+ return "número";
29
+ return "texto";
30
+ }
31
+ function lum(c) {
32
+ const f = (v) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4);
33
+ return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);
34
+ }
35
+ export const mix = (t, b) => ({ r: t.r * t.a + b.r * (1 - t.a), g: t.g * t.a + b.g * (1 - t.a), b: t.b * t.a + b.b * (1 - t.a), a: 1 });
36
+ export function ratio(c, f) {
37
+ const [a, b] = [lum(mix(c, f)), lum(f)].sort((x, y) => y - x);
38
+ return (a + 0.05) / (b + 0.05);
39
+ }
40
+ export const hex = (c) => `#${[c.r, c.g, c.b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}`;
41
+ export function classify(seen) {
42
+ const all = seen.map((t) => ({ ...t, px: r1(t.tam), pp: roleOf(t), sup: superficie(t.rota), pg: pagina(t.rota) }));
43
+ const desk = all.filter((t) => t.disp === "desktop");
44
+ const lugar = (xs) => new Set(xs.map((x) => `${x.pg}|${x.secao}`)).size;
45
+ const label = (x) => `${x.pg}: ${x.texto.slice(0, 44)}`;
46
+ const uniq = (xs) => [...new Set(xs)].sort();
47
+ // A ESCALA: tamanhos a menos de 5% um do outro são o mesmo passo; passo = usado em 3+ lugares
48
+ const tams = [...new Set(desk.map((t) => t.px))].sort((a, b) => a - b);
49
+ const grupos = [];
50
+ for (const v of tams) {
51
+ const g = grupos[grupos.length - 1];
52
+ if (g && v <= g[0] * (1 + VIZ))
53
+ g.push(v);
54
+ else
55
+ grupos.push([v]);
56
+ }
57
+ const passos = [];
58
+ for (const g of grupos) {
59
+ const xs = desk.filter((t) => g.includes(t.px));
60
+ if (lugar(xs) < 3)
61
+ continue;
62
+ const anc = [...g].sort((a, b) => lugar(xs.filter((t) => t.px === b)) - lugar(xs.filter((t) => t.px === a)) || xs.filter((t) => t.px === b).length - xs.filter((t) => t.px === a).length)[0];
63
+ passos.push(anc);
64
+ }
65
+ // OS PAPÉIS E A DERIVA: por papel e superfície; dentro de uma faixa (vizinhos até 1,4x), o tamanho com mais lugares manda
66
+ const por = new Map();
67
+ for (const t of desk)
68
+ if (t.pp.startsWith("h"))
69
+ por.set(`${t.sup}|${t.pp}`, [...(por.get(`${t.sup}|${t.pp}`) ?? []), t]);
70
+ const best = (vals, xs) => [...vals].sort((a, b) => lugar(xs.filter((x) => x.px === b)) - lugar(xs.filter((x) => x.px === a)) || b - a)[0];
71
+ const papeis = [];
72
+ const deriva = [];
73
+ for (const xs of por.values()) {
74
+ const { sup, pp } = xs[0];
75
+ const principal = best([...new Set(xs.map((x) => x.px))], xs);
76
+ const ps = xs.filter((x) => x.px === principal);
77
+ papeis.push({ papel: pp, superficie: sup, px: principal, lugares: lugar(ps), onde: uniq(ps.map(label)).slice(0, 3), sample: ps[0] });
78
+ const vs = [...new Set(xs.map((x) => x.px))].sort((a, b) => a - b);
79
+ const faixas = [];
80
+ for (const v of vs) {
81
+ const f = faixas[faixas.length - 1];
82
+ if (f && v <= f[f.length - 1] * FAIXA)
83
+ f.push(v);
84
+ else
85
+ faixas.push([v]);
86
+ }
87
+ for (const fx of faixas) {
88
+ if (fx.length < 2)
89
+ continue;
90
+ const grupo = xs.filter((x) => fx.includes(x.px));
91
+ const regra = best(fx, grupo);
92
+ const rs = grupo.filter((x) => x.px === regra);
93
+ for (const v of fx) {
94
+ if (v === regra)
95
+ continue;
96
+ const ys = grupo.filter((x) => x.px === v);
97
+ deriva.push({ papel: pp, superficie: sup, px: v, regraPx: regra, onde: uniq(ys.map(label)), sample: ys[0], ruleSample: rs[0] });
98
+ }
99
+ }
100
+ }
101
+ // O TÍTULO DE SEÇÃO = a maior faixa de h2 do site com 2+ textos (os próprios <h2>, não os <span> dentro deles)
102
+ const h2own = desk.filter((x) => x.sup === "site" && x.pp === "h2" && x.tag === "h2");
103
+ const h2 = h2own.length ? h2own : desk.filter((x) => x.sup === "site" && x.pp === "h2");
104
+ const near = (a, b) => Math.abs(a - b) <= Math.max(a, b) * VIZ;
105
+ const cands = [...new Set(h2.map((x) => x.px))].sort((a, b) => b - a);
106
+ const tituloSecao = cands.find((v) => new Set(h2.filter((x) => near(x.px, v)).map((x) => x.texto)).size >= 2) ?? passos[passos.length - 1] ?? 0;
107
+ // AS QUEBRAS
108
+ const derivados = new Set(deriva.map((d) => `${d.superficie}|${d.papel}|${d.px}`));
109
+ const quebras = new Map();
110
+ for (const t of desk) {
111
+ if (t.sup !== "site")
112
+ continue;
113
+ let q = null;
114
+ if (t.px > tituloSecao * 1.04 && !derivados.has(`${t.sup}|${t.pp}|${t.px}`))
115
+ q = { regra: "Q1", nome: "above the heading scale", papel: t.pp, px: t.px, medida: `${t.px}px against the ${tituloSecao}px section title` };
116
+ else if ((t.pp === "número" || t.pp === "decoração") && t.px >= 40)
117
+ q = { regra: "Q3", nome: "big motif outside the headings", papel: t.pp, px: t.px, medida: `${t.px}px, ${t.decorativo ? "hidden from screen readers" : "read as content"}` };
118
+ if (!q || quebras.has(`${q.regra}|${q.papel}|${q.px}`))
119
+ continue;
120
+ const xs = desk.filter((x) => x.sup === "site" && x.pp === t.pp && x.px === t.px);
121
+ quebras.set(`${q.regra}|${q.papel}|${q.px}`, { ...q, vezes: xs.length, onde: uniq(xs.map(label)).slice(0, 5), sample: t });
122
+ }
123
+ const giros = new Map();
124
+ for (const t of desk)
125
+ if (t.giro)
126
+ giros.set(`${t.pg}|${t.giro}`, [...(giros.get(`${t.pg}|${t.giro}`) ?? []), t]);
127
+ for (const xs of giros.values()) {
128
+ const alvo = xs.find((x) => x.pp === "h1") ?? xs[0];
129
+ const k = `Q2|bloco|${alvo.px}`;
130
+ if (!quebras.has(k))
131
+ quebras.set(k, { regra: "Q2", nome: "off the axis (tilted)", papel: "bloco", px: alvo.px, medida: `${alvo.giro}° - the whole block, ${xs.length} texts inside`, vezes: 1, onde: uniq(xs.map(label)).slice(0, 4), sample: alvo });
132
+ }
133
+ // O CONTRASTE - candidatos pelo fundo do DOM; quem chama confere cada um pelos pixels (`pixelRatio`)
134
+ const falhas = new Map();
135
+ let sobreImagem = 0;
136
+ for (const t of all) {
137
+ if (t.decorativo || !t.cor)
138
+ continue;
139
+ if (t.sobreImagem) {
140
+ sobreImagem++;
141
+ continue;
142
+ }
143
+ const r = ratio(t.cor, t.fundo);
144
+ const alvo = t.tam >= 24 || (t.tam >= 18.66 && t.peso >= 700) ? 3 : 4.5;
145
+ if (r >= alvo)
146
+ continue;
147
+ const k = `${hex(mix(t.cor, t.fundo))}|${hex(t.fundo)}|${Math.round(t.tam)}`;
148
+ const f = falhas.get(k) ?? { razao: Math.round(r * 100) / 100, alvo, texto: hex(mix(t.cor, t.fundo)), fundo: hex(t.fundo), px: t.tam, onde: [], sample: t };
149
+ f.onde = uniq([...f.onde, label(t)]).slice(0, 5);
150
+ falhas.set(k, f);
151
+ }
152
+ return { passos, tituloSecao, papeis, deriva, quebras: [...quebras.values()], contraste: [...falhas.values()].sort((a, b) => a.razao - b.razao), sobreImagem };
153
+ }
154
+ /**
155
+ * A TINTA DE VERDADE, PELOS PIXELS - na régua, a conta pelo fundo do DOM acusou 1,0:1 num "$" visível (o fundo vinha de
156
+ * outra camada), e o linear.app teve 7 textos que nem estavam pintados. Recebe os pixels RGBA da caixa do texto: o fundo é
157
+ * a cor mais presente; a tinta, entre as que ocupam 3%+ da caixa, a que mais contrasta com ele. Sem tinta (< 1,2:1) é
158
+ * texto não pintado ali, nunca uma falha.
159
+ */
160
+ export function pixelRatio(rgba) {
161
+ const n = Math.floor(rgba.length / 4);
162
+ if (n < 4)
163
+ return null;
164
+ const buckets = new Map();
165
+ for (let i = 0; i < n; i++) {
166
+ const r = rgba[i * 4], g = rgba[i * 4 + 1], b = rgba[i * 4 + 2];
167
+ const k = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4);
168
+ const e = buckets.get(k) ?? { c: 0, r: 0, g: 0, b: 0 };
169
+ e.c++;
170
+ e.r += r;
171
+ e.g += g;
172
+ e.b += b;
173
+ buckets.set(k, e);
174
+ }
175
+ const cores = [...buckets.values()].sort((a, b) => b.c - a.c).map((e) => ({ c: e.c, cor: { r: e.r / e.c, g: e.g / e.c, b: e.b / e.c, a: 1 } }));
176
+ const fundo = cores[0].cor;
177
+ const tintas = cores.slice(1).filter((x) => x.c / n >= 0.03);
178
+ if (tintas.length === 0)
179
+ return cores.length > 1 ? ratio(cores[1].cor, fundo) : 1;
180
+ return Math.max(...tintas.map((x) => ratio(x.cor, fundo)));
181
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * O QUE RODA DENTRO DA PÁGINA - a medida de cada texto como o visitante o vê (jornada "a leitura no agente", E02, 18/10).
3
+ *
4
+ * O mesmo leitor da régua do conjunto (`synthesisui-tests/regua/conjunto/ler.mjs`), reduzido ao que a classificação usa:
5
+ * tamanho, família, peso, cor, o fundo real atrás do texto (subindo pelos ancestrais e misturando transparência), se está
6
+ * sobre imagem, inclinação, ênfase, cartão (estrutura repetida 3+), texto corrido, o COMPONENTE (pela fibra do React, no
7
+ * `next dev` - a ligação ao código que independe da forma de estilo) e a classe. É uma string porque roda no navegador,
8
+ * não no Node: `page.evaluate(PAGE_SCRIPT)`.
9
+ */
10
+ export const PAGE_SCRIPT = String.raw `(() => {
11
+ const parse = (s) => { const m = s.match(/rgba?\(([^)]+)\)/); if (!m) return null; const v = m[1].split(/[ ,/]+/).filter(Boolean).map(Number); return { r: v[0], g: v[1], b: v[2], a: v.length > 3 ? v[3] : 1 }; };
12
+ const mix = (top, bot) => ({ r: top.r * top.a + bot.r * (1 - top.a), g: top.g * top.a + bot.g * (1 - top.a), b: top.b * top.a + bot.b * (1 - top.a), a: 1 });
13
+ const fundo = (el) => {
14
+ const camadas = []; let img = false;
15
+ for (let e = el; e; e = e.parentElement) {
16
+ const cs = getComputedStyle(e);
17
+ if (cs.backgroundImage && cs.backgroundImage !== "none") img = img || /url\(|gradient/.test(cs.backgroundImage);
18
+ const bg = parse(cs.backgroundColor); if (bg && bg.a > 0) { camadas.push(bg); if (bg.a >= 1) break; }
19
+ if (e.parentElement && [...e.parentElement.children].some((s) => s !== e && /^(VIDEO|IMG)$/.test(s.tagName) && getComputedStyle(s).position === "absolute")) img = true;
20
+ }
21
+ let cor = { r: 255, g: 255, b: 255, a: 1 };
22
+ for (let i = camadas.length - 1; i >= 0; i--) cor = mix(camadas[i], cor);
23
+ return { cor, img };
24
+ };
25
+ const secao = (el) => { for (let e = el; e; e = e.parentElement) { if (/^(SECTION|HEADER|FOOTER|NAV|MAIN|ASIDE|ARTICLE|DIALOG)$/.test(e.tagName) || e.id) return e.tagName.toLowerCase() + (e.id ? "#" + e.id : ""); } return "body"; };
26
+ const textos = [];
27
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
28
+ const vistos = new Set();
29
+ while (walker.nextNode()) {
30
+ const t = walker.currentNode; const el = t.parentElement; if (!el || vistos.has(el)) continue;
31
+ const txt = t.textContent.trim(); if (!txt) continue;
32
+ const cs = getComputedStyle(el);
33
+ if (cs.visibility === "hidden" || cs.display === "none" || Number(cs.opacity) === 0) continue;
34
+ const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) continue;
35
+ vistos.add(el);
36
+ let giro = 0; for (let e = el; e && e !== document.body; e = e.parentElement) { const tf = getComputedStyle(e).transform; if (tf && tf !== "none") { const m = tf.match(/matrix\(([^)]+)\)/); if (m) { const [a, bb] = m[1].split(",").map(Number); const ang = Math.round(Math.atan2(bb, a) * 1800 / Math.PI) / 10; if (ang) { giro = ang; break; } } } }
37
+ const tit = el.closest("h1,h2,h3"); const enfase = !!(tit && tit !== el && getComputedStyle(tit).color !== cs.color);
38
+ let cartao = false;
39
+ for (let e = el, i = 0; e && e.parentElement && i < 6 && !cartao; e = e.parentElement, i++) {
40
+ const irmaos = [...e.parentElement.children].filter((x) => x.tagName === e.tagName && (x.getAttribute("class") || "") === (e.getAttribute("class") || ""));
41
+ if (irmaos.length >= 3 && e !== el) cartao = true;
42
+ }
43
+ const corrido = !!(tit || el).closest("article") || (((tit || el).parentElement && (tit || el).parentElement.querySelectorAll(":scope > p").length) || 0) >= 3;
44
+ let comp = null;
45
+ const fk = Object.keys(el).find((k) => k.startsWith("__reactFiber$"));
46
+ for (let f = fk ? el[fk] : null, i = 0; f && i < 40 && !comp; f = f.return, i++) {
47
+ const st = f._debugStack && String(f._debugStack.stack || "");
48
+ if (st) for (const ln of st.split("\n").slice(1)) { const mm = ln.match(/at ([A-Z][A-Za-z0-9_]*) \(/); if (mm && !/^(Object|Array|Error|Promise)$/.test(mm[1])) { comp = mm[1]; break; } }
49
+ }
50
+ let op = 1; for (let e = el; e; e = e.parentElement) op *= Number(getComputedStyle(e).opacity);
51
+ const cor = parse(cs.color); if (cor) cor.a *= op;
52
+ const f = fundo(el);
53
+ textos.push({
54
+ texto: txt.slice(0, 60), tag: el.tagName.toLowerCase(),
55
+ papel: (el.closest("h1,h2,h3,h4,h5,h6,button,a,label,th,figcaption,blockquote,input") || { tagName: "" }).tagName.toLowerCase() || null,
56
+ tam: parseFloat(cs.fontSize), familia: cs.fontFamily.split(",")[0].replace(/["']/g, "").trim(), peso: Number(cs.fontWeight),
57
+ cor, fundo: f.cor, sobreImagem: f.img, decorativo: !!el.closest("[aria-hidden=true]"),
58
+ cartao, corrido, giro, enfase, comp,
59
+ cls: [el, ...(tit && tit !== el ? [tit] : []), el.parentElement].filter(Boolean).map((x) => x.getAttribute("class") || "").join(" | ").slice(0, 600),
60
+ secao: secao(el), x: r.left + scrollX, y: r.top + scrollY, w: r.width, h: r.height,
61
+ });
62
+ }
63
+ return textos;
64
+ })()`;
@@ -0,0 +1,66 @@
1
+ import { inflateSync } from "node:zlib";
2
+ /**
3
+ * O PNG DE UMA CAIXA DE TEXTO, EM PIXELS - sem dependência (o CLI não tem nenhuma, e não é a conferência do contraste que vai
4
+ * inaugurar a primeira). Lê o que o navegador devolve num `screenshot`: 8 bits, RGB ou RGBA, sem entrelaçamento. Qualquer
5
+ * outra forma devolve `null`, e quem chama trata como "não conferido" - nunca como falha.
6
+ */
7
+ export function decodePng(buf) {
8
+ if (buf.length < 8 || buf.readUInt32BE(0) !== 0x89504e47)
9
+ return null;
10
+ let off = 8, w = 0, h = 0, type = 0, depth = 0, interlace = 0;
11
+ const idat = [];
12
+ while (off + 8 <= buf.length) {
13
+ const len = buf.readUInt32BE(off);
14
+ const kind = buf.toString("ascii", off + 4, off + 8);
15
+ const data = buf.subarray(off + 8, off + 8 + len);
16
+ if (kind === "IHDR") {
17
+ w = data.readUInt32BE(0);
18
+ h = data.readUInt32BE(4);
19
+ depth = data[8];
20
+ type = data[9];
21
+ interlace = data[12];
22
+ }
23
+ else if (kind === "IDAT")
24
+ idat.push(data);
25
+ else if (kind === "IEND")
26
+ break;
27
+ off += 12 + len;
28
+ }
29
+ if (depth !== 8 || interlace !== 0 || (type !== 2 && type !== 6) || !w || !h)
30
+ return null;
31
+ const bpp = type === 6 ? 4 : 3;
32
+ const raw = inflateSync(Buffer.concat(idat));
33
+ const stride = w * bpp;
34
+ const out = new Uint8Array(w * h * 4);
35
+ const prev = new Uint8Array(stride);
36
+ const cur = new Uint8Array(stride);
37
+ for (let y = 0; y < h; y++) {
38
+ const filter = raw[y * (stride + 1)];
39
+ for (let x = 0; x < stride; x++) {
40
+ const v = raw[y * (stride + 1) + 1 + x];
41
+ const a = x >= bpp ? cur[x - bpp] : 0;
42
+ const b = prev[x];
43
+ const c = x >= bpp ? prev[x - bpp] : 0;
44
+ let p = 0;
45
+ if (filter === 1)
46
+ p = a;
47
+ else if (filter === 2)
48
+ p = b;
49
+ else if (filter === 3)
50
+ p = (a + b) >> 1;
51
+ else if (filter === 4) {
52
+ const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
53
+ p = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
54
+ }
55
+ cur[x] = (v + p) & 255;
56
+ }
57
+ for (let x = 0; x < w; x++) {
58
+ out[(y * w + x) * 4] = cur[x * bpp];
59
+ out[(y * w + x) * 4 + 1] = cur[x * bpp + 1];
60
+ out[(y * w + x) * 4 + 2] = cur[x * bpp + 2];
61
+ out[(y * w + x) * 4 + 3] = bpp === 4 ? cur[x * bpp + 3] : 255;
62
+ }
63
+ prev.set(cur);
64
+ }
65
+ return out;
66
+ }
@@ -0,0 +1,168 @@
1
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { basename, extname, join } from "node:path";
3
+ export async function loadRoleRules(root) {
4
+ const ds = join(root, "_synthesisui", "ds");
5
+ const slugs = await readdir(ds).catch(() => []);
6
+ for (const slug of slugs) {
7
+ const raw = await readFile(join(ds, slug, "rules.json"), "utf8").catch(() => null);
8
+ if (!raw)
9
+ continue;
10
+ try {
11
+ const r = JSON.parse(raw);
12
+ if (Array.isArray(r.roles))
13
+ return { ...r, breaks: r.breaks ?? [] };
14
+ }
15
+ catch {
16
+ /* um rules.json quebrado não derruba a checagem: ela só não fala de papéis */
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+ const NAMED = { xs: 12, sm: 14, base: 16, lg: 18, xl: 20, "2xl": 24, "3xl": 30, "4xl": 36, "5xl": 48, "6xl": 60, "7xl": 72, "8xl": 96, "9xl": 128 };
22
+ /** Só classe de TAMANHO: `text-[#1b1406]` é cor e não entra (achado na leitura do synthesisui.com, 18/10). */
23
+ const SIZE = /(?:^|\s)((?:[a-z0-9]+:)?text-(?:\[(?:clamp\([^\]\s]+\)|[\d.]+(?:rem|px|em))\]|xs|sm|base|lg|[2-9]?xl))(?=\s|$)/g;
24
+ /** O tamanho final (desktop) de uma classe: o teto do `clamp`, o valor do colchete, ou a escala do Tailwind. */
25
+ export function pxOf(cls) {
26
+ const bare = cls.replace(/^(?:[a-z0-9]+:)+/, "");
27
+ const named = bare.match(/^text-(xs|sm|base|lg|[2-9]?xl)$/);
28
+ if (named)
29
+ return NAMED[named[1]] ?? null;
30
+ const inner = bare.match(/^text-\[(.+)\]$/)?.[1];
31
+ if (!inner)
32
+ return null;
33
+ const last = inner.startsWith("clamp(") ? inner.slice(6, -1).split(",").pop()?.trim() : inner;
34
+ const m = last?.match(/^([\d.]+)(rem|px)$/);
35
+ if (!m)
36
+ return null;
37
+ return m[2] === "rem" ? Number(m[1]) * 16 : Number(m[1]);
38
+ }
39
+ /** O maior tamanho entre as classes de um elemento - o que vale no desktop (a régua mede em 1440). */
40
+ function sizeClass(attrs) {
41
+ const cls = attrs.match(/className=\{?\s*["'`]([^"'`]*)["'`]/)?.[1] ?? attrs.match(/className=\{cn\(([^)]*)\)/)?.[1] ?? "";
42
+ const found = [...` ${cls} `.matchAll(SIZE)].map((m) => m[1]);
43
+ if (found.length === 0)
44
+ return null;
45
+ return found.sort((a, b) => (pxOf(b) ?? 0) - (pxOf(a) ?? 0))[0] ?? null;
46
+ }
47
+ /** Dentro de um `.map(` ainda aberto = o título se repete = é de cartão. A mesma pergunta que a régua faz ao DOM. */
48
+ function insideMap(src, at) {
49
+ const before = src.slice(0, at);
50
+ const open = before.lastIndexOf(".map(");
51
+ if (open < 0)
52
+ return false;
53
+ let depth = 0;
54
+ for (const ch of before.slice(open + 4))
55
+ depth += ch === "(" ? 1 : ch === ")" ? -1 : 0;
56
+ return depth > 0;
57
+ }
58
+ const near = (a, b) => Math.abs(a - b) / Math.max(a, b) < 0.03;
59
+ export function checkRoles(rel, src, rules) {
60
+ if (rules.enforce === false || !/\.(tsx|jsx)$/.test(rel))
61
+ return [];
62
+ const component = basename(rel, extname(rel));
63
+ const known = new Set((rules.knownDrift ?? []).filter((d) => d.at.startsWith(rel)).map((d) => d.class));
64
+ const section = rules.roles.find((r) => r.element === "h2" && r.context === "section");
65
+ const out = [];
66
+ for (const m of src.matchAll(/<(h1|h2|h3)\b([^>]*)>/g)) {
67
+ const el = m[1];
68
+ const cls = sizeClass(m[2]);
69
+ if (!cls)
70
+ continue;
71
+ const line = src.slice(0, m.index).split("\n").length;
72
+ const context = insideMap(src, m.index ?? 0) ? "card" : "section";
73
+ const bare = cls.replace(/^(?:[a-z0-9]+:)+/, "");
74
+ const mine = rules.roles.filter((r) => r.element === el);
75
+ if (mine.some((r) => r.class === bare))
76
+ continue;
77
+ if (rules.breaks.some((b) => b.class === bare && b.allowedIn === component))
78
+ continue;
79
+ if (known.has(bare))
80
+ continue;
81
+ const px = pxOf(bare);
82
+ const role = mine.find((r) => r.context === context) ?? mine[0];
83
+ const brk = rules.breaks.find((b) => b.class === bare);
84
+ if (brk) {
85
+ out.push(` line ${line} <${el}> ${bare} is the break "${brk.rule}", which the person chose for ${brk.allowedIn} only (${brk.home}).${role ? ` Here, use the ${role.name}: ${role.class} (${role.home}).` : ""}`);
86
+ continue;
87
+ }
88
+ if (role && px && near(px, role.px)) {
89
+ out.push(` line ${line} <${el}> ${bare} renders like the ${role.name} but is written differently - use ${role.class} (${role.home}), so the two stay one rule.`);
90
+ continue;
91
+ }
92
+ if (section && px && px > section.px * 1.04 && el !== "h1") {
93
+ out.push(` line ${line} <${el}> ${bare} (~${Math.round(px)}px) goes above this project's section titles (${Math.round(section.px)}px). Only the breaks the person chose go there. Use the ${role?.name ?? "section title"} (${role?.class ?? section.class}), or ask whether this is a new break.`);
94
+ continue;
95
+ }
96
+ if (!role || !px)
97
+ continue;
98
+ /**
99
+ * PERTO DO PAPEL É DERIVA; LONGE DELE É PROVAVELMENTE UM PAPEL NOVO - e papel novo é decisão da pessoa. Medido na
100
+ * primeira versão, contra o corte: o título DENTRO do artigo (36px) ouviu "use o título de seção (86px)", e seguir
101
+ * isso estragaria a página. A mesma faixa da régua (1,4x) separa os dois casos.
102
+ */
103
+ if (px >= role.px / 1.4 && px <= role.px * 1.4)
104
+ out.push(` line ${line} <${el}> ${bare} (~${Math.round(px)}px) - the ${role.name} role in this project is ${role.class} (${Math.round(role.px)}px, ${role.home}). Use it, so the role stays one size.`);
105
+ else
106
+ out.push(` line ${line} <${el}> ${bare} (~${Math.round(px)}px) is a heading size this project has no role for yet (its ${role.name} is ${Math.round(role.px)}px). If it is a new role - a heading inside an article, a card - keep it and name the role to the person in one line; if it is a ${role.name}, use ${role.class}.`);
107
+ }
108
+ /**
109
+ * A QUEBRA FORA DO LUGAR, EM QUALQUER ELEMENTO - medido na linha B (18/10): sem checagem, a página do cachorro copiou os
110
+ * números grandes da landing. Os títulos já estão acima; aqui, todo elemento que não é título e usa a classe de uma
111
+ * quebra fora do componente dela. A cópia com OUTRO tamanho (41,6 no lugar de 57,6) não aparece no código - é a régua
112
+ * da página renderizada que vê ("o mesmo motivo"), no fecho do turno.
113
+ */
114
+ for (const b of rules.breaks) {
115
+ if (!b.class || b.allowedIn === component)
116
+ continue;
117
+ for (const m of src.matchAll(/<([a-z][a-z0-9]*)\b([^>]*)>/g)) {
118
+ if (/^h[1-3]$/.test(m[1]))
119
+ continue;
120
+ const cls = m[2].match(/className=\{?\s*["'`]([^"'`]*)["'`]/)?.[1] ?? "";
121
+ if (!` ${cls} `.includes(` ${b.class} `))
122
+ continue;
123
+ const line = src.slice(0, m.index).split("\n").length;
124
+ out.push(` line ${line} <${m[1]}> ${b.class} is the break "${b.rule}" the person chose for ${b.allowedIn} (${b.home}). Reuse that component here instead of copying the motif, or ask whether this page gets its own.`);
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+ /** O bloco do contrato: os papéis e as quebras, para o agente saber ANTES de escrever (a prevenção). */
130
+ export function rolesContract(rules) {
131
+ const roles = rules.roles
132
+ .map((r) => `- ${r.name} (\`<${r.element}>\`${r.context === "card" ? " in a repeated card" : ""}): \`${r.class}\` - ${r.home}`)
133
+ .join("\n");
134
+ const breaks = rules.breaks
135
+ .filter((b) => b.class)
136
+ .map((b) => `- ${b.rule}: \`${b.class}\` - only in ${b.allowedIn} (${b.home})`)
137
+ .join("\n");
138
+ return `\n\n**The type roles this project already decided** (read from its own pages). A new heading takes the class of its role - never a new size for an existing role:\n${roles}${breaks ? `\n\n**The breaks the person chose** - they stay where they live, and nowhere else:\n${breaks}` : ""}`;
139
+ }
140
+ /**
141
+ * O PAPEL NOVO É DITO UMA VEZ - medido na linha A (18/10): o aviso "a heading size this project has no role for yet" voltou
142
+ * em TODO fim de turno nos mesmos arquivos do blog, e cada repetição custou leitura e resposta do agente (a rodada saiu
143
+ * ~US$ 1 mais cara por turno que o corte). Papel novo é decisão da pessoa; depois de dito, repetir é ruído. A deriva (o
144
+ * título perto do papel e escrito diferente) continua sendo dita sempre - ela é conserto, não decisão.
145
+ */
146
+ const SAID = (root) => join(root, "_synthesisui", ".roles-said.json");
147
+ export async function onlyOnce(root, rel, lines) {
148
+ const said = new Set(JSON.parse((await readFile(SAID(root), "utf8").catch(() => "[]")) || "[]"));
149
+ const out = [];
150
+ let changed = false;
151
+ for (const l of lines) {
152
+ if (!l.includes("has no role for yet")) {
153
+ out.push(l);
154
+ continue;
155
+ }
156
+ const key = `${rel}|${l.match(/> (\S+)/)?.[1] ?? l}`;
157
+ if (said.has(key))
158
+ continue;
159
+ said.add(key);
160
+ changed = true;
161
+ out.push(l);
162
+ }
163
+ if (changed) {
164
+ await mkdir(join(root, "_synthesisui"), { recursive: true }).catch(() => undefined);
165
+ await writeFile(SAID(root), JSON.stringify([...said], null, 1)).catch(() => undefined);
166
+ }
167
+ return out;
168
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.499",
3
+ "version": "0.16.500",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {