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 +9 -2
- package/dist/commands/doctor.js +24 -1
- package/dist/commands/hook.js +34 -2
- package/dist/commands/read.js +317 -0
- package/dist/doctor/broken-refs.js +1 -1
- package/dist/doctor/scan.js +67 -3
- package/dist/index.js +18 -0
- package/dist/install-marks.js +30 -3
- package/dist/new-project-theme.js +13 -0
- 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/dist/their-tongue.js +30 -1
- 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/doctor.js
CHANGED
|
@@ -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
|
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
|
+
}
|
|
@@ -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
|
*
|
package/dist/doctor/scan.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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({
|
package/dist/install-marks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/their-tongue.js
CHANGED
|
@@ -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
|
-
|
|
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