synthesisui 0.16.503 → 0.16.505

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