synthesisui 0.16.503 → 0.16.504

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.
@@ -4,6 +4,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
4
4
  import { dirname, join, relative, resolve } from "node:path";
5
5
  import { readAutoSync, startSync } from "../auto-sync.js";
6
6
  import { changedSince } from "../changed-files.js";
7
+ import { rereadScreens } from "../reading/reread.js";
7
8
  import { emptyTally, exportedNames, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
8
9
  import { checkContracts } from "../doctor/contract-check.js";
9
10
  import { componentOf } from "../doctor/component-of.js";
@@ -767,6 +768,10 @@ async function endOfTurn(root, input) {
767
768
  const grown = await takeGrowthNote(root).catch(() => null);
768
769
  if (grown)
769
770
  said.push(grown);
771
+ /** A tela relida - E01 da fase 3 (20/10): o contraste e o tamanho que só a página renderizada mostra. */
772
+ const screens = changed.length > 0 ? await rereadScreens(root, changed, input.transcript_path).catch(() => null) : null;
773
+ if (screens)
774
+ said.push(screens);
770
775
  const offer = await offerTheReading(root, changed, input.transcript_path, clock).catch(() => null);
771
776
  await moveTurnClock(root);
772
777
  out(said.length === 0 && !offer
@@ -1,14 +1,15 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { desligarCaptura, ligarCaptura } from "../transcript.js";
3
3
  import { adopt } from "./adopt.js";
4
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { createRequire } from "node:module";
6
6
  import { homedir } from "node:os";
7
7
  import { join, relative } from "node:path";
8
8
  import { classify, pixelRatio } from "../reading/classify.js";
9
9
  import { PAGE_SCRIPT } from "../reading/page-script.js";
10
+ import { headingsOf } from "../reading/reread.js";
10
11
  import { decodePng } from "../reading/png.js";
11
- import { globalClassOf, globalStyles, moduleClassName, pxOf } from "../role-rules.js";
12
+ import { PENDING, globalClassOf, loadRoleRules, globalStyles, moduleClassName, pxOf, readPending } from "../role-rules.js";
12
13
  import { agentsToMaintain } from "../agents-chosen.js";
13
14
  import { syncClaudeMd } from "../claude-md.js";
14
15
  import { projectRootFrom } from "./hook.js";
@@ -28,23 +29,26 @@ import { projectRootFrom } from "./hook.js";
28
29
  */
29
30
  const READER_VERSION = "1.58.2";
30
31
  const READER_DIR = join(homedir(), ".synthesisui", "reader");
31
- const VIEWPORTS = [
32
+ export const VIEWPORTS = [
32
33
  ["desktop", 1440, 900],
33
34
  ["celular", 390, 844],
34
35
  ];
35
- function loadReader(say) {
36
+ /** `download: false` é o fim do turno: ele nunca baixa nada sozinho - sem o leitor na máquina, não relê (E01, fase 3). */
37
+ export function loadReader(say, download = true) {
36
38
  const override = process.env.SYNTHESISUI_PLAYWRIGHT;
37
39
  if (override)
38
40
  return createRequire(join(override, "package.json"))(override);
39
41
  const mod = join(READER_DIR, "node_modules", "playwright-core");
40
42
  if (!existsSync(mod)) {
43
+ if (!download)
44
+ return null;
41
45
  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
46
  mkdirSync(READER_DIR, { recursive: true });
43
47
  execFileSync("npm", ["install", "--prefix", READER_DIR, "--no-save", "--no-audit", "--no-fund", `playwright-core@${READER_VERSION}`], { stdio: "ignore" });
44
48
  }
45
49
  return createRequire(join(READER_DIR, "package.json"))("playwright-core");
46
50
  }
47
- async function launch(pw, say) {
51
+ export async function launch(pw, say, download = true) {
48
52
  for (const o of [{}, { channel: "chrome" }, { channel: "msedge" }]) {
49
53
  try {
50
54
  return await pw.chromium.launch(o);
@@ -53,6 +57,8 @@ async function launch(pw, say) {
53
57
  /* o próximo */
54
58
  }
55
59
  }
60
+ if (!download)
61
+ return null;
56
62
  say("No Chromium found on this machine: downloading one for the reader (about 150 MB, once).");
57
63
  execFileSync(process.execPath, [join(READER_DIR, "node_modules", "playwright-core", "cli.js"), "install", "chromium"], { stdio: "ignore" });
58
64
  return pw.chromium.launch();
@@ -62,7 +68,7 @@ async function launch(pw, say) {
62
68
  * (`-p 3389`, `--port 3389`, `PORT=3389`) vem antes das portas de sempre. Sem isto, numa máquina com outro projeto na
63
69
  * 3000, a leitura descreveria a página de outra pessoa.
64
70
  */
65
- function declaredPort(root) {
71
+ export function declaredPort(root) {
66
72
  try {
67
73
  const dev = (JSON.parse(readFileSync(join(root, "package.json"), "utf8")).scripts?.dev ?? "");
68
74
  const m = dev.match(/(?:-p|--port)[ =](\d{2,5})|PORT=(\d{2,5})/);
@@ -72,12 +78,12 @@ function declaredPort(root) {
72
78
  return null;
73
79
  }
74
80
  }
75
- async function findServer(url, root) {
81
+ export async function findServer(url, root, waitMs = 4000) {
76
82
  const port = declaredPort(root);
77
83
  const cands = url ? [url] : [...(port ? [`http://localhost:${port}`] : []), "http://localhost:3000", "http://localhost:3001", "http://localhost:5173", "http://localhost:4321"];
78
84
  for (const u of cands) {
79
85
  try {
80
- const r = await fetch(u, { signal: AbortSignal.timeout(4000) });
86
+ const r = await fetch(u, { signal: AbortSignal.timeout(waitMs) });
81
87
  if (r.status < 500)
82
88
  return u;
83
89
  }
@@ -87,6 +93,26 @@ async function findServer(url, root) {
87
93
  }
88
94
  return null;
89
95
  }
96
+ /**
97
+ * CADA CONTRASTE BAIXO CONFERIDO PELOS PIXELS: sem tinta é texto não pintado; acima do alvo, o DOM errou o fundo. A mesma
98
+ * conferência na leitura e no fim do turno (E01 da fase 3) - duas cópias divergiriam no primeiro conserto.
99
+ */
100
+ export async function confirmByPixels(found, pageOf) {
101
+ const confirmed = [];
102
+ for (const f of found) {
103
+ const page = pageOf(f.sample);
104
+ let px = null;
105
+ if (page && f.sample.w >= 2 && f.sample.h >= 2) {
106
+ const png = await 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);
107
+ const rgba = png ? decodePng(png) : null;
108
+ px = rgba ? pixelRatio(rgba) : null;
109
+ }
110
+ if (px !== null && (px < 1.2 || px >= f.alvo))
111
+ continue;
112
+ confirmed.push({ ...f, pixels: px });
113
+ }
114
+ return confirmed;
115
+ }
90
116
  /** O maior tamanho entre as classes de um texto (o do desktop), sem o prefixo de tela - a mesma escolha da checagem. */
91
117
  function sizeClassOf(cls) {
92
118
  const toks = cls.match(/(?:[a-z0-9]+:)?text-(?:\[(?:clamp\([^\]\s]+\)|[\d.]+(?:rem|px|em))\]|xs|sm|base|lg|[2-9]?xl)(?=\s|$|\|)/g) ?? [];
@@ -95,7 +121,7 @@ function sizeClassOf(cls) {
95
121
  const best = toks.sort((a, b) => (pxOf(b) ?? 0) - (pxOf(a) ?? 0))[0];
96
122
  return best.replace(/^(?:[a-z0-9]+:)+/, "");
97
123
  }
98
- function sources(root) {
124
+ export function sources(root) {
99
125
  const out = [];
100
126
  const walk = (d) => {
101
127
  for (const n of readdirSync(d)) {
@@ -135,7 +161,7 @@ function moduleOf(cls, files) {
135
161
  return null;
136
162
  }
137
163
  let stylesOf = null;
138
- function homeOf(root, files, t) {
164
+ export function homeOf(root, files, t) {
139
165
  const sized = sizeClassOf(t.cls);
140
166
  /* a classe global (o juízo dele, 19/10): `className="section-title"` com o tamanho no globals.css - a do elemento
141
167
  primeiro, depois a do pai (o `<h3>` sem classe dentro de `.route`, medido em `.route h3`) */
@@ -195,12 +221,18 @@ function plainRole(role) {
195
221
  return "block";
196
222
  return nameOf(role);
197
223
  }
198
- export function questionsFor(breaks, drift) {
224
+ export function questionsFor(breaks, drift, newRoles = []) {
199
225
  const qs = breaks.map((b) => ({
200
226
  id: `break:${b.rule}:${b.role}:${b.px}`,
201
227
  text: `Is ${quoted(b.seen)} 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}).`,
202
228
  options: ["Yes, it is part of my design", "No, make it follow the scale"],
203
229
  }));
230
+ for (const n of newRoles)
231
+ qs.push({
232
+ id: newRoleId(n),
233
+ text: `Is ${quoted([n.text])} a new kind of heading? It is ${n.px}px, and no heading role here has that size${n.closest ? ` (the closest is the ${n.closest.name}, ${n.closest.px}px)` : ""}.`,
234
+ options: ["Yes, it is a new kind of heading", n.closest ? `No, it is the ${n.closest.name} (${n.closest.px}px)` : "No, make it follow the scale"],
235
+ });
204
236
  const groups = new Map();
205
237
  for (const d of drift) {
206
238
  const g = groups.get(`${d.role}|${d.surface}`) ?? { role: d.role, surface: d.surface, rulePx: d.rulePx, pxs: [] };
@@ -215,6 +247,26 @@ export function questionsFor(breaks, drift) {
215
247
  });
216
248
  return qs.slice(0, 8);
217
249
  }
250
+ const newRoleId = (n) => `newrole:${n.element}:${n.class}:${n.file}`;
251
+ function newRolesOnPage(pending, seen, home,
252
+ /** os papéis do sistema que ela JÁ confirmou - a pergunta é contra eles, nunca contra o que esta leitura acabou de
253
+ * inferir (medido em 20/10: a leitura fez do próprio título novo um papel, e a pergunta saiu "o mais perto é ele mesmo") */
254
+ roles) {
255
+ const out = [];
256
+ for (const p of pending) {
257
+ const t = seen.find((s) => s.disp === "desktop" && s.papel === p.element && s.tag === p.element && (() => {
258
+ const h = home(s);
259
+ return h?.file === p.file && h?.cls === p.class;
260
+ })());
261
+ if (!t)
262
+ continue;
263
+ const context = t.cartao ? "card" : t.corrido ? "prose" : "section";
264
+ const same = roles.filter((r) => r.element === p.element && r.class !== p.class);
265
+ const c = [...same].sort((a, b) => Math.abs(a.px - t.tam) - Math.abs(b.px - t.tam))[0];
266
+ out.push({ ...p, px: Math.round(t.tam * 10) / 10, text: t.texto, context, closest: c ? { id: c.id, name: c.name, px: c.px } : null });
267
+ }
268
+ return out;
269
+ }
218
270
  const contextOf = (role) => (role.includes("cartão") ? "card" : role.includes("texto corrido") ? "prose" : "section");
219
271
  const NAME = { h1: "cover title", h2: "section title", h3: "subheading" };
220
272
  const nameOf = (role) => {
@@ -277,7 +329,27 @@ export async function confirm(opts) {
277
329
  /* o ARQUIVO onde a quebra mora, como a checagem compara (role-rules.ts) - não o componente do React, que pode
278
330
  ser um de vários no mesmo arquivo (o DogCard mora em Entries.tsx) */
279
331
  allowedIn: (b.home?.file.split("/").pop()?.replace(/\.[jt]sx?$/, "") ?? ""), home: at(b.home) }));
280
- 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 };
332
+ /* o papel novo (E02 da fase 3): "sim" vira papel com a classe e o tamanho dele; "não" grava qual papel ele é, e a checagem
333
+ passa a cobrar esse papel naquele arquivo (`decided` em role-rules.ts) */
334
+ const decided = [];
335
+ for (const n of (R.newRoles ?? [])) {
336
+ if (pick(newRoleId(n)) === 1 && n.closest) {
337
+ decided.push({ class: n.class, at: n.file, role: n.closest.id });
338
+ /* a classificação pode ter feito dele um papel sozinha (o título do texto corrido): o "não" dela vale mais */
339
+ for (let i = roles.length - 1; i >= 0; i--)
340
+ if (roles[i].class === n.class && roles[i].id !== n.closest.id)
341
+ roles.splice(i, 1);
342
+ continue;
343
+ }
344
+ if (pick(newRoleId(n)) !== 0 || roles.some((r) => r.class === n.class))
345
+ continue;
346
+ const shape = `${n.element}${n.context === "card" ? " de cartão" : n.context === "prose" ? " do texto corrido" : ""}`;
347
+ roles.push({ id: `${n.element}-${n.context}-site-${n.px}`, name: `${nameOf(shape)} (${n.px}px)`, element: n.element,
348
+ context: n.context, surface: "site", class: n.class, px: n.px, home: `${n.file}:${n.line}`, places: 1 });
349
+ }
350
+ 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, ...(decided.length ? { decided } : {}) };
351
+ /* a leitura viu a página: o que ela perguntou está respondido, e o que não estava mais nela saiu junto */
352
+ rmSync(PENDING(root), { force: true });
281
353
  writeFileSync(join(dsDir, slug, "rules.json"), `${JSON.stringify(rules, null, 2)}\n`);
282
354
  const contract = await syncClaudeMd(root, await agentsToMaintain(root));
283
355
  say(`Your system: ${roles.length} roles and ${breaks.length} breaks, written to _synthesisui/ds/${slug}/rules.json.`);
@@ -299,7 +371,7 @@ export async function read(opts) {
299
371
  }
300
372
  const routes = opts.routes?.length ? opts.routes : ["/"];
301
373
  const pw = loadReader(say);
302
- const browser = await launch(pw, say);
374
+ const browser = (await launch(pw, say));
303
375
  const seen = [];
304
376
  const pages = new Map();
305
377
  try {
@@ -316,21 +388,7 @@ export async function read(opts) {
316
388
  }
317
389
  }
318
390
  const reading = classify(seen);
319
- // cada contraste baixo conferido pelos pixels: sem tinta é texto não pintado; acima do alvo, o DOM errou o fundo
320
- const confirmed = [];
321
- for (const f of reading.contraste) {
322
- const p = pages.get(`${f.sample.rota}|${f.sample.disp}`);
323
- let px = null;
324
- if (p && f.sample.w >= 2 && f.sample.h >= 2) {
325
- 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);
326
- const rgba = png ? decodePng(png) : null;
327
- px = rgba ? pixelRatio(rgba) : null;
328
- }
329
- if (px !== null && (px < 1.2 || px >= f.alvo))
330
- continue;
331
- confirmed.push({ ...f, pixels: px });
332
- }
333
- reading.contraste = confirmed;
391
+ reading.contraste = await confirmByPixels(reading.contraste, (t) => pages.get(`${t.rota}|${t.disp}`)?.page);
334
392
  const files = sources(root);
335
393
  const home = (t) => homeOf(root, files, t);
336
394
  const out = {
@@ -345,9 +403,14 @@ export async function read(opts) {
345
403
  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) })),
346
404
  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) })),
347
405
  overImages: reading.sobreImagem,
406
+ /** Cada título como estava quando a pessoa confirmou - a base do fecho do turno (E01 da fase 3): ele acusa o que
407
+ * o turno MUDOU, nunca o que a página já era. */
408
+ headings: headingsOf(seen),
409
+ newRoles: [],
348
410
  questions: [],
349
411
  };
350
- out.questions = questionsFor(out.breaks, out.drift);
412
+ out.newRoles = newRolesOnPage(await readPending(root), seen, home, (await loadRoleRules(root).catch(() => null))?.roles ?? []);
413
+ out.questions = questionsFor(out.breaks, out.drift, out.newRoles);
351
414
  mkdirSync(join(root, "_synthesisui"), { recursive: true });
352
415
  writeFileSync(join(root, "_synthesisui", "reading.json"), `${JSON.stringify(out, null, 2)}\n`);
353
416
  const at = (x) => (x ? `${x.file}${x.line ? `:${x.line}` : ""}` : "?");
@@ -585,7 +585,14 @@ export const COUNTED_DIFFERENTLY = "this run counts a value as named only when Y
585
585
  * oferece. E a checagem por papel lê CSS Modules (`className={styles.h2}`, o tamanho no `.module.css`): um hook anterior
586
586
  * não confere papel nenhum numa página escrita assim.
587
587
  */
588
- export const CHECKER_SINCE = "0.16.501";
588
+ /**
589
+ * 0.16.501 -> 0.16.504 em 20/10, e o passo 1 dá **SIM** (jornada "o fecho do turno", E01): com o sistema confirmado e o dev
590
+ * server do projeto de pé, o fim do turno relê as telas mexidas e devolve ao agente o texto ilegível (o fundo real) e o
591
+ * título que renderiza fora do papel - o que a checagem por arquivo não vê. Um hook pinado antes não relê tela nenhuma.
592
+ * E (E02, no mesmo PR) guarda o tamanho de título sem papel para a próxima leitura perguntar, e cobra o papel que a pessoa
593
+ * escolheu no "não" (`decided` no rules.json): um hook pinado antes diz "papel novo" uma vez e nunca cobra.
594
+ */
595
+ export const CHECKER_SINCE = "0.16.504";
589
596
  /**
590
597
  * A ÚLTIMA VERSÃO EM QUE OS LEITORES PASSARAM A PRODUZIR UM CENSO DIFERENTE.
591
598
  *
@@ -0,0 +1,364 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { basename, extname, join, relative, sep } from "node:path";
4
+ import { confirmByPixels, declaredPort, findServer, homeOf, launch, loadReader, sources } from "../commands/read.js";
5
+ import { loadRoleRules } from "../role-rules.js";
6
+ import { classify } from "./classify.js";
7
+ import { PAGE_SCRIPT } from "./page-script.js";
8
+ /**
9
+ * A TELA RELIDA NO FIM DO TURNO - E01 da jornada "o fecho do turno" (fase 3 do mapa, 20/10).
10
+ *
11
+ * A checagem por arquivo (`role-rules.ts`) confere o que está ESCRITO: a classe de cada título, a quebra fora do lugar. O
12
+ * que só a página renderizada responde ficava sem ninguém olhar depois da confirmação:
13
+ *
14
+ * CONTRASTE a cor atrás do texto vem de um ancestral, às vezes de outro componente ("Only my pins", 3,17:1, 18/10).
15
+ * Nenhuma conta sobre o arquivo vê o par
16
+ * TAMANHO o tamanho que o título GANHA na tela - o papel cuja regra mudou no globals.css, o seletor de ancestral,
17
+ * a cascata. O arquivo do título não mudou e diz a mesma classe; a tela diz outro tamanho
18
+ *
19
+ * SÓ AS TELAS MEXIDAS, SÓ COM O SISTEMA CONFIRMADO, E NUNCA SOBE NADA. Sem `rules.json` não há papel com que comparar (a
20
+ * porta é da fase 2). Sem dev server de pé, não relê e diz isso uma vez por sessão - subir servidor dentro do fim do turno
21
+ * deixaria processo para trás e custaria 10-30 s (decidido em 20/10, as quatro perspectivas, na abertura da jornada). Sem
22
+ * o leitor na máquina, também não baixa: quem baixa é o `read`, que diz o que vai baixar.
23
+ *
24
+ * Desktop só (1440): os papéis do `rules.json` foram gravados na medida do desktop - comparar o celular com eles acusaria
25
+ * todo título responsivo.
26
+ */
27
+ /** Uma tela: o mesmo filtro do fim do turno (`SCREEN` em `hook.ts`). */
28
+ const SCREEN = /\.(tsx|jsx|vue|svelte|astro|html|css|scss)$/;
29
+ const PAGE_FILE = /(?:^|\/)(?:src\/)?app\/(.*?)\/?page\.(?:tsx|jsx|ts|js|mdx)$|(?:^|\/)(?:src\/)?pages\/(.*?)\.(?:tsx|jsx)$/;
30
+ /** Quatro rotas no máximo: um turno que mexe no layout não vira a leitura do site inteiro. */
31
+ const AT_MOST_ROUTES = 4;
32
+ /**
33
+ * O teto do fecho inteiro: estourou, o turno passa e DIZ que a tela não foi relida - um fecho que prende a sessão é pior
34
+ * que nenhum, e um que some calado é pior ainda. Medido em 20/10, na Naiker: a primeira resposta do `next dev` depois de
35
+ * uma mudança no globals.css levou 46 s (frio); as seguintes, 0,03 s.
36
+ */
37
+ const BUDGET_MS = 30_000;
38
+ const near = (a, b) => Math.abs(a - b) / Math.max(a, b) < 0.03;
39
+ const BASELINE = (root) => join(root, "_synthesisui", ".reread-baseline.json");
40
+ /** Os caminhos dos links da página, do mesmo site - é deles que sai o endereço de uma rota dinâmica. */
41
+ const LINKS = `[...document.querySelectorAll("a[href]")].map((a) => new URL(a.href, location.href)).filter((u) => u.origin === location.origin).map((u) => u.pathname)`;
42
+ const SAID = (root) => join(root, "_synthesisui", ".reread-said.json");
43
+ const NO_SERVER = (root) => join(root, "_synthesisui", ".reread-no-server");
44
+ export const TOO_SLOW = "The screens this turn touched were not re-read: the dev server did not answer within 30 s (it is probably still compiling). The file check above still ran.";
45
+ export const NOT_REREAD = "The screens this turn touched were not re-read: this project's dev server is not up (the port in its `dev` script, or the one `synthesisui read` used). The file check above still ran. Tell the person in one line that contrast and the real size of each heading are checked only while `npm run dev` is running.";
46
+ /**
47
+ * O SERVIDOR DESTE PROJETO, E SÓ ELE - medido em 20/10, na prova do servidor parado: o `read` tenta as portas de sempre
48
+ * (:3000, :3001, :5173, :4321) e, com a Naiker parada, o fecho achou o PAINEL do hub na :4321 e acusou seis contrastes
49
+ * da página de outro projeto. Na leitura a pessoa vê a URL e pode corrigir; no fim do turno ninguém vê. Então só duas
50
+ * portas valem: a que o script `dev` do projeto declara, e a que a leitura confirmada usou (`reading.json`).
51
+ */
52
+ async function ownServer(root) {
53
+ const port = declaredPort(root);
54
+ const read = JSON.parse((await readFile(join(root, "_synthesisui", "reading.json"), "utf8").catch(() => "{}")) || "{}");
55
+ for (const u of [...(port ? [`http://localhost:${port}`] : []), ...(read.url ? [read.url] : [])]) {
56
+ const found = await findServer(u, root, 1500);
57
+ if (found)
58
+ return found;
59
+ }
60
+ return null;
61
+ }
62
+ /**
63
+ * A ROTA DE UM ARQUIVO DE PÁGINA DO NEXT (app ou pages), com o segmento dinâmico como está (`/dogs/[slug]`); `null` para
64
+ * API ou o que não é página. A página de cada cachorro é exatamente onde a deriva nasce
65
+ * (18/10: o h2 do cachorro a 79,2px) - pular rota dinâmica deixaria a releitura cega no caso que motivou a fase. O
66
+ * endereço concreto sai dos links das páginas já abertas (ver `measure`).
67
+ */
68
+ export function patternOf(rel) {
69
+ const m = rel.split(sep).join("/").match(PAGE_FILE);
70
+ if (!m)
71
+ return null;
72
+ const raw = m[1] ?? m[2] ?? "";
73
+ if (m[2] !== undefined && (/^(_app|_document|_error|api)(\/|$)/.test(raw)))
74
+ return null;
75
+ const parts = raw.split("/").filter((p) => p && !/^\(.*\)$/.test(p) && !p.startsWith("@") && p !== "index");
76
+ return `/${parts.join("/")}`;
77
+ }
78
+ function walk(dir, out = []) {
79
+ let names = [];
80
+ try {
81
+ names = readdirSync(dir);
82
+ }
83
+ catch {
84
+ return out;
85
+ }
86
+ for (const n of names) {
87
+ if (n === "node_modules" || n.startsWith(".") || n === "_synthesisui" || n === "public")
88
+ continue;
89
+ const p = join(dir, n);
90
+ try {
91
+ if (statSync(p).isDirectory())
92
+ walk(p, out);
93
+ else
94
+ out.push(p);
95
+ }
96
+ catch {
97
+ /* o próximo */
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+ /**
103
+ * AS TELAS QUE O TURNO MEXEU. A página mexida é a rota dela. Um componente mexido é toda página que o importa (até dois
104
+ * saltos de import, pelo nome do arquivo). Folha de estilo, layout ou componente que nenhuma página importa diretamente:
105
+ * todas as rotas estáticas, até o teto - a raiz primeiro.
106
+ */
107
+ export function routesFor(root, changed) {
108
+ const screens = changed.filter((c) => SCREEN.test(c.rel));
109
+ if (screens.length === 0)
110
+ return [];
111
+ const files = walk(existsSync(join(root, "src")) ? join(root, "src") : root).concat(existsSync(join(root, "src")) && existsSync(join(root, "app")) ? walk(join(root, "app")) : []);
112
+ const pages = files.map((f) => ({ f, route: patternOf(relative(root, f)) })).filter((p) => p.route !== null);
113
+ const all = [...new Set(pages.map((p) => p.route))];
114
+ const out = new Set();
115
+ let everywhere = false;
116
+ const code = files.filter((f) => /\.(tsx|jsx|ts|js)$/.test(f));
117
+ const textOf = new Map();
118
+ const read = (f) => {
119
+ if (!textOf.has(f))
120
+ textOf.set(f, (() => { try {
121
+ return readFileSync(f, "utf8");
122
+ }
123
+ catch {
124
+ return "";
125
+ } })());
126
+ return textOf.get(f);
127
+ };
128
+ const importers = (stem) => code.filter((f) => new RegExp(`from\\s+["'][^"']*\\/${stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\.[jt]sx?)?["']`).test(read(f)));
129
+ for (const c of screens) {
130
+ const own = patternOf(c.rel);
131
+ if (own) {
132
+ out.add(own);
133
+ continue;
134
+ }
135
+ const stem = basename(c.rel, extname(c.rel));
136
+ if (/\.(css|scss)$/.test(c.rel) || /^(layout|template)$/.test(stem)) {
137
+ everywhere = true;
138
+ continue;
139
+ }
140
+ const first = importers(stem);
141
+ const reach = new Set(first);
142
+ for (const f of first)
143
+ for (const g of importers(basename(f, extname(f))))
144
+ reach.add(g);
145
+ const hit = pages.filter((p) => reach.has(p.f)).map((p) => p.route);
146
+ if (hit.length === 0)
147
+ everywhere = true;
148
+ for (const r of hit)
149
+ out.add(r);
150
+ }
151
+ if (everywhere)
152
+ for (const r of all)
153
+ out.add(r);
154
+ const rank = (r) => (r === "/" ? 0 : r.includes("[") ? 2 : 1);
155
+ return [...out].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)).slice(0, AT_MOST_ROUTES);
156
+ }
157
+ export function headingsOf(seen) {
158
+ const out = new Map();
159
+ for (const t of seen) {
160
+ const el = t.papel;
161
+ if (el !== "h1" && el !== "h2" && el !== "h3")
162
+ continue;
163
+ if (t.disp !== "desktop" || t.tag !== el || t.decorativo || t.giro !== 0 || !(t.tam > 0))
164
+ continue;
165
+ const k = `${t.rota}|${el}|${t.texto}`;
166
+ if (!out.has(k))
167
+ out.set(k, { rota: t.rota, el, text: t.texto, px: round(t.tam) });
168
+ }
169
+ return [...out.values()];
170
+ }
171
+ /**
172
+ * O TÍTULO NA TELA CONTRA O PAPEL - E O QUE O TURNO MUDOU, NUNCA O QUE A PÁGINA JÁ ERA. Medido na primeira rodada, na
173
+ * Naiker sem mudança nenhuma (20/10): cinco acusações sobre a página que a pessoa tinha acabado de confirmar - os quatro
174
+ * `<h3>` de 20px que a leitura não fez papel, e a quebra de 144px que mora num subcomponente da Landing. Um fecho que
175
+ * acusa a página confirmada ensina o agente a ignorá-lo.
176
+ *
177
+ * Então vale a BASE: o tamanho de cada título quando a pessoa confirmou (`headings` no `reading.json`). Título que está
178
+ * nela no mesmo tamanho passa. Título novo, ou que mudou de tamanho, é comparado com a mesma faixa da checagem por arquivo:
179
+ *
180
+ * perto de um papel (até 1,4x) e fora dele deriva - volta ao agente com o papel
181
+ * no tamanho de uma quebra, fora do lugar a quebra copiada
182
+ * acima do título de seção, sem ser quebra passou da escala
183
+ * longe de todo papel provável papel novo - é decisão da pessoa (E02), não deriva
184
+ */
185
+ export function offRole(seen, rules, baseline, fileOf) {
186
+ const out = [];
187
+ const before = new Map((baseline ?? []).map((h) => [`${h.rota}|${h.el}|${h.text}`, h.px]));
188
+ const section = rules.roles.find((r) => r.element === "h2" && r.context === "section");
189
+ const sample = new Map();
190
+ for (const t of seen)
191
+ if (t.disp === "desktop")
192
+ sample.set(`${t.rota}|${t.papel}|${t.texto}`, t);
193
+ for (const h of headingsOf(seen)) {
194
+ const t = sample.get(`${h.rota}|${h.el}|${h.text}`);
195
+ const was = before.get(`${h.rota}|${h.el}|${h.text}`);
196
+ if (was !== undefined && near(was, h.px))
197
+ continue;
198
+ const mine = rules.roles.filter((r) => r.element === h.el);
199
+ if (mine.length === 0 || mine.some((r) => near(h.px, r.px)))
200
+ continue;
201
+ const role = mine.find((r) => r.context === (t.cartao ? "card" : t.corrido ? "prose" : "section")) ?? mine[0];
202
+ const said = `"${h.text.length > 40 ? `${h.text.slice(0, 39)}…` : h.text}"`;
203
+ const brk = rules.breaks.find((b) => b.element === h.el && near(h.px, b.px));
204
+ if (brk) {
205
+ const home = brk.home.split(":")[0];
206
+ if (t.comp === brk.allowedIn || (home && fileOf(t) === home))
207
+ continue;
208
+ out.push({ t, el: h.el, text: `<${h.el}> ${said} renders at ${h.px}px - the size of the break "${brk.rule}", which the person chose for ${brk.allowedIn} only (${brk.home}). Here, the ${role.name} is ${round(role.px)}px (${role.class}, ${role.home}).` });
209
+ continue;
210
+ }
211
+ if (section && h.el !== "h1" && h.px > section.px * 1.04) {
212
+ out.push({ t, el: h.el, text: `<${h.el}> ${said} renders at ${h.px}px, above this project's section titles (${round(section.px)}px). Only the breaks the person chose go there - use the ${role.name} (${role.class}), or ask whether this is a new break.` });
213
+ continue;
214
+ }
215
+ if (h.px >= role.px / 1.4 && h.px <= role.px * 1.4)
216
+ out.push({ t, el: h.el, text: `<${h.el}> ${said} renders at ${h.px}px${was !== undefined ? ` (it was ${was}px when the person confirmed)` : ""}, and this project's ${role.name} is ${round(role.px)}px (${role.class}, ${role.home}).` });
217
+ }
218
+ return out;
219
+ }
220
+ const round = (n) => Math.round(n * 10) / 10;
221
+ /**
222
+ * A BASE: os títulos que o `read` gravou na confirmação. Leitura antiga, sem eles (confirmada antes da fase 3): a primeira
223
+ * releitura de cada rota vira a base, calada - perde-se um turno de checagem de tamanho naquela rota, e não se acusa a
224
+ * página inteira que a pessoa já tinha.
225
+ */
226
+ async function baselineOf(root, seen) {
227
+ const fromRead = JSON.parse((await readFile(join(root, "_synthesisui", "reading.json"), "utf8").catch(() => "{}")) || "{}");
228
+ const kept = JSON.parse((await readFile(BASELINE(root), "utf8").catch(() => "[]")) || "[]");
229
+ /* com a base da confirmação, uma rota fora dela é página NOVA - e página nova é onde a deriva nasce (a página do
230
+ cachorro, 18/10): ela é conferida inteira, nunca vira base calada */
231
+ if (fromRead.headings)
232
+ return fromRead.headings;
233
+ const base = kept;
234
+ const known = new Set(base.map((h) => h.rota));
235
+ const fresh = headingsOf(seen).filter((h) => !known.has(h.rota));
236
+ if (fresh.length > 0) {
237
+ await mkdir(join(root, "_synthesisui"), { recursive: true }).catch(() => undefined);
238
+ await writeFile(BASELINE(root), JSON.stringify([...kept, ...fresh], null, 1)).catch(() => undefined);
239
+ }
240
+ return [...base, ...fresh];
241
+ }
242
+ async function onlyNew(root, keys) {
243
+ const said = new Set(JSON.parse((await readFile(SAID(root), "utf8").catch(() => "[]")) || "[]"));
244
+ const fresh = new Set(keys.filter((k) => !said.has(k)));
245
+ if (fresh.size > 0) {
246
+ for (const k of fresh)
247
+ said.add(k);
248
+ await mkdir(join(root, "_synthesisui"), { recursive: true }).catch(() => undefined);
249
+ await writeFile(SAID(root), JSON.stringify([...said], null, 1)).catch(() => undefined);
250
+ }
251
+ return fresh;
252
+ }
253
+ /** A linha do "não relida" vale uma vez por sessão: a sessão é o registro da conversa que o Claude Code passa. */
254
+ async function noServerOnce(root, session) {
255
+ const key = session ?? "no-session";
256
+ if ((await readFile(NO_SERVER(root), "utf8").catch(() => "")).trim() === key)
257
+ return null;
258
+ await mkdir(join(root, "_synthesisui"), { recursive: true }).catch(() => undefined);
259
+ await writeFile(NO_SERVER(root), `${key}\n`).catch(() => undefined);
260
+ return NOT_REREAD;
261
+ }
262
+ export async function rereadScreens(root, changed, session) {
263
+ if (!changed.some((c) => SCREEN.test(c.rel)))
264
+ return null;
265
+ const rules = await loadRoleRules(root).catch(() => null);
266
+ if (!rules || rules.enforce === false)
267
+ return null;
268
+ const routes = routesFor(root, changed);
269
+ if (routes.length === 0)
270
+ return null;
271
+ const base = await ownServer(root);
272
+ if (!base)
273
+ return noServerOnce(root, session);
274
+ const pw = loadReader(() => { }, false);
275
+ if (!pw)
276
+ return null;
277
+ let timer;
278
+ const budget = new Promise((ok) => {
279
+ timer = setTimeout(() => ok(TOO_SLOW), BUDGET_MS);
280
+ });
281
+ const held = { browser: null };
282
+ try {
283
+ return await Promise.race([measure(root, rules, routes, base, pw, held), budget]);
284
+ }
285
+ finally {
286
+ clearTimeout(timer);
287
+ /* estourado o teto, o navegador fecha aqui - senão o processo do gancho fica de pé esperando a página, e o turno junto */
288
+ await held.browser?.close().catch(() => { });
289
+ }
290
+ }
291
+ async function measure(root, rules, routes, base, pw, held) {
292
+ const browser = await launch(pw, () => { }, false);
293
+ if (!browser)
294
+ return null;
295
+ held.browser = browser;
296
+ const pages = new Map();
297
+ try {
298
+ const seen = [];
299
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, reducedMotion: "reduce" });
300
+ const links = [];
301
+ const open = async (path) => {
302
+ const page = await ctx.newPage();
303
+ const ok = await page.goto(new URL(path, base).href, { waitUntil: "networkidle", timeout: 25_000 }).then(() => true, () => false);
304
+ if (ok)
305
+ links.push(...(await page.evaluate(LINKS).catch(() => [])));
306
+ return ok ? page : null;
307
+ };
308
+ for (const route of routes) {
309
+ let rota = route;
310
+ if (route.includes("[")) {
311
+ if (links.length === 0)
312
+ await open("/");
313
+ const re = new RegExp(`^${route.replace(/\[\.\.\.[^\]]+\]/g, ".+").replace(/\[[^\]]+\]/g, "[^/]+")}$`);
314
+ const hit = links.find((l) => re.test(l));
315
+ if (!hit)
316
+ continue;
317
+ rota = hit;
318
+ }
319
+ const page = await open(rota);
320
+ if (!page)
321
+ continue;
322
+ await page.waitForTimeout(500);
323
+ const textos = (await page.evaluate(PAGE_SCRIPT));
324
+ for (const t of textos)
325
+ seen.push({ ...t, rota, disp: "desktop" });
326
+ pages.set(rota, page);
327
+ }
328
+ if (seen.length === 0)
329
+ return null;
330
+ const low = await confirmByPixels(classify(seen).contraste, (t) => pages.get(t.rota));
331
+ const files = sources(root);
332
+ const fileOf = (t) => homeOf(root, files, t)?.file ?? null;
333
+ const baseline = await baselineOf(root, seen);
334
+ const off = offRole(seen, rules, baseline, fileOf);
335
+ const at = (t) => {
336
+ const h = homeOf(root, files, t);
337
+ return h ? `${h.file}${h.line ? `:${h.line}` : ""}` : `${t.rota}${t.comp ? `, ${t.comp}` : ""}`;
338
+ };
339
+ const lowLines = low.map((c) => ({
340
+ key: `contrast|${c.sample.rota}|${c.sample.texto}|${c.razao}`,
341
+ text: ` "${c.sample.texto}" - ${c.razao}:1, needs ${c.alvo}:1 (${c.texto} on ${c.fundo}) - ${at(c.sample)}`,
342
+ }));
343
+ const offLines = off.map((o) => ({ key: `size|${o.t.rota}|${o.el}|${o.t.texto}|${round(o.t.tam)}`, text: ` ${o.text} - ${at(o.t)}` }));
344
+ const fresh = await onlyNew(root, [...lowLines, ...offLines].map((l) => l.key));
345
+ const lows = lowLines.filter((l) => fresh.has(l.key)).map((l) => l.text);
346
+ const offs = offLines.filter((l) => fresh.has(l.key)).map((l) => l.text);
347
+ if (lows.length === 0 && offs.length === 0)
348
+ return null;
349
+ return [
350
+ `Re-read the screens this turn touched (${[...pages.keys()].join(", ")}) on ${base}, as the visitor sees them:`,
351
+ ...(lows.length ? ["Hard to read (the real background, checked against the pixels):", ...lows] : []),
352
+ ...(offs.length ? ["Headings that render off this project's roles:", ...offs] : []),
353
+ ...(lows.length ? ["Raise the contrast of each one where the text sits - the color, the opacity, or the background behind it."] : []),
354
+ ...(offs.length
355
+ ? ["Bring each heading back to its role. If the heading already uses the role's class, the change was to the role itself (its rule in the stylesheet): that changes the system the person confirmed - put it back, or ask them in one line."]
356
+ : []),
357
+ ].join("\n");
358
+ }
359
+ finally {
360
+ for (const p of pages.values())
361
+ await p.close?.().catch(() => { });
362
+ await browser.close().catch(() => { });
363
+ }
364
+ }
@@ -205,6 +205,12 @@ export function checkRoles(rel, src, rules, root) {
205
205
  if (known.has(bare))
206
206
  continue;
207
207
  const px = mod ? pxOfModule(mod.path, mod.local) : glob ? glob.px : pxOf(bare);
208
+ const told = rules.decided?.find((d) => d.class === bare && d.at === rel);
209
+ const toldRole = told ? rules.roles.find((r) => r.id === told.role) : undefined;
210
+ if (toldRole) {
211
+ out.push(` line ${line} <${el}> ${bare} - the person said this heading is the ${toldRole.name}. Use ${toldRole.class} (${toldRole.home}).`);
212
+ continue;
213
+ }
208
214
  const role = mine.find((r) => r.context === context) ?? mine[0];
209
215
  const brk = rules.breaks.find((b) => b.class === bare);
210
216
  if (brk) {
@@ -296,6 +302,31 @@ export async function onlyOnce(root, rel, lines) {
296
302
  if (changed) {
297
303
  await mkdir(join(root, "_synthesisui"), { recursive: true }).catch(() => undefined);
298
304
  await writeFile(SAID(root), JSON.stringify([...said], null, 1)).catch(() => undefined);
305
+ await rememberNewRoles(root, rel, out.filter((l) => l.includes("has no role for yet"))).catch(() => undefined);
299
306
  }
300
307
  return out;
301
308
  }
309
+ export const PENDING = (root) => join(root, "_synthesisui", "roles-pending.json");
310
+ export async function readPending(root) {
311
+ try {
312
+ const p = JSON.parse(await readFile(PENDING(root), "utf8"));
313
+ return Array.isArray(p) ? p : [];
314
+ }
315
+ catch {
316
+ return [];
317
+ }
318
+ }
319
+ async function rememberNewRoles(root, rel, lines) {
320
+ if (lines.length === 0)
321
+ return;
322
+ const pending = await readPending(root);
323
+ for (const l of lines) {
324
+ const m = l.match(/line (\d+)\s+<(h[1-3])> (.+?) \(~(\d+)px\)/);
325
+ if (!m)
326
+ continue;
327
+ const entry = { element: m[2], class: m[3], px: Number(m[4]), file: rel, line: Number(m[1]) };
328
+ if (!pending.some((p) => p.class === entry.class && p.file === entry.file && p.element === entry.element))
329
+ pending.push(entry);
330
+ }
331
+ await writeFile(PENDING(root), `${JSON.stringify(pending, null, 1)}\n`);
332
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.503",
3
+ "version": "0.16.504",
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": {