synthesisui 0.16.153 → 0.16.155

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.
@@ -0,0 +1,154 @@
1
+ import { deltaE, JND } from "./doctor/color-distance.js";
2
+ import { nearestToken } from "./doctor/tokens.js";
3
+ /** Onde cada tipo de valor mora na fundação. `color` é o único com dois segmentos. */
4
+ const HOME = {
5
+ color: "color",
6
+ radius: "radius",
7
+ spacing: "spacing",
8
+ font: "typography",
9
+ motion: "motion.durations",
10
+ };
11
+ /** Uma palavra de categoria no começo do nome deles não é família - é a categoria repetida. */
12
+ const CATEGORY = /^(color|colour|radius|rounded|spacing|space|gap|font|type|duration|motion|ease|easing)-/;
13
+ const clean = (name) => name.replace(/^--/, "").toLowerCase();
14
+ /**
15
+ * O CAMINHO A PARTIR DO NOME DELES, e nada além dele.
16
+ *
17
+ * Cor precisa de família E step, e a forma que o mundo real produz é `<família>-<número>` - a mesma que
18
+ * o censo lê para montar as rampas deles. Sem o número não há step, e inventar um (`.500`) seria
19
+ * escolher uma posição na escala por eles: aí o valor volta sem caminho e a pessoa decide.
20
+ *
21
+ * O namespace é PRESERVADO de propósito: um repo com `--color-lightgray-300` e
22
+ * `--dashboard-lightgray-300` tem duas famílias, e `dashboard-lightgray` é o nome honesto da segunda.
23
+ */
24
+ export function pathFor(kind, theirName) {
25
+ if (!theirName)
26
+ return "";
27
+ const bare = clean(theirName).replace(CATEGORY, "");
28
+ if (!bare)
29
+ return "";
30
+ if (kind === "color") {
31
+ const m = /^(.*[a-z0-9])-(\d{2,4})$/.exec(bare);
32
+ return m ? `color.${m[1]}.${m[2]}` : "";
33
+ }
34
+ return `${HOME[kind]}.${bare}`;
35
+ }
36
+ /**
37
+ * O TOKEN DELES MAIS PRÓXIMO DESTE VALOR, quando existe um perto o bastante para valer a frase.
38
+ *
39
+ * Cor por ΔE76 (2.3 é a diferença que um olho começa a notar; abaixo disso são a mesma cor discutindo).
40
+ * Comprimento pelo mesmo `nearestToken` que o doctor já usa para dizer "snap to". Motion e tipografia
41
+ * ficam de fora: 0.2s e 0.25s não são "quase iguais", são durações diferentes de propósito.
42
+ */
43
+ function nearestOwn(theirs, literal, kind) {
44
+ if (kind === "color") {
45
+ let best = null;
46
+ for (const [name, value] of theirs.byName) {
47
+ const d = deltaE(literal, value);
48
+ if (d === null || d === 0)
49
+ continue;
50
+ if (best === null || d < best.away)
51
+ best = { name, value, away: d };
52
+ }
53
+ /** Três ΔE: perto o bastante para alguém querer olhar, longe o bastante para não ser óbvio. */
54
+ if (!best || best.away > JND * 1.3)
55
+ return undefined;
56
+ return {
57
+ name: clean(best.name),
58
+ value: best.value,
59
+ away: `${best.away.toFixed(1)} ΔE`,
60
+ };
61
+ }
62
+ if (kind === "radius" || kind === "spacing") {
63
+ const hit = nearestToken(theirs, literal);
64
+ return hit
65
+ ? {
66
+ name: clean(hit.name),
67
+ value: hit.value,
68
+ away: `${hit.delta}${/rem$/.test(literal) ? "rem" : "px"} apart`,
69
+ }
70
+ : undefined;
71
+ }
72
+ return undefined;
73
+ }
74
+ /**
75
+ * A PROPOSTA: os valores mais repetidos que o sistema não nomeia, com o caminho quando ele é derivável.
76
+ *
77
+ * `theirs` é a tabela do vocabulário DELES (`buildTable({ source: "yours" })`), lida da raiz mesmo
78
+ * quando a medição é escopada - é o mesmo princípio que já vale no doctor: o vocabulário vem da raiz, só
79
+ * a leitura é escopada.
80
+ *
81
+ * `have` são os caminhos que a fundação já tem, para a proposta nunca sugerir sobrescrever um token
82
+ * existente com outro valor - isso não é absorver, é repintar.
83
+ */
84
+ export function absorbPlan(d, theirs, have, cap = 40) {
85
+ const unnamed = d.repeats.filter((r) => !r.token);
86
+ const entries = [];
87
+ for (const r of unnamed) {
88
+ if (entries.length >= cap)
89
+ break;
90
+ const theirNames = theirs.byValue.get(r.literal.toLowerCase()) ?? [];
91
+ const theirName = theirNames[0];
92
+ const path = pathFor(r.kind, theirName);
93
+ const near = nearestOwn(theirs, r.literal, r.kind);
94
+ /** Já existe na fundação com OUTRO valor: absorver aqui seria repintar o token deles. */
95
+ if (path && have.has(path))
96
+ continue;
97
+ entries.push({
98
+ value: r.literal,
99
+ kind: r.kind,
100
+ files: r.files,
101
+ count: r.count,
102
+ path,
103
+ ...(theirName ? { theirName: clean(theirName) } : {}),
104
+ ...(near ? { near } : {}),
105
+ });
106
+ }
107
+ return { entries, more: Math.max(0, unnamed.length - entries.length) };
108
+ }
109
+ /** Quantas linhas da proposta ainda precisam de um nome humano. */
110
+ export const needingName = (plan) => plan.entries.filter((e) => !e.path);
111
+ /**
112
+ * A proposta em texto - e ela lidera pelo que está PRONTO para enviar.
113
+ *
114
+ * Uma lista que abre pelas pendências faz parecer que nada pode ser feito hoje; a maioria das linhas de
115
+ * um repo em migração já tem nome no vocabulário do próprio cliente, e essas viajam sem ninguém digitar
116
+ * nada.
117
+ */
118
+ export function describeAbsorb(plan) {
119
+ const ready = plan.entries.filter((e) => e.path);
120
+ const pending = needingName(plan);
121
+ const lines = [];
122
+ if (plan.entries.length === 0)
123
+ return [
124
+ "Nothing to absorb: every repeated value your code writes by hand already has a name in the system.",
125
+ ];
126
+ lines.push(`${ready.length} of ${plan.entries.length} values already have a name in YOUR vocabulary - those travel as they are:`);
127
+ for (const e of ready.slice(0, 8))
128
+ lines.push(` ${e.value.padEnd(24)} ${e.path.padEnd(28)} ${e.files} file${e.files === 1 ? "" : "s"}${e.theirName ? ` · your ${e.theirName}` : ""}`);
129
+ if (ready.length > 8)
130
+ lines.push(` (${ready.length - 8} more)`);
131
+ if (pending.length > 0) {
132
+ const close = pending.filter((e) => e.near);
133
+ lines.push("");
134
+ lines.push(`${pending.length} nobody names yet. Naming is a design decision, so those wait for a word from you:`);
135
+ for (const e of pending.slice(0, 8))
136
+ lines.push(` ${e.value.padEnd(24)} ${`<${e.kind}, ${e.files} file${e.files === 1 ? "" : "s"}>`.padEnd(28)} ${e.near ? `≈ your ${e.near.name} (${e.near.away})` : 'path: ""'}`);
137
+ if (pending.length > 8)
138
+ lines.push(` (${pending.length - 8} more)`);
139
+ /**
140
+ * E A LEITURA QUE MUDA O TRABALHO: quando a maioria dos sem-nome está perto de um token que eles já
141
+ * têm, isto não é uma fila de batismos - é uma fila de NORMALIZAÇÃO, e sai muito mais barato.
142
+ */
143
+ if (close.length > 0) {
144
+ lines.push("");
145
+ lines.push(`${close.length} of those ${close.length === 1 ? "is" : "are"} within a hair of a token you already have (marked ≈). Those are probably not new names - they are the same decision written twice, and pointing the code at what you have is cheaper than naming them.`);
146
+ }
147
+ }
148
+ /** O teto NUNCA é silencioso: um corte que não se diz lê como "é tudo o que existe". */
149
+ if (plan.more > 0) {
150
+ lines.push("");
151
+ lines.push(`${plan.more} more unnamed values are below the cut - the file holds the ${plan.entries.length} most repeated. Absorb these, re-run, and the next batch appears.`);
152
+ }
153
+ return lines;
154
+ }
@@ -0,0 +1,211 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { absorbPlan, describeAbsorb, needingName, } from "../absorb-plan.js";
4
+ import { readProjectConfig, readToken, resolveRegistry } from "../config.js";
5
+ import { diagnose, scanSource } from "../doctor/scan.js";
6
+ import { buildTable } from "../doctor/tokens.js";
7
+ import { measuredScope, scopePaths } from "../measured-scope.js";
8
+ import { body, paint, section, snippet } from "../output.js";
9
+ import { loadSystem, walkAll } from "./doctor.js";
10
+ /**
11
+ * `synthesisui absorb` - O SISTEMA APRENDE O DESIGN QUE O CÓDIGO JÁ TEM.
12
+ *
13
+ * O outro sentido da esteira, e o que faltava para o ciclo de migração fechar (dono, 06/08, movendo
14
+ * design de três apps para `packages/ui`):
15
+ *
16
+ * doctor --migrate 4223 valores que o sistema não nomeia, por frequência
17
+ * absorb ESTE: eles viram tokens do sistema
18
+ * upgrade o repo pega a versão nova
19
+ * doctor --fix agora eles têm nome, e a troca é automática
20
+ *
21
+ * DUAS FASES, e a do meio é humana de propósito. A primeira escreve uma PROPOSTA em
22
+ * `_synthesisui/absorb.json`: cada valor com o caminho derivado do nome que eles já usam, e vazio
23
+ * quando ninguém nomeia. A pessoa preenche os vazios num arquivo, de uma vez - vinte perguntas no
24
+ * terminal é como uma migração vira uma tarde perdida. Aí `--send` manda o lote.
25
+ *
26
+ * O arquivo é o contrato entre as duas fases, e é revisável por outra pessoa antes de ir: um lote de
27
+ * tokens novos num sistema compartilhado é exatamente o tipo de mudança que um time quer ver num diff.
28
+ */
29
+ const FILE = "_synthesisui/absorb.json";
30
+ export async function absorb(opts) {
31
+ const root = resolve(opts.dir ?? process.cwd());
32
+ const path = join(root, FILE);
33
+ if (opts.send)
34
+ return sendProposal(root, path, opts.registry);
35
+ const installed = await loadSystem(root);
36
+ if (!installed.table.slug) {
37
+ console.log(section("Absorb"));
38
+ console.log(body("No system installed here, so there is nothing to absorb INTO. `synthesisui list` shows what you can add."));
39
+ return;
40
+ }
41
+ const measured = await measuredScope(root);
42
+ const rel = scopePaths(measured);
43
+ const roots = rel.length > 0 ? rel.map((s) => resolve(root, s)) : [root];
44
+ /**
45
+ * DUAS TABELAS, e é a diferença entre elas que define o trabalho.
46
+ *
47
+ * A nossa diz o que o sistema JÁ nomeia - o que ela nomeia é assunto do `--fix`. A DELES diz como
48
+ * eles chamam o valor, e é de lá que o nome sai. Lida da RAIZ mesmo quando a medição é escopada: o
49
+ * vocabulário é do projeto, e um app que pega um token emprestado do `packages/ui` deles não o
50
+ * redeclara.
51
+ */
52
+ let css = "";
53
+ for await (const file of walkAll([root]))
54
+ if (/\.(css|scss|sass|less)$/i.test(file))
55
+ css += `\n${await readFile(file, "utf8").catch(() => "")}`;
56
+ const theirs = buildTable({ css, source: "yours" });
57
+ const reports = [];
58
+ for await (const file of walkAll(roots)) {
59
+ const source = await readFile(file, "utf8").catch(() => null);
60
+ if (source === null)
61
+ continue;
62
+ reports.push(scanSource(file.slice(root.length + 1), source, installed.table));
63
+ }
64
+ const plan = absorbPlan(diagnose(reports), theirs, pathsInSystem(installed.documents), opts.cap ?? 40);
65
+ console.log(section("What the system could absorb"));
66
+ console.log(body(paint.dim(`read from ${rel.length > 0 ? rel.join(", ") : "this project"} · your own vocabulary read from the root`)));
67
+ console.log("");
68
+ for (const line of describeAbsorb(plan))
69
+ console.log(body(line));
70
+ if (plan.entries.length === 0)
71
+ return;
72
+ const proposal = {
73
+ slug: installed.table.slug,
74
+ at: new Date().toISOString(),
75
+ scope: rel,
76
+ entries: plan.entries,
77
+ more: plan.more,
78
+ };
79
+ await mkdir(dirname(path), { recursive: true });
80
+ await writeFile(path, `${JSON.stringify(proposal, null, 2)}\n`, "utf8");
81
+ console.log("");
82
+ console.log(body(`Written to ${FILE} - nothing was sent.`));
83
+ const pending = needingName(plan).length;
84
+ if (pending > 0)
85
+ console.log(body(`Fill the ${pending} empty \`path\` field${pending === 1 ? "" : "s"} with the name you want (\`color.<family>.<step>\`, \`spacing.<key>\`), delete the lines you do not want, then:`));
86
+ else
87
+ console.log(body("Delete the lines you do not want and send the rest with:"));
88
+ console.log(paint.blue(snippet(["npx synthesisui@latest absorb --send"])));
89
+ }
90
+ /**
91
+ * OS CAMINHOS QUE A FUNDAÇÃO JÁ TEM, lidos do documento que o `add` deixou ao lado dos tokens.
92
+ *
93
+ * Serve para a proposta nunca sugerir escrever num token existente: isso não seria absorver o design
94
+ * deles, seria repintar uma decisão que eles já tomaram. A plataforma recusa de novo do outro lado - ela
95
+ * é a autoridade -, e recusar aqui evita a pessoa preencher um nome que vai ser rejeitado.
96
+ */
97
+ function pathsInSystem(documents) {
98
+ const out = new Set();
99
+ const walk = (node, path) => {
100
+ if (node && typeof node === "object" && !Array.isArray(node))
101
+ for (const [k, v] of Object.entries(node))
102
+ walk(v, [...path, k]);
103
+ else if (typeof node === "string" && path.length > 0)
104
+ out.add(path.join("."));
105
+ };
106
+ for (const doc of documents) {
107
+ const d = (doc ?? {});
108
+ const foundations = d.foundations ?? {};
109
+ const color = (foundations.color ?? {});
110
+ walk(color.primitives, ["color"]);
111
+ for (const [key, value] of Object.entries(foundations))
112
+ if (key !== "color")
113
+ walk(value, [key]);
114
+ walk(d.motion?.durations, ["motion", "durations"]);
115
+ walk(d.motion?.easings, ["motion", "easings"]);
116
+ }
117
+ return out;
118
+ }
119
+ /** Manda o lote gravado. Só o que tem caminho viaja - o resto ainda espera um nome. */
120
+ async function sendProposal(root, path, registry) {
121
+ const raw = await readFile(path, "utf8").catch(() => null);
122
+ if (!raw) {
123
+ console.log(section("Absorb"));
124
+ console.log(body(`No proposal at ${FILE}. Run \`npx synthesisui@latest absorb\` first - it writes one and sends nothing.`));
125
+ return;
126
+ }
127
+ let proposal;
128
+ try {
129
+ proposal = JSON.parse(raw);
130
+ }
131
+ catch {
132
+ console.log(section("Absorb"));
133
+ console.log(body(`${FILE} is not readable JSON anymore. Fix the file, or delete it and run \`absorb\` again.`));
134
+ return;
135
+ }
136
+ const ready = (proposal.entries ?? []).filter((e) => typeof e.path === "string" && e.path.trim().length > 0);
137
+ const waiting = (proposal.entries ?? []).length - ready.length;
138
+ if (ready.length === 0) {
139
+ console.log(section("Absorb"));
140
+ console.log(body(`Every line in ${FILE} still has an empty \`path\`. A name is the one thing this cannot decide for you.`));
141
+ return;
142
+ }
143
+ /**
144
+ * O ALVO DECIDE SE ISSO SAI DA MÁQUINA - ver `ProjectConfig.absorb`.
145
+ *
146
+ * `code`: o vocabulário canônico é o CSS deles, então a nossa parte é devolver o bloco e a deles é
147
+ * colar. Nunca escrevemos no arquivo do cliente - e o import seguinte lê os tokens de volta, o que faz
148
+ * o caminho fechar sem nós tocarmos em nada.
149
+ */
150
+ const target = (await readProjectConfig(root)).absorb ?? "system";
151
+ if (target === "code") {
152
+ console.log(section("Paste this into your own vocabulary"));
153
+ console.log(body(paint.dim("Your config says absorbed values belong in your CSS, so nothing was sent.")));
154
+ console.log("");
155
+ console.log(" :root {");
156
+ for (const e of ready)
157
+ console.log(` --${e.path.replace(/\./g, "-")}: ${e.value};`);
158
+ console.log(" }");
159
+ console.log("");
160
+ console.log(body("The next import or `sync` reads these back as tokens you declared, and from then on the doctor cobra them by your own names."));
161
+ return;
162
+ }
163
+ const token = await readToken();
164
+ if (!token) {
165
+ console.log(section("Absorb"));
166
+ console.log(body("Sending needs a login - the tokens land in YOUR system:"));
167
+ console.log(paint.blue(snippet(["npx synthesisui@latest login"])));
168
+ return;
169
+ }
170
+ const base = resolveRegistry(registry);
171
+ const res = await fetch(`${base}/api/registry/ds/${proposal.slug}/absorb`, {
172
+ method: "POST",
173
+ headers: {
174
+ "content-type": "application/json",
175
+ Authorization: `Bearer ${token}`,
176
+ },
177
+ body: JSON.stringify({
178
+ tokens: ready.map((e) => ({
179
+ path: e.path.trim(),
180
+ value: e.value,
181
+ ...(e.theirName ? { was: e.theirName } : {}),
182
+ })),
183
+ }),
184
+ }).catch(() => null);
185
+ console.log(section("Absorb"));
186
+ if (!res?.ok) {
187
+ console.log(body(res
188
+ ? `The registry answered ${res.status}. Nothing was absorbed, and ${FILE} is untouched - fix and send again.`
189
+ : "Could not reach the registry. Nothing was absorbed."));
190
+ return;
191
+ }
192
+ const answer = (await res.json());
193
+ console.log(body(`${answer.added ?? 0} token${answer.added === 1 ? "" : "s"} are now part of your system${answer.draft ? `, in the v${answer.draft} draft` : ""}.`));
194
+ for (const s of (answer.skipped ?? []).slice(0, 8))
195
+ console.log(body(paint.dim(` skipped ${s.path} - ${s.why}`)));
196
+ if (waiting > 0)
197
+ console.log(body(paint.dim(`${waiting} line${waiting === 1 ? "" : "s"} stayed behind with no name. They are still in ${FILE}.`)));
198
+ /**
199
+ * E O CICLO SE FECHA AQUI, dito em voz alta.
200
+ *
201
+ * Absorver não muda o código: os literais continuam escritos à mão até alguém trocá-los, e agora
202
+ * existe por quem trocar. Ninguém adivinharia essa ordem - foi ela que faltou na primeira conversa
203
+ * sobre migração (dono, 06/08).
204
+ */
205
+ console.log("");
206
+ console.log(body("Two commands and the code catches up:"));
207
+ console.log(paint.blue(snippet([
208
+ `npx synthesisui@latest upgrade ${proposal.slug}`,
209
+ "npx synthesisui@latest doctor --fix",
210
+ ])));
211
+ }
@@ -1,5 +1,6 @@
1
1
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, join, relative, resolve } from "node:path";
3
+ import { describeIntent, intentOf, readProjectConfig } from "../config.js";
3
4
  import { describeFix, planFix, readerFor, writeFix, } from "../doctor/apply-fix.js";
4
5
  import { baselineOf, compareToBaseline, describeVerdict, githubAnnotations, sarifOf, } from "../doctor/ci-format.js";
5
6
  import { findDivergences } from "../doctor/coherence.js";
@@ -13,6 +14,7 @@ import { checkableName, readRequests, verifyAndCloseRequests, } from "../doctor/
13
14
  import { diagnose, scanSource, siblingTokens, } from "../doctor/scan.js";
14
15
  import { findSelfConflicts, forbiddenProps, isReset, propMatchesLabel, } from "../doctor/self-conflict.js";
15
16
  import { buildTable, EMPTY_TABLE, nearestToken, } from "../doctor/tokens.js";
17
+ import { describeScope, measuredScope, scopePaths } from "../measured-scope.js";
16
18
  import { body, paint, section, snippet } from "../output.js";
17
19
  import { resolveDeps } from "../stack.js";
18
20
  /**
@@ -412,7 +414,32 @@ async function readWiring(root, slug) {
412
414
  const DEFAULT_BASELINE = "_synthesisui/drift-baseline.json";
413
415
  export async function doctor(opts) {
414
416
  const root = resolve(opts.dir ?? process.cwd());
415
- const scopes = (opts.scopes ?? []).map((s) => resolve(root, s));
417
+ /**
418
+ * O ESCOPO QUE O IMPORT JÁ GRAVOU - ver `measured-scope.ts`.
419
+ *
420
+ * Um caminho passado à mão manda, sempre: quem digitou sabe o que quer. Sem ele, a resposta já está
421
+ * no disco (`census.json`: `scope` + `usage`), e varrer a raiz ignorando isso foi o que produziu uma
422
+ * lista de prioridades tirada de um app consumidor num repo de 2787 arquivos (dono, 06/08).
423
+ */
424
+ const measured = (opts.scopes ?? []).length > 0
425
+ ? { system: null, usage: [], from: "none" }
426
+ : await measuredScope(root);
427
+ const relScopes = (opts.scopes ?? []).length > 0
428
+ ? opts.scopes
429
+ : scopePaths(measured);
430
+ const scopes = relScopes.map((s) => resolve(root, s));
431
+ /**
432
+ * CORRIDA COMPLETA É "NINGUÉM DIGITOU UM CAMINHO", e não "varreu tudo".
433
+ *
434
+ * O ledger só guarda retrato de corrida completa, porque a cobertura de um canto na linha do tempo do
435
+ * projeto lê como o projeto tendo melhorado quando alguém só apontou a ferramenta para outra pasta.
436
+ * Com o escopo agora vindo do censo, `scopes.length === 0` deixaria de acontecer - e o histórico
437
+ * pararia de existir em todo repo que tem censo. O que caracteriza a corrida completa é a INTENÇÃO de
438
+ * medir o projeto, e ela é: nenhum caminho na linha de comando.
439
+ */
440
+ const fullRun = (opts.scopes ?? []).length === 0;
441
+ /** A intenção ordena o relatório, e a precedência é flag > config > default. */
442
+ const intent = intentOf(await readProjectConfig(root), opts.intent);
416
443
  const installed = await loadSystem(root);
417
444
  const { recipes, documents } = installed;
418
445
  let table = installed.table;
@@ -533,9 +560,24 @@ export async function doctor(opts) {
533
560
  : table.source === "yours"
534
561
  ? `Your own tokens - ${plural(table.byName.size, "token")} found, ${plural(d.scanned, "file")} read`
535
562
  : `No system installed - ${plural(d.scanned, "file")} read`));
563
+ /**
564
+ * ONDE ELE LEU, SEMPRE - e de onde essa decisão veio.
565
+ *
566
+ * "2787 files read" sem dizer o universo é um número que a pessoa preenche com o que imagina, e ela
567
+ * imagina o sistema dela. Quando o escopo veio do censo, a frase nomeia os dois papéis: a pasta que
568
+ * É o sistema, e os apps que o CONSOMEM.
569
+ */
536
570
  if (scopes.length > 0) {
537
- console.log(body(`scope: ${opts.scopes?.join(", ")}`));
571
+ const said = measured.from === "census" ? describeScope(measured) : null;
572
+ console.log(body(said ?? `scope: ${relScopes.join(", ")}`));
538
573
  }
574
+ /**
575
+ * E A INTENÇÃO, em toda rodada, com a data e o jeito de inverter.
576
+ *
577
+ * É o que impede uma fase declarada em agosto de ordenar o relatório em dezembro sem ninguém saber
578
+ * por quê - a mesma regra que fez `not observed since` existir em vez de um `fixed` inferido.
579
+ */
580
+ console.log(body(paint.dim(describeIntent(intent))));
539
581
  // What a token could never have held, said out loud. A report that quietly
540
582
  // drops things is only trustworthy until someone notices, and then it is
541
583
  // worth less than one that says too much.
@@ -574,6 +616,15 @@ export async function doctor(opts) {
574
616
  // Fonts only count as missing when `init` actually wrote the file - a
575
617
  // non-Next project has no fonts.ts and must not be nagged about one.
576
618
  const fontsPending = wiring.fontsWritten && !wiring.fontsMapped;
619
+ /**
620
+ * A FIAÇÃO É PORTÃO, e não um aviso ao lado de um número.
621
+ *
622
+ * Sem o `@import` ou sem o `data-ds`, os tokens não chegam ao navegador. Até 06/08 a tela dizia isso
623
+ * e, três linhas abaixo, imprimia uma barra de 0% e uma lista de cinco prioridades - se contradizendo
624
+ * na mesma tela (dono: "fiquei perdido"). Ou o número não significa nada, ou existe uma lista de
625
+ * prioridades; as duas não cabem juntas.
626
+ */
627
+ const blocked = hasSystem && (!wiring.imported || !wiring.scoped);
577
628
  const unwired = table.source === "installed" &&
578
629
  (!wiring.imported || !wiring.scoped || fontsPending);
579
630
  if (unwired) {
@@ -598,10 +649,13 @@ export async function doctor(opts) {
598
649
  // working and the faces are not - saying "nothing reaches the browser"
599
650
  // there would be false, and a report that overstates is one nobody trusts
600
651
  // the next time.
601
- const blocked = !wiring.imported || !wiring.scoped;
602
652
  if (blocked) {
603
- console.log(body(`Until the first two are true, none of the ${table.byName.size} tokens reach the`));
604
- console.log(body("browser and the number below cannot mean anything."));
653
+ /**
654
+ * A FRASE SEGUE O QUE VEM DEPOIS DELA. Ela dizia "o número abaixo não pode significar nada"
655
+ * enquanto imprimia o número abaixo; agora não há número abaixo, e prometer um seria a mesma
656
+ * incoerência ao contrário.
657
+ */
658
+ console.log(body(`Until both are true, none of the ${table.byName.size} tokens reach the browser.`));
605
659
  }
606
660
  else {
607
661
  console.log(body("Colour and spacing are working. Type is not: the system's faces"));
@@ -610,6 +664,41 @@ export async function doctor(opts) {
610
664
  }
611
665
  console.log(body("The exact snippets are in the output of `init`."));
612
666
  }
667
+ /**
668
+ * E AQUI O RELATÓRIO PARA, quando a fiação está vermelha: uma instrução, e nada mais.
669
+ *
670
+ * O que fica de fora é justamente o que não pode ser verdade ainda - a barra de cobertura, a lista de
671
+ * prioridades e a oferta do `--fix`. O `--fix` é o mais grave: no repo do dono ele teria trocado 217
672
+ * literais por `var(--ds-…)` que não resolvem em lugar nenhum, e o navegador descarta a declaração -
673
+ * ou seja, o comando que promete adotar o sistema quebraria a aparência do app.
674
+ *
675
+ * O ledger é gravado ANTES de sair: a corrida aconteceu, e o histórico não pode depender de o
676
+ * projeto estar bem configurado.
677
+ */
678
+ if (blocked) {
679
+ if (fullRun)
680
+ await appendEvent(root, {
681
+ kind: "doctor",
682
+ at: new Date().toISOString(),
683
+ coverage: d.coverage,
684
+ named: d.named,
685
+ ...(d.findings.some((f) => f.crossFamily)
686
+ ? { crossFamily: d.findings.filter((f) => f.crossFamily).length }
687
+ : {}),
688
+ }).catch(() => { });
689
+ console.log("");
690
+ if (opts.fix) {
691
+ console.log(body(`--fix is refused while those two are false. ${d.named} of your hand-written values`));
692
+ console.log(body("do have a name in the system, and swapping them now would point them at"));
693
+ console.log(body("variables the browser cannot resolve - the declarations would be dropped"));
694
+ console.log(body("and the page would change. Wire the two lines first."));
695
+ }
696
+ else {
697
+ console.log(body(`${d.named} of the ${d.findings.length} hand-written values found already have a name in`));
698
+ console.log(body("your system, and that number becomes actionable the moment both are true."));
699
+ }
700
+ return;
701
+ }
613
702
  /**
614
703
  * SHADCN IS THE MOST LIKELY THING ALREADY IN THE PROJECT, AND THE BRIDGE WAS
615
704
  * INVISIBLE.
@@ -671,7 +760,7 @@ export async function doctor(opts) {
671
760
  * question a design-system lead asks before paying for anything, and until
672
761
  * today the honest answer was that nobody knew.
673
762
  */
674
- if (hasSystem && measurable && scopes.length === 0) {
763
+ if (hasSystem && measurable && fullRun) {
675
764
  /**
676
765
  * E A CONTAGEM DE COINCIDÊNCIAS VAI JUNTO - ver `crossFamily` em `scan.ts`.
677
766
  *
@@ -688,7 +777,7 @@ export async function doctor(opts) {
688
777
  ...(crossFamily > 0 ? { crossFamily } : {}),
689
778
  });
690
779
  }
691
- if (hasSystem && scopes.length === 0) {
780
+ if (hasSystem && fullRun) {
692
781
  const record = summarize(await readEvents(root));
693
782
  if (record.checks >= 2) {
694
783
  console.log("");
@@ -716,7 +805,7 @@ export async function doctor(opts) {
716
805
  * the considered-and-rejected entries arrive already argued, by the party
717
806
  * that actually hit the gap.
718
807
  */
719
- if (hasSystem && scopes.length === 0) {
808
+ if (hasSystem && fullRun) {
720
809
  // Verification first: whether an ask is still missing is a FACT about
721
810
  // the installed css, so the queue re-checks before it reports. A request
722
811
  // the build now delivers closes itself and says so - nobody marks done
@@ -1143,18 +1232,31 @@ export async function doctor(opts) {
1143
1232
  cheap: false,
1144
1233
  });
1145
1234
  }
1146
- for (const r of d.repeats.filter((x) => x.token).slice(0, 3)) {
1235
+ /**
1236
+ * AS DUAS INTENÇÕES ORDENAM ESTA LISTA AO CONTRÁRIO UMA DA OUTRA.
1237
+ *
1238
+ * `adopt`: o que o sistema JÁ nomeia vem primeiro - é troca de uma linha e o `--fix` faz.
1239
+ * `migrate`: o que ele NÃO nomeia vem primeiro - é a decisão de design que a biblioteca tem que
1240
+ * absorver, e é o trabalho de quem está movendo design de um app para um pacote compartilhado.
1241
+ *
1242
+ * As duas listas continuam presentes nos dois modos (dono, 06/08): esconder metade faria a tela
1243
+ * mentir por omissão. Muda a ordem e muda quantos de cada aparecem no teto de cinco.
1244
+ */
1245
+ const migrating = intent.intent === "migrate";
1246
+ const named = d.repeats.filter((x) => x.token);
1247
+ const unnamed = d.repeats.filter((x) => !x.token);
1248
+ for (const r of named.slice(0, migrating ? 2 : 3)) {
1147
1249
  plan.push({
1148
- rank: 3,
1250
+ rank: migrating ? 4 : 3,
1149
1251
  what: `${r.literal} → ${r.token}`,
1150
1252
  size: `${r.files} file${r.files === 1 ? "" : "s"}`,
1151
1253
  cheap: true,
1152
1254
  });
1153
1255
  }
1154
- for (const r of d.repeats.filter((x) => !x.token).slice(0, 2)) {
1256
+ for (const r of unnamed.slice(0, migrating ? 3 : 2)) {
1155
1257
  const near = nearestToken(table, r.literal);
1156
1258
  plan.push({
1157
- rank: 4,
1259
+ rank: migrating ? 3 : 4,
1158
1260
  what: near
1159
1261
  ? `${r.literal} - name it, or snap to ${near.name}`
1160
1262
  : `${r.literal} - the system has no name for it`,
@@ -1318,7 +1420,8 @@ export async function doctor(opts) {
1318
1420
  await writeFile(path, `${JSON.stringify(baselineOf(d, {
1319
1421
  at: new Date().toISOString(),
1320
1422
  ...(opts.cli ? { cli: opts.cli } : {}),
1321
- ...(opts.scopes?.length ? { scope: opts.scopes.join(",") } : {}),
1423
+ /** O escopo EFETIVO, e não a flag: comparar dois universos diferentes inventa regressão. */
1424
+ ...(relScopes.length ? { scope: relScopes.join(",") } : {}),
1322
1425
  }), null, 1)}\n`, "utf8");
1323
1426
  console.log(section("Baseline written"));
1324
1427
  console.log(body(`${d.findings.length} findings and ${d.phantomUses} phantom uses are now the line to hold. A run that goes above it fails; everything at or below it passes.`));
@@ -1337,7 +1440,7 @@ export async function doctor(opts) {
1337
1440
  return;
1338
1441
  }
1339
1442
  const verdict = compareToBaseline(d, base, {
1340
- ...(opts.scopes?.length ? { scope: opts.scopes.join(",") } : {}),
1443
+ ...(relScopes.length ? { scope: relScopes.join(",") } : {}),
1341
1444
  });
1342
1445
  console.log(section(verdict.worse ? "Drift went up" : "Ratchet"));
1343
1446
  for (const line of describeVerdict(verdict))
@@ -857,6 +857,16 @@ export async function takeCensus(root, opts) {
857
857
  ...(v.unslotted.length > 0
858
858
  ? { unplaced: [...new Set(v.unslotted)] }
859
859
  : {}),
860
+ /**
861
+ * AS NOTAS SÃO OUTRA LISTA - ver `notes` em `variant-read.ts`.
862
+ *
863
+ * Um layer que FICOU e precisa de um `data-*` não é um look sem lugar: ele está na receita.
864
+ * As duas viajavam em `unplaced`, e a tela de causas cobrava conserto de 16 linhas que
865
+ * diziam, no próprio texto, que nada tinha sido perdido (dono, 06/08).
866
+ */
867
+ ...(v.notes.length > 0
868
+ ? { renderNotes: [...new Set(v.notes)] }
869
+ : {}),
860
870
  ...(Object.keys(v.axes).length > 0 ? { declaredAxes: v.axes } : {}),
861
871
  ...(Object.keys(defaults).length > 0 ? { defaults } : {}),
862
872
  };
@@ -1253,6 +1263,30 @@ export async function takeCensus(root, opts) {
1253
1263
  console.log(body(paint.faint(` and ${unplacedByComponent.length - 8} more components`)));
1254
1264
  }
1255
1265
  }
1266
+ /**
1267
+ * E AS NOTAS DE RENDERIZAÇÃO, que são o oposto de uma perda.
1268
+ *
1269
+ * O layer ESTÁ na receita: o que falta é um atributo no elemento para ele aplicar. Até 06/08 estas
1270
+ * linhas viajavam junto com as de "não achei onde pôr" - 16 de 33 num repo real -, e a tela de causas
1271
+ * cobrava conserto de quem não tinha o que consertar. Separadas, elas viram o que sempre foram: uma
1272
+ * instrução para quem for renderizar.
1273
+ */
1274
+ const notesByComponent = Object.entries(looks)
1275
+ .filter(([, look]) => (look.renderNotes ?? []).length > 0)
1276
+ .map(([component, look]) => ({
1277
+ component,
1278
+ lines: look.renderNotes ?? [],
1279
+ }));
1280
+ if (notesByComponent.length > 0) {
1281
+ const total = notesByComponent.reduce((n, u) => n + u.lines.length, 0);
1282
+ console.log("");
1283
+ console.log(section("Looks that are in the recipe and need an attribute"));
1284
+ console.log(body(`${total} decision${total === 1 ? "" : "s"} across ${notesByComponent.length} component${notesByComponent.length === 1 ? "" : "s"} were read and KEPT - they apply once the element carries the attribute the line names:`));
1285
+ for (const u of notesByComponent.slice(0, 6))
1286
+ console.log(body(paint.faint(` ${u.component}: ${u.lines[0]}`)));
1287
+ if (notesByComponent.length > 6)
1288
+ console.log(body(paint.faint(` and ${notesByComponent.length - 6} more components`)));
1289
+ }
1256
1290
  /**
1257
1291
  * THE DROPS, SAID OUT LOUD. `p-spacing-sm` on a real menu popup read as nothing and
1258
1292
  * the popup shipped with no padding, invisibly - "if the compiler drops them, the drop
@@ -15,6 +15,16 @@ export async function init(opts) {
15
15
  pagesDir: opts.pagesDir ?? (target === "next" ? "app" : DEFAULT_CONFIG.pagesDir),
16
16
  componentsDir: opts.componentsDir ?? DEFAULT_CONFIG.componentsDir,
17
17
  styles: opts.styles === "tailwind" ? "tailwind" : "css",
18
+ /**
19
+ * Gravada COM A DATA, e só quando alguém respondeu. Migração é uma fase que termina, e uma fase sem
20
+ * data é a etiqueta que envelhece em silêncio - o doctor imprime as duas em toda rodada.
21
+ */
22
+ ...(opts.intent === "migrate" || opts.intent === "adopt"
23
+ ? { intent: opts.intent, intentAt: new Date().toISOString() }
24
+ : {}),
25
+ ...(opts.absorb === "code" || opts.absorb === "system"
26
+ ? { absorb: opts.absorb }
27
+ : {}),
18
28
  };
19
29
  await writeProjectConfig(root, config);
20
30
  console.log("✓ wrote _synthesisui/config.json");
@@ -22,6 +32,10 @@ export async function init(opts) {
22
32
  console.log(` pagesDir: ${config.pagesDir}`);
23
33
  console.log(` componentsDir: ${config.componentsDir}`);
24
34
  console.log(` styles: ${config.styles}`);
35
+ if (config.absorb)
36
+ console.log(` absorb: ${config.absorb}${config.absorb === "code" ? " (we hand you the CSS block; your file stays yours)" : " (absorbed values become tokens of the system)"}`);
37
+ if (config.intent)
38
+ console.log(` intent: ${config.intent}${config.intent === "migrate" ? " (the doctor leads with what your system does not name yet)" : " (the doctor leads with what your system already names)"}`);
25
39
  // --ds bootstraps the project with a system in one step (tokens + philosophy
26
40
  // + rules + CLAUDE.md all arrive via `add`).
27
41
  if (opts.ds) {