synthesisui 0.16.265 → 0.16.268

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,62 @@
1
+ /** Um patch que não diz nada não é um patch - e distinguir isso de `undefined` importa. */
2
+ export function hasEdits(patch) {
3
+ return Boolean((patch?.names?.length ?? 0) > 0 || (patch?.fold?.length ?? 0) > 0);
4
+ }
5
+ /**
6
+ * O PATCH APLICADO SOBRE A ÁRVORE DERIVADA - e ela é a fonte, não o rascunho.
7
+ *
8
+ * Idempotente por construção: aplicar duas vezes o mesmo patch dá o mesmo resultado, porque a
9
+ * operação é indexada por `at` e não acumula.
10
+ *
11
+ * O que um índice desconhecido faz: NADA, e em silêncio é errado. Quem chama recebe a lista dos
12
+ * índices que não existiam, para dizer em voz alta - um nome escrito para um nó que a derivação não
13
+ * produziu é uma decisão perdida, e perder calado é o defeito que a lei 8 proíbe.
14
+ */
15
+ export function applyAnatomyPatch(read, patch) {
16
+ const byAt = new Map();
17
+ for (const n of patch.names ?? [])
18
+ byAt.set(n.at, n.name);
19
+ const fold = new Set(patch.fold ?? []);
20
+ const seen = new Set();
21
+ let renamed = 0;
22
+ let folded = 0;
23
+ const walk = (nodes) => {
24
+ const out = [];
25
+ for (const node of nodes) {
26
+ const at = typeof node.at === "number" ? node.at : null;
27
+ if (at !== null)
28
+ seen.add(at);
29
+ const children = Array.isArray(node.children) ? walk(node.children) : [];
30
+ /**
31
+ * DOBRAR SOBE OS FILHOS. Um nó dobrado que era uma FRONTEIRA não some: `component` e
32
+ * `external` são o que o cliente declarou, e a Lei "o código deles vence" não abre exceção
33
+ * para economia nossa.
34
+ */
35
+ if (at !== null &&
36
+ fold.has(at) &&
37
+ node.as !== "component" &&
38
+ node.as !== "external") {
39
+ folded += 1;
40
+ out.push(...children);
41
+ continue;
42
+ }
43
+ const name = at !== null ? byAt.get(at) : undefined;
44
+ if (name !== undefined && name !== node.name)
45
+ renamed += 1;
46
+ out.push({
47
+ ...node,
48
+ ...(name !== undefined ? { name } : {}),
49
+ ...(children.length > 0 ? { children } : {}),
50
+ });
51
+ }
52
+ return out;
53
+ };
54
+ const patched = walk(read);
55
+ const asked = [...byAt.keys(), ...fold];
56
+ return {
57
+ read: patched,
58
+ renamed,
59
+ folded,
60
+ unknown: [...new Set(asked.filter((at) => !seen.has(at)))].sort((a, b) => a - b),
61
+ };
62
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * TODA FORMA DE COR, REDUZIDA A UMA - e sem isto o detector via duas cores onde há uma.
3
+ *
4
+ * O QUÊ: `#fff`, `#ffffff`, `rgb(255 255 255)` e `rgba(255,255,255,1)` passam a ser o mesmo valor, e
5
+ * uma cor com alfa passa a ser dita como *"o seu token com N% de opacidade"* em vez de uma cor nova.
6
+ *
7
+ * POR QUÊ: medido no `codelevel-ds` em 21/08 e é literal - o leitor gravou `#fff` do código-fonte e a
8
+ * receita compilada carrega `#ffffff`. **Para um detector que compara string são duas cores**, então o
9
+ * branco pintado **43 vezes** nas receitas dele nunca apareceu na lista de "valores que o seu sistema
10
+ * nunca nomeou". E as maiores órfãs dele são `rgba(15,23,42,0.30)` e `rgba(192,132,252,0.18)`, que o
11
+ * filtro de `#hex` descartava inteiras.
12
+ *
13
+ * A meta é 100% de cobertura das receitas (dono, 21/08), e ela não sobrevive a um comparador de
14
+ * string.
15
+ *
16
+ * ─────────────────────────────────────────────────────────────────────────
17
+ * O ALFA É SEPARADO DO RGB, e essa é a decisão que impede poluir o vocabulário dele
18
+ *
19
+ * `rgba(37,99,235,0.5)` tem duas leituras possíveis e só ele sabe qual é: *"uma cor nova"* ou *"o meu
20
+ * `brand-blue` com metade da opacidade"*. Guardar o alfa à parte é o que permite a segunda: o RGB
21
+ * casa com um token que ele já declara, e a tela diz **opacidade** em vez de sugerir um nome novo.
22
+ * Sem isso, cada nível de transparência de uma mesma cor pediria um token próprio.
23
+ *
24
+ * ─────────────────────────────────────────────────────────────────────────
25
+ * O QUE ESTE MÓDULO NÃO FAZ, e é declarado em vez de fingido
26
+ *
27
+ * `color-mix(in srgb, #fff 40%, transparent)` é uma FUNÇÃO de composição, não um valor: resolvê-la
28
+ * exige o espaço de cor e o segundo operando, e chutar produziria um valor que o navegador nunca
29
+ * pinta. O que este módulo faz é EXTRAIR as cores literais de dentro dela, que é o que permite dizer
30
+ * *"esta mistura usa um branco que nada nomeia"* sem afirmar a cor resultante.
31
+ *
32
+ * `currentColor`, `transparent`, `inherit` e nome de cor CSS não entram: os três primeiros não são
33
+ * decisão de paleta, e um nome CSS (`rebeccapurple`) é raro o bastante para não valer uma tabela de
34
+ * 148 entradas que envelhece.
35
+ */
36
+ const clamp255 = (n) => Math.max(0, Math.min(255, Math.round(n)));
37
+ const hex2 = (n) => clamp255(n).toString(16).padStart(2, "0");
38
+ /** `#f0a` -> `#ff00aa`, e `#ff00aa80` devolve o alfa junto. */
39
+ function fromHex(raw) {
40
+ const h = raw.trim().replace(/^#/, "");
41
+ if (!/^[0-9a-f]{3,8}$/i.test(h))
42
+ return null;
43
+ const dup = (s) => s
44
+ .split("")
45
+ .map((c) => c + c)
46
+ .join("");
47
+ if (h.length === 3 || h.length === 4) {
48
+ const [r, g, b, a] = dup(h).match(/../g);
49
+ return {
50
+ hex: `#${r}${g}${b}`.toLowerCase(),
51
+ alpha: a === undefined ? 1 : Number.parseInt(a, 16) / 255,
52
+ };
53
+ }
54
+ if (h.length === 6)
55
+ return { hex: `#${h}`.toLowerCase(), alpha: 1 };
56
+ if (h.length === 8)
57
+ return {
58
+ hex: `#${h.slice(0, 6)}`.toLowerCase(),
59
+ alpha: Number.parseInt(h.slice(6, 8), 16) / 255,
60
+ };
61
+ return null;
62
+ }
63
+ /** `50%` -> 0.5 · `.5` -> 0.5 · `1` -> 1. Serve alfa e componente percentual. */
64
+ const unit = (raw, of = 1) => {
65
+ const t = raw.trim();
66
+ if (t.endsWith("%")) {
67
+ const n = Number.parseFloat(t);
68
+ return Number.isFinite(n) ? (n / 100) * of : Number.NaN;
69
+ }
70
+ const n = Number.parseFloat(t);
71
+ return Number.isFinite(n) ? n : Number.NaN;
72
+ };
73
+ /** `rgb(255 0 0 / 50%)` e `rgba(255,0,0,.5)` - a sintaxe moderna e a legada. */
74
+ function fromRgb(raw) {
75
+ const m = /^rgba?\(([^)]*)\)$/i.exec(raw.trim());
76
+ if (!m)
77
+ return null;
78
+ const [body, alphaPart] = m[1].split("/");
79
+ const parts = body
80
+ .trim()
81
+ .split(/[\s,]+/)
82
+ .filter(Boolean);
83
+ if (parts.length < 3)
84
+ return null;
85
+ const [r, g, b] = parts.slice(0, 3).map((p) => unit(p, 255));
86
+ if (![r, g, b].every(Number.isFinite))
87
+ return null;
88
+ /** O alfa vem depois da barra na sintaxe moderna, ou como quarto item na legada. */
89
+ const rawAlpha = alphaPart ?? parts[3];
90
+ const alpha = rawAlpha === undefined ? 1 : unit(rawAlpha);
91
+ return {
92
+ hex: `#${hex2(r)}${hex2(g)}${hex2(b)}`,
93
+ alpha: Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1,
94
+ };
95
+ }
96
+ /** `hsl(210 40% 50%)` e `hsla(210,40%,50%,.5)`. */
97
+ function fromHsl(raw) {
98
+ const m = /^hsla?\(([^)]*)\)$/i.exec(raw.trim());
99
+ if (!m)
100
+ return null;
101
+ const [body, alphaPart] = m[1].split("/");
102
+ const parts = body
103
+ .trim()
104
+ .split(/[\s,]+/)
105
+ .filter(Boolean);
106
+ if (parts.length < 3)
107
+ return null;
108
+ const h = ((Number.parseFloat(parts[0]) % 360) + 360) % 360;
109
+ const s = unit(parts[1]);
110
+ const l = unit(parts[2]);
111
+ if (![h, s, l].every(Number.isFinite))
112
+ return null;
113
+ const c = (1 - Math.abs(2 * l - 1)) * s;
114
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
115
+ const mm = l - c / 2;
116
+ const seg = Math.floor(h / 60) % 6;
117
+ const table = [
118
+ [c, x, 0],
119
+ [x, c, 0],
120
+ [0, c, x],
121
+ [0, x, c],
122
+ [x, 0, c],
123
+ [c, 0, x],
124
+ ];
125
+ const [r, g, b] = table[seg].map((v) => (v + mm) * 255);
126
+ const rawAlpha = alphaPart ?? parts[3];
127
+ const alpha = rawAlpha === undefined ? 1 : unit(rawAlpha);
128
+ return {
129
+ hex: `#${hex2(r)}${hex2(g)}${hex2(b)}`,
130
+ alpha: Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1,
131
+ };
132
+ }
133
+ /**
134
+ * QUALQUER FORMA -> a canônica. `null` para o que não é uma cor literal.
135
+ *
136
+ * Palavra-chave fica fora de propósito: `transparent` e `currentColor` não são decisão de paleta, e um
137
+ * token para elas seria vocabulário que ninguém procura.
138
+ */
139
+ export function colorForm(value) {
140
+ const v = value.trim();
141
+ if (!v || /^(transparent|currentcolor|inherit|initial|unset|none)$/i.test(v))
142
+ return null;
143
+ return fromHex(v) ?? fromRgb(v) ?? fromHsl(v);
144
+ }
145
+ /**
146
+ * AS CORES LITERAIS DENTRO DE UM VALOR - inclusive as de dentro de uma função de composição.
147
+ *
148
+ * Um `boxShadow` carrega a cor no meio de offsets (`0 2px 4px rgba(0,0,0,.02)`), e um `color-mix`
149
+ * carrega duas. Comparar o valor inteiro contra uma paleta nunca casaria; extrair as cores casa.
150
+ *
151
+ * A ordem de varredura é a das formas mais longas primeiro (`rgba(` antes de `#`), senão um hex dentro
152
+ * de um `color-mix` seria contado duas vezes - uma pela função e outra por ele mesmo.
153
+ */
154
+ export function colorsIn(value) {
155
+ const out = [];
156
+ const seen = new Set();
157
+ const push = (c) => {
158
+ if (!c)
159
+ return;
160
+ const key = `${c.hex}|${c.alpha}`;
161
+ if (seen.has(key))
162
+ return;
163
+ seen.add(key);
164
+ out.push(c);
165
+ };
166
+ const rest = value.replace(/\b(?:rgba?|hsla?)\([^)]*\)/gi, (m) => (push(colorForm(m)), " "));
167
+ for (const m of rest.match(/#[0-9a-f]{3,8}\b/gi) ?? [])
168
+ push(colorForm(m));
169
+ return out;
170
+ }
171
+ /**
172
+ * A COMPARAÇÃO "É O MEU TOKEN COM ALFA?" NÃO MORA AQUI, e a razão é o `reachable`.
173
+ *
174
+ * Escrevi um `sameHue(a, b)` e ele nasceu órfão: quem faz essa pergunta - `v2-proposal` - precisa
175
+ * responder para uma PALETA inteira, e um mapa de hex canônico -> token responde em O(1) onde uma
176
+ * comparação par a par pediria uma varredura. O helper era mais bonito e menos útil.
177
+ *
178
+ * O que este módulo entrega para aquela pergunta é o `hex` canônico separado do `alpha`, que é a parte
179
+ * difícil. Comparar hex é `===`.
180
+ */
@@ -225,10 +225,16 @@ export async function connect(opts) {
225
225
  * Custou tempo de gente de verdade: 20/08, `codelevel-monorepo`. Rodou `connect`, conferiu o
226
226
  * `CLAUDE.md`, achou o bloco vazio, e não havia nada dizendo que faltava um comando. Lacuna
227
227
  * silenciosa é o que faz um produto correto parecer quebrado.
228
+ *
229
+ * E SÃO DOIS CAMINHOS, não um. A primeira versão desta mensagem mandava `add <slug>` e só - o que
230
+ * desorienta justamente quem acabou de rodar `connect` num projeto que AINDA NÃO TEM sistema, que é
231
+ * a primeira corrida e o caso mais comum. Para essa pessoa o passo seguinte é a skill que este mesmo
232
+ * comando acabou de instalar, e não um comando de terminal. Quem já tem sistema na plataforma é o
233
+ * outro caso, e ele também é dito.
228
234
  */
229
235
  const anyInstalled = (await installedSlugs(root).catch(() => [])).length > 0;
230
236
  if (!anyInstalled)
231
- console.log(body("· no design system installed here yet - `synthesisui list` shows yours, and `synthesisui add <slug>` brings one in. Until then the agent has no index and memory has nothing to belong to."));
237
+ console.log(body("· no design system installed here yet - the agent has no index, and memory has nothing to belong to.\n This repo has none yet: ask the agent for `/sui-init`, or `/sui-import-ds` to turn this project into one.\n You already have one: `synthesisui list` shows them, `synthesisui add <slug>` brings it in."));
232
238
  /**
233
239
  * O QUE ESTE CLI REESCREVEU NA PASTA DO SISTEMA - dito primeiro, porque é o que a pessoa não sabia
234
240
  * que estava devendo. Ela rodou `connect` para atualizar a fiação; os arquivos do install estarem
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, join, relative } from "node:path";
3
3
  import { anatomyFromSketch } from "../anatomy-from-sketch.js";
4
+ import { applyAnatomyPatch, hasEdits, } from "../anatomy-patch.js";
4
5
  import { resolveAnatomy, resolveFlatParts, safePartName, } from "../anatomy-read.js";
5
6
  import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
6
7
  import { declaredElsewhere } from "../declared-elsewhere.js";
@@ -3101,16 +3102,37 @@ quiet = false) {
3101
3102
  ? (entry?.root).at
3102
3103
  : undefined)
3103
3104
  : anatomy;
3105
+ /**
3106
+ * A DERIVAÇÃO COM O PATCH DO AGENTE POR CIMA - a fonte é o CLI, a decisão é dele.
3107
+ *
3108
+ * A ordem das três é deliberada: anatomia autoral vence (ela sempre venceu), depois a derivada
3109
+ * COM patch, depois a derivada crua. Um patch sobre uma árvore que não existe não tem onde
3110
+ * aplicar, então ele só é consultado quando há derivação.
3111
+ *
3112
+ * O que sai daqui é `AnatomyRead[]` nos três casos - por isso `resolveAnatomy`, `censusToPatch`,
3113
+ * o codegen e o Canvas não sabem que o patch existe.
3114
+ */
3115
+ const patched = derived && derived.read.length > 0 && hasEdits(entry?.patch)
3116
+ ? applyAnatomyPatch(derived.read, entry?.patch)
3117
+ : null;
3118
+ if (patched && patched.unknown.length > 0)
3119
+ /**
3120
+ * DITO, NUNCA ENGOLIDO: um nome escrito para um nó que a derivação não produziu é uma decisão
3121
+ * perdida, e o agente precisa saber que a perdeu para escrevê-la onde ela cabe.
3122
+ */
3123
+ notes.add(`${component}: the anatomy patch named ${patched.unknown.length} node${patched.unknown.length === 1 ? "" : "s"} the derivation does not have (sketch ${patched.unknown.join(", ")}) - those names were dropped`);
3104
3124
  const resolved = Array.isArray(framed) && framed.length > 0
3105
3125
  ? resolveAnatomy(framed, declared, deps, resolveRef, entry?.root,
3106
3126
  // The component's own sketch, so a node naming itself by index reads
3107
3127
  // the exact class string the census measured (dono, 01/08).
3108
3128
  looks[component]?.sketch, census.sxSpacing)
3109
- : derived && derived.read.length > 0
3110
- ? resolveAnatomy(derived.read, declared, deps, resolveRef, entry?.root, looks[component]?.sketch, census.sxSpacing)
3111
- : Array.isArray(entry?.parts) && entry.parts.length > 0
3112
- ? resolveFlatParts(entry.parts, declared)
3113
- : null;
3129
+ : patched
3130
+ ? resolveAnatomy(patched.read, declared, deps, resolveRef, entry?.root, looks[component]?.sketch, census.sxSpacing)
3131
+ : derived && derived.read.length > 0
3132
+ ? resolveAnatomy(derived.read, declared, deps, resolveRef, entry?.root, looks[component]?.sketch, census.sxSpacing)
3133
+ : Array.isArray(entry?.parts) && entry.parts.length > 0
3134
+ ? resolveFlatParts(entry.parts, declared)
3135
+ : null;
3114
3136
  if (!resolved)
3115
3137
  continue;
3116
3138
  const hasParts = Object.keys(resolved.parts).length > 0;
@@ -1,3 +1,4 @@
1
+ import { colorForm } from "../color-forms.js";
1
2
  /**
2
3
  * PERCEPTUAL COLOUR DISTANCE, in the CLI.
3
4
  *
@@ -18,20 +19,40 @@
18
19
  * exactly the evidence the normalisation pass is looking for. With this, a rare
19
20
  * value rides along when it sits close to one we already kept.
20
21
  */
21
- const HEX6 = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
22
22
  /** The just-noticeable difference for ΔE76. Same constant as the platform's. */
23
23
  export const JND = 2.3;
24
- function toRgb(hex) {
25
- const h = hex.trim().replace("#", "");
26
- if (!HEX6.test(`#${h}`))
24
+ /**
25
+ * QUALQUER FORMA DE COR -> RGB, e é aqui que o motor deixou de ser hex-only (21/08).
26
+ *
27
+ * O QUÊ: `rgb(79 70 229)` e `hsl(0 0% 100%)` são cor, e este motor devolvia `null` para as duas.
28
+ *
29
+ * POR QUÊ, medido: num projeto que declara a paleta em `rgb`/`hsl`, `deltaE` devolvia `null` para todo
30
+ * par, o que reconhecia **0 de 2** tokens dele - e a consequência era o oposto do propósito do
31
+ * produto: propor NOME NOVO para as duas cores que ele já nomeia. A ferramenta cunhando exatamente a
32
+ * duplicata que existe para retirar. Não é caso exótico: é o que Tailwind 4 emite em `@theme` e o que
33
+ * qualquer projeto de CSS Modules escreve à mão.
34
+ *
35
+ * COMO: `colorForm` já sabe ler hex 3/6/8, `rgb`/`rgba` (legado e moderno) e `hsl`/`hsla`, então a
36
+ * conversão passa por ela em vez de por um regex próprio. `src/color-forms.ts` é byte-idêntico ao
37
+ * `apps/web/src/lib/engine/color-forms.ts`, garantido por spec - o CLI publica standalone e não pode
38
+ * importar do app.
39
+ *
40
+ * ─────────────────────────────────────────────────────────────────────────
41
+ * TRANSLÚCIDO CONTINUA FORA, e isso é deliberado
42
+ *
43
+ * `rgba(0,0,0,.02)` e `#000000` têm o mesmo RGB e aparências opostas. Aceitar o alfa aqui faria o
44
+ * agrupamento dobrar um véu de 2% dentro de um preto sólido com ΔE 0 - "a mesma decisão", que é
45
+ * falso. Um valor translúcido é *"o token dele com alfa"* (decisão 4 do dono, 21/08), e quem trata
46
+ * disso é a lista de opacidades da proposta, não a distância perceptual.
47
+ *
48
+ * Então: alfa < 1 devolve `null`, exatamente como antes - o hex de 8 dígitos e o `rgba` já caíam fora
49
+ * por não casarem o regex. O que muda é só a cor OPACA escrita em outra forma.
50
+ */
51
+ function toRgb(value) {
52
+ const form = colorForm(value);
53
+ if (!form || form.alpha < 1)
27
54
  return null;
28
- const full = h.length === 3
29
- ? h
30
- .split("")
31
- .map((c) => c + c)
32
- .join("")
33
- : h;
34
- const n = Number.parseInt(full, 16);
55
+ const n = Number.parseInt(form.hex.slice(1), 16);
35
56
  if (Number.isNaN(n))
36
57
  return null;
37
58
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
@@ -1,6 +1,39 @@
1
1
  import { anatomyFromSketch } from "./anatomy-from-sketch.js";
2
2
  /** O nó que a derivação não soube nomear. `box` é o último recurso dela. */
3
3
  const UNNAMED = "box";
4
+ /**
5
+ * O ENCANAMENTO, RECONHECIDO POR REGRA - e ele sai da fila sem sair da árvore.
6
+ *
7
+ * O playbook já enuncia a regra: *"What you still collapse is a wrapper whose only job is `flex` -
8
+ * fold it into the `row` it already is, because that is not a region, it is plumbing."* E a Lei 12 já
9
+ * lê `flex-col`/`grid`/`flex` do class string em `arrangement()`, então **a informação de layout já
10
+ * viaja**. Pedir um nome para um `div` cujo class list inteiro é `mt-5` é pedir uma decisão de modelo
11
+ * que não acrescenta nada ao que o cliente recebe.
12
+ *
13
+ * PADDING NÃO É ENCANAMENTO, e a medição é o que decidiu. `p-4` num modal É o corpo dele; `space-y-3
14
+ * p-4` num cartão É o miolo. Margem, `gap` e `flex` movem o nó no pai e não desenham caixa nenhuma;
15
+ * padding cria uma área interna, que é exatamente o que uma região "body" parece.
16
+ *
17
+ * O custo de ser conservador é pequeno e foi medido, não suposto: dos 757 nós da fila em
18
+ * `apps/web-dashboard`, **311 são só arranjo** e 3 não têm classe, contra **12 de padding puro** e 35
19
+ * de arranjo+padding. Excluir padding custa 47 nós e preserva 47 regiões de verdade.
20
+ *
21
+ * Foi o fixture do próprio spec que levantou a dúvida - `p-[1rem] pr-[2rem] pl-[2rem]`, do
22
+ * `SubscribeSetting` real - e ele continua sendo cobrado, porque continua sendo uma região.
23
+ *
24
+ * NADA É RENOMEADO. O nó continua na árvore com o `box` que a derivação lhe deu, e `part` continua
25
+ * saindo igual - inclusive o sufixo `-2`. Renomear seria mudar chave de `part` no documento do
26
+ * cliente, que é referenciada por codegen e por override dele: um ganho de tempo nosso não paga isso.
27
+ *
28
+ * A RÉGUA É CONSERVADORA NO LADO CERTO: qualquer classe de APARÊNCIA mantém o nó na fila.
29
+ * `w-full bg-darkgray-800/95 absolute h-full` sobrevive por causa do `bg-`; o que cai é `mt-5`.
30
+ */
31
+ const PLUMBING = /^(?:m[trblxy]?-|space-[xy]-|gap(?:-[xy])?-|flex$|flex-|grid$|grid-|items-|justify-|self-|content-|place-|w-full$|h-full$|min-[wh]-|max-[wh]-|relative$|absolute$|static$|block$|inline(?:-|$)|shrink|grow|order-|col-|row-|basis-)/;
32
+ /** Só encanamento, ou nenhuma classe: os dois casos são estrutura sem aparência. */
33
+ function isPlumbing(classes) {
34
+ const words = (classes ?? "").trim().split(/\s+/).filter(Boolean);
35
+ return words.every((w) => PLUMBING.test(w));
36
+ }
4
37
  function walk(read, onNode) {
5
38
  for (const node of read) {
6
39
  onNode(node);
@@ -21,6 +54,7 @@ export function namingQueue(looks, defines, versionOf) {
21
54
  let nodes = 0;
22
55
  let named = 0;
23
56
  let settled = 0;
57
+ let plumbing = 0;
24
58
  for (const component of Object.keys(looks)) {
25
59
  const sketch = looks[component]?.sketch;
26
60
  if (!Array.isArray(sketch) || sketch.length === 0)
@@ -40,6 +74,14 @@ export function namingQueue(looks, defines, versionOf) {
40
74
  if (typeof at !== "number")
41
75
  return;
42
76
  const raw = sketch[at];
77
+ /**
78
+ * Encanamento não entra na fila, e é CONTADO - senão a diferença entre "não havia trabalho" e
79
+ * "a régua o dispensou" fica invisível, e um número que ninguém explica é pior que nenhum.
80
+ */
81
+ if (isPlumbing(raw?.classes)) {
82
+ plumbing += 1;
83
+ return;
84
+ }
43
85
  unnamed.push({
44
86
  tag: raw?.tag ?? "div",
45
87
  at,
@@ -52,5 +94,5 @@ export function namingQueue(looks, defines, versionOf) {
52
94
  }
53
95
  pending.push({ component, nodes: unnamed });
54
96
  }
55
- return { components, nodes, named, settled, pending };
97
+ return { components, nodes, named, settled, plumbing, pending };
56
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.265",
3
+ "version": "0.16.268",
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": {