synthesisui 0.16.227 → 0.16.231

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.
@@ -1,5 +1,5 @@
1
1
  import { deltaE, JND } from "./doctor/color-distance.js";
2
- import { nearestToken } from "./doctor/tokens.js";
2
+ import { nearestToken, normalizeValue, } from "./doctor/tokens.js";
3
3
  /** Onde cada tipo de valor mora na fundação. `color` é o único com dois segmentos. */
4
4
  const HOME = {
5
5
  color: "color",
@@ -9,8 +9,36 @@ const HOME = {
9
9
  motion: "motion.durations",
10
10
  };
11
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)-/;
12
+ const CATEGORY = /^(color|colour|radius|rounded|spacing|space|gap|font|type|text|duration|motion|ease|easing)-/;
13
13
  const clean = (name) => name.replace(/^--/, "").toLowerCase();
14
+ /**
15
+ * QUAL DOS NOMES DELE, quando mais de um segura o mesmo valor - e aqui a natureza do ACHADO decide.
16
+ *
17
+ * `24px` é `--text-h2` E `--spacing-lg` no vocabulário do repositório real. Um `[0]` pega o de tipo
18
+ * para um achado de espaçamento, e o caminho que sai daí é `typography.h2` sobre uma margem - o mesmo
19
+ * erro de categoria que o `crossFamily` foi criado para impedir do outro lado da esteira.
20
+ *
21
+ * A primeira palavra do nome dele é a pista, e é a mesma lista que o `CATEGORY` acima já usa. Quando
22
+ * nenhum candidato concorda com a natureza do achado, COR ainda passa - um hex numa posição de cor
23
+ * não tem ambiguidade de família - e comprimento não passa: sem concordância não há o que separasse
24
+ * um token de tipo de um de espaçamento, e o valor volta sem caminho para a pessoa decidir.
25
+ */
26
+ const KIND_WORDS = {
27
+ color: ["color", "colour"],
28
+ radius: ["radius", "rounded"],
29
+ spacing: ["spacing", "space", "gap"],
30
+ font: ["font", "type", "text"],
31
+ motion: ["duration", "motion", "ease", "easing", "transition"],
32
+ };
33
+ function nameOf(kind, candidates) {
34
+ if (!candidates || candidates.length === 0)
35
+ return undefined;
36
+ const head = (n) => clean(n).split("-")[0] ?? "";
37
+ const agrees = candidates.find((n) => KIND_WORDS[kind].includes(head(n)));
38
+ if (agrees)
39
+ return agrees;
40
+ return kind === "color" ? candidates[0] : undefined;
41
+ }
14
42
  /**
15
43
  * O CAMINHO A PARTIR DO NOME DELES, e nada além dele.
16
44
  *
@@ -87,10 +115,34 @@ export function absorbPlan(d, theirs, have, cap = 40) {
87
115
  for (const r of unnamed) {
88
116
  if (entries.length >= cap)
89
117
  break;
90
- const theirNames = theirs.byValue.get(r.literal.toLowerCase()) ?? [];
91
- const theirName = theirNames[0];
118
+ /**
119
+ * O VALOR NORMALIZADO, e não o literal cru - era aqui que a metade pronta desta lista sumia.
120
+ *
121
+ * `byValue` é indexada pela forma canônica: cor em `#rrggbbaa`, comprimento em px sobre a raiz
122
+ * medida. A busca usava o literal como ele aparece no código, então:
123
+ *
124
+ * get("#fff") -> nada get("#ffffffff") -> --dashboard-white-500
125
+ * get("1em") -> nada get("16px") -> --spacing-md
126
+ * get("24px") -> acertava por acidente, porque px já é a forma canônica
127
+ *
128
+ * Cor NUNCA casava (a chave tem alfa), `em`/`rem` nunca casavam, e `px` casava por coincidência.
129
+ * Medido no repositório real em 14/08:
130
+ *
131
+ * na lista que ele VÊ (as 40 mais repetidas) 0 -> 5 com nome dele, 404 arquivos
132
+ * na fila inteira (338 valores) 1 -> 22 com nome dele, 478 arquivos
133
+ *
134
+ * A primeira leitura desta medição contou os 22 e escreveu "22 de 40", que é a fila inteira sobre
135
+ * o denominador da página - o número certo na conta errada. São 5 na tela e 22 na fila.
136
+ *
137
+ * O custo não era só a lista curta: o `doctor` mandava trocar `#fff` por
138
+ * `var(--dashboard-white-500)` e o `absorb`, no mesmo dia, pedia que ele batizasse `#fff`. Dois
139
+ * comandos com conselhos opostos sobre o mesmo valor.
140
+ */
141
+ const theirName = nameOf(r.kind, theirs.byValue.get(normalizeValue(r.literal, theirs.rootPx)));
92
142
  const path = pathFor(r.kind, theirName);
93
- const near = nearestOwn(theirs, r.literal, r.kind);
143
+ /** "≈ perto de um token seu" só interessa a quem não tem um EXATO - com o nome na mão, a dica
144
+ * vira ruído, e no arquivo de proposta ela vira uma segunda opção que não é opção. */
145
+ const near = theirName ? undefined : nearestOwn(theirs, r.literal, r.kind);
94
146
  /** Já existe na fundação com OUTRO valor: absorver aqui seria repintar o token deles. */
95
147
  if (path && have.has(path))
96
148
  continue;
@@ -3,7 +3,6 @@ import { dirname, join, resolve } from "node:path";
3
3
  import { absorbPlan, describeAbsorb, needingName, } from "../absorb-plan.js";
4
4
  import { readProjectConfig, readToken, resolveRegistry } from "../config.js";
5
5
  import { diagnose, scanSource } from "../doctor/scan.js";
6
- import { buildTable } from "../doctor/tokens.js";
7
6
  import { measuredScope, scopePaths } from "../measured-scope.js";
8
7
  import { body, paint, section, snippet } from "../output.js";
9
8
  import { loadSystem, walkAll } from "./doctor.js";
@@ -48,12 +47,12 @@ export async function absorb(opts) {
48
47
  * eles chamam o valor, e é de lá que o nome sai. Lida da RAIZ mesmo quando a medição é escopada: o
49
48
  * vocabulário é do projeto, e um app que pega um token emprestado do `packages/ui` deles não o
50
49
  * redeclara.
50
+ *
51
+ * A varredura era daqui e mudou de lugar: `loadSystem` a faz agora, porque o `doctor`, o `hook` e
52
+ * o MCP passaram a precisar da MESMA tabela dele - ver `their-names.ts`. Este comando descreveu a
53
+ * ideia primeiro e ficou com a implementação de todo mundo.
51
54
  */
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" });
55
+ const theirs = installed.theirs;
57
56
  const reports = [];
58
57
  for await (const file of walkAll(roots)) {
59
58
  const source = await readFile(file, "utf8").catch(() => null);
@@ -12,8 +12,9 @@ import { appendEvent, COVERAGE_RULE, readEvents, suggestionsFrom, summarize, } f
12
12
  import { bindingsFromDocument, countComponents, findOverrides, } from "../doctor/overrides.js";
13
13
  import { checkableName, readRequests, verifyAndCloseRequests, } from "../doctor/requests.js";
14
14
  import { DEFAULT_ROOT_PX, rootSizeOf, saidOfRoot, } from "../doctor/root-size.js";
15
- import { diagnose, scanSource, siblingTokens, } from "../doctor/scan.js";
15
+ import { diagnose, nameToWrite, scanSource, siblingTokens, } from "../doctor/scan.js";
16
16
  import { findSelfConflicts, forbiddenProps, isReset, propMatchesLabel, } from "../doctor/self-conflict.js";
17
+ import { withTheirNames } from "../doctor/their-names.js";
17
18
  import { buildTable, EMPTY_TABLE, nearestToken, } from "../doctor/tokens.js";
18
19
  import { describeScope, measuredScope, scopePaths } from "../measured-scope.js";
19
20
  import { body, paint, section, snippet } from "../output.js";
@@ -167,8 +168,12 @@ measured) {
167
168
  .map((e) => e.name);
168
169
  }
169
170
  catch {
171
+ /** Sem nada nosso instalado, o vocabulário dele é o ÚNICO que existe - e é contra ele que a
172
+ * medição acontece. Era a queda que já existia, agora servida do mesmo lugar que o resto. */
173
+ const theirs = await harvestOwnTokens([root], measured);
170
174
  return {
171
175
  table: EMPTY_TABLE,
176
+ theirs,
172
177
  recipes: new Map(),
173
178
  documents: [],
174
179
  requires: [],
@@ -275,13 +280,39 @@ measured) {
275
280
  }
276
281
  }
277
282
  }
283
+ const ours = buildTable({
284
+ css,
285
+ lock,
286
+ source: adopted ? "adopted" : "installed",
287
+ ...(measured ? { rootPx: measured.px, rootFrom: measured.from } : {}),
288
+ });
289
+ /**
290
+ * O VOCABULÁRIO DELE, LIDO SEMPRE - e isto revisa uma escolha declarada, então ela se revisa em
291
+ * voz alta.
292
+ *
293
+ * A nota antiga aqui dizia que o `hook` NÃO varre css de propósito, por orçamento: ele roda depois
294
+ * de cada escrita, e uma varredura por edição trocaria um relatório instantâneo por um que a
295
+ * pessoa desliga. O princípio dela continua valendo e é ele que manda mudar agora - *"erra para o
296
+ * lado de oferecer MENOS, nunca de oferecer a troca errada"*. Sem o vocabulário dele, o hook não
297
+ * oferece menos: ele oferece o NOME ERRADO, mandando escrever `--ds-color-gray-400` num arquivo de
298
+ * um repositório que declara `--color-lightgray-700` para aquele mesmo valor.
299
+ *
300
+ * O custo, medido ponta a ponta no repositório real em 13/08 - o hook inteiro, e não a varredura
301
+ * isolada, porque é o hook que tem o orçamento:
302
+ *
303
+ * antes 80ms mediana de 5 execuções
304
+ * depois 84ms mediana de 9, sobre 686 folhas e 797KB de css
305
+ *
306
+ * A primeira estimativa desta varredura foi 28ms, medida num processo à parte e sem o `walkAll`
307
+ * do CLI, que pula o que não interessa. Ficam os 4ms medidos, que são o que a pessoa paga.
308
+ *
309
+ * E a raiz medida atravessa junto, para as duas tabelas normalizarem `4px` e `0.25rem` na mesma
310
+ * unidade. Uma raiz por tabela seria a forma mais silenciosa de discordar.
311
+ */
312
+ const theirs = await harvestOwnTokens([root], measured);
278
313
  return {
279
- table: buildTable({
280
- css,
281
- lock,
282
- source: adopted ? "adopted" : "installed",
283
- ...(measured ? { rootPx: measured.px, rootFrom: measured.from } : {}),
284
- }),
314
+ table: withTheirNames(ours, theirs),
315
+ theirs,
285
316
  recipes,
286
317
  documents,
287
318
  requires,
@@ -315,14 +346,22 @@ async function sheetsIn(roots) {
315
346
  }
316
347
  return out;
317
348
  }
318
- async function harvestOwnTokens(roots) {
349
+ async function harvestOwnTokens(roots,
350
+ /** A MESMA raiz que a nossa tabela usa - duas raízes seriam a forma mais silenciosa de discordar,
351
+ * e `4px` deixaria de casar com `0.25rem` de um lado só. */
352
+ measured) {
319
353
  let css = "";
320
354
  for await (const file of walkAll(roots)) {
321
355
  if (!/\.(css|scss|sass|less)$/i.test(file))
322
356
  continue;
323
357
  css += `\n${await readFile(file, "utf8").catch(() => "")}`;
324
358
  }
325
- return buildTable({ css, source: "yours" });
359
+ return buildTable({
360
+ css,
361
+ source: "yours",
362
+ rootPx: measured?.px,
363
+ rootFrom: measured?.from ?? null,
364
+ });
326
365
  }
327
366
  const KIND_LABEL = {
328
367
  color: "colour",
@@ -528,6 +567,14 @@ export async function doctor(opts) {
528
567
  from: rootSize.from,
529
568
  });
530
569
  const { recipes, documents } = installed;
570
+ /**
571
+ * A TABELA JÁ VEM COM O VOCABULÁRIO DELE DENTRO - ver `loadSystem` e `their-names.ts`.
572
+ *
573
+ * O que muda aqui é só a QUEDA: quando não há nada nosso instalado, a dele passa a ser a tabela
574
+ * inteira, que é o que sempre foi. Ela é lida da RAIZ mesmo com a leitura escopada, pela mesma
575
+ * razão de sempre: o vocabulário é do projeto, e um app que pega um token emprestado do
576
+ * `packages/ui` dele não o redeclara.
577
+ */
531
578
  let table = installed.table;
532
579
  // Nothing of ours here does not mean nothing to measure against. Fall back
533
580
  // to whatever vocabulary the project already declares for itself.
@@ -545,9 +592,20 @@ export async function doctor(opts) {
545
592
  * Same rule our own installed system already follows: found from the root,
546
593
  * measured in the scope.
547
594
  */
548
- table = await harvestOwnTokens([root]);
595
+ table = withTheirNames(installed.theirs, installed.theirs);
549
596
  }
550
597
  const hasSystem = table.byName.size > 0;
598
+ /**
599
+ * O NOME DELE, ONDE ANTES ESTAVA "the system".
600
+ *
601
+ * O cabeçalho já abre com `SignalUI v8` e três linhas abaixo o mesmo relatório dizia *"0 from the
602
+ * system"* num repositório que tem DOIS vocabulários - o dele e o nosso. "the system" obriga quem lê
603
+ * a adivinhar de qual dos dois a linha fala, e a resposta certa é a que ele mesmo batizou. Mesma
604
+ * decisão do #937 um nível acima: o nome que a gente devolve é o nome dele.
605
+ *
606
+ * Quando não há sistema instalado, cai em "this system", porque aí não existe nome para usar.
607
+ */
608
+ const systemName = table.name ?? table.slug ?? "this system";
551
609
  const reports = [];
552
610
  const overrides = [];
553
611
  const used = new Map();
@@ -655,7 +713,7 @@ export async function doctor(opts) {
655
713
  */
656
714
  if (scopes.length > 0) {
657
715
  const said = measured.from === "census" ? describeScope(measured) : null;
658
- console.log(body(said ?? `scope: ${relScopes.join(", ")}`));
716
+ console.log(body(said ?? `read from: ${relScopes.join(", ")}`));
659
717
  }
660
718
  /**
661
719
  * A RAIZ QUE ESTA RODADA USOU - e ela é impressa SEMPRE, inclusive quando é o padrão.
@@ -699,9 +757,13 @@ export async function doctor(opts) {
699
757
  // `var(--ds-color-semantic-knob, #ffffff)` - is set aside for the opposite
700
758
  // reason: it is already tokenized. The summary said the false half out
701
759
  // loud and hid the true half behind a flag.
702
- console.log(body(`set aside ${plural(asideTotal, "value")} that are not drift` +
760
+ /**
761
+ * "not drift" era a nossa palavra para o motivo, e ela não diz o motivo. As duas razões reais
762
+ * cabem na linha: ou nenhum token poderia segurar aquele valor, ou ele já vem de um.
763
+ */
764
+ console.log(body(`${plural(asideTotal, "value")} left out of the count - a token could never hold them, or they already come from one` +
703
765
  (skippedProjects.length > 0
704
- ? ` and ${plural(skippedProjects.length, "nested project")} that speak their own systems`
766
+ ? `, and ${plural(skippedProjects.length, "nested project")} that speak their own systems`
705
767
  : "") +
706
768
  " (--verbose for why)"));
707
769
  }
@@ -784,7 +846,7 @@ export async function doctor(opts) {
784
846
  ...(d.findings.some((f) => f.crossFamily)
785
847
  ? { crossFamily: d.findings.filter((f) => f.crossFamily).length }
786
848
  : {}),
787
- ...(d.repeats.some((r) => r.token)
849
+ ...(d.repeats.some((r) => nameToWrite(r))
788
850
  ? { matched: suggestionsFrom(d.repeats) }
789
851
  : {}),
790
852
  }).catch(() => { });
@@ -835,7 +897,7 @@ export async function doctor(opts) {
835
897
  if (hasSystem && measurable) {
836
898
  console.log("");
837
899
  console.log(body(`Token coverage ${meter(d.coverage)} ${paint.strong(`${String(d.coverage).padStart(3)}%`)}`));
838
- console.log(body(paint.dim(` ${d.tokenUses} from the system${d.ownUses > 0 ? `, ${d.ownUses} from your own tokens` : ""}, ${d.findings.length} by hand${d.phantomUses > 0 ? `, ${d.phantomUses} naming nothing` : ""}`)));
900
+ console.log(body(paint.dim(` ${d.tokenUses} from ${systemName}${d.ownUses > 0 ? `, ${d.ownUses} from your own tokens` : ""}, ${d.findings.length} by hand${d.phantomUses > 0 ? `, ${d.phantomUses} naming nothing` : ""}`)));
839
901
  /**
840
902
  * A CAMADA DE TOKEN DELE, CONTADA - e a decisão é do dono, em 13/08.
841
903
  *
@@ -850,7 +912,7 @@ export async function doctor(opts) {
850
912
  * os 122 usos seguem o sistema sem tocar em um componente sequer.
851
913
  */
852
914
  if (d.ownTokens > 0) {
853
- console.log(body(paint.dim(` ${d.ownTokens} token${d.ownTokens === 1 ? "" : "s"} of your own, used ${d.ownUses}x${d.ownMirrored > 0 ? ` - ${d.ownMirrored} hold a value this system also names` : ""}`)));
915
+ console.log(body(paint.dim(` ${d.ownTokens} token${d.ownTokens === 1 ? "" : "s"} of your own, used ${d.ownUses}x${d.ownMirrored > 0 ? ` - ${d.ownMirrored} hold a value ${systemName} also names` : ""}`)));
854
916
  if (d.ownMirrored > 0)
855
917
  console.log(body(paint.dim(` point those at the \`--ds-*\` that holds it: ${d.ownMirrored} lines, one file`)));
856
918
  }
@@ -869,12 +931,34 @@ export async function doctor(opts) {
869
931
  * Medido no repo do dono: 1 uso do sistema, 4 433 à mão, 243 com nome - o medidor diz 0% e a
870
932
  * linha diz 5%.
871
933
  */
872
- const nameable = d.findings.filter((f) => f.token).length;
934
+ const named = d.findings.filter((f) => nameToWrite(f));
935
+ /**
936
+ * "UM COMANDO DE DISTÂNCIA" SÓ VALE PARA O QUE O COMANDO ESCREVE - ver `Finding.fontRelative`.
937
+ *
938
+ * `1em` ganhou nome nesta versão, e ganhar nome não é ganhar troca automática: o `--fix` recusa
939
+ * o comprimento que segue a fonte do elemento, porque o pixel pode mudar. Contá-los aqui faria a
940
+ * tela prometer 100% e o comando entregar 0 - e um relatório que promete o que o comando seguinte
941
+ * recusa gasta mais confiança do que teria custado dizer o número menor.
942
+ *
943
+ * Medido no arquivo que o dono apontou (13/08): 4 valores, 4 com nome, e os 4 são `em`. A linha
944
+ * dizia "100% is one command away" sobre um `--fix` que não escreveria nenhum deles.
945
+ */
946
+ const nameable = named.filter((f) => !f.fontRelative).length;
947
+ const yours = named.length - nameable;
873
948
  if (nameable > 0) {
874
949
  const reach = Math.round(((d.tokenUses + nameable) / (d.tokenUses + d.findings.length)) * 100);
875
950
  console.log(body(paint.dim(` ${reach}% is one command away - ${nameable} of those have a name waiting`)));
876
951
  console.log(body(paint.dim(" npx synthesisui doctor --fix")));
877
952
  }
953
+ /**
954
+ * E O QUE TEM NOME MAS NÃO TEM TROCA APARECE MESMO ASSIM, com o motivo.
955
+ *
956
+ * Sem esta linha, o `em` volta a ser invisível por outra porta: ele sai da conta de "um comando
957
+ * de distância" e não entra em lugar nenhum, e a pessoa fica sem saber que o sistema dela nomeia
958
+ * aquele valor. A lacuna é declarada, não escondida.
959
+ */
960
+ if (yours > 0)
961
+ console.log(body(paint.dim(` ${yours} more your system names, in \`em\` - the same number, but it follows the element's font size, so that swap is yours to make`)));
878
962
  /**
879
963
  * ZERO NUM REPO QUE ORIGINOU O SISTEMA É O ESTADO CERTO, e sem esta linha ele lê como falha
880
964
  * NOSSA.
@@ -903,14 +987,14 @@ export async function doctor(opts) {
903
987
  */
904
988
  if (d.coverage === 0 && measured.system) {
905
989
  console.log(body(paint.dim(` zero is the expected start here - this system was measured FROM`)));
906
- console.log(body(paint.dim(` \`${measured.system}\`, so these values are its source, not a drift`)));
907
- console.log(body(paint.dim(` from it. They count once the code points at the names they became.`)));
990
+ console.log(body(paint.dim(` \`${measured.system}\`, so these values are where the tokens`)));
991
+ console.log(body(paint.dim(` came from. They count once the code points at the names they became.`)));
908
992
  }
909
993
  // The strongest number leads, not trails: it used to sit two screens
910
994
  // down, after the phantom list (30/07). For the reader who already owns
911
995
  // a system - the ICP - THIS line is the report.
912
996
  if (table.source === "yours" && d.findings.length > 0) {
913
- const named = d.findings.filter((f) => f.token).length;
997
+ const named = d.findings.filter((f) => nameToWrite(f)).length;
914
998
  if (named > 0) {
915
999
  console.log("");
916
1000
  console.log(body(`${paint.strong(String(named))} of the ${d.findings.length} hand-written values have a name waiting in YOUR system - they are still literals.`));
@@ -950,7 +1034,7 @@ export async function doctor(opts) {
950
1034
  named: d.named,
951
1035
  ...(crossFamily > 0 ? { crossFamily } : {}),
952
1036
  /** E os pares em si, para a plataforma reavaliar sem esperar outra medição - ver `matched`. */
953
- ...(d.repeats.some((r) => r.token)
1037
+ ...(d.repeats.some((r) => nameToWrite(r))
954
1038
  ? { matched: suggestionsFrom(d.repeats) }
955
1039
  : {}),
956
1040
  });
@@ -979,8 +1063,9 @@ export async function doctor(opts) {
979
1063
  * para 37% sem uma linha de código mudada. Somar as duas fotos anunciaria um progresso
980
1064
  * inventado; calar a mudança deixaria alguém procurar o que fez o número pular.
981
1065
  */
1066
+ /** E SEM O NÚMERO DA VERSÃO: o que mudou a conta é a conta, não a nossa release. */
982
1067
  if (record.ruleChanged)
983
- console.log(body(" the coverage ruler changed in 0.16.219 - your own tokens now count, so the trend restarts here."));
1068
+ console.log(body(" the way this is counted changed - your own tokens count now too, so the trend restarts here."));
984
1069
  }
985
1070
  }
986
1071
  /**
@@ -1087,15 +1172,15 @@ export async function doctor(opts) {
1087
1172
  const w = Math.max(...repeats.map((r) => r.literal.length));
1088
1173
  for (const r of repeats) {
1089
1174
  const where = `${r.count}\u00d7 in ${r.files} file${r.files === 1 ? "" : "s"}`;
1090
- const near = r.token ? null : nearestToken(table, r.literal);
1175
+ const near = nameToWrite(r) ? null : nearestToken(table, r.literal);
1091
1176
  /**
1092
1177
  * COINCIDÊNCIA NÃO É RESPOSTA - ver `crossFamily` em `scan.ts`. Para aquele `kind` o
1093
1178
  * sistema não tem nome; o valor mora noutra família, e dizer só a seta afirma o contrário.
1094
1179
  */
1095
1180
  const named = r.crossFamily
1096
1181
  ? ` → no ${r.kind} named for it · the value lives as ${r.token}`
1097
- : r.token
1098
- ? ` → ${r.token}`
1182
+ : nameToWrite(r)
1183
+ ? ` → ${nameToWrite(r)}`
1099
1184
  : near
1100
1185
  ? ` → nearest is ${near.name} (${near.value})`
1101
1186
  : "";
@@ -1113,11 +1198,11 @@ export async function doctor(opts) {
1113
1198
  for (const x of shown) {
1114
1199
  // A dead end with a neighbour is not a dead end. Only for lengths -
1115
1200
  // "nearly the same blue" is the guess this tool must never make.
1116
- const near = x.token ? null : nearestToken(table, x.literal);
1201
+ const near = nameToWrite(x) ? null : nearestToken(table, x.literal);
1117
1202
  const named = x.crossFamily
1118
1203
  ? `→ no ${x.kind} named for it · the value lives as ${x.token}`
1119
- : x.token
1120
- ? `→ ${x.token}`
1204
+ : nameToWrite(x)
1205
+ ? `→ ${nameToWrite(x)}`
1121
1206
  : near
1122
1207
  ? `→ nearest is ${near.name} (${near.value})`
1123
1208
  : "→ no token holds this value yet";
@@ -1449,25 +1534,36 @@ export async function doctor(opts) {
1449
1534
  * Ele não some: vai para o grupo de quem NÃO tem nome, que é literalmente o que ele é para
1450
1535
  * aquele kind - e a linha diz onde o valor mora hoje.
1451
1536
  */
1452
- const named = d.repeats.filter((x) => x.token && !x.crossFamily);
1453
- const unnamed = d.repeats.filter((x) => !x.token || x.crossFamily);
1537
+ const named = d.repeats.filter((x) => nameToWrite(x) && (!x.crossFamily || x.theirToken));
1538
+ const unnamed = d.repeats.filter((x) => !nameToWrite(x) || (x.crossFamily && !x.theirToken));
1454
1539
  for (const r of named.slice(0, migrating ? 2 : 3)) {
1455
1540
  plan.push({
1456
1541
  rank: migrating ? 4 : 3,
1457
- what: `${r.literal} → ${r.token}`,
1542
+ /** Curto de propósito: a coluna trunca, e uma ressalva cortada ao meio é pior que nenhuma.
1543
+ * O porquê inteiro está na linha do medidor, logo acima. */
1544
+ what: r.fontRelative
1545
+ ? `${r.literal} → ${nameToWrite(r)} · your call`
1546
+ : `${r.literal} → ${nameToWrite(r)}`,
1458
1547
  size: `${r.files} file${r.files === 1 ? "" : "s"}`,
1459
- cheap: true,
1548
+ /** `cheap` promete "não muda um pixel", e um `em` pode mudar - ver `Finding.fontRelative`. */
1549
+ cheap: !r.fontRelative,
1460
1550
  });
1461
1551
  }
1462
1552
  for (const r of unnamed.slice(0, migrating ? 3 : 2)) {
1463
1553
  const near = nearestToken(table, r.literal);
1464
1554
  plan.push({
1465
1555
  rank: migrating ? 3 : 4,
1556
+ /**
1557
+ * "the system has no name for it" era verdade sobre O SISTEMA e o repositório tem dois
1558
+ * vocabulários - o dele também foi consultado (ver `their-names.ts`), e chegar aqui significa
1559
+ * que NENHUM dos dois nomeia o valor. É isso que a linha passa a dizer, e é o que separa esta
1560
+ * fila da anterior: aquela tem nome esperando, esta precisa de um.
1561
+ */
1466
1562
  what: near
1467
1563
  ? `${r.literal} - name it, or snap to ${near.name}`
1468
1564
  : r.crossFamily
1469
1565
  ? `${r.literal} - no ${r.kind} named for it, and the value lives as ${r.token} in another family`
1470
- : `${r.literal} - the system has no name for it`,
1566
+ : `${r.literal} - no name for it, here or in your CSS`,
1471
1567
  size: `${r.files} file${r.files === 1 ? "" : "s"}`,
1472
1568
  cheap: false,
1473
1569
  });
@@ -1589,7 +1685,7 @@ export async function doctor(opts) {
1589
1685
  * 1500 achados e depois dizer "troquei 900" é fazer a pessoa procurar a linha que importa.
1590
1686
  */
1591
1687
  if (opts.fix) {
1592
- const read = await readerFor(root, d.findings.filter((f) => f.token).map((f) => f.file));
1688
+ const read = await readerFor(root, d.findings.filter((f) => nameToWrite(f)).map((f) => f.file));
1593
1689
  const { result, next } = planFix(d, read);
1594
1690
  const writing = opts.write === true;
1595
1691
  if (writing)
@@ -1,7 +1,7 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { join, relative, resolve } from "node:path";
3
3
  import { appendEvent } from "../doctor/ledger.js";
4
- import { diagnose, scanSource } from "../doctor/scan.js";
4
+ import { diagnose, nameToWrite, scanSource } from "../doctor/scan.js";
5
5
  import { loadSystem } from "./doctor.js";
6
6
  const pass = () => ({ continue: true });
7
7
  const speak = (context) => ({
@@ -56,7 +56,7 @@ async function report(root, filePath) {
56
56
  return null;
57
57
  const rel = relative(root, filePath);
58
58
  const d = diagnose([scanSource(rel, src, table)]);
59
- const named = d.findings.filter((f) => f.token);
59
+ const named = d.findings.filter((f) => nameToWrite(f));
60
60
  const phantoms = d.files.flatMap((f) => f.phantoms ?? []);
61
61
  // Every check leaves one line in the ledger - INCLUDING the clean ones,
62
62
  // because a fix is itself a write, so the clean re-check of a file that was
@@ -83,7 +83,7 @@ async function report(root, filePath) {
83
83
  if (named.length > 0) {
84
84
  lines.push("", "Values written by hand that this system already has a name for:", ...named
85
85
  .slice(0, 20)
86
- .map((f) => ` line ${f.line} ${f.literal} → ${f.token}`), "", "Replace them now, while you still have this file in mind.");
86
+ .map((f) => ` line ${f.line} ${f.literal} → ${nameToWrite(f)}`), "", "Replace them now, while you still have this file in mind.");
87
87
  }
88
88
  if (phantoms.length > 0) {
89
89
  lines.push("", "Names this system does not declare. These look tokenized and apply nothing at all:", ...phantoms.slice(0, 20).map((p) => ` line ${p.line} ${p.name}`), "", "Use a name the system has, or say which value you need and what you would call it. Do NOT invent a token.");
@@ -5,7 +5,7 @@ import { pinnedHookVersion } from "../agent-wiring.js";
5
5
  import { readToken, resolveRegistry } from "../config.js";
6
6
  import { readEvents } from "../doctor/ledger.js";
7
7
  import { fileRequest } from "../doctor/requests.js";
8
- import { diagnose, scanSource } from "../doctor/scan.js";
8
+ import { diagnose, nameToWrite, scanSource } from "../doctor/scan.js";
9
9
  import { nearestToken, tokenFor } from "../doctor/tokens.js";
10
10
  import { repoStateOf } from "../repo-state.js";
11
11
  import { component } from "./component.js";
@@ -253,8 +253,8 @@ async function checkFile(root, path) {
253
253
  if (d.findings.length > 0) {
254
254
  out.push("", "Written by hand:");
255
255
  for (const f of d.findings.slice(0, 40)) {
256
- out.push(f.token
257
- ? ` ${f.file}:${f.line} ${f.literal} - this system calls it ${f.token}`
256
+ out.push(nameToWrite(f)
257
+ ? ` ${f.file}:${f.line} ${f.literal} - this repo calls it ${nameToWrite(f)}`
258
258
  : ` ${f.file}:${f.line} ${f.literal} - no token holds this value`);
259
259
  }
260
260
  out.push("", "Replace the ones that have a token. For a value with none, do NOT invent a token: say which value it is and what you would call it, and let a person decide.");
package/dist/config.js CHANGED
@@ -143,19 +143,26 @@ export function intentOf(config, flag) {
143
143
  };
144
144
  return { intent: "adopt", from: "default" };
145
145
  }
146
- /** A frase que acompanha o relatório - ver `intentOf` para por que ela é obrigatória. */
146
+ /**
147
+ * A frase que acompanha o relatório - ver `intentOf` para por que ela é obrigatória.
148
+ *
149
+ * E ELA DIZ O QUE FAZ, não como a gente chama - dono, 13/08, sobre a saída inteira do doctor:
150
+ * *"ele quer saber do que é feito no projeto dele"*. `ADOPTION` e `MIGRATION` são a nossa taxonomia,
151
+ * e quem lê esta linha não tem como saber que uma ordena por "já tem nome" e a outra por "ainda não
152
+ * tem". A informação é a ORDEM DA LISTA que ele está prestes a ler, então é isso que a frase diz.
153
+ */
147
154
  export function describeIntent(source) {
148
155
  const what = source.intent === "migrate"
149
- ? "reading as a MIGRATION - values your system does not name yet come first"
150
- : "reading as ADOPTION - values your system already names come first";
156
+ ? "sorted by what your design system does not name yet"
157
+ : "sorted by what your design system already names";
151
158
  const where = source.from === "flag"
152
159
  ? "this run only"
153
160
  : source.from === "config"
154
161
  ? `set in _synthesisui/config.json${source.at ? ` on ${source.at.slice(0, 10)}` : ""}`
155
- : "nobody declared one, so this is the default";
162
+ : "nobody chose, so this is the default";
156
163
  const flip = source.intent === "migrate"
157
- ? "`doctor --adopt` flips it"
158
- : "`doctor --migrate` flips it";
164
+ ? "`doctor --adopt` sorts the other way"
165
+ : "`doctor --migrate` sorts the other way";
159
166
  return `${what} · ${where} · ${flip}`;
160
167
  }
161
168
  /** Writes the project config (committable, plain JSON). */
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { readFile, writeFile } from "node:fs/promises";
27
27
  import { join } from "node:path";
28
+ import { nameToWrite } from "./scan.js";
28
29
  /**
29
30
  * A substituição numa linha só, e ela é deliberadamente burra.
30
31
  *
@@ -56,7 +57,15 @@ export function planFix(d, read) {
56
57
  * literal no texto que o primeiro já deixou.
57
58
  */
58
59
  for (const f of d.findings) {
59
- if (!f.token) {
60
+ /**
61
+ * O NOME DELE GANHA - ver `nameToWrite` em `scan.ts`.
62
+ *
63
+ * `--fix --write` edita o arquivo DELE, então o nome que entra ali é o do vocabulário dele
64
+ * quando existe um. Era aqui que a troca virava renomeação: 737 das 995 sugestões medidas no
65
+ * repo vivo escreviam `--ds-*` sobre um valor que uma variável dele já nomeia.
66
+ */
67
+ const name = nameToWrite(f);
68
+ if (!name) {
60
69
  skipped.push({
61
70
  file: f.file,
62
71
  line: f.line,
@@ -73,7 +82,23 @@ export function planFix(d, read) {
73
82
  * `var(--ds-typography-scale-h1-font-size)` no repo do dono (05/08) - um canto que se move no dia
74
83
  * em que alguém edita a escala de tipo. Medido lá: 14 de 129, e 0 de 76 na biblioteca dele.
75
84
  */
76
- if (f.crossFamily) {
85
+ /**
86
+ * O COMPRIMENTO QUE SEGUE A FONTE É DECISÃO DELE - ver `Finding.fontRelative`.
87
+ *
88
+ * `1em` e o degrau `1rem` do sistema são o mesmo número e não necessariamente o mesmo pixel. O
89
+ * comando MOSTRA a troca, porque sem ela não há conversa; escrevê-la sozinho em 933 lugares seria
90
+ * mudar o layout dele em silêncio por uma equivalência que a gente não pode conferir.
91
+ */
92
+ if (f.fontRelative) {
93
+ skipped.push({
94
+ file: f.file,
95
+ line: f.line,
96
+ literal: f.literal,
97
+ because: "font-relative",
98
+ });
99
+ continue;
100
+ }
101
+ if (f.crossFamily && !f.theirToken) {
77
102
  skipped.push({
78
103
  file: f.file,
79
104
  line: f.line,
@@ -100,7 +125,7 @@ export function planFix(d, read) {
100
125
  continue;
101
126
  const idx = f.line - 1;
102
127
  const current = body[idx];
103
- const swapped = current === undefined ? null : swap(current, f.literal, f.token);
128
+ const swapped = current === undefined ? null : swap(current, f.literal, name);
104
129
  if (swapped === null) {
105
130
  /** O arquivo mudou desde a medição, ou o literal já foi trocado por outro achado. */
106
131
  skipped.push({
@@ -116,7 +141,7 @@ export function planFix(d, read) {
116
141
  file: f.file,
117
142
  line: f.line,
118
143
  literal: f.literal,
119
- token: f.token,
144
+ token: name,
120
145
  });
121
146
  }
122
147
  for (const [file, body] of lines) {
@@ -154,11 +179,15 @@ export function describeFix(result, dry) {
154
179
  const coincidence = skipped.filter((s) => s.because === "cross-family").length;
155
180
  const unread = skipped.filter((s) => s.because === "unreadable").length;
156
181
  const decisions = skipped.filter((s) => s.because === "no-token").length;
182
+ /** O `em` que o comando mostra e não escreve - ver o guard em `planFix`. */
183
+ const relative = skipped.filter((s) => s.because === "font-relative").length;
157
184
  const lines = [];
158
185
  if (applied.length === 0) {
159
186
  lines.push(decisions > 0
160
187
  ? `Nothing to apply. All ${decisions} findings are values your system has no name for - those are design decisions, not fixes.`
161
- : "Nothing to apply.");
188
+ : relative > 0
189
+ ? `Nothing to apply. All ${relative} findings are lengths in \`em\`, which follow the element's font size - swapping them can move the layout, so that call is yours.`
190
+ : "Nothing to apply.");
162
191
  return lines;
163
192
  }
164
193
  lines.push(`${dry ? "Would replace" : "Replaced"} ${applied.length} hand-written value${applied.length === 1 ? "" : "s"} with the token your system already has, across ${result.files} file${result.files === 1 ? "" : "s"}.`);
@@ -169,6 +198,12 @@ export function describeFix(result, dry) {
169
198
  .sort((a, b) => b[1] - a[1])
170
199
  .slice(0, 6))
171
200
  lines.push(` var(${token}) · ${n} time${n === 1 ? "" : "s"}`);
201
+ /**
202
+ * DITO SEMPRE QUE ACONTECE - senão o número de "trocado" some da conta sem explicação, e a pessoa
203
+ * conclui que o comando falhou onde ele se recusou de propósito.
204
+ */
205
+ if (relative > 0)
206
+ lines.push(` ${relative} more ${relative === 1 ? "is a length" : "are lengths"} in \`em\`, which follow the element's font size - your system names the same number, but the pixel may differ. Left for you to decide.`);
172
207
  if (moved > 0)
173
208
  lines.push(`${moved} line${moved === 1 ? "" : "s"} changed since the scan and ${moved === 1 ? "was" : "were"} left alone - run it again.`);
174
209
  if (coincidence > 0)