synthesisui 0.16.406 → 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, proposeFromTheirUse, tooCloseToPropose, whyNotProposedFromUse, } 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",
@@ -116,7 +116,13 @@ export function absorbPlan(d, theirs, have, cap,
116
116
  * ocorrência do defeito em que a tela promete o que a chamada não passou. Quem não tem a contagem
117
117
  * passa um mapa vazio e a fonte 2 cala - explicitamente.
118
118
  */
119
- uses) {
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) {
120
126
  /**
121
127
  * A REPETIÇÃO É UM FILTRO DE RUÍDO, E RUÍDO É UMA PROPRIEDADE DA ESCALA - não do valor.
122
128
  *
@@ -220,14 +226,27 @@ uses) {
220
226
  * A fonte 1 é escala de cor: medido no `frontend-hub` real em 08/09, ela propôs
221
227
  * `dashboard.blue.650` para `30px`, `40px` e `50px`, porque `lightness("30px")` lia um prefixo
222
228
  * 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`.
229
+ * também. A fonte 2 (a FUNÇÃO) é 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`.
226
232
  */
227
- const proposal = r.kind !== "color" || theirName || tooCloseToPropose(r.literal, theirs)
233
+ const colour = r.kind !== "color" || theirName || tooCloseToPropose(r.literal, theirs)
228
234
  ? null
229
235
  : (proposeFromTheirScale(r.literal, theirs) ??
230
236
  proposeFromTheirUse(r.literal, uses, theirs.rootPx, r.count));
237
+ /** "≈ perto de um token seu" só interessa a quem não tem um EXATO - com o nome na mão, a dica
238
+ * vira ruído, e no arquivo de proposta ela vira uma segunda opção que não é opção. */
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;
231
250
  const own = pathFor(r.kind, theirName);
232
251
  /** Já existe na fundação com OUTRO valor: absorver aqui seria repintar o token deles. */
233
252
  if (own && have.has(own))
@@ -245,17 +264,24 @@ uses) {
245
264
  const path = own || (collided || rival ? "" : (proposal?.path ?? ""));
246
265
  if (proposal !== null && !own && !collided && !rival)
247
266
  taken.set(proposal.path, { literal: r.literal, count: r.count });
248
- /** "≈ perto de um token seu" só interessa a quem não tem um EXATO - com o nome na mão, a dica
249
- * vira ruído, e no arquivo de proposta ela vira uma segunda opção que não é opção. */
250
- const near = theirName ? undefined : nearestOwn(theirs, r.literal, r.kind);
251
- /** E quando as duas fontes calam e não vizinho, o campo em branco diz por quê. */
267
+ /**
268
+ * E QUANDO AS FONTES CALAM E NÃO 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;
252
275
  const unproposed = collided
253
- ? `would be ${proposal?.path}, but you already named a different colour that way`
276
+ ? `would be ${proposal?.path}, but you already named a different ${r.kind === "color" ? "colour" : "value"} that way`
254
277
  : rival
255
278
  ? `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
279
+ : silent && r.kind === "color"
257
280
  ? whyNotProposedFromUse(r.literal, uses, theirs.rootPx, r.count)
258
- : null;
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;
259
285
  entries.push({
260
286
  value: r.literal,
261
287
  kind: r.kind,
@@ -277,6 +303,28 @@ uses) {
277
303
  unnamed: d.repeats.length + d.once.length,
278
304
  };
279
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
+ }
280
328
  /** Quantas linhas da proposta ainda precisam de um nome humano. */
281
329
  export const needingName = (plan) => plan.entries.filter((e) => !e.path);
282
330
  /**
@@ -286,7 +334,14 @@ export const needingName = (plan) => plan.entries.filter((e) => !e.path);
286
334
  * um repo em migração já tem nome no vocabulário do próprio cliente, e essas viajam sem ninguém digitar
287
335
  * nada.
288
336
  */
289
- 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) {
290
345
  /**
291
346
  * O QUE ELE JÁ NOMEIA E O QUE A PLATAFORMA PROPÔS SÃO DUAS LISTAS - e juntá-las é dizer que ele
292
347
  * nomeou o que ele não nomeou.
@@ -332,11 +387,18 @@ export function describeAbsorb(plan) {
332
387
  if (proposed.length > 0) {
333
388
  if (ready.length > 0)
334
389
  lines.push("");
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:`);
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:`);
336
391
  for (const e of proposed.slice(0, 8))
337
392
  lines.push(` ${e.value.padEnd(24)} ${e.path.padEnd(28)} ${e.proposed}`);
338
393
  if (proposed.length > 8)
339
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
+ }
340
402
  }
341
403
  if (pending.length > 0) {
342
404
  const close = pending.filter((e) => e.near);
@@ -1,14 +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 { DEFAULT_ROOT_PX, rootSizeOf } from "../doctor/root-size.js";
5
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
11
  import { countUseByProperty } from "../use-by-property.js";
11
- import { loadSystem, walkAll } from "./doctor.js";
12
+ import { loadSystem, sheetsIn, walkAll } from "./doctor.js";
12
13
  /**
13
14
  * `synthesisui absorb` - O SISTEMA APRENDE O DESIGN QUE O CÓDIGO JÁ TEM.
14
15
  *
@@ -34,7 +35,26 @@ export async function absorb(opts) {
34
35
  const path = join(root, FILE);
35
36
  if (opts.send)
36
37
  return sendProposal(root, path, opts.registry);
37
- 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
+ });
38
58
  /**
39
59
  * SEM SISTEMA INSTALADO ISTO CONTINUA VALENDO - e recusar aqui fechava o dia 1.
40
60
  *
@@ -99,15 +119,17 @@ export async function absorb(opts) {
99
119
  const rel = file.slice(root.length + 1);
100
120
  reports.push(scanSource(rel, source, table));
101
121
  if (!IS_FIXTURE.test(rel))
102
- countUseByProperty(source, uses, table.rootPx);
122
+ countUseByProperty(source, uses, rootSize);
103
123
  }
104
- const plan = absorbPlan(diagnose(reports), theirs, pathsInSystem(installed.documents), opts.cap ?? 40, uses);
124
+ const plan = absorbPlan(diagnose(reports), theirs, pathsInSystem(installed.documents), opts.cap ?? 40, uses, rootSize);
105
125
  console.log(section(hasSystem
106
126
  ? "What the system could absorb"
107
127
  : "Name what you wrote by hand"));
108
128
  console.log(body(paint.dim(`read from ${rel.length > 0 ? rel.join(", ") : "this project"} · your own vocabulary read from the root`)));
109
129
  console.log("");
110
- 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))
111
133
  console.log(body(line));
112
134
  if (plan.entries.length === 0)
113
135
  return;
@@ -225,6 +247,13 @@ async function sendProposal(root, path, registry) {
225
247
  for (const e of ready)
226
248
  console.log(` --${e.path.replace(/\./g, "-")}: ${e.value};`);
227
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
+ }
228
257
  console.log("");
229
258
  console.log(body(major === 4
230
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))
@@ -1,6 +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
+ import { lengthKey, ROOTS_OF_KIND, SVG, } from "./use-by-property.js";
4
4
  function stepsOf(theirs) {
5
5
  const out = [];
6
6
  for (const [name, value] of theirs.byName) {
@@ -216,3 +216,163 @@ export function whyNotProposedFromUse(literal, uses, rootPx, counted) {
216
216
  return "only in svg";
217
217
  return `${u.total}× in ${root}, but the value does not read as a colour`;
218
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());
254
+ /**
255
+ * RAIZ SEM UMA RESPOSTA NÃO CONVERTE `rem` - e é o mesmo lado do erro que o `doctor` já escolheu.
256
+ *
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`.
284
+ *
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.
325
+ *
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` só quando o literal nem chegou ao passe - aí o plano usa o motivo
329
+ * genérico *"never inside a style property…"*. Nunca mente sozinha: devolve `null` onde
330
+ * `proposeFromTheirValue` propõe.
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
+ }
@@ -2,9 +2,22 @@ import { COLOR } from "./doctor/scan.js";
2
2
  import { normalizeValue } from "./doctor/tokens.js";
3
3
  /** A raiz que conta como função e NUNCA vira palavra: cor de ícone, não do sistema dele. */
4
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
+ };
5
17
  /**
6
18
  * UTILIDADE TAILWIND → PROPRIEDADE CSS QUE ELA ESCREVE. A palavra é a da direita, sempre: `ring` não
7
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`.
8
21
  */
9
22
  export const TAILWIND_WRITES = {
10
23
  bg: "background",
@@ -24,7 +37,35 @@ export const TAILWIND_WRITES = {
24
37
  caret: "caret-color",
25
38
  fill: SVG,
26
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",
27
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;
28
69
  /**
29
70
  * `background:` · `backgroundColor:` · `"background-color":` · `fill="`
30
71
  *
@@ -32,21 +73,23 @@ export const TAILWIND_WRITES = {
32
73
  * ternário não é uma propriedade), e a aspa de abertura, quando existe, tem de fechar antes do `:` -
33
74
  * senão `"#fff" :` de um ternário viraria a âncora `fff`. O `=` só vale na forma de ATRIBUTO - colado
34
75
  * 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.
76
+ * função. O que casa aqui ainda passa por `propertyRoot`: só o que pinta ou espaça vira âncora.
36
77
  */
37
78
  const PROPERTY = /(?<![\w.$@#-])(["']?)[a-zA-Z][\w-]*\1(?:\s*:|=(?=["'{]))/g;
38
79
  /**
39
- * `bg-[#…]` · `hover:text-[#…]` · `border-t-[rgba(…)]` · `divide-x-[…]` - as utilidades da tabela,
40
- * com o sufixo de lado que algumas aceitam.
80
+ * `bg-[#…]` · `hover:text-[#…]` · `border-t-[rgba(…)]` · `gap-x-[…]` · `rounded-tl-[…]` - as
81
+ * utilidades da tabela, com o sufixo de lado que algumas aceitam.
41
82
  */
42
83
  const UTILITY = new RegExp(`(?<![\\w-])(${Object.keys(TAILWIND_WRITES)
43
84
  .sort((a, b) => b.length - a.length)
44
- .join("|")})(?:-[trblxyse])?-\\[`, "g");
85
+ .join("|")})(?:-[trblxyse]{1,2})?-\\[`, "g");
45
86
  /**
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`,
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`,
48
89
  * `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`.
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`.
50
93
  */
51
94
  export function propertyRoot(raw) {
52
95
  const kebab = raw
@@ -54,6 +97,14 @@ export function propertyRoot(raw) {
54
97
  .replace(/^["']|["']$/g, "")
55
98
  .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
56
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";
57
108
  if (/^background(-|$)/.test(kebab))
58
109
  return "background";
59
110
  if (/^border(-|$)/.test(kebab))
@@ -72,28 +123,78 @@ export function propertyRoot(raw) {
72
123
  return kebab;
73
124
  return null;
74
125
  }
75
- /** Toda âncora da linha que PINTA, com onde ela termina - a mais próxima antes do literal é a dele. */
76
- function anchorsOf(line) {
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) {
77
142
  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
- }
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
+ });
83
150
  for (const m of line.matchAll(UTILITY)) {
84
151
  const root = TAILWIND_WRITES[m[1] ?? ""];
85
- if (root)
86
- out.push({ end: m.index + m[0].length, root });
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);
87
169
  }
88
- return out.sort((a, b) => a.end - b.end);
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;
89
189
  }
90
190
  /** A linha FECHOU a declaração: o que vier depois não é continuação dela. */
91
191
  const ENDS_DECLARATION = /[;{}]\s*$/;
92
192
  /**
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.
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.
97
198
  *
98
199
  * A DECLARAÇÃO PODE CONTINUAR NA LINHA SEGUINTE - regra do CSS, não de um repositório:
99
200
  *
@@ -106,29 +207,33 @@ const ENDS_DECLARATION = /[;{}]\s*$/;
106
207
  * não tem âncora e a anterior não FECHOU a declaração (não termina em `;`, `}` ou `{`), a âncora da
107
208
  * anterior vale para esta. `background: #111;` seguido de `#222` NÃO herda: o `;` fechou.
108
209
  */
109
- export function countUseByProperty(source, into, rootPx) {
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
+ };
110
218
  /** A âncora da declaração ainda aberta, vinda das linhas anteriores. */
111
219
  let carried = null;
112
220
  for (const line of source.split("\n")) {
113
- const anchors = anchorsOf(line);
221
+ const marks = marksOf(line);
114
222
  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);
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);
128
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. */
129
234
  if (ENDS_DECLARATION.test(line))
130
235
  carried = null;
131
- else if (anchors.length > 0)
132
- carried = anchors[anchors.length - 1]?.root ?? null;
236
+ else if (marks.length > 0)
237
+ carried = marks[marks.length - 1]?.root ?? null;
133
238
  }
134
239
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.406",
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": {