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