synthesisui 0.16.404 → 0.16.406

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,6 +1,6 @@
1
1
  import { deltaE, JND } from "./doctor/color-distance.js";
2
2
  import { familySays, nearestToken, normalizeValue, } from "./doctor/tokens.js";
3
- import { proposeFromTheirScale, tooCloseToPropose } from "./proposed-name.js";
3
+ import { proposeFromTheirScale, proposeFromTheirUse, tooCloseToPropose, whyNotProposedFromUse, } from "./proposed-name.js";
4
4
  /** Onde cada tipo de valor mora na fundação. `color` é o único com dois segmentos. */
5
5
  const HOME = {
6
6
  color: "color",
@@ -109,7 +109,14 @@ function nearestOwn(theirs, literal, kind) {
109
109
  * `have` são os caminhos que a fundação já tem, para a proposta nunca sugerir sobrescrever um token
110
110
  * existente com outro valor - isso não é absorver, é repintar.
111
111
  */
112
- export function absorbPlan(d, theirs, have, cap = 40) {
112
+ export function absorbPlan(d, theirs, have, cap,
113
+ /**
114
+ * A FUNÇÃO DE CADA COR NO CÓDIGO DELE, contada por `countUseByProperty` sobre os mesmos arquivos
115
+ * da varredura. OBRIGATÓRIO de propósito: um parâmetro opcional que dá para esquecer é a próxima
116
+ * ocorrência do defeito em que a tela promete o que a chamada não passou. Quem não tem a contagem
117
+ * passa um mapa vazio e a fonte 2 cala - explicitamente.
118
+ */
119
+ uses) {
113
120
  /**
114
121
  * A REPETIÇÃO É UM FILTRO DE RUÍDO, E RUÍDO É UMA PROPRIEDADE DA ESCALA - não do valor.
115
122
  *
@@ -136,6 +143,14 @@ export function absorbPlan(d, theirs, have, cap = 40) {
136
143
  ? [...repeated, ...single]
137
144
  : repeated;
138
145
  const entries = [];
146
+ /**
147
+ * OS CAMINHOS JÁ PROPOSTOS NESTA RODADA, por qualquer fonte. Dois literais diferentes podem cair
148
+ * na mesma posição (`#111111` e `#121212` são ambos luminosidade 7): o primeiro da lista - o mais
149
+ * repetido, porque é a ordem em que ele vê a lista - leva o caminho, e o segundo fica com o
150
+ * motivo. Sem isto os dois saíam com `color.background.7` e o `--send` postava dois tokens para o
151
+ * mesmo nome.
152
+ */
153
+ const taken = new Map();
139
154
  for (const r of unnamed) {
140
155
  if (entries.length >= cap)
141
156
  break;
@@ -194,19 +209,53 @@ export function absorbPlan(d, theirs, have, cap = 40) {
194
209
  * entra - e ela devolve `null` na dúvida (ver `proposed-name.ts`).
195
210
  */
196
211
  /**
197
- * UMA FONTE POR ENQUANTO - a escala dele. A segunda está especificada e NÃO entrou, e o motivo
198
- * está medido: ver `proposed-name.ts`, no fim.
212
+ * DUAS FONTES, NESTA ORDEM (D4, dono, 08/09): a escala dele, e onde ela cala, a FUNÇÃO que o
213
+ * valor exerce no código dele. Perto demais de um token dele veta as duas - ali a resposta é
214
+ * normalizar, não batizar. A segunda fonte só existe para cor: é a única natureza em que a
215
+ * propriedade escrita é uma função (`background`, `border`), e a única que a contagem lê.
216
+ */
217
+ /**
218
+ * E SÓ PARA COR - por dois motivos distintos, e vale dizer os dois.
219
+ *
220
+ * A fonte 1 é escala de cor: medido no `frontend-hub` real em 08/09, ela propôs
221
+ * `dashboard.blue.650` para `30px`, `40px` e `50px`, porque `lightness("30px")` lia um prefixo
222
+ * hexadecimal de "3300pp" - consertado na raiz em `color-distance.ts`, e a porta fechada aqui
223
+ * também. A fonte 2 para cor é ESCOPO desta parte, não natureza: `12px` 20× em `gap` também é
224
+ * função medida, e fica para a próxima parte. O `≈` de vizinhança para comprimento continua
225
+ * sendo o de `nearestOwn`.
199
226
  */
200
- const proposal = theirName || tooCloseToPropose(r.literal, theirs)
227
+ const proposal = r.kind !== "color" || theirName || tooCloseToPropose(r.literal, theirs)
201
228
  ? null
202
- : proposeFromTheirScale(r.literal, theirs);
203
- const path = pathFor(r.kind, theirName) || proposal?.path || "";
229
+ : (proposeFromTheirScale(r.literal, theirs) ??
230
+ proposeFromTheirUse(r.literal, uses, theirs.rootPx, r.count));
231
+ const own = pathFor(r.kind, theirName);
232
+ /** Já existe na fundação com OUTRO valor: absorver aqui seria repintar o token deles. */
233
+ if (own && have.has(own))
234
+ continue;
235
+ /**
236
+ * COLISÃO DECLARADA, NUNCA DESCARTADA. Quando o caminho PROPOSTO já existe na fundação para
237
+ * outro valor, a primeira versão fazia `continue` - e a entrada sumia da lista enquanto a
238
+ * última linha do comando dizia "re-run and the next batch appears". Reproduzido pela revisão de
239
+ * DX em 08/09 em duas rodadas seguidas do `absorb`. A entrada FICA, sem caminho, e o motivo
240
+ * diz o que aconteceria: ele decide se é o mesmo token ou outro nome.
241
+ */
242
+ const collided = proposal !== null && have.has(proposal.path);
243
+ /** EMPATE NA MESMA RODADA, declarado: só um leva o caminho, e o outro diz quem levou. */
244
+ const rival = proposal !== null && !collided ? taken.get(proposal.path) : undefined;
245
+ const path = own || (collided || rival ? "" : (proposal?.path ?? ""));
246
+ if (proposal !== null && !own && !collided && !rival)
247
+ taken.set(proposal.path, { literal: r.literal, count: r.count });
204
248
  /** "≈ perto de um token seu" só interessa a quem não tem um EXATO - com o nome na mão, a dica
205
249
  * vira ruído, e no arquivo de proposta ela vira uma segunda opção que não é opção. */
206
250
  const near = theirName ? undefined : nearestOwn(theirs, r.literal, r.kind);
207
- /** existe na fundação com OUTRO valor: absorver aqui seria repintar o token deles. */
208
- if (path && have.has(path))
209
- continue;
251
+ /** E quando as duas fontes calam e não vizinho, o campo em branco diz por quê. */
252
+ const unproposed = collided
253
+ ? `would be ${proposal?.path}, but you already named a different colour that way`
254
+ : rival
255
+ ? `would be ${proposal?.path}, but ${rival.literal} (${rival.count}×) takes that position this round - name one of them and the other follows`
256
+ : r.kind === "color" && !theirName && !proposal && !near
257
+ ? whyNotProposedFromUse(r.literal, uses, theirs.rootPx, r.count)
258
+ : null;
210
259
  entries.push({
211
260
  value: r.literal,
212
261
  kind: r.kind,
@@ -215,8 +264,11 @@ export function absorbPlan(d, theirs, have, cap = 40) {
215
264
  path,
216
265
  ...(theirName ? { theirName: clean(theirName) } : {}),
217
266
  /** A PROCEDÊNCIA VIAJA COM A PROPOSTA - ele julga pela origem, nunca pela palavra. */
218
- ...(proposal ? { proposed: proposal.because } : {}),
267
+ ...(proposal && !collided && !rival
268
+ ? { proposed: proposal.because }
269
+ : {}),
219
270
  ...(near ? { near } : {}),
271
+ ...(unproposed ? { unproposed } : {}),
220
272
  });
221
273
  }
222
274
  return {
@@ -280,7 +332,7 @@ export function describeAbsorb(plan) {
280
332
  if (proposed.length > 0) {
281
333
  if (ready.length > 0)
282
334
  lines.push("");
283
- lines.push(`${proposed.length} ${proposed.length === 1 ? "value has" : "values have"} a name PROPOSED from your own scale - approve, edit, or leave ${proposed.length === 1 ? "it" : "them"} hard-coded:`);
335
+ lines.push(`${proposed.length} ${proposed.length === 1 ? "value has" : "values have"} a name PROPOSED from your own vocabulary - your scale, or how you use the value - approve, edit, or leave ${proposed.length === 1 ? "it" : "them"} hard-coded:`);
284
336
  for (const e of proposed.slice(0, 8))
285
337
  lines.push(` ${e.value.padEnd(24)} ${e.path.padEnd(28)} ${e.proposed}`);
286
338
  if (proposed.length > 8)
@@ -308,7 +360,10 @@ export function describeAbsorb(plan) {
308
360
  : /** A proposta chega COM a procedência: ele julga pela origem, não pela palavra. */
309
361
  e.proposed
310
362
  ? `${e.path} · ${e.proposed}`
311
- : 'path: ""'}`);
363
+ : /** E o campo em branco chega com o motivo, quando a função medida o tem. */
364
+ e.unproposed
365
+ ? `path: "" · ${e.unproposed}`
366
+ : 'path: ""'}`);
312
367
  if (pending.length > 8)
313
368
  lines.push(` (${pending.length - 8} more)`);
314
369
  /**
@@ -2,11 +2,12 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  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
- import { diagnose, scanSource } from "../doctor/scan.js";
5
+ import { diagnose, IS_FIXTURE, scanSource, } from "../doctor/scan.js";
6
6
  import { withTheirNames } from "../doctor/their-names.js";
7
7
  import { measuredScope, scopePaths } from "../measured-scope.js";
8
8
  import { body, paint, section, snippet } from "../output.js";
9
9
  import { resolveDeps, tailwindMajor } from "../stack.js";
10
+ import { countUseByProperty } from "../use-by-property.js";
10
11
  import { loadSystem, walkAll } from "./doctor.js";
11
12
  /**
12
13
  * `synthesisui absorb` - O SISTEMA APRENDE O DESIGN QUE O CÓDIGO JÁ TEM.
@@ -85,13 +86,22 @@ export async function absorb(opts) {
85
86
  ? installed.table
86
87
  : withTheirNames(theirs, theirs);
87
88
  const reports = [];
89
+ /**
90
+ * A FUNÇÃO DE CADA COR, contada num passe próprio sobre os MESMOS arquivos - é a segunda fonte do
91
+ * nome proposto (`proposed-name.ts`). Fixture e teste ficam de fora pela mesma regra do scan: as
92
+ * cores de um `.stories` são do exemplo, não do produto dele.
93
+ */
94
+ const uses = new Map();
88
95
  for await (const file of walkAll(roots)) {
89
96
  const source = await readFile(file, "utf8").catch(() => null);
90
97
  if (source === null)
91
98
  continue;
92
- reports.push(scanSource(file.slice(root.length + 1), source, table));
99
+ const rel = file.slice(root.length + 1);
100
+ reports.push(scanSource(rel, source, table));
101
+ if (!IS_FIXTURE.test(rel))
102
+ countUseByProperty(source, uses, table.rootPx);
93
103
  }
94
- const plan = absorbPlan(diagnose(reports), theirs, pathsInSystem(installed.documents), opts.cap ?? 40);
104
+ const plan = absorbPlan(diagnose(reports), theirs, pathsInSystem(installed.documents), opts.cap ?? 40, uses);
95
105
  console.log(section(hasSystem
96
106
  ? "What the system could absorb"
97
107
  : "Name what you wrote by hand"));
@@ -97,20 +97,34 @@ export function isNearDuplicate(a, b, threshold = JND) {
97
97
  * the two halves deciding differently what counts as a dark canvas is worse
98
98
  * than either of them being wrong.
99
99
  */
100
- export function lightness(hex) {
101
- const h = hex.trim().replace("#", "");
102
- const full = h.length === 3 || h.length === 4
100
+ /**
101
+ * OS SEIS DÍGITOS DE UMA COR, OU NADA - e o "nada" é o que faltava.
102
+ *
103
+ * `lightness("30px")` devolvia 0.143: "30px" tem 4 caracteres, era expandido para "3300pp", e
104
+ * `parseInt("3300pp", 16)` lê o prefixo hexadecimal em vez de devolver NaN. Efeito medido no
105
+ * `frontend-hub` real em 08/09, pelo `absorb`: a fonte 1 propôs `dashboard.blue.650` para `30px`
106
+ * (espaçamento), `40px` e `50px` (raio) - "between your dashboard-blue-500 and dashboard-blue-800" -,
107
+ * um nome de cor sobre um comprimento. `rgb()`/`hsl()`/`oklch()` não chegam aqui: `normalizeValue`
108
+ * os converte para `#rrggbbaa` antes; o que chega e NÃO é cor é comprimento, tempo ou texto, e a
109
+ * resposta certa para eles é `null`.
110
+ */
111
+ function sixDigits(hex) {
112
+ const h = hex.trim().replace(/^#/, "");
113
+ if (!/^[0-9a-f]{3,4}$|^[0-9a-f]{6}$|^[0-9a-f]{8}$/i.test(h))
114
+ return null;
115
+ return h.length === 3 || h.length === 4
103
116
  ? h
104
117
  .slice(0, 3)
105
118
  .split("")
106
119
  .map((c) => c + c)
107
120
  .join("")
108
121
  : h.slice(0, 6);
109
- if (full.length !== 6)
122
+ }
123
+ export function lightness(hex) {
124
+ const full = sixDigits(hex);
125
+ if (full === null)
110
126
  return null;
111
127
  const n = Number.parseInt(full, 16);
112
- if (Number.isNaN(n))
113
- return null;
114
128
  return ((0.2126 * ((n >> 16) & 255) +
115
129
  0.7152 * ((n >> 8) & 255) +
116
130
  0.0722 * (n & 255)) /
@@ -120,19 +134,10 @@ export function lightness(hex) {
120
134
  * chromatic, and treating it as coloured throws every off-white surface out of
121
135
  * the neutral ladder. */
122
136
  export function chroma(hex) {
123
- const h = hex.trim().replace("#", "");
124
- const full = h.length === 3 || h.length === 4
125
- ? h
126
- .slice(0, 3)
127
- .split("")
128
- .map((c) => c + c)
129
- .join("")
130
- : h.slice(0, 6);
131
- if (full.length !== 6)
137
+ const full = sixDigits(hex);
138
+ if (full === null)
132
139
  return null;
133
140
  const n = Number.parseInt(full, 16);
134
- if (Number.isNaN(n))
135
- return null;
136
141
  const r = (n >> 16) & 255;
137
142
  const g = (n >> 8) & 255;
138
143
  const b = n & 255;
@@ -54,7 +54,7 @@ const IGNORE_LINE = /^\s*(import|@import|\/\/|\*|\/\*)/;
54
54
  * VISTO pelo scanner e INTERPRETADO por `colorForm` para chegar a uma comparação. As duas metades
55
55
  * entraram na mesma mudança porque meia jornada não é meio valor.
56
56
  */
57
- const COLOR = /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|oklch\([^)]*\)/g;
57
+ export const COLOR = /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|oklch\([^)]*\)/g;
58
58
  /**
59
59
  * THE QUOTE THAT MADE HALF THE DRIFT INVISIBLE.
60
60
  *
@@ -386,7 +386,7 @@ const IDIOM = new Set(["0", "0px", "1px", "9999px", "100%", "50%"]);
386
386
  * Counting them accuses the author of drift they did not write, and crediting
387
387
  * them inflates a coverage number nobody can act on.
388
388
  */
389
- const IS_FIXTURE = /(\.(spec|test|stories)\.[a-z]+$|__tests__\/|\/fixtures?\/|(^|\/)\.storybook\/)/;
389
+ export const IS_FIXTURE = /(\.(spec|test|stories)\.[a-z]+$|__tests__\/|\/fixtures?\/|(^|\/)\.storybook\/)/;
390
390
  export function scanSource(file, source, table) {
391
391
  if (IS_FIXTURE.test(file)) {
392
392
  const real = scanCore(file, source, table);
@@ -1,5 +1,6 @@
1
1
  import { chroma, deltaE, lightness } from "./doctor/color-distance.js";
2
2
  import { normalizeValue } from "./doctor/tokens.js";
3
+ import { SVG } from "./use-by-property.js";
3
4
  function stepsOf(theirs) {
4
5
  const out = [];
5
6
  for (const [name, value] of theirs.byName) {
@@ -102,20 +103,116 @@ export function tooCloseToPropose(literal, theirs, jnd = 2) {
102
103
  }
103
104
  return false;
104
105
  }
106
+ /** O que o mapa diz deste literal: as funções, mais usada primeiro, e quantas vezes ao todo. */
107
+ function usesOf(literal, uses, rootPx) {
108
+ const key = normalizeValue(literal, rootPx);
109
+ const per = uses.get(key);
110
+ if (!per)
111
+ return null;
112
+ const entries = [...per.entries()].filter(([, n]) => n > 0);
113
+ if (entries.length === 0)
114
+ return null;
115
+ return {
116
+ key,
117
+ /** Mais usada primeiro; no empate, a ordem alfabética - a frase não pode depender de qual
118
+ * arquivo foi lido antes. */
119
+ per: entries.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)),
120
+ total: entries.reduce((n, [, c]) => n + c, 0),
121
+ };
122
+ }
123
+ /**
124
+ * A POSIÇÃO É A LUMINOSIDADE DO PRÓPRIO VALOR, em 0..100 - estável entre rodadas, e é isso que a
125
+ * torna um NOME e não um rank.
126
+ *
127
+ * A primeira versão ordenava os literais da mesma função e dava `.1` ao mais escuro. Reproduzido
128
+ * pela revisão de DX em 08/09, rodando o `absorb` duas vezes (o fluxo que o próprio teto de 40
129
+ * recomenda): rodada 1, `#111111` é o mais escuro de dois, sai `color.background.1` e é enviado;
130
+ * rodada 2, aparece `#000000`, mais escuro - ele vira `.1`, colide com o nome já dado, e era
131
+ * descartado em silêncio enquanto a mensagem final dizia "re-run and the next batch appears". Um
132
+ * rank colide sistematicamente com qualquer valor novo mais escuro que os já nomeados.
133
+ *
134
+ * `Math.round(lightness × 100)` do próprio valor não depende de vizinho nenhum: `#111111` é `.7`
135
+ * hoje e em qualquer rodada, `#000000` é `.0`, `#fafafa` é `.98`. Refinamento da D3 (o espírito é
136
+ * "posição por luminosidade; só a posição é calculada"), decisão de implementação de 08/09,
137
+ * reabrível pelo dono.
138
+ */
139
+ function positionOf(key) {
140
+ const l = lightness(key);
141
+ return l === null ? null : Math.round(l * 100);
142
+ }
143
+ /**
144
+ * O NOME PELA FUNÇÃO MEDIDA: `color.<propriedade-dele>.<luminosidade 0..100>`.
145
+ *
146
+ * `counted` é quantas ocorrências o SCAN viu para este literal (`r.count` no plano). Se o passe
147
+ * alcançou menos do que isso, parte dos usos está fora de qualquer propriedade de estilo e a
148
+ * contagem é parcial: `unknown` é estado, e uma contagem parcial não vira afirmação - nem "uma
149
+ * função só", nem "dois trabalhos".
150
+ *
151
+ * Só fala onde `theirName` e `proposeFromTheirScale` calam - a ordem é de `absorb-plan.ts` (D4).
152
+ * O `because` carrega a contagem E a posição, para ele julgar pela origem e não pela palavra:
153
+ * *"12× in background, never anywhere else · lightness 7 of 100"*.
154
+ */
155
+ export function proposeFromTheirUse(literal, uses, rootPx, counted) {
156
+ const u = usesOf(literal, uses, rootPx);
157
+ if (!u || u.total < counted)
158
+ return null;
159
+ if (u.per.length !== 1 || u.total < 2)
160
+ return null;
161
+ const root = u.per[0]?.[0] ?? "";
162
+ if (root === SVG)
163
+ return null;
164
+ const position = positionOf(u.key);
165
+ if (position === null)
166
+ return null;
167
+ return {
168
+ path: `color.${root}.${position}`,
169
+ because: `${u.total}× in ${root}, never anywhere else · lightness ${position} of 100`,
170
+ };
171
+ }
172
+ const WORDS = [
173
+ "",
174
+ "one",
175
+ "two",
176
+ "three",
177
+ "four",
178
+ "five",
179
+ "six",
180
+ "seven",
181
+ "eight",
182
+ "nine",
183
+ ];
105
184
  /**
106
- * A SEGUNDA FONTE - ESPECIFICADA, TENTADA, E NÃO ENTREGUE. O motivo é medido, e ele é o valor deste
107
- * comentário.
185
+ * POR QUE O CAMPO FICOU EM BRANCO, em uma linha - a lacuna declarada em vez de calada.
108
186
  *
109
- * A especificação da PARTE 2 (07/09, por entrevista) admite derivar *"da função observada no código
110
- * dele - o valor aparece 12× em `background` e em texto, então é proposto como uma superfície"*.
187
+ * *"4× background · border - two jobs, no single name"* é a leitura que o cliente consegue agir
188
+ * sobre: ou o valor é dois tokens, ou é um e ele diz qual. A linha de motivo é verdadeira nas MESMAS
189
+ * condições da proposta - e quando a contagem não alcança, ela diz que não alcança.
111
190
  *
112
- * O QUE A TENTATIVA ENCONTROU, no mesmo dia: `scanSource` reporta UMA ocorrência por literal POR
113
- * LINHA. Numa linha com `background: "#7a1f1f"` e `borderColor: "#7a1f1f"`, ele devolve um achado -
114
- * e a contagem por propriedade opera sobre uma amostra que não é a contagem real. A proposta então
115
- * afirmaria *"todos os seus 2 usos são texto"* sobre um valor que também é borda.
191
+ * NUNCA MENTE SOZINHA: devolve `null` quando `proposeFromTheirUse` propõe para o mesmo literal, para
192
+ * que o guard não more em quem chama.
116
193
  *
117
- * Uma afirmação falsa sobre o repositório DELE é exatamente o que esta parte existe para impedir, e
118
- * é a razão de a fonte 2 não estar aqui. O pré-requisito está nomeado: o scan precisa reportar cada
119
- * ocorrência, e não a primeira de cada literal por linha - o que muda a granularidade de tudo que
120
- * consome `findings`, e por isso é uma frente própria, não uma linha a mais neste arquivo.
194
+ * E NENHUMA COR FICA EM BRANCO CALADA. Medido no `web-subscribe` real em 08/09: de 644 cores, 55
195
+ * saíam sem contagem nenhuma - `let strokes = ['#26bcfd', …]`, `export const boulder = '#797979'`,
196
+ * `@mixin nav-divider($color: #e5e5e5)` - e o campo em branco não dizia nada. Um valor que nunca
197
+ * está dentro de uma propriedade de estilo não exerce função: é declarado ou calculado, não pintado,
198
+ * e isso é um motivo, não um silêncio. Ou a cor tem proposta, ou tem `≈`, ou tem motivo.
121
199
  */
200
+ export function whyNotProposedFromUse(literal, uses, rootPx, counted) {
201
+ if (proposeFromTheirUse(literal, uses, rootPx, counted))
202
+ return null;
203
+ const u = usesOf(literal, uses, rootPx);
204
+ if (!u)
205
+ return "never inside a style property - declared or computed, not painted";
206
+ if (u.total < counted)
207
+ return `read ${u.total} of ${counted} uses - the rest sit outside a style property`;
208
+ if (u.per.length > 1) {
209
+ const jobs = WORDS[u.per.length] ?? String(u.per.length);
210
+ return `${u.per.map(([root, n]) => `${n}× ${root}`).join(" · ")} - ${jobs} jobs, no single name`;
211
+ }
212
+ if (u.total < 2)
213
+ return "seen once";
214
+ const root = u.per[0]?.[0] ?? "";
215
+ if (root === SVG)
216
+ return "only in svg";
217
+ return `${u.total}× in ${root}, but the value does not read as a colour`;
218
+ }
@@ -72,6 +72,82 @@ function attributeExpressions(source) {
72
72
  }
73
73
  return out;
74
74
  }
75
+ /**
76
+ * A DECLARAÇÃO DE UMA CONST QUE O ATRIBUTO USA - o salto que faltava, e a mesma regra do atributo.
77
+ *
78
+ * O QUE FICAVA DE FORA, e estava declarado no contrato com o número: a tabela de variantes mora
79
+ * numa const separada e chega ao atributo por VARIÁVEL. O caso real, medido no `frontend-hub`:
80
+ *
81
+ * ```
82
+ * const buttonVariants = cva("… rounded-[var(--radius-md)] …", { variants: { … } });
83
+ * …
84
+ * className={twUtils.cn(buttonVariants({ variant, size, className }))}
85
+ * ```
86
+ *
87
+ * As classes estão na declaração, não no atributo - então o leitor de 08/09, que já colhia toda
88
+ * string DENTRO da expressão, via `buttonVariants(...)` e não via nenhuma classe. Medido nas duas
89
+ * árvores: **4 + 41** declarações `cva(` e **0 + 85** `tv(`.
90
+ *
91
+ * A REGRA CONTINUA SENDO A MESMA, um salto adiante: o atributo diz quais identificadores importam,
92
+ * e a declaração daqueles identificadores é lida no mesmo arquivo. Nada aqui conhece `cva`, `tv` ou
93
+ * `cn` - o próximo cliente escreve a tabela dele num objeto pelado, e cai na mesma regra.
94
+ *
95
+ * E O ERRO CONTINUA CAINDO PARA O LADO SEGURO: uma const que não é classe - um rótulo, uma
96
+ * mensagem - entrega palavras que o `@theme` não declara, então elas somem na peneira final. O
97
+ * cabeçalho deste módulo diz que entre errar cobrando e errar calando, cobrar é o erro que a pessoa
98
+ * percebe.
99
+ */
100
+ function declarationsBehind(source, exprs) {
101
+ /**
102
+ * TODO IDENTIFICADOR DA EXPRESSÃO, e não só os que ela CHAMA.
103
+ *
104
+ * A primeira versão pegava `nome(`, e o objeto pelado - `pick(byTone, tone)` - passa a tabela
105
+ * como ARGUMENTO. O que decide é o atributo mencionar o identificador; se ele é chamado ou
106
+ * passado é sintaxe, e sintaxe é o que muda de projeto para projeto.
107
+ */
108
+ const names = new Set();
109
+ for (const expr of exprs)
110
+ for (const m of expr.matchAll(/\b([A-Za-z_$][\w$]*)\b/g))
111
+ names.add(m[1]);
112
+ if (names.size === 0)
113
+ return [];
114
+ const out = [];
115
+ for (const name of names) {
116
+ /** `const <nome> = ` - e o valor vai até a linha que fecha no MESMO nível de indentação. */
117
+ const at = source.search(new RegExp(`\\b(?:const|let|var)\\s+${name}\\s*=`));
118
+ if (at < 0)
119
+ continue;
120
+ let depth = 0;
121
+ let i = source.indexOf("=", at);
122
+ let quote = null;
123
+ let started = false;
124
+ for (; i < source.length; i += 1) {
125
+ const c = source[i];
126
+ if (quote) {
127
+ if (c === "\\")
128
+ i += 1;
129
+ else if (c === quote)
130
+ quote = null;
131
+ continue;
132
+ }
133
+ if (c === '"' || c === "'" || c === "`")
134
+ quote = c;
135
+ else if (c === "(" || c === "{" || c === "[") {
136
+ depth += 1;
137
+ started = true;
138
+ }
139
+ else if (c === ")" || c === "}" || c === "]") {
140
+ depth -= 1;
141
+ if (started && depth <= 0)
142
+ break;
143
+ }
144
+ else if (c === ";" && !started)
145
+ break;
146
+ }
147
+ out.push(source.slice(at, i + 1));
148
+ }
149
+ return out;
150
+ }
75
151
  /** As classes escritas no código - `hover:animate-shimmer` conta como `animate-shimmer`. */
76
152
  function classesIn(source) {
77
153
  const out = new Set();
@@ -89,9 +165,16 @@ function classesIn(source) {
89
165
  * E TODA STRING DENTRO DA EXPRESSÃO - ver `attributeExpressions`. As três formas de escrever uma
90
166
  * string em JS/TS, porque quem escolhe a aspa é o formatador dele, não a gente.
91
167
  */
92
- for (const expr of attributeExpressions(source))
93
- for (const lit of expr.matchAll(/"([^"]*)"|'([^']*)'|`([^`]*)`/g))
168
+ const exprs = attributeExpressions(source);
169
+ const strings = (text) => {
170
+ for (const lit of text.matchAll(/"([^"]*)"|'([^']*)'|`([^`]*)`/g))
94
171
  collect(lit[1] ?? lit[2] ?? lit[3] ?? "");
172
+ };
173
+ for (const expr of exprs)
174
+ strings(expr);
175
+ /** E a declaração de quem o atributo chama - ver `declarationsBehind`. */
176
+ for (const decl of declarationsBehind(source, exprs))
177
+ strings(decl);
95
178
  return out;
96
179
  }
97
180
  /**
@@ -0,0 +1,134 @@
1
+ import { COLOR } from "./doctor/scan.js";
2
+ import { normalizeValue } from "./doctor/tokens.js";
3
+ /** A raiz que conta como função e NUNCA vira palavra: cor de ícone, não do sistema dele. */
4
+ export const SVG = "svg";
5
+ /**
6
+ * UTILIDADE TAILWIND → PROPRIEDADE CSS QUE ELA ESCREVE. A palavra é a da direita, sempre: `ring` não
7
+ * é propriedade CSS nenhuma - o Tailwind a compila em `box-shadow`, e é isso que o código dele pinta.
8
+ */
9
+ export const TAILWIND_WRITES = {
10
+ bg: "background",
11
+ from: "background",
12
+ via: "background",
13
+ to: "background",
14
+ text: "color",
15
+ placeholder: "color",
16
+ border: "border",
17
+ divide: "border",
18
+ shadow: "box-shadow",
19
+ ring: "box-shadow",
20
+ "ring-offset": "box-shadow",
21
+ outline: "outline",
22
+ decoration: "text-decoration-color",
23
+ accent: "accent-color",
24
+ caret: "caret-color",
25
+ fill: SVG,
26
+ stroke: SVG,
27
+ };
28
+ /**
29
+ * `background:` · `backgroundColor:` · `"background-color":` · `fill="`
30
+ *
31
+ * O identificador não pode vir colado a `.`, `$`, `@`, `-`, letra ou dígito (`colors.dark :` num
32
+ * ternário não é uma propriedade), e a aspa de abertura, quando existe, tem de fechar antes do `:` -
33
+ * senão `"#fff" :` de um ternário viraria a âncora `fff`. O `=` só vale na forma de ATRIBUTO - colado
34
+ * ao nome e seguido de aspa ou `{` -, porque `const BRAND = "#0f5132"` é uma constante, não uma
35
+ * função. O que casa aqui ainda passa por `propertyRoot`: só o que carrega cor vira âncora.
36
+ */
37
+ const PROPERTY = /(?<![\w.$@#-])(["']?)[a-zA-Z][\w-]*\1(?:\s*:|=(?=["'{]))/g;
38
+ /**
39
+ * `bg-[#…]` · `hover:text-[#…]` · `border-t-[rgba(…)]` · `divide-x-[…]` - as utilidades da tabela,
40
+ * com o sufixo de lado que algumas aceitam.
41
+ */
42
+ const UTILITY = new RegExp(`(?<![\\w-])(${Object.keys(TAILWIND_WRITES)
43
+ .sort((a, b) => b.length - a.length)
44
+ .join("|")})(?:-[trblxyse])?-\\[`, "g");
45
+ /**
46
+ * A RAIZ DE UMA PROPRIEDADE CSS QUE CARREGA COR (D1) - ou `null` para o que não é uma. A palavra
47
+ * que ele escreveu, sem o sufixo que só diz o lado ou o dialeto: `background-color`,
48
+ * `backgroundColor` e `background-image` são `background`; `border-top-color` e `borderColor` são
49
+ * `border`. Uma chave de objeto, uma variável, uma propriedade que não pinta: `null`.
50
+ */
51
+ export function propertyRoot(raw) {
52
+ const kebab = raw
53
+ .trim()
54
+ .replace(/^["']|["']$/g, "")
55
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
56
+ .toLowerCase();
57
+ if (/^background(-|$)/.test(kebab))
58
+ return "background";
59
+ if (/^border(-|$)/.test(kebab))
60
+ return "border";
61
+ if (kebab === "color")
62
+ return "color";
63
+ if (/^outline(-|$)/.test(kebab))
64
+ return "outline";
65
+ if (kebab === "box-shadow")
66
+ return "box-shadow";
67
+ if (kebab === "text-shadow")
68
+ return "text-shadow";
69
+ if (/^(fill|stroke|stop-color|flood-color|lighting-color)$/.test(kebab))
70
+ return SVG;
71
+ if (/^(caret-color|accent-color|text-decoration-color|text-emphasis-color|column-rule-color)$/.test(kebab))
72
+ return kebab;
73
+ return null;
74
+ }
75
+ /** Toda âncora da linha que PINTA, com onde ela termina - a mais próxima antes do literal é a dele. */
76
+ function anchorsOf(line) {
77
+ const out = [];
78
+ for (const m of line.matchAll(PROPERTY)) {
79
+ const root = propertyRoot(m[0].replace(/\s*[:=]$/, ""));
80
+ if (root !== null)
81
+ out.push({ end: m.index + m[0].length, root });
82
+ }
83
+ for (const m of line.matchAll(UTILITY)) {
84
+ const root = TAILWIND_WRITES[m[1] ?? ""];
85
+ if (root)
86
+ out.push({ end: m.index + m[0].length, root });
87
+ }
88
+ return out.sort((a, b) => a.end - b.end);
89
+ }
90
+ /** A linha FECHOU a declaração: o que vier depois não é continuação dela. */
91
+ const ENDS_DECLARATION = /[;{}]\s*$/;
92
+ /**
93
+ * Conta cada ocorrência de cor de UM arquivo, linha a linha, na função da âncora mais próxima
94
+ * antes dela. `box-shadow: 0 1px #000, 0 2px #fff` conta as duas em `box-shadow`; uma linha com
95
+ * `background` e `borderColor` do mesmo hex conta as duas funções. Acumula em `into` para que o
96
+ * repositório inteiro caiba num único mapa.
97
+ *
98
+ * A DECLARAÇÃO PODE CONTINUAR NA LINHA SEGUINTE - regra do CSS, não de um repositório:
99
+ *
100
+ * box-shadow:
101
+ * 1px 1px 0 0 #2b2927,
102
+ * 2px 2px 0 0 #2b2927;
103
+ *
104
+ * Medido no `web-subscribe` real em 08/09 (`framework/style/_term.scss`): 9 ocorrências assim, sem
105
+ * âncora na própria linha, saíam sem função nenhuma - e o campo em branco saía calado. Quando a linha
106
+ * não tem âncora e a anterior não FECHOU a declaração (não termina em `;`, `}` ou `{`), a âncora da
107
+ * anterior vale para esta. `background: #111;` seguido de `#222` NÃO herda: o `;` fechou.
108
+ */
109
+ export function countUseByProperty(source, into, rootPx) {
110
+ /** A âncora da declaração ainda aberta, vinda das linhas anteriores. */
111
+ let carried = null;
112
+ for (const line of source.split("\n")) {
113
+ const anchors = anchorsOf(line);
114
+ for (const m of line.matchAll(COLOR)) {
115
+ /** A âncora mais próxima que termina ANTES do literal - ou a herdada, se a linha não tem. */
116
+ let root = anchors.length === 0 ? carried : null;
117
+ for (const a of anchors) {
118
+ if (a.end > m.index)
119
+ break;
120
+ root = a.root;
121
+ }
122
+ if (root === null)
123
+ continue;
124
+ const key = normalizeValue(m[0], rootPx);
125
+ const per = into.get(key) ?? new Map();
126
+ per.set(root, (per.get(root) ?? 0) + 1);
127
+ into.set(key, per);
128
+ }
129
+ if (ENDS_DECLARATION.test(line))
130
+ carried = null;
131
+ else if (anchors.length > 0)
132
+ carried = anchors[anchors.length - 1]?.root ?? null;
133
+ }
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.404",
3
+ "version": "0.16.406",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {