synthesisui 0.16.498 → 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 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
@@ -2,7 +2,8 @@ import { describeDistinct, describeDistinctVerbose, distinctValues } from "../do
2
2
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { readStyleSheet, scssVariables } from "../doctor/scss-lens.js";
4
4
  import { THEME_SOURCE, tsTokenObjects, tsTokensAsVars } from "../doctor/ts-tokens-lens.js";
5
- import { dirname, join, relative, resolve } from "node:path";
5
+ import { dirname, join, relative, resolve, sep } from "node:path";
6
+ import { SET_PROPERTY } from "../doctor/broken-refs.js";
6
7
  import { describeIntent, intentOf, readProjectConfig } from "../config.js";
7
8
  import { describeFix, planFix, readerFor, writeFix, } from "../doctor/apply-fix.js";
8
9
  import { baselineOf, compareToBaseline, describeVerdict, githubAnnotations, sarifOf, } from "../doctor/ci-format.js";
@@ -777,6 +778,28 @@ export async function doctor(opts) {
777
778
  for await (const file of walkAll([root]))
778
779
  if (THEME_SOURCE.test(file) && !/\.d\.ts$/.test(file))
779
780
  tsObjects.push(...tsTokenObjects(await readFile(file, "utf8").catch(() => "")));
781
+ /**
782
+ * O QUE O JAVASCRIPT DELE MEDE, por pasta - ver `TokenTable.measuredInJs` (aula 01, 15/10). A reserva de
783
+ * `var(--sui-hero-name, clamp(...))` sai da conta só quando a pasta do CSS é a que grava a variável.
784
+ */
785
+ const measuredInJs = new Map();
786
+ for await (const file of walkAll([root])) {
787
+ if (!/\.(tsx|jsx|ts|js|mjs)$/.test(file) || /\.d\.ts$/.test(file))
788
+ continue;
789
+ const text = await readFile(file, "utf8").catch(() => "");
790
+ if (!text.includes("setProperty"))
791
+ continue;
792
+ const rel = relative(root, file);
793
+ const dir = rel.includes(sep) ? rel.slice(0, rel.lastIndexOf(sep)).split(sep).join("/") : "";
794
+ for (const m of text.matchAll(SET_PROPERTY)) {
795
+ const name = m[1].toLowerCase();
796
+ const dirs = measuredInJs.get(name) ?? [];
797
+ if (!dirs.includes(dir))
798
+ measuredInJs.set(name, [...dirs, dir]);
799
+ }
800
+ }
801
+ if (measuredInJs.size > 0)
802
+ table = { ...table, measuredInJs };
780
803
  const tally = emptyTally();
781
804
  const internalSpecs = await internalSpecifiers(root);
782
805
  for await (const file of scopes.length > 0
@@ -1,6 +1,7 @@
1
1
  import { readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { ourSpellingLines } from "../our-spelling.js";
3
- import { join, relative, resolve } from "node:path";
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
+ }
@@ -40,7 +40,7 @@ const VAR_REF = /var\(\s*(--[a-zA-Z0-9_-]+)\s*(,)?/g;
40
40
  * Medido em quatro populações: 2 nomes no `codelevel-ui`, 0 no `packages/ui` do `frontend-hub`, 2 no
41
41
  * dashboard dele e 3 no nosso próprio app. Três de quatro escrevem variável por JS.
42
42
  */
43
- const SET_PROPERTY = /setProperty\(\s*['"`](--[a-zA-Z0-9_-]+)/g;
43
+ export const SET_PROPERTY = /setProperty\(\s*['"`](--[a-zA-Z0-9_-]+)/g;
44
44
  /**
45
45
  * VARIABLES A HEADLESS LIBRARY SETS AT RUNTIME, which no stylesheet can declare.
46
46
  *
@@ -174,6 +174,15 @@ const SPACING_DECL = new RegExp(`(?:padding|margin|gap)(?:[A-Z][a-z]+|(?:-[a-z]+
174
174
  const SPACING_UTIL = /(?:\b[pmg](?:[trblxy])?-\[|gap-\[)([^\]]*)/g;
175
175
  /** A font stack written by hand rather than taken from the type scale. */
176
176
  const FONT = /font-family\s*:\s*([^;}\n]+)/g;
177
+ /**
178
+ * O TAMANHO DE TEXTO ESCRITO À MÃO (defeito 3 da aula 01, 15/10) - `font-size: 14px`, `fontSize: "1rem"`,
179
+ * `[font-size:0.875rem]`. Não havia leitor nenhum: `distinct-values` contava esses valores como "by hand" e a cobertura,
180
+ * na mesma tela, não, então o `--fix` não tinha o que trocar com `--text-sm` declarado no projeto. A família é `font`,
181
+ * porque é a palavra que os nomes de escala de tipo carregam (`--text-sm`, `--ds-typography-...`).
182
+ */
183
+ const FONT_SIZE_DECL = new RegExp(`font-?[Ss]ize${DECL_VALUE}`, "g");
184
+ /** `text-[14px]` - o valor entre colchetes; `text-[#111]` não tem comprimento e não rende nada. */
185
+ const FONT_SIZE_UTIL = /\btext-\[([^\]]*)/g;
177
186
  /**
178
187
  * MOTION, the family the scan never read (item 12, F3).
179
188
  *
@@ -351,6 +360,38 @@ function fallbackSpans(line, table) {
351
360
  }
352
361
  return out;
353
362
  }
363
+ /** `var(--x, clamp(4rem, 17vw, 16rem))` - o nome e a reserva INTEIRA, com os parênteses de dentro contados. */
364
+ function fallbacksOf(line) {
365
+ const out = [];
366
+ for (const m of line.matchAll(VAR_WITH_FALLBACK)) {
367
+ const start = (m.index ?? 0) + m[0].length;
368
+ let depth = 1;
369
+ let end = start;
370
+ for (; end < line.length && depth > 0; end++) {
371
+ if (line[end] === "(")
372
+ depth++;
373
+ else if (line[end] === ")")
374
+ depth--;
375
+ }
376
+ if (depth === 0)
377
+ out.push({ name: m[1].toLowerCase(), span: [start, end - 1] });
378
+ }
379
+ return out;
380
+ }
381
+ /**
382
+ * A RESERVA DE UM TAMANHO QUE O COMPONENTE MEDE SOZINHO - ver `TokenTable.measuredInJs` (aula 01, 15/10).
383
+ *
384
+ * Só quando um arquivo da MESMA pasta grava a variável: um `var(--qualquer, 18px)` que nada grava continua acusado, senão
385
+ * a reserva vira esconderijo de valor à mão.
386
+ */
387
+ function measuredSpans(file, line, table) {
388
+ if (!table.measuredInJs?.size)
389
+ return [];
390
+ const dir = file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "";
391
+ return fallbacksOf(line)
392
+ .filter((f) => table.measuredInJs?.get(f.name)?.includes(dir))
393
+ .map((f) => f.span);
394
+ }
354
395
  /**
355
396
  * ONDE A CASCATA DO APP NÃO ALCANÇA - e trocar por `var()` ali APAGA a cor.
356
397
  *
@@ -754,6 +795,8 @@ function scanCore(file, source, table) {
754
795
  const declared = declarationSpans(line, inTokenBlock && !opensTokenBlock);
755
796
  const inDeclaration = (at) => declared.some(([a, b]) => at >= a && at < b);
756
797
  const spans = fallbackSpans(line, table);
798
+ const measured = measuredSpans(file, line, table);
799
+ const inMeasured = (at) => measured.some(([a, b]) => at >= a && at <= b);
757
800
  const inFallback = (at) => spans.some(([a, b]) => at >= a && at <= b);
758
801
  /** Ver `markupTextSpans`: HTML montado como texto sai do alcance da folha do app. */
759
802
  const markup = markupTextSpans(file, line);
@@ -770,6 +813,10 @@ function scanCore(file, source, table) {
770
813
  /** A definição do token não é drift - ver `declarationSpans`. */
771
814
  if (col >= 0 && inDeclaration(col))
772
815
  return;
816
+ if (col >= 0 && inMeasured(col)) {
817
+ setAside("the fallback of a size the component measures itself");
818
+ return;
819
+ }
773
820
  if (col >= 0 && inFallback(col)) {
774
821
  setAside("a token's own fallback");
775
822
  return;
@@ -800,14 +847,20 @@ function scanCore(file, source, table) {
800
847
  seen.add(key);
801
848
  // The kind is already known here, and without passing it the lookup
802
849
  // answers a `gap` with a radius token.
803
- const match = tokenMatch(table, literal, kind);
850
+ /**
851
+ * O `_` DA CLASSE ARBITRÁRIA É O ESPAÇO DO TAILWIND (aula 01, 15/10). O achado guarda o valor como ESTÁ ESCRITO -
852
+ * `Anton,_sans-serif` -, porque é nessa grafia que o `--fix` acha a coluna para trocar; só a busca do nome lê o `_`
853
+ * como espaço, que é como o token declara a pilha.
854
+ */
855
+ const lookup = kind === "font" ? literal.replace(/_/g, " ") : literal;
856
+ const match = tokenMatch(table, lookup, kind);
804
857
  /**
805
858
  * O NOME DELE VEM DA MESMA CHAVE QUE A NOSSA FAMÍLIA VALIDOU - ver `theirNames`.
806
859
  *
807
860
  * A chave carrega o `kind` porque o valor sozinho não decide: `4px` é `--radius-xs` e
808
861
  * `--spacing` no vocabulário dele, e qual dos dois está certo depende de onde o literal está.
809
862
  */
810
- const aliased = table.aliases.get(`${kind}:${normalizeValue(literal, table.rootPx)}`);
863
+ const aliased = table.aliases.get(`${kind}:${normalizeValue(lookup, table.rootPx)}`);
811
864
  /** O valor igual não basta - o papel também, ver `roleFits` (r13 e r14, 28/09). */
812
865
  const theirs = aliased && roleFits(propertyAt(line, col), aliased.name) ? aliased : undefined;
813
866
  /** A procedência do `token`, lida da tabela que o achou - ver `tokenIsTheirs`. */
@@ -896,8 +949,19 @@ function scanCore(file, source, table) {
896
949
  pushLengths("spacing", m);
897
950
  for (const m of line.matchAll(SPACING_DECL))
898
951
  pushLengths("spacing", m);
952
+ for (const m of line.matchAll(FONT_SIZE_UTIL))
953
+ pushLengths("font", m);
954
+ for (const m of line.matchAll(FONT_SIZE_DECL))
955
+ pushLengths("font", m);
899
956
  for (const m of line.matchAll(FONT)) {
900
- const stack = m[1].trim();
957
+ /**
958
+ * A CLASSE ARBITRÁRIA DO TAILWIND TERMINA NO `]` (pedido da aula 01, 15/10). Em `[font-family:Inter,_sans-serif]
959
+ * font-medium ...` a regra de CSS - que só para em `;`, `}` ou fim de linha - engolia o resto da string de classes, e
960
+ * o `--verbose` mostrava `Inter,_sans-serif] font-medium [white-space:nowrap] ...` como uma família. Dentro do
961
+ * colchete o valor vai até o `]`; o `_` fica como está escrito, e quem o lê como espaço é a busca do nome, em `push`.
962
+ */
963
+ const inBrackets = line[(m.index ?? 0) - 1] === "[";
964
+ const stack = (inBrackets ? (m[1].split("]")[0] ?? "") : m[1]).trim();
901
965
  // A stack already reading from the system is the point, not a problem.
902
966
  if (stack.startsWith("var("))
903
967
  continue;
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({
@@ -338,7 +338,19 @@
338
338
  * novo grava no `.lock` os nomes que acabou de declarar, e o `template` e o `component` passam a escrevê-los - 51 nomes e 3
339
339
  * valores crus no hero, contra 0 e 54 antes. Uma pasta escrita antes traz a landing com os valores à mão.
340
340
  */
341
- export const MATERIALISER_SINCE = "0.16.498";
341
+ /**
342
+ * 0.16.498 -> 0.16.499 em 15/10, e o passo 1 dá **SIM** (o defeito 1 da aula 01): o tamanho de texto pedido pelo PAPEL
343
+ * (`--ds-typography-role-sm-font-size`) passa a sair com o nome do degrau que vale o mesmo, quando o projeto o declara -
344
+ * `[font-size:var(--text-sm)]` em vez de `[font-size:0.875rem]`. Um componente escrito antes traz os tamanhos à mão. No
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
+ */
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";
342
354
  /**
343
355
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
344
356
  *
@@ -551,7 +563,18 @@ export const COUNTED_DIFFERENTLY = "this run counts a value as named only when Y
551
563
  * à que tem o nome dele. Medido no `codelevel`: 33 checagens do componente mais checado chegavam sem componente. Um
552
564
  * hook anterior continua mandando essas checagens sem dono, e elas não contam para o degrau.
553
565
  */
554
- export const CHECKER_SINCE = "0.16.487";
566
+ /**
567
+ * 0.16.487 -> 0.16.499 em 15/10, e o passo 1 dá **SIM** (os defeitos 2 e 3 da aula 01): a checagem passa a acusar o tamanho
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
569
+ * engolir o resto da linha. Um hook anterior diz "limpo" sobre um `[font-size:0.875rem]` com `--text-sm` declarado.
570
+ */
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";
555
578
  /**
556
579
  * A ÚLTIMA VERSÃO EM QUE OS LEITORES PASSARAM A PRODUZIR UM CENSO DIFERENTE.
557
580
  *
@@ -1027,7 +1050,11 @@ export const CHECKER_SINCE = "0.16.487";
1027
1050
  * mostra quando duas folhas brigam, deixa de fora a folha que ninguém carrega, conta o pages router como rota e diz os
1028
1051
  * componentes em Vue, Svelte, Astro e Angular. Um censo lido antes guardou "names no token yet" onde havia tokens.
1029
1052
  */
1030
- export const READER_SINCE = "0.16.485";
1053
+ /**
1054
+ * 0.16.485 -> 0.16.499 em 15/10, e o passo 1 dá **SIM** (o defeito 3 da aula 01): a varredura lê o tamanho de texto escrito
1055
+ * à mão, e o censo passa a gravá-lo. Um censo lido antes não tem os tamanhos na fila de trabalho dele.
1056
+ */
1057
+ export const READER_SINCE = "0.16.499";
1031
1058
  /**
1032
1059
  * O QUE ESTÁ INSTALADO AQUI FICOU PARA TRÁS - e as DUAS condições que fazem isso ser verdade.
1033
1060
  *
@@ -278,6 +278,15 @@ async function readTheirs(root, slug) {
278
278
  * rodada: medido na prova de 27/09, 82 nomes viraram 38.
279
279
  */
280
280
  /** Os mesmos nomes de `systemNames`, com a variável nossa ao lado - o par que o `.lock` guarda. */
281
+ /**
282
+ * A LARGURA DA PÁGINA - a régua em que as seções do catálogo param o conteúdo (defeito 4 da aula 01, 15/10).
283
+ *
284
+ * Ela não é token do sistema: é a régua do catálogo (`apps/web/src/lib/catalog/page-width.ts`, pedido dele em 14/10), e as
285
+ * seções a escrevem como `var(--ds-layout-page-width, 1440px)`. Aqui ela ganha o nome que o Tailwind v4 lê
286
+ * (`--container-*`), e o par vai para o `.lock`: a seção materializada escreve `var(--container-page)`, e a pessoa muda a
287
+ * largura num lugar só. O valor é o MESMO do catálogo - `page-width-twin.spec.ts` compara os dois.
288
+ */
289
+ export const PAGE_WIDTH = { ours: "--ds-layout-page-width", theirs: "--container-page", value: "1440px" };
281
290
  function systemPairs(tokensCss, lookup = new Map()) {
282
291
  const { names, values } = tongueOf([], tokensCss);
283
292
  const out = [];
@@ -290,6 +299,8 @@ function systemPairs(tokensCss, lookup = new Map()) {
290
299
  if (value && !value.includes("var(--ds-"))
291
300
  out.push({ ours, theirs, value });
292
301
  }
302
+ if (out.length > 0)
303
+ out.push({ ...PAGE_WIDTH });
293
304
  return out;
294
305
  }
295
306
  function systemNames(tokensCss, lookup = new Map()) {
@@ -304,6 +315,8 @@ function systemNames(tokensCss, lookup = new Map()) {
304
315
  if (value && !value.includes("var(--ds-"))
305
316
  out.push([theirs, value]);
306
317
  }
318
+ if (out.length > 0)
319
+ out.push([PAGE_WIDTH.theirs, PAGE_WIDTH.value]);
307
320
  return out;
308
321
  }
309
322
  const same = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
@@ -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
+ }
@@ -71,7 +71,8 @@ export function inTheirTongue(css, tongue, destination = "stylesheet") {
71
71
  inlined += 1;
72
72
  if (tongue.frozen.has(name))
73
73
  froze += 1;
74
- const font = destination === "stylesheet" ? viaNextFont(name, value, tongue) : null;
74
+ /** Nas duas saídas - a classe do Tailwind também (defeito 1 da aula 01, 15/10); `write` escapa o espaço. */
75
+ const font = viaNextFont(name, value, tongue);
75
76
  return write(font ?? value);
76
77
  }
77
78
  /**
@@ -155,6 +156,8 @@ artifacts) {
155
156
  */
156
157
  export function tongueOf(map, installedCss) {
157
158
  const names = new Map(map.map((p) => [p.ours, p.theirs]));
159
+ /** O valor que o `.lock` mediu para cada par - a folha reescrita já não o declara (`var(--text-sm)`). */
160
+ const pairValue = new Map(map.flatMap((p) => (p.value ? [[p.ours, p.value]] : [])));
158
161
  const values = new Map();
159
162
  /**
160
163
  * O que cada variável nossa declara na folha, antes de qualquer indireção ser seguida.
@@ -226,6 +229,32 @@ export function tongueOf(map, installedCss) {
226
229
  else
227
230
  values.set(ours, destination.value);
228
231
  }
232
+ /**
233
+ * O PAPEL DE TIPO FALA O NOME DO DEGRAU QUE VALE O MESMO (defeito 1 da aula 01, 15/10).
234
+ *
235
+ * O componente pede o tamanho pelo PAPEL (`--ds-typography-role-sm-font-size`), e o nome dele só existe para o DEGRAU da
236
+ * escala (`--ds-typography-scale-sm-font-size` -> `--text-sm`). O papel saía como `[font-size:0.875rem]` num projeto
237
+ * que declara `--text-sm: 0.875rem`, e o `doctor` acusava na hora um valor com nome esperando.
238
+ *
239
+ * Só quando o MESMO degrau (`role-sm` -> `scale-sm`) JÁ tem nome dele e vale exatamente o mesmo, na mesma propriedade -
240
+ * é a troca que o `--fix` faria depois. O papel nunca vira um degrau que o projeto não declara: sem nome, continua o valor.
241
+ */
242
+ for (const [ours, value] of values) {
243
+ const role = /^--ds-typography-role-(.+)-(font-size|line-height|letter-spacing)$/.exec(ours);
244
+ if (!role)
245
+ continue;
246
+ /** O MESMO degrau, nunca outro que por acaso vale o mesmo: `role-sm` -> `scale-sm` (prova de 15/10). */
247
+ const step = `--ds-typography-scale-${role[1]}-${role[2]}`;
248
+ const theirName = names.get(step);
249
+ if (!theirName)
250
+ continue;
251
+ const written = declared.get(step);
252
+ const stepValue = written?.startsWith("var(") ? pairValue.get(step) : written;
253
+ if (stepValue === undefined || stepValue.trim().toLowerCase() !== value.trim().toLowerCase())
254
+ continue;
255
+ names.set(ours, theirName);
256
+ values.delete(ours);
257
+ }
229
258
  /**
230
259
  * AS QUE MUDAM ENTRE ESQUEMAS E VIRAM LITERAL - o subconjunto que congela.
231
260
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.498",
3
+ "version": "0.16.500",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {