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.
- package/dist/commands/hook.js +5 -0
- package/dist/commands/read.js +78 -52
- package/dist/install-marks.js +8 -1
- package/dist/reading/read-page.js +93 -0
- package/dist/reading/reread.js +365 -0
- package/dist/role-rules.js +31 -0
- package/package.json +1 -1
package/dist/commands/hook.js
CHANGED
|
@@ -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
|
package/dist/commands/read.js
CHANGED
|
@@ -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 {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
304
|
-
|
|
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.
|
|
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
|
-
|
|
377
|
-
await ctx.close().catch(() => { });
|
|
403
|
+
await site.close();
|
|
378
404
|
await browser.close().catch(() => { });
|
|
379
405
|
}
|
|
380
406
|
}
|
package/dist/install-marks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/role-rules.js
CHANGED
|
@@ -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