synthesisui 0.16.405 → 0.16.407

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, proposeFromTheirValue, tooCloseToPropose, whyNotProposedFromUse, whyNotProposedFromValue, } 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,20 @@ 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,
120
+ /**
121
+ * A RAIZ MEDIDA no projeto dele, com a ambiguidade - e OBRIGATÓRIA pelo mesmo motivo que `uses`:
122
+ * `theirs.rootPx` carrega o número e perde o "não sei", e é o "não sei" que impede a proposta de
123
+ * afirmar `spacing.32` sobre um `2rem` de um projeto com duas raízes. Ver `unconvertible`.
124
+ */
125
+ root) {
113
126
  /**
114
127
  * A REPETIÇÃO É UM FILTRO DE RUÍDO, E RUÍDO É UMA PROPRIEDADE DA ESCALA - não do valor.
115
128
  *
@@ -136,6 +149,14 @@ export function absorbPlan(d, theirs, have, cap = 40) {
136
149
  ? [...repeated, ...single]
137
150
  : repeated;
138
151
  const entries = [];
152
+ /**
153
+ * OS CAMINHOS JÁ PROPOSTOS NESTA RODADA, por qualquer fonte. Dois literais diferentes podem cair
154
+ * na mesma posição (`#111111` e `#121212` são ambos luminosidade 7): o primeiro da lista - o mais
155
+ * repetido, porque é a ordem em que ele vê a lista - leva o caminho, e o segundo fica com o
156
+ * motivo. Sem isto os dois saíam com `color.background.7` e o `--send` postava dois tokens para o
157
+ * mesmo nome.
158
+ */
159
+ const taken = new Map();
139
160
  for (const r of unnamed) {
140
161
  if (entries.length >= cap)
141
162
  break;
@@ -194,19 +215,73 @@ export function absorbPlan(d, theirs, have, cap = 40) {
194
215
  * entra - e ela devolve `null` na dúvida (ver `proposed-name.ts`).
195
216
  */
196
217
  /**
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.
218
+ * DUAS FONTES, NESTA ORDEM (D4, dono, 08/09): a escala dele, e onde ela cala, a FUNÇÃO que o
219
+ * valor exerce no código dele. Perto demais de um token dele veta as duas - ali a resposta é
220
+ * normalizar, não batizar. A segunda fonte só existe para cor: é a única natureza em que a
221
+ * propriedade escrita é uma função (`background`, `border`), e a única que a contagem lê.
199
222
  */
200
- const proposal = theirName || tooCloseToPropose(r.literal, theirs)
223
+ /**
224
+ * E SÓ PARA COR - por dois motivos distintos, e vale dizer os dois.
225
+ *
226
+ * A fonte 1 é escala de cor: medido no `frontend-hub` real em 08/09, ela propôs
227
+ * `dashboard.blue.650` para `30px`, `40px` e `50px`, porque `lightness("30px")` lia um prefixo
228
+ * hexadecimal de "3300pp" - consertado na raiz em `color-distance.ts`, e a porta fechada aqui
229
+ * também. A fonte 2 (a FUNÇÃO) é só para cor porque a medição de 08/09 mostrou que a função não
230
+ * separa espaçamento (`8px` é 409× padding e 303× margin no `web-subscribe`): comprimento é
231
+ * proposto pelo próprio VALOR, logo abaixo. O `≈` de vizinhança continua sendo o de `nearestOwn`.
232
+ */
233
+ const colour = r.kind !== "color" || theirName || tooCloseToPropose(r.literal, theirs)
201
234
  ? null
202
- : proposeFromTheirScale(r.literal, theirs);
203
- const path = pathFor(r.kind, theirName) || proposal?.path || "";
235
+ : (proposeFromTheirScale(r.literal, theirs) ??
236
+ proposeFromTheirUse(r.literal, uses, theirs.rootPx, r.count));
204
237
  /** "≈ perto de um token seu" só interessa a quem não tem um EXATO - com o nome na mão, a dica
205
238
  * vira ruído, e no arquivo de proposta ela vira uma segunda opção que não é opção. */
206
239
  const near = theirName ? undefined : nearestOwn(theirs, r.literal, r.kind);
240
+ /**
241
+ * ESPAÇO E RAIO: A TERCEIRA FONTE, e a ordem é a D5 (lead, 08/09) - nome dele > `≈` um token
242
+ * dele > o próprio valor. O `≈` vem ANTES da proposta porque um `15px` a 1px do `--spacing-md`
243
+ * dele é a mesma decisão escrita duas vezes, e propor `spacing.15` ali empurraria para BATIZAR
244
+ * o que a leitura já diz para NORMALIZAR. Ver `proposeFromTheirValue`.
245
+ */
246
+ const length = (r.kind === "spacing" || r.kind === "radius") && !theirName && !near
247
+ ? proposeFromTheirValue(r.literal, r.kind, uses, root, r.count)
248
+ : null;
249
+ const proposal = colour ?? length;
250
+ const own = pathFor(r.kind, theirName);
207
251
  /** Já existe na fundação com OUTRO valor: absorver aqui seria repintar o token deles. */
208
- if (path && have.has(path))
252
+ if (own && have.has(own))
209
253
  continue;
254
+ /**
255
+ * COLISÃO DECLARADA, NUNCA DESCARTADA. Quando o caminho PROPOSTO já existe na fundação para
256
+ * outro valor, a primeira versão fazia `continue` - e a entrada sumia da lista enquanto a
257
+ * última linha do comando dizia "re-run and the next batch appears". Reproduzido pela revisão de
258
+ * DX em 08/09 em duas rodadas seguidas do `absorb`. A entrada FICA, sem caminho, e o motivo
259
+ * diz o que aconteceria: ele decide se é o mesmo token ou outro nome.
260
+ */
261
+ const collided = proposal !== null && have.has(proposal.path);
262
+ /** EMPATE NA MESMA RODADA, declarado: só um leva o caminho, e o outro diz quem levou. */
263
+ const rival = proposal !== null && !collided ? taken.get(proposal.path) : undefined;
264
+ const path = own || (collided || rival ? "" : (proposal?.path ?? ""));
265
+ if (proposal !== null && !own && !collided && !rival)
266
+ taken.set(proposal.path, { literal: r.literal, count: r.count });
267
+ /**
268
+ * E QUANDO AS FONTES CALAM E NÃO HÁ VIZINHO, O CAMPO EM BRANCO DIZ POR QUÊ - em toda natureza.
269
+ * Cor: a função medida. Espaço e raio: a unidade, o degrau, o zero, o negativo. Motion: a
270
+ * contagem, e que duração não é proposta ainda. E o que nem chegou ao passe: o motivo genérico.
271
+ * Nenhuma linha em branco calada é a promessa da `INV-VOC-08`, estendida às outras naturezas
272
+ * em 08/09.
273
+ */
274
+ const silent = !theirName && !proposal && !near;
275
+ const unproposed = collided
276
+ ? `would be ${proposal?.path}, but you already named a different ${r.kind === "color" ? "colour" : "value"} that way`
277
+ : rival
278
+ ? `would be ${proposal?.path}, but ${rival.literal} (${rival.count}×) takes that position this round - name one of them and the other follows`
279
+ : silent && r.kind === "color"
280
+ ? whyNotProposedFromUse(r.literal, uses, theirs.rootPx, r.count)
281
+ : silent && r.kind !== "font"
282
+ ? (whyNotProposedFromValue(r.literal, r.kind, uses, root, r.count) ??
283
+ "never inside a style property - declared or computed, not painted")
284
+ : null;
210
285
  entries.push({
211
286
  value: r.literal,
212
287
  kind: r.kind,
@@ -215,8 +290,11 @@ export function absorbPlan(d, theirs, have, cap = 40) {
215
290
  path,
216
291
  ...(theirName ? { theirName: clean(theirName) } : {}),
217
292
  /** A PROCEDÊNCIA VIAJA COM A PROPOSTA - ele julga pela origem, nunca pela palavra. */
218
- ...(proposal ? { proposed: proposal.because } : {}),
293
+ ...(proposal && !collided && !rival
294
+ ? { proposed: proposal.because }
295
+ : {}),
219
296
  ...(near ? { near } : {}),
297
+ ...(unproposed ? { unproposed } : {}),
220
298
  });
221
299
  }
222
300
  return {
@@ -225,6 +303,28 @@ export function absorbPlan(d, theirs, have, cap = 40) {
225
303
  unnamed: d.repeats.length + d.once.length,
226
304
  };
227
305
  }
306
+ /**
307
+ * NO TAILWIND v4 O NOME É A UTILIDADE - e aprovar `spacing.16` muda o que `p-16` já significa.
308
+ *
309
+ * `absorb --send` no caminho "code" faz ele colar `@theme { --spacing-16: 16px }`, e na v4
310
+ * `--spacing-<n>` é o namespace que resolve `p-16`, `m-16`, `gap-16`, `w-16`, `size-16` e
311
+ * `space-y-16`. Com o padrão (`--spacing: 0.25rem`), `p-16` vale 64px; depois do paste vale 16px, e
312
+ * o projeto dele muda inteiro, calado. Todo degrau de 1 a 96 colide, sempre.
313
+ *
314
+ * A PLATAFORMA NÃO MUDA O NOME (decisão do lead, 08/09, reabrível): o degrau é o VALOR dele, e isso
315
+ * é a promessa da `INV-VOC-09`. Ela DECLARA a consequência quando a stack é v4 - `tailwindMajor` já
316
+ * sabe qual é -, na linha da proposta e no bloco que ele cola. Renomear é escolha dele, no arquivo.
317
+ *
318
+ * Uma frase, um lugar: a lista e o bloco a leem daqui, senão as duas telas divergem no dia em que
319
+ * alguém editar uma.
320
+ */
321
+ export function spacingUtilitiesRedefined(paths) {
322
+ const hit = paths.find((p) => /^spacing\.\d+$/.test(p));
323
+ if (!hit)
324
+ return null;
325
+ const step = hit.slice("spacing.".length);
326
+ return `On Tailwind v4 the name IS the utility: --spacing-${step} is what p-${step}, m-${step}, gap-${step} and w-${step} read, so approving ${hit} changes what those already mean in your project. The value is yours - rename the step in the file if you want the utilities untouched.`;
327
+ }
228
328
  /** Quantas linhas da proposta ainda precisam de um nome humano. */
229
329
  export const needingName = (plan) => plan.entries.filter((e) => !e.path);
230
330
  /**
@@ -234,7 +334,14 @@ export const needingName = (plan) => plan.entries.filter((e) => !e.path);
234
334
  * um repo em migração já tem nome no vocabulário do próprio cliente, e essas viajam sem ninguém digitar
235
335
  * nada.
236
336
  */
237
- export function describeAbsorb(plan) {
337
+ export function describeAbsorb(plan,
338
+ /**
339
+ * A STACK DELE, e OBRIGATÓRIA de propósito: o aviso do namespace da v4 (ver
340
+ * `spacingUtilitiesRedefined`) é a metade que faz a proposta ser honesta naquele projeto, e um
341
+ * argumento opcional que dá para esquecer é a próxima ocorrência do defeito em que a tela promete
342
+ * o que a chamada não passou. `false` é resposta explícita.
343
+ */
344
+ tailwind4) {
238
345
  /**
239
346
  * O QUE ELE JÁ NOMEIA E O QUE A PLATAFORMA PROPÔS SÃO DUAS LISTAS - e juntá-las é dizer que ele
240
347
  * nomeou o que ele não nomeou.
@@ -280,11 +387,18 @@ export function describeAbsorb(plan) {
280
387
  if (proposed.length > 0) {
281
388
  if (ready.length > 0)
282
389
  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:`);
390
+ lines.push(`${proposed.length} ${proposed.length === 1 ? "value has" : "values have"} a name PROPOSED from your own vocabulary - your scale, how you use the value, or the value itself - approve, edit, or leave ${proposed.length === 1 ? "it" : "them"} hard-coded:`);
284
391
  for (const e of proposed.slice(0, 8))
285
392
  lines.push(` ${e.value.padEnd(24)} ${e.path.padEnd(28)} ${e.proposed}`);
286
393
  if (proposed.length > 8)
287
394
  lines.push(` (${proposed.length - 8} more)`);
395
+ const redefined = tailwind4
396
+ ? spacingUtilitiesRedefined(proposed.map((e) => e.path))
397
+ : null;
398
+ if (redefined) {
399
+ lines.push("");
400
+ lines.push(redefined);
401
+ }
288
402
  }
289
403
  if (pending.length > 0) {
290
404
  const close = pending.filter((e) => e.near);
@@ -308,7 +422,10 @@ export function describeAbsorb(plan) {
308
422
  : /** A proposta chega COM a procedência: ele julga pela origem, não pela palavra. */
309
423
  e.proposed
310
424
  ? `${e.path} · ${e.proposed}`
311
- : 'path: ""'}`);
425
+ : /** E o campo em branco chega com o motivo, quando a função medida o tem. */
426
+ e.unproposed
427
+ ? `path: "" · ${e.unproposed}`
428
+ : 'path: ""'}`);
312
429
  if (pending.length > 8)
313
430
  lines.push(` (${pending.length - 8} more)`);
314
431
  /**
@@ -1,13 +1,15 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { absorbPlan, describeAbsorb, needingName, } from "../absorb-plan.js";
3
+ import { absorbPlan, describeAbsorb, needingName, spacingUtilitiesRedefined, } from "../absorb-plan.js";
4
4
  import { readProjectConfig, readToken, resolveRegistry } from "../config.js";
5
- import { diagnose, scanSource } from "../doctor/scan.js";
5
+ import { DEFAULT_ROOT_PX, rootSizeOf } from "../doctor/root-size.js";
6
+ import { diagnose, IS_FIXTURE, scanSource, } from "../doctor/scan.js";
6
7
  import { withTheirNames } from "../doctor/their-names.js";
7
8
  import { measuredScope, scopePaths } from "../measured-scope.js";
8
9
  import { body, paint, section, snippet } from "../output.js";
9
10
  import { resolveDeps, tailwindMajor } from "../stack.js";
10
- import { loadSystem, walkAll } from "./doctor.js";
11
+ import { countUseByProperty } from "../use-by-property.js";
12
+ import { loadSystem, sheetsIn, walkAll } from "./doctor.js";
11
13
  /**
12
14
  * `synthesisui absorb` - O SISTEMA APRENDE O DESIGN QUE O CÓDIGO JÁ TEM.
13
15
  *
@@ -33,7 +35,26 @@ export async function absorb(opts) {
33
35
  const path = join(root, FILE);
34
36
  if (opts.send)
35
37
  return sendProposal(root, path, opts.registry);
36
- const installed = await loadSystem(root);
38
+ /**
39
+ * A RAIZ, MEDIDA ANTES DE PROPOR NOME NENHUM - e sem isto este comando AFIRMA um número falso.
40
+ *
41
+ * Este comando chamava `loadSystem(root)` sem a raiz medida, então `1rem` valia 16px sempre. Até a
42
+ * Parte 2 isso só o fazia CASAR menos - um `0.25rem` dele não achava o token em `4px`, e o erro
43
+ * era por omissão. A proposta pelo valor (`INV-VOC-09`) transformou a omissão em afirmação: num
44
+ * projeto que escreve `html { font-size: 62.5% }`, um `padding: 2rem` saía como `spacing.32` e
45
+ * *"32px, used 2× as margin and padding"* - e `2rem` ali vale 20px. Um nome errado e um número
46
+ * falso no terminal dele, sobre o repositório dele. Medido no comando real em 08/09.
47
+ *
48
+ * A medição é a MESMA do `doctor` (`rootSizeOf` sobre as folhas de estilo, só o bloco
49
+ * `html`/`:root`), sobre a RAIZ do projeto - que é a população de onde `loadSystem` colhe o
50
+ * vocabulário dele, mesmo quando a leitura é escopada. Ambíguo (duas raízes, um `calc()`) cai no
51
+ * padrão do navegador, como no `doctor`: quem decide isso é `root-size.ts`, não este comando.
52
+ */
53
+ const rootSize = rootSizeOf(await sheetsIn([root]));
54
+ const installed = await loadSystem(root, {
55
+ px: rootSize.ambiguous ? DEFAULT_ROOT_PX : rootSize.px,
56
+ from: rootSize.from,
57
+ });
37
58
  /**
38
59
  * SEM SISTEMA INSTALADO ISTO CONTINUA VALENDO - e recusar aqui fechava o dia 1.
39
60
  *
@@ -85,19 +106,30 @@ export async function absorb(opts) {
85
106
  ? installed.table
86
107
  : withTheirNames(theirs, theirs);
87
108
  const reports = [];
109
+ /**
110
+ * A FUNÇÃO DE CADA COR, contada num passe próprio sobre os MESMOS arquivos - é a segunda fonte do
111
+ * nome proposto (`proposed-name.ts`). Fixture e teste ficam de fora pela mesma regra do scan: as
112
+ * cores de um `.stories` são do exemplo, não do produto dele.
113
+ */
114
+ const uses = new Map();
88
115
  for await (const file of walkAll(roots)) {
89
116
  const source = await readFile(file, "utf8").catch(() => null);
90
117
  if (source === null)
91
118
  continue;
92
- reports.push(scanSource(file.slice(root.length + 1), source, table));
119
+ const rel = file.slice(root.length + 1);
120
+ reports.push(scanSource(rel, source, table));
121
+ if (!IS_FIXTURE.test(rel))
122
+ countUseByProperty(source, uses, rootSize);
93
123
  }
94
- const plan = absorbPlan(diagnose(reports), theirs, pathsInSystem(installed.documents), opts.cap ?? 40);
124
+ const plan = absorbPlan(diagnose(reports), theirs, pathsInSystem(installed.documents), opts.cap ?? 40, uses, rootSize);
95
125
  console.log(section(hasSystem
96
126
  ? "What the system could absorb"
97
127
  : "Name what you wrote by hand"));
98
128
  console.log(body(paint.dim(`read from ${rel.length > 0 ? rel.join(", ") : "this project"} · your own vocabulary read from the root`)));
99
129
  console.log("");
100
- for (const line of describeAbsorb(plan))
130
+ /** A stack dele decide uma frase da lista - ver `spacingUtilitiesRedefined`. */
131
+ const major = tailwindMajor(await resolveDeps(root));
132
+ for (const line of describeAbsorb(plan, major === 4))
101
133
  console.log(body(line));
102
134
  if (plan.entries.length === 0)
103
135
  return;
@@ -215,6 +247,13 @@ async function sendProposal(root, path, registry) {
215
247
  for (const e of ready)
216
248
  console.log(` --${e.path.replace(/\./g, "-")}: ${e.value};`);
217
249
  console.log(" }");
250
+ /** E O QUE ESSE PASTE MUDA NO PROJETO DELE, dito antes de ele colar - ver
251
+ * `spacingUtilitiesRedefined`. Na v4 o nome É a utilidade. */
252
+ const redefined = major === 4 ? spacingUtilitiesRedefined(ready.map((e) => e.path)) : null;
253
+ if (redefined) {
254
+ console.log("");
255
+ console.log(body(redefined));
256
+ }
218
257
  console.log("");
219
258
  console.log(body(major === 4
220
259
  ? "In `@theme` and not `:root`: on Tailwind v4 only `@theme` generates the utility, so this is what makes `text-ink-700` exist in your code."
@@ -385,7 +385,7 @@ measured) {
385
385
  * common path pays nothing for it.
386
386
  */
387
387
  /** As folhas de estilo dele, para a medição da raiz. Só css - nada de `.tsx`. */
388
- async function sheetsIn(roots) {
388
+ export async function sheetsIn(roots) {
389
389
  const out = [];
390
390
  for await (const file of walkAll(roots)) {
391
391
  if (!/\.(css|scss|sass|less)$/i.test(file))
@@ -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 { lengthKey, ROOTS_OF_KIND, 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,276 @@ 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
+ ];
184
+ /**
185
+ * POR QUE O CAMPO FICOU EM BRANCO, em uma linha - a lacuna declarada em vez de calada.
186
+ *
187
+ * *"4× background · 1× 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.
190
+ *
191
+ * NUNCA MENTE SOZINHA: devolve `null` quando `proposeFromTheirUse` propõe para o mesmo literal, para
192
+ * que o guard não more só em quem chama.
193
+ *
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.
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
+ }
219
+ /**
220
+ * A TERCEIRA FONTE - O PRÓPRIO VALOR, para espaço e raio. Especificada em 08/09
221
+ * (`steps/03-parte-3-espacamento-na-lista.md`), depois de a medição descartar a função como
222
+ * separadora de espaçamento: no `web-subscribe` real, `8px` é 409× padding, 303× margin e 18× gap -
223
+ * um degrau de espaço é usado em padding E margin por definição, e "fonte 2 para spacing" proporia
224
+ * na cauda e calaria na cabeça, exatamente onde ele olha.
225
+ *
226
+ * O QUE O CLIENTE GANHA: depois da cor, a primeira tela do `absorb` no `web-subscribe` tinha 24 de 40
227
+ * linhas de espaçamento em branco e SEM motivo. Agora `16px` chega como `spacing.16` com *"16px, used
228
+ * 642× as padding, margin and gap - one step of your spacing"*; ele renomeia na revisão (`spacing.md`)
229
+ * ou deixa hard-coded. E `1em` chega com o motivo: depende da fonte de cada elemento, não há degrau
230
+ * fixo.
231
+ *
232
+ * O CAMINHO É O PRÓPRIO VALOR (D2): é o único nome derivável do repositório dele que é VERDADEIRO. A
233
+ * palavra `spacing`/`radius` é a casa que o produto já usa nos caminhos dos nomes DELE (`HOME[kind]`
234
+ * em `absorb-plan.ts`) - não é uma palavra nossa sobre o valor, é onde o valor mora. `sm`, `md`,
235
+ * `tight` seriam invenção, e não entram nem como sugestão.
236
+ *
237
+ * AS PORTAS (D2-D4), todas para o lado do "propor de menos":
238
+ *
239
+ * natureza só `spacing` e `radius`; motion tem motivo e não tem proposta (fora, declarado)
240
+ * unidade FIXA px ou rem; `em` vale 16px só quando a fonte do elemento é a da raiz - é a regra
241
+ * do `fontRelative` que o `--fix` já segue (D3)
242
+ * degrau INTEIRO `3.2px` (de `0.2em` ou `0.2rem`) não é um degrau (D4)
243
+ * positivo zero não é degrau; negativo não é degrau (D4)
244
+ * contado o valor chegou ao passe em alguma função da natureza certa - sem contagem não
245
+ * há distribuição para dizer, e o motivo é o genérico do plano
246
+ */
247
+ /** `16px` → 16 · `1rem` → 16 · `0.2rem` → 3.2 · não-comprimento → null. */
248
+ function pxOf(literal, rootPx) {
249
+ const m = /^(-?\d*\.?\d+)px$/.exec(normalizeValue(literal, rootPx));
250
+ return m ? Number.parseFloat(m[1] ?? "") : null;
251
+ }
252
+ const isEm = (literal) => /(?<!r)em$/i.test(literal.trim());
253
+ const isRem = (literal) => /rem$/i.test(literal.trim());
105
254
  /**
106
- * A SEGUNDA FONTE - ESPECIFICADA, TENTADA, E NÃO ENTREGUE. O motivo é medido, e ele é o valor deste
107
- * comentário.
255
+ * RAIZ SEM UMA RESPOSTA NÃO CONVERTE `rem` - e é o mesmo lado do erro que o `doctor` já escolheu.
108
256
  *
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"*.
257
+ * Duas declarações de `font-size` na raiz (ou uma que não dá para ler) e `1rem` deixa de ter um
258
+ * valor em px: propor `spacing.32` ali seria afirmar uma conversão que a plataforma não sabe fazer.
259
+ * `px` continua propondo, porque px não depende da raiz de nada. Decisão de implementação do lead
260
+ * (08/09), reabrível pelo dono; a régua que decide o que é ambíguo é `root-size.ts`, não esta.
261
+ */
262
+ const unconvertible = (literal, root) => Boolean(root.ambiguous) && isRem(literal);
263
+ /** As funções da natureza certa em que o valor aparece, mais usada primeiro, com o total. */
264
+ function lengthUses(literal, kind, uses, root) {
265
+ const roots = ROOTS_OF_KIND[kind];
266
+ if (!roots)
267
+ return null;
268
+ const per = uses.get(lengthKey(literal, root));
269
+ if (!per)
270
+ return null;
271
+ const entries = [...per.entries()]
272
+ .filter(([root, n]) => n > 0 && roots.includes(root))
273
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
274
+ if (entries.length === 0)
275
+ return null;
276
+ return { per: entries, total: entries.reduce((n, [, c]) => n + c, 0) };
277
+ }
278
+ /** `padding, margin and gap` · `padding and margin` · `padding`. */
279
+ const listed = (roots) => roots.length <= 1
280
+ ? (roots[0] ?? "")
281
+ : `${roots.slice(0, -1).join(", ")} and ${roots[roots.length - 1]}`;
282
+ /**
283
+ * O NOME PELO PRÓPRIO VALOR: `spacing.16`, `radius.6`.
111
284
  *
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.
285
+ * O `because` carrega a distribuição por função, contada por ocorrência no mesmo passe da cor, para
286
+ * ele julgar pela origem: *"16px, used 642× as padding, margin and gap - one step of your spacing"*.
287
+ */
288
+ export function proposeFromTheirValue(literal, kind, uses,
289
+ /**
290
+ * A RAIZ MEDIDA, com a ambiguidade - o objeto inteiro, e não os pixels soltos: a ambiguidade é
291
+ * parte da medição, e perdê-la no caminho é o que fazia um `2rem` sair `spacing.32` num projeto
292
+ * cuja raiz não tem uma resposta. Ver `unconvertible`.
293
+ */
294
+ root,
295
+ /**
296
+ * QUANTAS OCORRÊNCIAS O SCAN VIU - e a proposta cala quando o passe alcançou MENOS: a
297
+ * distribuição seria uma afirmação sobre um pedaço. É a mesma metade `unknown` que a cor já tem
298
+ * (`INV-VOC-08`), e é obrigatória por isso.
299
+ */
300
+ counted) {
301
+ if (kind !== "spacing" && kind !== "radius")
302
+ return null;
303
+ if (isEm(literal) || unconvertible(literal, root))
304
+ return null;
305
+ const px = pxOf(literal, root.px);
306
+ if (px === null || !Number.isInteger(px) || px <= 0)
307
+ return null;
308
+ const u = lengthUses(literal, kind, uses, root);
309
+ /**
310
+ * DUAS OCORRÊNCIAS, A MESMA RÉGUA DA COR (D2 do dono, 07/09: 100% e >= 2, *"na dúvida,
311
+ * hard-coded"*). Uma cor vista uma vez sai "seen once" sem proposta; um comprimento visto uma vez
312
+ * saía `spacing.3 · "one step of your spacing"` - duas réguas de repetição na mesma tela, e a
313
+ * mais frouxa era a que propunha. A D2 é do dono; estendê-la a comprimento é decisão do lead
314
+ * (08/09), reabrível.
315
+ */
316
+ if (!u || u.total < 2 || u.total < counted)
317
+ return null;
318
+ return {
319
+ path: `${kind}.${px}`,
320
+ because: `${px}px, used ${u.total}× as ${listed(u.per.map(([root]) => root))} - one step of your ${kind}`,
321
+ };
322
+ }
323
+ /**
324
+ * POR QUE NÃO HÁ PROPOSTA PELO VALOR, em uma linha - e nunca `null` quando há contagem.
116
325
  *
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.
326
+ * *"font-relative (em): 597× across padding and margin - depends on each element's font, so no fixed
327
+ * step"* é a leitura que ele consegue agir sobre. `counted` é o que o scan viu, e serve a motion, que
328
+ * o passe não conta. `null` quando o literal nem chegou ao passe - o plano usa o motivo
329
+ * genérico *"never inside a style property…"*. Nunca mente sozinha: devolve `null` onde
330
+ * `proposeFromTheirValue` propõe.
121
331
  */
332
+ export function whyNotProposedFromValue(literal, kind, uses, root, counted) {
333
+ if (kind === "motion")
334
+ return `${counted}× - durations are not proposed yet`;
335
+ if (proposeFromTheirValue(literal, kind, uses, root, counted))
336
+ return null;
337
+ if (kind !== "spacing" && kind !== "radius")
338
+ return null;
339
+ /**
340
+ * A RAIZ AMBÍGUA É O MOTIVO ANTES DE QUALQUER CONTAGEM - ela não depende de quantas vezes ele
341
+ * escreveu o valor, e dizer "read 0 of 4 uses" ali culparia a propriedade por uma lacuna que é da
342
+ * raiz. Ver `unconvertible`.
343
+ */
344
+ if (unconvertible(literal, root)) {
345
+ const saw = root.ambiguous?.saw.length ?? 0;
346
+ return `your root font-size is not one answer (${saw} declaration${saw === 1 ? "" : "s"}) - px and rem are not compared here, so no fixed step from ${literal.trim().toLowerCase()}`;
347
+ }
348
+ const u = lengthUses(literal, kind, uses, root);
349
+ /**
350
+ * O PASSE NÃO ALCANÇOU O QUE O SCAN CONTOU: `unknown` é estado, e a frase diz qual é a lacuna.
351
+ *
352
+ * `scroll-padding: 18px`, `grid-gap: 22px` e `scroll-margin: 26px` chegam ao scan e não a este
353
+ * passe - a propriedade não está nas que ele lê. O motivo genérico dizia *"never inside a style
354
+ * property - declared or computed, not painted"*, falso em dois pontos: o valor ESTÁ numa
355
+ * propriedade de estilo, e "painted" é vocabulário de cor numa linha de espaçamento.
356
+ */
357
+ if ((u?.total ?? 0) < counted)
358
+ return `read ${u?.total ?? 0} of ${counted} uses - the property it sits in is not one this reads yet`;
359
+ if (!u)
360
+ return null;
361
+ const across = listed(u.per.map(([root]) => root));
362
+ if (isEm(literal))
363
+ return `font-relative (em): ${u.total}× across ${across} - depends on each element's font, so no fixed step`;
364
+ const px = pxOf(literal, root.px);
365
+ if (px === null)
366
+ return `${u.total}× across ${across} - not a length we can read`;
367
+ if (px === 0)
368
+ return "zero is not a step";
369
+ if (px < 0)
370
+ return "negative values are not steps";
371
+ if (!Number.isInteger(px)) {
372
+ const written = literal.trim().toLowerCase();
373
+ const from = written === `${px}px` ? "" : ` (from ${written})`;
374
+ return `${px}px${from} is not a whole step`;
375
+ }
376
+ /** Uma ocorrência não é uso medido - a mesma frase que a cor já usa (D2). */
377
+ return "seen once";
378
+ }
@@ -0,0 +1,239 @@
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
+ /** As raízes que recebem COMPRIMENTO - espaço e raio. Tudo o mais recebe cor. */
6
+ const LENGTH_ROOTS = new Set([
7
+ "padding",
8
+ "margin",
9
+ "gap",
10
+ "border-radius",
11
+ ]);
12
+ /** As raízes de espaço, por natureza do achado - o `because` só lista as da natureza certa. */
13
+ export const ROOTS_OF_KIND = {
14
+ spacing: ["padding", "margin", "gap"],
15
+ radius: ["border-radius"],
16
+ };
17
+ /**
18
+ * UTILIDADE TAILWIND → PROPRIEDADE CSS QUE ELA ESCREVE. A palavra é a da direita, sempre: `ring` não
19
+ * é propriedade CSS nenhuma - o Tailwind a compila em `box-shadow`, e é isso que o código dele pinta.
20
+ * `px` é `padding` (dos dois lados), `mt` é `margin`, `rounded` é `border-radius`.
21
+ */
22
+ export const TAILWIND_WRITES = {
23
+ bg: "background",
24
+ from: "background",
25
+ via: "background",
26
+ to: "background",
27
+ text: "color",
28
+ placeholder: "color",
29
+ border: "border",
30
+ divide: "border",
31
+ shadow: "box-shadow",
32
+ ring: "box-shadow",
33
+ "ring-offset": "box-shadow",
34
+ outline: "outline",
35
+ decoration: "text-decoration-color",
36
+ accent: "accent-color",
37
+ caret: "caret-color",
38
+ fill: SVG,
39
+ stroke: SVG,
40
+ p: "padding",
41
+ px: "padding",
42
+ py: "padding",
43
+ pt: "padding",
44
+ pr: "padding",
45
+ pb: "padding",
46
+ pl: "padding",
47
+ ps: "padding",
48
+ pe: "padding",
49
+ m: "margin",
50
+ mx: "margin",
51
+ my: "margin",
52
+ mt: "margin",
53
+ mr: "margin",
54
+ mb: "margin",
55
+ ml: "margin",
56
+ ms: "margin",
57
+ me: "margin",
58
+ gap: "gap",
59
+ rounded: "border-radius",
60
+ };
61
+ /**
62
+ * `16px` · `1.5rem` · `.5em` · `-1px` · `1PX` - o número e a unidade, como o scan os lê. O
63
+ * lookbehind impede `2px` dentro de `12px`; o lookahead impede `em` dentro de `emphasis`.
64
+ *
65
+ * A CAIXA NÃO DECIDE: CSS não distingue `1px` de `1PX`, e código gerado escreve das duas formas.
66
+ * Sem a flag `i` um `margin: 1REM` não era contado, e a linha dele saía sem motivo.
67
+ */
68
+ const LENGTH = /(?<![\w.#-])-?\d*\.?\d+(?:px|rem|em)(?![\w-])/gi;
69
+ /**
70
+ * `background:` · `backgroundColor:` · `"background-color":` · `fill="`
71
+ *
72
+ * O identificador não pode vir colado a `.`, `$`, `@`, `-`, letra ou dígito (`colors.dark :` num
73
+ * ternário não é uma propriedade), e a aspa de abertura, quando existe, tem de fechar antes do `:` -
74
+ * senão `"#fff" :` de um ternário viraria a âncora `fff`. O `=` só vale na forma de ATRIBUTO - colado
75
+ * ao nome e seguido de aspa ou `{` -, porque `const BRAND = "#0f5132"` é uma constante, não uma
76
+ * função. O que casa aqui ainda passa por `propertyRoot`: só o que pinta ou espaça vira âncora.
77
+ */
78
+ const PROPERTY = /(?<![\w.$@#-])(["']?)[a-zA-Z][\w-]*\1(?:\s*:|=(?=["'{]))/g;
79
+ /**
80
+ * `bg-[#…]` · `hover:text-[#…]` · `border-t-[rgba(…)]` · `gap-x-[…]` · `rounded-tl-[…]` - as
81
+ * utilidades da tabela, com o sufixo de lado que algumas aceitam.
82
+ */
83
+ const UTILITY = new RegExp(`(?<![\\w-])(${Object.keys(TAILWIND_WRITES)
84
+ .sort((a, b) => b.length - a.length)
85
+ .join("|")})(?:-[trblxyse]{1,2})?-\\[`, "g");
86
+ /**
87
+ * A RAIZ DE UMA PROPRIEDADE CSS QUE CARREGA COR OU COMPRIMENTO (D1) - ou `null` para o que não é
88
+ * uma. A palavra que ele escreveu, sem o sufixo que só diz o lado ou o dialeto: `background-color`,
89
+ * `backgroundColor` e `background-image` são `background`; `border-top-color` e `borderColor` são
90
+ * `border`; `padding-top`, `paddingTop` são `padding`; `border-top-left-radius` e `borderRadius`
91
+ * são `border-radius`. Uma chave de objeto, uma variável, uma propriedade que não pinta nem espaça:
92
+ * `null`.
93
+ */
94
+ export function propertyRoot(raw) {
95
+ const kebab = raw
96
+ .trim()
97
+ .replace(/^["']|["']$/g, "")
98
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
99
+ .toLowerCase();
100
+ if (/^border(-[a-z]+)*-radius$/.test(kebab))
101
+ return "border-radius";
102
+ if (/^padding(-|$)/.test(kebab))
103
+ return "padding";
104
+ if (/^margin(-|$)/.test(kebab))
105
+ return "margin";
106
+ if (/^(gap|row-gap|column-gap)$/.test(kebab))
107
+ return "gap";
108
+ if (/^background(-|$)/.test(kebab))
109
+ return "background";
110
+ if (/^border(-|$)/.test(kebab))
111
+ return "border";
112
+ if (kebab === "color")
113
+ return "color";
114
+ if (/^outline(-|$)/.test(kebab))
115
+ return "outline";
116
+ if (kebab === "box-shadow")
117
+ return "box-shadow";
118
+ if (kebab === "text-shadow")
119
+ return "text-shadow";
120
+ if (/^(fill|stroke|stop-color|flood-color|lighting-color)$/.test(kebab))
121
+ return SVG;
122
+ if (/^(caret-color|accent-color|text-decoration-color|text-emphasis-color|column-rule-color)$/.test(kebab))
123
+ return kebab;
124
+ return null;
125
+ }
126
+ /**
127
+ * A CHAVE DE UM COMPRIMENTO: o valor em px sobre a raiz medida, e `@em` quando a unidade escrita
128
+ * é `em` - porque aí o valor não é fixo, e uma contagem que o somasse ao `16px` afirmaria um degrau
129
+ * sobre ocorrências que dependem da fonte de cada elemento (D3). `1rem` e `16px` são a mesma chave.
130
+ */
131
+ export function lengthKey(literal, root) {
132
+ const px = normalizeValue(literal, root.px);
133
+ const written = literal.trim();
134
+ if (/(?<!r)em$/i.test(written))
135
+ return `${px}@em`;
136
+ if (root.ambiguous && /rem$/i.test(written))
137
+ return `${px}@rem?`;
138
+ return px;
139
+ }
140
+ /** Toda declaração escrita na linha, em ordem, com o limite de cada uma. */
141
+ function marksOf(line) {
142
+ const out = [];
143
+ for (const m of line.matchAll(PROPERTY))
144
+ out.push({
145
+ start: m.index,
146
+ end: m.index + m[0].length,
147
+ root: propertyRoot(m[0].replace(/\s*[:=]$/, "")),
148
+ until: Number.POSITIVE_INFINITY,
149
+ });
150
+ for (const m of line.matchAll(UTILITY)) {
151
+ const root = TAILWIND_WRITES[m[1] ?? ""];
152
+ if (!root)
153
+ continue;
154
+ const end = m.index + m[0].length;
155
+ const close = line.indexOf("]", end);
156
+ out.push({
157
+ start: m.index,
158
+ end,
159
+ root,
160
+ until: close < 0 ? Number.POSITIVE_INFINITY : close,
161
+ });
162
+ }
163
+ out.sort((a, b) => a.start - b.start);
164
+ /** O limite de cada uma é o começo da próxima - quem escreveu outra propriedade fechou esta. */
165
+ for (const [i, mark] of out.entries()) {
166
+ const next = out[i + 1];
167
+ if (next)
168
+ mark.until = Math.min(mark.until, next.start);
169
+ }
170
+ return out;
171
+ }
172
+ /**
173
+ * A DECLARAÇÃO QUE COBRE ESTE LITERAL - ou `null`, e `null` é resposta: o valor não está numa
174
+ * propriedade que este passe lê, então ele não exerce função nenhuma que a gente possa afirmar.
175
+ * Sem declaração na linha, vale a que ficou aberta na anterior.
176
+ */
177
+ function anchorFor(marks, at, carried) {
178
+ if (marks.length === 0)
179
+ return carried;
180
+ let hit = null;
181
+ for (const mark of marks) {
182
+ if (mark.end > at)
183
+ break;
184
+ hit = mark;
185
+ }
186
+ if (hit === null || hit.root === null)
187
+ return null;
188
+ return at < hit.until ? hit.root : null;
189
+ }
190
+ /** A linha FECHOU a declaração: o que vier depois não é continuação dela. */
191
+ const ENDS_DECLARATION = /[;{}]\s*$/;
192
+ /**
193
+ * Conta cada ocorrência de cor e de comprimento de UM arquivo, linha a linha, na função da âncora
194
+ * mais próxima antes dela. `box-shadow: 0 1px #000, 0 2px #fff` conta as duas cores em
195
+ * `box-shadow` (e nenhum dos comprimentos); `padding: 1px 2px` conta `1px` e `2px` uma vez cada em
196
+ * `padding`; uma linha com `background` e `borderColor` do mesmo hex conta as duas funções. Acumula
197
+ * em `into` para que o repositório inteiro caiba num único mapa.
198
+ *
199
+ * A DECLARAÇÃO PODE CONTINUAR NA LINHA SEGUINTE - regra do CSS, não de um repositório:
200
+ *
201
+ * box-shadow:
202
+ * 1px 1px 0 0 #2b2927,
203
+ * 2px 2px 0 0 #2b2927;
204
+ *
205
+ * Medido no `web-subscribe` real em 08/09 (`framework/style/_term.scss`): 9 ocorrências assim, sem
206
+ * âncora na própria linha, saíam sem função nenhuma - e o campo em branco saía calado. Quando a linha
207
+ * não tem âncora e a anterior não FECHOU a declaração (não termina em `;`, `}` ou `{`), a âncora da
208
+ * anterior vale para esta. `background: #111;` seguido de `#222` NÃO herda: o `;` fechou.
209
+ */
210
+ export function countUseByProperty(source, into,
211
+ /** A raiz MEDIDA, com a ambiguidade dela - ver `lengthKey`. */
212
+ root) {
213
+ const bump = (key, root) => {
214
+ const per = into.get(key) ?? new Map();
215
+ per.set(root, (per.get(root) ?? 0) + 1);
216
+ into.set(key, per);
217
+ };
218
+ /** A âncora da declaração ainda aberta, vinda das linhas anteriores. */
219
+ let carried = null;
220
+ for (const line of source.split("\n")) {
221
+ const marks = marksOf(line);
222
+ for (const m of line.matchAll(COLOR)) {
223
+ const at = anchorFor(marks, m.index, carried);
224
+ if (at !== null && !LENGTH_ROOTS.has(at))
225
+ bump(normalizeValue(m[0], root.px), at);
226
+ }
227
+ for (const m of line.matchAll(LENGTH)) {
228
+ const at = anchorFor(marks, m.index, carried);
229
+ if (at !== null && LENGTH_ROOTS.has(at))
230
+ bump(lengthKey(m[0], root), at);
231
+ }
232
+ /** A ÚLTIMA declaração da linha é a que pode continuar - e se ela é de uma propriedade que este
233
+ * passe não lê, o que continua é o silêncio dela, nunca a âncora de antes. */
234
+ if (ENDS_DECLARATION.test(line))
235
+ carried = null;
236
+ else if (marks.length > 0)
237
+ carried = marks[marks.length - 1]?.root ?? null;
238
+ }
239
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.405",
3
+ "version": "0.16.407",
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": {