synthesisui 0.16.502 → 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.
- package/dist/claude-md.js +1 -1
- package/dist/commands/hook.js +5 -0
- package/dist/commands/read.js +104 -30
- package/dist/install-marks.js +8 -1
- package/dist/reading/reread.js +364 -0
- package/dist/role-rules.js +31 -0
- package/package.json +1 -1
package/dist/claude-md.js
CHANGED
|
@@ -10,7 +10,7 @@ import { readResponsiveForm, responsiveLine } from "./responsive-form.js";
|
|
|
10
10
|
* A LEITURA - o caminho do sistema desde a fase 1 (18/10). Escrita uma vez e usada nos dois contratos: o de quem já tem
|
|
11
11
|
* sistema sem `rules.json` e o de quem chegou pela porta e ainda não tem sistema nenhum (fase 2).
|
|
12
12
|
*/
|
|
13
|
-
const READ_THE_PAGE = "**Once the first screen is done, offer to read it - once.** If their next message does not take the offer up, do not offer it again; they can still ask. When the person asks you to read their page - or right after the creation of the first screen ends (with impeccable, once it has written `DESIGN.md`; never while it still asks for another round) - run `npx synthesisui read` with the dev server up, tell them in a few lines the system it found, then ask the questions in `_synthesisui/reading.json`
|
|
13
|
+
const READ_THE_PAGE = "**Once the first screen is done, offer to read it - once.** If their next message does not take the offer up, do not offer it again; they can still ask. When the person asks you to read their page - or right after the creation of the first screen ends (with impeccable, once it has written `DESIGN.md`; never while it still asks for another round) - run `npx synthesisui read` with the dev server up, tell them in a few lines the system it found, then ask ALL the questions in `_synthesisui/reading.json` TOGETHER, in one call of your question tool (up to 4 per call; more go in a second call), in plain words and quoting the text on the page each one is about (the first option is the default). Save their answers as `{ \"<question id>\": <option index> }` and run `npx synthesisui read --confirm <that file>`: their answers become the roles and breaks this project keeps.";
|
|
14
14
|
/** A frase do revisor depois da criação - ver o comentário em `renderRegion`, junto dos papéis. */
|
|
15
15
|
const REVIEWER_AFTER_CREATION = "\n\n**How a change closes here.** The visual world is decided - the roles and breaks above. From here on SynthesisUI's check closes a change (`check_file`, and the end-of-turn check). Impeccable's reviewer is for creating: spawn `impeccable-finish-reviewer` or run `impeccable detect` only when the person asks for a new visual direction or a large new surface with its own look - never as the routine close of a change, and never its degraded self-review in its place.";
|
|
16
16
|
const START = "<!-- synthesisui:start -->";
|
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,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
|
-
|
|
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(
|
|
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`) */
|
|
@@ -174,6 +200,17 @@ function homeOf(root, files, t) {
|
|
|
174
200
|
* "5-8 perguntas por projeto, não 300"). As quebras vêm primeiro: dizer sim à própria assinatura é o caminho feliz. A
|
|
175
201
|
* primeira opção é sempre a padrão, e é o que vale quando a pessoa não responde.
|
|
176
202
|
*/
|
|
203
|
+
/**
|
|
204
|
+
* O TEXTO QUE A PESSOA VÊ NA PÁGINA - o juízo dele (19/10): "O título de capa passa bem acima da escala (180px contra
|
|
205
|
+
* 70px)" pede que ela ache sozinha qual título é. Com o texto ("TRILHO 07") ela reconhece na hora. `seen` vem como
|
|
206
|
+
* "rota: texto"; até 40 caracteres.
|
|
207
|
+
*/
|
|
208
|
+
function quoted(seen) {
|
|
209
|
+
const t = seen?.[0]?.replace(/^[^:]*:\s*/, "").trim();
|
|
210
|
+
if (!t)
|
|
211
|
+
return "this";
|
|
212
|
+
return `"${t.length > 40 ? `${t.slice(0, 39)}…` : t}"`;
|
|
213
|
+
}
|
|
177
214
|
/** O nome que a pessoa entende - nunca o rótulo interno da classificação ("h3 de cartão", "número"). */
|
|
178
215
|
function plainRole(role) {
|
|
179
216
|
if (role === "número")
|
|
@@ -184,12 +221,18 @@ function plainRole(role) {
|
|
|
184
221
|
return "block";
|
|
185
222
|
return nameOf(role);
|
|
186
223
|
}
|
|
187
|
-
function questionsFor(breaks, drift) {
|
|
224
|
+
export function questionsFor(breaks, drift, newRoles = []) {
|
|
188
225
|
const qs = breaks.map((b) => ({
|
|
189
226
|
id: `break:${b.rule}:${b.role}:${b.px}`,
|
|
190
|
-
text: `Is
|
|
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}).`,
|
|
191
228
|
options: ["Yes, it is part of my design", "No, make it follow the scale"],
|
|
192
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
|
+
});
|
|
193
236
|
const groups = new Map();
|
|
194
237
|
for (const d of drift) {
|
|
195
238
|
const g = groups.get(`${d.role}|${d.surface}`) ?? { role: d.role, surface: d.surface, rulePx: d.rulePx, pxs: [] };
|
|
@@ -204,6 +247,26 @@ function questionsFor(breaks, drift) {
|
|
|
204
247
|
});
|
|
205
248
|
return qs.slice(0, 8);
|
|
206
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
|
+
}
|
|
207
270
|
const contextOf = (role) => (role.includes("cartão") ? "card" : role.includes("texto corrido") ? "prose" : "section");
|
|
208
271
|
const NAME = { h1: "cover title", h2: "section title", h3: "subheading" };
|
|
209
272
|
const nameOf = (role) => {
|
|
@@ -266,7 +329,27 @@ export async function confirm(opts) {
|
|
|
266
329
|
/* o ARQUIVO onde a quebra mora, como a checagem compara (role-rules.ts) - não o componente do React, que pode
|
|
267
330
|
ser um de vários no mesmo arquivo (o DogCard mora em Entries.tsx) */
|
|
268
331
|
allowedIn: (b.home?.file.split("/").pop()?.replace(/\.[jt]sx?$/, "") ?? ""), home: at(b.home) }));
|
|
269
|
-
|
|
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 });
|
|
270
353
|
writeFileSync(join(dsDir, slug, "rules.json"), `${JSON.stringify(rules, null, 2)}\n`);
|
|
271
354
|
const contract = await syncClaudeMd(root, await agentsToMaintain(root));
|
|
272
355
|
say(`Your system: ${roles.length} roles and ${breaks.length} breaks, written to _synthesisui/ds/${slug}/rules.json.`);
|
|
@@ -288,7 +371,7 @@ export async function read(opts) {
|
|
|
288
371
|
}
|
|
289
372
|
const routes = opts.routes?.length ? opts.routes : ["/"];
|
|
290
373
|
const pw = loadReader(say);
|
|
291
|
-
const browser = await launch(pw, say);
|
|
374
|
+
const browser = (await launch(pw, say));
|
|
292
375
|
const seen = [];
|
|
293
376
|
const pages = new Map();
|
|
294
377
|
try {
|
|
@@ -305,21 +388,7 @@ export async function read(opts) {
|
|
|
305
388
|
}
|
|
306
389
|
}
|
|
307
390
|
const reading = classify(seen);
|
|
308
|
-
|
|
309
|
-
const confirmed = [];
|
|
310
|
-
for (const f of reading.contraste) {
|
|
311
|
-
const p = pages.get(`${f.sample.rota}|${f.sample.disp}`);
|
|
312
|
-
let px = null;
|
|
313
|
-
if (p && f.sample.w >= 2 && f.sample.h >= 2) {
|
|
314
|
-
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);
|
|
315
|
-
const rgba = png ? decodePng(png) : null;
|
|
316
|
-
px = rgba ? pixelRatio(rgba) : null;
|
|
317
|
-
}
|
|
318
|
-
if (px !== null && (px < 1.2 || px >= f.alvo))
|
|
319
|
-
continue;
|
|
320
|
-
confirmed.push({ ...f, pixels: px });
|
|
321
|
-
}
|
|
322
|
-
reading.contraste = confirmed;
|
|
391
|
+
reading.contraste = await confirmByPixels(reading.contraste, (t) => pages.get(`${t.rota}|${t.disp}`)?.page);
|
|
323
392
|
const files = sources(root);
|
|
324
393
|
const home = (t) => homeOf(root, files, t);
|
|
325
394
|
const out = {
|
|
@@ -334,9 +403,14 @@ export async function read(opts) {
|
|
|
334
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) })),
|
|
335
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) })),
|
|
336
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: [],
|
|
337
410
|
questions: [],
|
|
338
411
|
};
|
|
339
|
-
out.
|
|
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);
|
|
340
414
|
mkdirSync(join(root, "_synthesisui"), { recursive: true });
|
|
341
415
|
writeFileSync(join(root, "_synthesisui", "reading.json"), `${JSON.stringify(out, null, 2)}\n`);
|
|
342
416
|
const at = (x) => (x ? `${x.file}${x.line ? `:${x.line}` : ""}` : "?");
|
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,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
|
+
}
|
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