synthesisui 0.16.302 → 0.16.306

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.
@@ -37,20 +37,22 @@ export async function hookCommand(root, version) {
37
37
  : `npx synthesisui@${version} hook`;
38
38
  }
39
39
  /**
40
- * COMO A SESSÃO CHAMA O CLI, e por que este NÃO é pinado.
40
+ * COMO A SESSÃO CHAMA O CLI - pinado, como o hook de escrita, e por um número.
41
41
  *
42
- * O hook de escrita é pinado porque roda dezenas de vezes por sessão e um verificador que muda sob
43
- * você a cada edit é indebugável. Este roda UMA vez, e o trabalho dele é justamente dizer o que está
44
- * atrasado - inclusive o próprio CLI. Um `align` pinado no dia do `connect` não conhece nenhuma
45
- * verificação escrita depois, e passa a garantir silêncio em vez de alinho.
42
+ * Este já foi `@latest`, com a tese de que um `align` pinado não conhece verificação escrita depois
43
+ * dele. A tese perdeu para a medição de 25/08: `@latest` NUNCA cacheia - o npx faz round-trip no
44
+ * registro em TODA abertura de sessão (2,92s, sempre), enquanto a versão fixada cacheia (0,32s
45
+ * morno). O trabalho do align em si custa 50ms; o resto era o preço de manter a tese.
46
46
  *
47
- * O custo do `npx` (720ms-1.5s, medido em 27/07) é pago uma vez na abertura, não por escrita.
47
+ * E a tese tinha resposta: dizer o que está atrasado - inclusive o próprio CLI - continua sendo o
48
+ * trabalho do align, só que agora o fato vem do SERVIDOR (`cli` no `?meta=1`, ver `versionBehind`),
49
+ * que sabe o que está publicado sem que este binário precise se reinstalar para perguntar.
48
50
  */
49
- export async function alignCommand(root) {
51
+ export async function alignCommand(root, version) {
50
52
  const local = join(root, "node_modules", ".bin", "synthesisui");
51
53
  return (await exists(local))
52
54
  ? "npx --no-install synthesisui align"
53
- : "npx synthesisui@latest align";
55
+ : `npx synthesisui@${version} align`;
54
56
  }
55
57
  /**
56
58
  * Returns the command that IS in the file, not the one we would have written.
@@ -234,7 +236,7 @@ export async function wireAgent(root, version, want) {
234
236
  * logado" ou "este sistema não sabe de onde foi medido".
235
237
  */
236
238
  session: want.hook
237
- ? await wireSessionStart(root, await alignCommand(root))
239
+ ? await wireSessionStart(root, await alignCommand(root, version))
238
240
  : "skipped",
239
241
  mcp: want.mcp ? await wireMcp(root, version) : "skipped",
240
242
  };
@@ -234,6 +234,49 @@ function measuredAt(node, sketch) {
234
234
  out.text = String(measured.text).trim().slice(0, 120);
235
235
  return out;
236
236
  }
237
+ /**
238
+ * AS PALAVRAS DA CLASSE DELE QUE DESCREVEM UM NÓ - a matéria-prima do desempate.
239
+ *
240
+ * Uma lista de classes é meio vocabulário dele e meio gramática do framework. `animate-shimmer` diz
241
+ * o que aquele nó É; `absolute`, `inset-0` e `z-10` dizem onde ele está, e servem para qualquer nó
242
+ * da página.
243
+ *
244
+ * Então a régua é a INTENÇÃO, e a lista de descartes é do que é posicional, dimensional ou de
245
+ * estado. O que sobra é o que ele nomeou.
246
+ *
247
+ * MEDIDO no `Button` do dono: o `<span>` do brilho veste `animate-shimmer` entre seis classes de
248
+ * posição, e a palavra `shimmer` é a única que diz o que ele é.
249
+ *
250
+ * ORDEM ESTÁVEL, e ela importa: o mesmo componente lido duas vezes tem que produzir o mesmo nome,
251
+ * senão um re-import renomeia partes e o CSS dele para de casar.
252
+ */
253
+ function describingWords(classes) {
254
+ if (!classes)
255
+ return [];
256
+ /** Posição, caixa, camada, estado: verdadeiro para qualquer nó, então não descreve nenhum. */
257
+ const NOISE = /^(absolute|relative|fixed|sticky|static|inset|top|right|bottom|left|z|flex|grid|block|inline|hidden|w|h|min|max|p[xytrbl]?|m[xytrbl]?|gap|items|justify|self|order|shrink|grow|basis|overflow|opacity|transition|duration|ease|delay|pointer|select|cursor|group|peer|translate|rotate|scale|transform|origin|will)$/;
258
+ const out = [];
259
+ for (const raw of classes.split(/\s+/)) {
260
+ if (!raw)
261
+ continue;
262
+ /** Variante e valor arbitrário fora: `hover:`, `md:`, `[&>svg]:` não nomeiam o nó. */
263
+ if (raw.includes(":") || raw.includes("["))
264
+ continue;
265
+ const head = raw.replace(/^-/, "").split("/")[0];
266
+ const segs = head.split("-").filter(Boolean);
267
+ if (segs.length === 0)
268
+ continue;
269
+ if (NOISE.test(segs[0]))
270
+ continue;
271
+ /** `animate-shimmer` -> `shimmer`; `overlay` -> `overlay`. A última palavra é a que nomeia. */
272
+ const word = segs[segs.length - 1];
273
+ if (!/^[a-z][a-z0-9]{2,}$/.test(word))
274
+ continue;
275
+ if (!out.includes(word))
276
+ out.push(word);
277
+ }
278
+ return out;
279
+ }
237
280
  export function resolveAnatomy(read, declared, deps,
238
281
  /** Their name → the slug it reaches in this system. See `RefResolver`. */
239
282
  resolve,
@@ -297,10 +340,37 @@ globals) {
297
340
  top = top[0].children;
298
341
  notes.push("the outermost wrapper is the component itself, so its children are the anatomy");
299
342
  }
300
- /** Part names, deduped: a second `label` becomes `label-2`, deterministically. */
301
- const claim = (wanted) => {
343
+ /**
344
+ * O NOME DE UMA PARTE, DESEMPATADO - e o desempate DESCREVE antes de contar.
345
+ *
346
+ * O QUE O CLIENTE PERDIA: um número nosso no vocabulário dele, e não só na tela. O nome da parte
347
+ * vira CLASSE no código gerado (`partClassName` em `component-codegen.ts`), então
348
+ * `ds-button-text-2` acaba num arquivo que ele mantém - e ele não tem como saber que aquele é o
349
+ * brilho e este é o conteúdo sem abrir o original.
350
+ *
351
+ * MEDIDO no `codelevel` (24/08): 9 partes numeradas em 6 de 62 componentes. No `Button` dele são
352
+ * três `<span>` irmãos - a máscara do hover, o brilho que desliza, e o conteúdo -, e o segundo
353
+ * carrega `animate-shimmer` na própria classe. O nome estava na frente o tempo todo.
354
+ *
355
+ * ─────────────────────────────────────────────────────────────────────────
356
+ * A ORDEM É: O QUE ELE ESCREVEU, DEPOIS O NÚMERO.
357
+ *
358
+ * A classe do nó é vocabulário DELE - `animate-shimmer`, `overlay`, `track`. Derivar dali é usar o
359
+ * nome que ele já deu àquele pedaço; numerar é o que sobra quando essa pergunta não é feita.
360
+ *
361
+ * E o número CONTINUA existindo como último recurso, porque duas partes não podem dividir uma
362
+ * chave. A diferença é que ele deixou de ser a primeira resposta.
363
+ */
364
+ const claim = (wanted, classes) => {
302
365
  if (parts[wanted] == null)
303
366
  return wanted;
367
+ for (const hint of describingWords(classes)) {
368
+ const candidate = `${wanted}-${hint}`;
369
+ if (parts[candidate] == null)
370
+ return candidate;
371
+ if (parts[hint] == null)
372
+ return hint;
373
+ }
304
374
  for (let n = 2; n < 100; n++) {
305
375
  const candidate = `${wanted}-${n}`;
306
376
  if (parts[candidate] == null)
@@ -472,9 +542,17 @@ globals) {
472
542
  // value. A node with no name is pure structure and that is legitimate.
473
543
  const wanted = safePartName(String(node.name ?? ""));
474
544
  if (wanted) {
475
- const key = claim(wanted);
545
+ /**
546
+ * AS CLASSES DELE ENTRAM NO DESEMPATE - ver `claim` e `describingWords`.
547
+ *
548
+ * O nó pode carregar a lista verbatim (`classes`) ou apontar para o censo (`at`). As duas
549
+ * servem, e o censo é preferido pelo mesmo motivo de sempre: uma lista redigitada é uma
550
+ * cópia que pode perder um modificador.
551
+ */
552
+ const key = claim(wanted, node.classes ??
553
+ (node.at != null ? sketch?.[node.at]?.classes : undefined));
476
554
  if (key !== wanted) {
477
- notes.push(`two parts named \`${wanted}\` - the second is \`${key}\``);
555
+ notes.push(`two parts named \`${wanted}\` - the second is \`${key}\`, named from your own class`);
478
556
  }
479
557
  /**
480
558
  * THE CENSUS'S OWN STRING WINS when the node points at it. A retyped
@@ -459,6 +459,20 @@ export async function versionBehind(root, opts = {}) {
459
459
  says: `the rules that govern "${lock.slug}" changed since this repo materialized them - your agent reads the copy on disk, so it is still following the previous set.`,
460
460
  run: "npx synthesisui upgrade",
461
461
  };
462
+ /**
463
+ * ESTE BINÁRIO FICOU PARA TRÁS - a linha que devolve ao align o trabalho que o `@latest` fazia.
464
+ *
465
+ * A abertura de sessão roda uma versão PINADA desde 25/08 (o `@latest` custava 2,92s de registro
466
+ * em toda sessão, medido), então nada aqui descobre sozinho que existe CLI mais novo - o número
467
+ * vem do servidor, que consulta o npm e cala quando não sabe. Vem POR ÚLTIMO de propósito: um
468
+ * fato sobre o sistema dele vale mais que uma novidade sobre a nossa ferramenta, e esta linha só
469
+ * aparece quando todo o resto está em dia.
470
+ */
471
+ if (opts.cli && body.cli && isOlderCli(opts.cli, body.cli))
472
+ return {
473
+ says: `this session's opening check runs CLI ${opts.cli} and ${body.cli} is what installs today - the check is pinned on purpose, so it will not move by itself.`,
474
+ run: "npx synthesisui@latest connect",
475
+ };
462
476
  return null;
463
477
  }
464
478
  /**
@@ -550,7 +564,9 @@ export async function misalignments(root, opts = {}) {
550
564
  ...(opts.cli ? { cli: opts.cli } : {}),
551
565
  }).catch(() => []);
552
566
  /** A única linha que custa rede, e ela some inteira quando não há rede. */
553
- const remote = await versionBehind(root).catch(() => null);
567
+ const remote = await versionBehind(root, {
568
+ ...(opts.cli ? { cli: opts.cli } : {}),
569
+ }).catch(() => null);
554
570
  if (remote)
555
571
  items.push(remote);
556
572
  return items;
@@ -8,6 +8,7 @@ import { hasInteractiveTemplate, interactiveTemplate, } from "../interactive-tem
8
8
  import { body, section, snippet } from "../output.js";
9
9
  import { findCollision, reactMajorOf, readInstalledConvention, readInstalledScheme, } from "../project-facts.js";
10
10
  import { fetchComponent, RegistryError } from "../registry.js";
11
+ import { flavourResolver } from "../styles-flavour.js";
11
12
  import { inTheirTongue, tongueOf } from "../their-tongue.js";
12
13
  import { readCensus, unreadComment, unreadForComponent, } from "../unread-for-component.js";
13
14
  /**
@@ -59,7 +60,9 @@ export function localName(blueprint, asked) {
59
60
  * 2. YOUR component (unless --artifacts-only, target "next") →
60
61
  * `<componentsDir>/<name>/` from `_synthesisui/config.json`: a real
61
62
  * `export function <Pascal>()` with variants as typed props, in the
62
- * project's chosen flavor (`styles: "css" | "tailwind"`).
63
+ * language THIS component is already written in - `flavourResolver` reads the
64
+ * measured shape off the census, and `styles: "css" | "tailwind"` in the config
65
+ * overrides it for the whole project when they are migrating to one of them.
63
66
  *
64
67
  * The component's styles reference the DS tokens, so the system itself must be
65
68
  * installed (`synthesisui add <slug>`) for `tokens.css`/`theme.css` to resolve.
@@ -120,8 +123,20 @@ export async function component(slug, name, opts) {
120
123
  console.log(`✓ ${res.name} → _synthesisui/ds/${slug}/components/${res.name}.{json,css} (${slug} v${res.version})`);
121
124
  }
122
125
  // 2. YOUR component - a real, importable `export function <Pascal>()` in the
123
- // project's flavor (config: styles css|tailwind), under componentsDir.
126
+ // language it is already written in (config: styles match|css|tailwind), under
127
+ // componentsDir.
124
128
  const wantInteractive = opts.interactive && hasInteractiveTemplate(res.name);
129
+ /**
130
+ * A LÍNGUA DESTE COMPONENTE - e ela pode não ser a do projeto inteiro.
131
+ *
132
+ * Com `styles: "match"` a resposta é por componente: o `Card` que ele escreve em CSS Modules
133
+ * recebe um `.css` ao lado, e o `Button` que ele escreve em utilitários recebe utilitários.
134
+ *
135
+ * Resolvido UMA vez, acima de tudo que a usa - a materialização e as instruções de instalação
136
+ * precisam falar do mesmo arquivo, e duas resoluções separadas seriam duas chances de a
137
+ * instrução mandar importar um `.css` que o componente não ganhou.
138
+ */
139
+ const flavour = (await flavourResolver(root, config.styles))(res.name);
125
140
  if (!opts.artifactsOnly && config.target === "next") {
126
141
  /**
127
142
  * WE DO NOT TAKE A NAME THEY ARE USING.
@@ -210,7 +225,7 @@ export async function component(slug, name, opts) {
210
225
  filenames = [`${local}.tsx`, `${local}.css`, "index.ts"];
211
226
  }
212
227
  else {
213
- const files = generateComponentFiles(slug, res.name, res.recipe, css, res.version, config.styles, await reactMajorOf(root),
228
+ const files = generateComponentFiles(slug, res.name, res.recipe, css, res.version, flavour, await reactMajorOf(root),
214
229
  /**
215
230
  * THE SPELLING, FROM THE VERSION WE JUST FETCHED.
216
231
  *
@@ -246,18 +261,18 @@ export async function component(slug, name, opts) {
246
261
  for (const file of files) {
247
262
  await writeFile(join(compDir, file.filename), file.filename.endsWith(".tsx") ? `${nota}${file.code}` : file.code, "utf8");
248
263
  }
249
- if (config.styles === "tailwind")
264
+ if (flavour === "tailwind")
250
265
  await writeCn(root, compDir, slug);
251
266
  filenames = files.map((f) => f.filename);
252
267
  }
253
- const flavor = wantInteractive ? "interactive" : `styles: ${config.styles}`;
268
+ const flavor = wantInteractive ? "interactive" : `styles: ${flavour}`;
254
269
  console.log(`✓ ${config.componentsDir}/${local}/ → ${filenames.join(", ")} (${flavor})${local === res.name ? "" : ` - the "${res.name}" blueprint, under your name`}`);
255
270
  }
256
271
  else if (opts.interactive && !hasInteractiveTemplate(res.name)) {
257
272
  console.log(` note: no interactive template for "${res.name}" - materialized the standard shell.`);
258
273
  }
259
274
  // ── DX: concrete paths + copy-pasteable snippets, with breathing room ──
260
- const tailwind = config.styles === "tailwind";
275
+ const tailwind = flavour === "tailwind";
261
276
  const imports = tailwind
262
277
  ? [
263
278
  `@import "tailwindcss";`,
@@ -5,6 +5,7 @@ import { readProjectConfig, resolveRegistry } from "../config.js";
5
5
  import { installedSlugs } from "../installed.js";
6
6
  import { reactMajorOf, readInstalledConvention, readInstalledScheme, } from "../project-facts.js";
7
7
  import { postGenerate, RegistryError } from "../registry.js";
8
+ import { flavourResolver } from "../styles-flavour.js";
8
9
  /** PascalCase para o hint de import (course-card → CourseCard). */
9
10
  function pascalName(name) {
10
11
  return name.replace(/(^|[-_])([a-z0-9])/g, (_, __, c) => c.toUpperCase());
@@ -65,7 +66,9 @@ export async function generate(description, opts) {
65
66
  const version = await readActiveVersion(root, slug);
66
67
  const compDir = join(root, config.componentsDir, res.name);
67
68
  await mkdir(compDir, { recursive: true });
68
- const files = generateComponentFiles(slug, res.name, res.recipe, res.css, version, config.styles, await reactMajorOf(root),
69
+ // A LÍNGUA DE CADA COMPONENTE, resolvida uma vez - ver `flavourResolver`.
70
+ const flavourOf = await flavourResolver(root, config.styles);
71
+ const files = generateComponentFiles(slug, res.name, res.recipe, res.css, version, flavourOf(res.name), await reactMajorOf(root),
69
72
  // Read off the installed document: a generated component lands in the same
70
73
  // project as the stylesheet it has to match.
71
74
  await readInstalledConvention(root, slug), res.name, await readInstalledScheme(root, slug));
@@ -1,5 +1,5 @@
1
1
  import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
- import { basename, dirname, join, relative } from "node:path";
2
+ import { basename, dirname, join, relative, sep } from "node:path";
3
3
  import { anatomyFromSketch } from "../anatomy-from-sketch.js";
4
4
  import { applyAnatomyPatch, hasEdits, } from "../anatomy-patch.js";
5
5
  import { resolveAnatomy, resolveFlatParts, safePartName, } from "../anatomy-read.js";
@@ -402,8 +402,18 @@ export async function takeCensus(root, opts) {
402
402
  * Os caminhos que o leitor REALMENTE olhou são o escopo mais os de uso: `--scope packages/ui
403
403
  * --usage apps/web` lê os dois, então nenhum dos dois é "fora". Sem `scopeLabel` a função devolve
404
404
  * `null` por conta, porque aí ele apontou para a raiz.
405
+ *
406
+ * A CAMINHADA PARTE DA RAIZ DO REPO, não da raiz escopada. `root` aqui JÁ é
407
+ * `<repo>/<scopeLabel>` (o chamador faz `join(root, one)`), então medir a partir dele compara
408
+ * `src/Button.tsx` com `packages/ui/…` - nada casa, e os 121 arquivos que a esteira acabou de LER
409
+ * eram reportados como "fora do que você apontou - nada foi lido lá". Disparava SEMPRE com
410
+ * `--scope`, e o primeiro relatório do cliente abria com uma acusação falsa sobre o repositório
411
+ * dele (medido em 25/08: 121 contra os 14 reais).
405
412
  */
406
- const outside = await outsideScope(root, [
413
+ const repoRoot = scopeLabel && root.endsWith(join(sep, ...scopeLabel.split("/")))
414
+ ? root.slice(0, root.length - scopeLabel.length - 1)
415
+ : root;
416
+ const outside = await outsideScope(repoRoot, [
407
417
  ...(scopeLabel ? [scopeLabel] : []),
408
418
  ...(opts?.usage ?? []).map((u) => u.label),
409
419
  ]).catch(() => null);
@@ -558,6 +568,14 @@ export async function takeCensus(root, opts) {
558
568
  const islands = new Map();
559
569
  /** How each component expresses style, so the census can state its own coverage. */
560
570
  const shapes = new Map();
571
+ /**
572
+ * A FORMA DE ESTILO DE CADA COMPONENTE - o dado que decide em que língua ele volta.
573
+ *
574
+ * Um repositório real não tem UMA forma: no `frontend-hub` são 214 arquivos em CSS Modules, 157
575
+ * em lista de classes e 138 em template, e nenhuma passa de 30%. Um sabor global obriga a maioria
576
+ * do código dele a receber componentes na forma errada - ver `countShape`.
577
+ */
578
+ const shapeOf = new Map();
561
579
  /** A escala de espaçamento que o projeto declara para o `sx` - ver o uso abaixo. */
562
580
  let sxSpacing;
563
581
  /** Component FILES the gate accepted - the honest denominator for coverage. A first
@@ -681,7 +699,7 @@ export async function takeCensus(root, opts) {
681
699
  runtimeOf.set(d.name, runtime);
682
700
  }
683
701
  if (found.length > 0) {
684
- countShape(src, found[0].name, shapes);
702
+ countShape(src, found[0].name, shapes, shapeOf);
685
703
  componentFiles += 1;
686
704
  }
687
705
  defined.push(...found);
@@ -2224,6 +2242,16 @@ export async function takeCensus(root, opts) {
2224
2242
  components: coverage.components,
2225
2243
  read: coverage.read,
2226
2244
  declarations: coverage.declarations,
2245
+ /**
2246
+ * A FORMA POR COMPONENTE, ao lado da soma - ver `shapeOf`.
2247
+ *
2248
+ * A soma responde *"como este projeto escreve estilo"*; esta responde *"como ESTE
2249
+ * componente escreve"*, e é a segunda que decide em que língua ele volta. Aditiva e
2250
+ * omitida quando vazia, então nenhum censo existente muda de forma.
2251
+ */
2252
+ ...(shapeOf.size > 0
2253
+ ? { byComponent: Object.fromEntries([...shapeOf].sort()) }
2254
+ : {}),
2227
2255
  shapes: coverage.counts.map((c) => ({
2228
2256
  key: c.shape.key,
2229
2257
  label: c.shape.label,
@@ -1,5 +1,7 @@
1
1
  import { DEFAULT_CONFIG, writeProjectConfig } from "../config.js";
2
2
  import { body, section, snippet } from "../output.js";
3
+ import { resolveDeps, stylesFor, tailwindMajor } from "../stack.js";
4
+ import { formMix, measuredStyle } from "../styles-flavour.js";
3
5
  import { wiringPrompt } from "../wiring-prompt.js";
4
6
  import { add } from "./add.js";
5
7
  /**
@@ -16,7 +18,29 @@ export async function init(opts) {
16
18
  target,
17
19
  pagesDir: opts.pagesDir ?? (target === "next" ? "app" : DEFAULT_CONFIG.pagesDir),
18
20
  componentsDir: opts.componentsDir ?? DEFAULT_CONFIG.componentsDir,
19
- styles: opts.styles === "tailwind" ? "tailwind" : "css",
21
+ /**
22
+ * O SABOR SAI DO QUE O PROJETO DELE DECLARA - ver `stylesFor`.
23
+ *
24
+ * A flag continua vencendo: quem digitou `--styles` escolheu, e uma dedução nossa por cima de
25
+ * uma resposta dele seria a plataforma discordando de quem perguntou.
26
+ *
27
+ * O que mudou é o silêncio. Até 24/08, ausência de flag significava `"css"` - inclusive num
28
+ * projeto com `tailwindcss ^4.3.0` nas dependências, que é o caso do repositório do dono. Ele
29
+ * receberia componentes em CSS, com um `.css` ao lado de cada um, num repositório onde tudo o
30
+ * mais é Tailwind. E a informação para acertar já estava medida no mesmo segundo.
31
+ *
32
+ * E DESDE 25/08 A AUSÊNCIA DE FLAG É `match`, que é a resposta por componente. Um sabor único
33
+ * presume que o projeto tem um sabor único, e um repositório real não tem: medido no
34
+ * `frontend-hub`, 276 componentes importam um `.module.css` e 231 escrevem utilitários.
35
+ * Qualquer sabor único que se escolha ali manda mais de 200 arquivos de volta na língua errada.
36
+ *
37
+ * `match` não é uma dedução mais fraca, é uma mais fina: quando o projeto TEM uma forma só, ela
38
+ * responde exatamente o que `stylesFor` responderia, para todo componente. A linha impressa
39
+ * abaixo diz qual é o resultado neste projeto, com o número.
40
+ */
41
+ styles: opts.styles === "tailwind" || opts.styles === "css"
42
+ ? opts.styles
43
+ : "match",
20
44
  /**
21
45
  * Gravada COM A DATA, e só quando alguém respondeu. Migração é uma fase que termina, e uma fase sem
22
46
  * data é a etiqueta que envelhece em silêncio - o doctor imprime as duas em toda rodada.
@@ -33,7 +57,57 @@ export async function init(opts) {
33
57
  console.log(` target: ${config.target}`);
34
58
  console.log(` pagesDir: ${config.pagesDir}`);
35
59
  console.log(` componentsDir: ${config.componentsDir}`);
36
- console.log(` styles: ${config.styles}`);
60
+ /**
61
+ * E A DEDUÇÃO É DITA - senão a plataforma decide certo e em silêncio, que é meio caminho.
62
+ *
63
+ * A linha diz de onde veio a escolha e o que ela produz, porque as duas coisas mudam o arquivo que
64
+ * ele vai manter: em `tailwind` o componente sai com utilitários inline; em `css` sai um TSX fino
65
+ * com um `.css` ao lado.
66
+ *
67
+ * E o MAIOR do Tailwind aparece quando existe: `@theme` e `@utility` são v4, e um projeto v3
68
+ * recebendo sintaxe de v4 tem um arquivo que o build dele não entende. Dizer a versão que a gente
69
+ * leu é o que permite ele nos contradizer antes de gerar o primeiro componente.
70
+ */
71
+ const major = tailwindMajor(await resolveDeps(root).catch(() => ({})));
72
+ const { byComponent, classStyle: measured } = await measuredStyle(root);
73
+ const mix = formMix(byComponent);
74
+ const project = stylesFor(await resolveDeps(root).catch(() => ({})), measured);
75
+ /**
76
+ * A FRASE DIZ QUAL DAS DUAS COISAS DECIDIU - o que ele ESCREVE ou o que está INSTALADO.
77
+ *
78
+ * As duas podem discordar, e num projeto híbrido elas discordam: `tailwindcss` nas dependências e
79
+ * CSS Modules no código. Dizer só "tailwind" deixaria ele sem saber se a gente olhou o
80
+ * `package.json` ou os arquivos - e é essa diferença que ele precisa para nos contradizer.
81
+ */
82
+ /**
83
+ * E QUANDO EXISTE MISTURA, A LINHA DIZ A MISTURA - com os dois números.
84
+ *
85
+ * `match` sem número é uma palavra: ele não sabe se vai receber CSS, utilitários, ou os dois, e
86
+ * não tem como nos contradizer. Com os números ele lê a mesma medida que decidiu, e se ela
87
+ * estiver errada - porque ele está MIGRANDO, e quer receber na língua de destino - ele sabe
88
+ * exatamente o que reescrever com `--styles`.
89
+ */
90
+ const why = opts.styles === "tailwind" || opts.styles === "css"
91
+ ? "you asked for it"
92
+ : mix.mixed
93
+ ? `each component in the language it is already written in - ${mix.css} in CSS, ${mix.tailwind} in utilities${mix.undecided ? `, ${mix.undecided} following the project` : ""}. Run --styles css or --styles tailwind if you are migrating to one of them`
94
+ : measured === "utility"
95
+ ? `every component here writes utilities${major ? `, on tailwind v${major}` : ""}, so that is what they come back in`
96
+ : measured === "modules"
97
+ ? "every component here writes CSS Modules, so that is what they come back in"
98
+ : measured === "global"
99
+ ? "every component here writes a global stylesheet, so that is what they come back in"
100
+ : /**
101
+ * E NA PRIMEIRA CORRIDA NÃO EXISTE MEDIDA - `init` roda ANTES do import, que é a
102
+ * ordem que o `sui-init` prescreve. Dizer só "match" aqui deixaria ele achando que
103
+ * a plataforma escolheu, quando ela ainda não olhou.
104
+ *
105
+ * Então a linha diz as duas coisas: o que `match` significa, e que o número chega
106
+ * depois. A dependência entra como a única pista que existe hoje, nomeada como
107
+ * pista - senão ela se passa por medida.
108
+ */
109
+ `each component in the language it is already written in - measured at import. Nothing measured yet; ${project === "tailwind" ? `your dependencies declare tailwindcss${major ? ` v${major}` : ""}` : "no tailwind in your dependencies"}`;
110
+ console.log(` styles: ${config.styles} (${why})`);
37
111
  if (config.absorb)
38
112
  console.log(` absorb: ${config.absorb}${config.absorb === "code" ? " (we hand you the CSS block; your file stays yours)" : " (absorbed values become tokens of the system)"}`);
39
113
  if (config.intent)
@@ -6,6 +6,7 @@ import { installedSlugs } from "../installed.js";
6
6
  import { body, section, snippet } from "../output.js";
7
7
  import { reactMajorOf, readInstalledConvention, readInstalledScheme, } from "../project-facts.js";
8
8
  import { fetchComponent, postRefit, postSaveComponent, RegistryError, } from "../registry.js";
9
+ import { flavourResolver } from "../styles-flavour.js";
9
10
  /** Slugs INSTALLED under `_synthesisui/ds/` (a `.lock` marks a real install -
10
11
  * a folder holding only refit artifacts doesn't count). */
11
12
  /** True when the system is actually installed (tokens.css present). */
@@ -114,7 +115,9 @@ export async function refit(file, opts) {
114
115
  if (config.target === "next") {
115
116
  const compDir = join(root, config.componentsDir, res.name);
116
117
  await mkdir(compDir, { recursive: true });
117
- const files = generateComponentFiles(slug, res.name, res.recipe, res.css, saved.version, config.styles, await reactMajorOf(root), await readInstalledConvention(root, slug), res.name, await readInstalledScheme(root, slug));
118
+ // A LÍNGUA DE CADA COMPONENTE, resolvida uma vez - ver `flavourResolver`.
119
+ const flavourOf = await flavourResolver(root, config.styles);
120
+ const files = generateComponentFiles(slug, res.name, res.recipe, res.css, saved.version, flavourOf(res.name), await reactMajorOf(root), await readInstalledConvention(root, slug), res.name, await readInstalledScheme(root, slug));
118
121
  for (const f of files) {
119
122
  await writeFile(join(compDir, f.filename), f.code, "utf8");
120
123
  }
@@ -9,6 +9,7 @@ import { installedBehind, MATERIALISER_SINCE } from "../install-marks.js";
9
9
  import { body, section, snippet } from "../output.js";
10
10
  import { reactMajorOf, readInstalledConvention, readInstalledScheme, } from "../project-facts.js";
11
11
  import { fetchChangelog, fetchComponent, fetchDesignSystem, RegistryError, } from "../registry.js";
12
+ import { flavourResolver } from "../styles-flavour.js";
12
13
  import { readCensus, unreadComment, unreadForComponent, } from "../unread-for-component.js";
13
14
  import { add } from "./add.js";
14
15
  import { reportWhatIsLeft } from "./align.js";
@@ -284,6 +285,10 @@ export async function upgrade(asked, opts) {
284
285
  catch {
285
286
  // no componentsDir yet - nothing materialized
286
287
  }
288
+ // A LÍNGUA DE CADA COMPONENTE, resolvida uma vez para a corrida inteira e não por
289
+ // componente: `upgrade` reescreve tudo que já está instalado, e reler o censo a cada
290
+ // arquivo seria a mesma resposta lida N vezes. Ver `flavourResolver`.
291
+ const flavourOf = await flavourResolver(root, config.styles);
287
292
  for (const entry of entries) {
288
293
  const tsxPath = join(componentsRoot, entry, `${entry}.tsx`);
289
294
  let head = "";
@@ -297,7 +302,7 @@ export async function upgrade(asked, opts) {
297
302
  continue;
298
303
  try {
299
304
  const res = await fetchComponent(base, slug, entry);
300
- const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles,
305
+ const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, flavourOf(res.name),
301
306
  // Both were missing here, and `upgrade` is the command that REWRITES
302
307
  // components somebody already has: without the convention it would have
303
308
  // taken a working component and stripped its styles.
@@ -1338,7 +1338,15 @@ ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
1338
1338
  .join("\n")}`;
1339
1339
  }
1340
1340
  /** All files for one component, under `<componentsDir>/<name>/`. */
1341
- export function generateComponentFiles(slug, name, recipe, css, version, styles,
1341
+ export function generateComponentFiles(slug, name, recipe, css, version,
1342
+ /**
1343
+ * A LÍNGUA DESTE COMPONENTE, e não a do projeto - resolvida por `flavourResolver`.
1344
+ *
1345
+ * Já foi o sabor do projeto inteiro. Num repositório real isso devolvia mais de 200 arquivos na
1346
+ * língua errada: medido em 605 componentes, 276 importam um `.module.css` e 231 escrevem
1347
+ * utilitários, e nenhuma forma passa de metade.
1348
+ */
1349
+ styles,
1342
1350
  /** Consumer's React major, read from its package.json. Null = unknown, which
1343
1351
  * keeps the ref-less type rather than guessing in the unsafe direction. */
1344
1352
  reactMajor = null,
package/dist/config.js CHANGED
@@ -105,7 +105,7 @@ export const DEFAULT_CONFIG = {
105
105
  target: "next",
106
106
  pagesDir: "app",
107
107
  componentsDir: "components",
108
- styles: "css",
108
+ styles: "match",
109
109
  };
110
110
  const projectConfigPath = (root) => join(root, "_synthesisui", "config.json");
111
111
  /** Reads the project config, falling back to defaults when absent/invalid. */
@@ -121,7 +121,14 @@ export async function readProjectConfig(root) {
121
121
  componentsDir: typeof parsed.componentsDir === "string" && parsed.componentsDir
122
122
  ? parsed.componentsDir
123
123
  : DEFAULT_CONFIG.componentsDir,
124
- styles: parsed.styles === "tailwind" ? "tailwind" : "css",
124
+ // `match` é o padrão desde 24/08 e um config mais velho não o carrega. `"css"` continua
125
+ // sendo o que um config antigo pedia explicitamente, então ele é honrado como escrito - e a
126
+ // ausência do campo passa a valer `match`, que é a resposta medida.
127
+ styles: parsed.styles === "tailwind"
128
+ ? "tailwind"
129
+ : parsed.styles === "css"
130
+ ? "css"
131
+ : "match",
125
132
  /**
126
133
  * A INTENÇÃO NÃO TEM DEFAULT AQUI, de propósito.
127
134
  *
@@ -26,8 +26,14 @@ const MODULE_FILE = /\.module\.(css|scss|sass|less)$/i;
26
26
  const STYLESHEET = /\.(css|scss|sass|less)$/i;
27
27
  /** A class selector at the start of a rule: `.metric-card__title {`. */
28
28
  const CLASS_DECL = /^\s*\.([a-zA-Z][a-zA-Z0-9_-]*)\s*[,{:]/gm;
29
- /** A Tailwind-shaped utility in markup - enough to know they exist. */
30
- const UTILITY = /\b(?:bg|text|border|p|px|py|m|mx|my|gap|flex|grid|rounded|shadow)-[a-z0-9[\]./-]+/g;
29
+ /**
30
+ * A Tailwind-shaped utility in markup - enough to know they exist.
31
+ *
32
+ * EXPORTADO em 24/08 porque o censo passou a contar utilitários POR COMPONENTE, e não só por
33
+ * projeto: duas contagens que discordem sobre o que é um utilitário mandariam o mesmo arquivo para
34
+ * sabores diferentes na soma e no detalhe.
35
+ */
36
+ export const UTILITY = /\b(?:bg|text|border|p|px|py|m|mx|my|gap|flex|grid|rounded|shadow)-[a-z0-9[\]./-]+/g;
31
37
  /**
32
38
  * Below this many global class declarations, a "convention" is a handful of
33
39
  * one-off selectors rather than a house style - and adopting one from noise
@@ -1,20 +1,4 @@
1
- /**
2
- * WHAT THIS VERSION CAN AND CANNOT READ, MEASURED ON THEIR OWN REPO.
3
- *
4
- * The most commercially important thing in the pipeline, and it took a person saying
5
- * "nothing works" to see it (dono, 01/08).
6
- *
7
- * Before this, importing a CSS-Modules app produced 283 components with zero
8
- * declarations and said nothing about why. A person seeing that concludes the product is
9
- * broken, and they are reasoning correctly from what they were shown. The defect was
10
- * never the gap - every reader has a boundary. The defect was the SILENCE about it.
11
- *
12
- * So the census measures its own coverage and says the number before anything is sent:
13
- * how each component expresses style, how many of those this version reads, and which
14
- * ones it does not - by name, with the count from their repo rather than a disclaimer.
15
- *
16
- * A governance product that cannot state what it does not govern is not trustworthy.
17
- */
1
+ import { UTILITY } from "./class-style.js";
18
2
  /**
19
3
  * Every shape, in the order the enumeration found them. Each entry survived being
20
4
  * counted on a real repo: a shape with no evidence is a shape I assumed.
@@ -79,7 +63,27 @@ export const STYLE_SHAPES = [
79
63
  test: /\bsx=\{|@chakra-ui|@pandacss/,
80
64
  },
81
65
  ];
82
- export function countShape(source, name, into) {
66
+ export function countShape(source, name, into,
67
+ /**
68
+ * A FORMA DE CADA COMPONENTE, e não só a soma - o dado que a esteira media e jogava fora.
69
+ *
70
+ * O QUE ELE DESTRAVA: componentes gerados na língua do arquivo onde eles nascem. Um repositório
71
+ * real não tem UMA forma - medido no `frontend-hub` (24/08): 214 arquivos em CSS Modules, 157 em
72
+ * lista de classes escrita, 138 em template com valor de runtime. Nenhuma passa de 30%.
73
+ *
74
+ * Escolher um sabor global obriga a maioria do código dele a receber componentes na forma errada.
75
+ * Com a forma por componente, o `Button` que ele escreve em utilitários recebe utilitários e o
76
+ * `Card` em CSS Modules recebe CSS Modules - cada um na língua que aquele arquivo fala.
77
+ *
78
+ * A PRIMEIRA FORMA VENCE, e a ordem é a de `STYLE_SHAPES`: um arquivo pode casar mais de um teste
79
+ * (um componente em CSS Modules que também usa `style={{ }}` numa linha), e a primeira da lista é
80
+ * a mais estrutural. Somar as duas diria que ele usa as duas igualmente, o que não é verdade.
81
+ *
82
+ * Opcional porque `countShape` tem outro chamador que só quer a soma - e um parâmetro obrigatório
83
+ * ali seria mudar uma pergunta que não mudou.
84
+ */
85
+ perComponent) {
86
+ let first = null;
83
87
  for (const shape of STYLE_SHAPES) {
84
88
  if (!shape.test.test(source))
85
89
  continue;
@@ -88,7 +92,13 @@ export function countShape(source, name, into) {
88
92
  if (at.examples.length < 4)
89
93
  at.examples.push(name);
90
94
  into.set(shape.key, at);
95
+ first ??= shape.key;
91
96
  }
97
+ if (first && perComponent && !perComponent.has(name))
98
+ perComponent.set(name, {
99
+ shape: first,
100
+ utilities: source.match(UTILITY)?.length ?? 0,
101
+ });
92
102
  }
93
103
  export function summarizeCoverage(counts, components, read, declarations) {
94
104
  return {
@@ -45,8 +45,12 @@ const STYLED_OPEN = /\bstyled(?:\.[a-zA-Z][\w]*|\([^)]{1,60}\))\s*(?:<[^>]{0,120
45
45
  const PIECES = /([^{};]*)([{};]|$)/g;
46
46
  /** Uma declaração `prop: value`, já sem o terminador. */
47
47
  const DECLARATION = /^(-{0,2}[a-zA-Z][\w-]*)\s*:\s*([^;{}]{1,200})$/;
48
- /** Uma classe que só um literal pode ter: começa com letra minúscula. */
49
- const LOOKS_LIKE_CLASS = /^[a-z[]/;
48
+ /**
49
+ * Uma classe que só um literal pode ter: começa com letra minúscula - ou com o `!` de important,
50
+ * que é como o Tailwind v3 escreve `!text-ink-900`. Sem o `!`, um className inteiro de important
51
+ * nem entrava no denominador: não era lido E não era "não lido" (25/08).
52
+ */
53
+ const LOOKS_LIKE_CLASS = /^[!a-z[]/;
50
54
  /**
51
55
  * `${styles.card}` dentro de um template: uma classe de CSS module, cujo valor mora no arquivo de
52
56
  * estilo - que a esteira lê. É a mesma regra que `CLASS_RUNTIME` já aplica a `className={styles.x}`.
@@ -669,7 +669,17 @@ function withAlpha(d, alpha) {
669
669
  }
670
670
  /** `w-1/2`, `basis-2/3` - a fraction is a width, and the `/` is not an alpha. */
671
671
  const FRACTION = /^(w|h|basis|max-w|max-h|min-w|min-h|top|right|bottom|left|inset)-(\d+)\/(\d+)$/;
672
- export function readUtility(utility, declared) {
672
+ export function readUtility(raw, declared) {
673
+ /**
674
+ * O `!` DE IMPORTANT SAI ANTES DE TUDO - `!text-ink-900` (v3) e `text-ink-900!` (v4).
675
+ *
676
+ * O important é como ele força o cascade DENTRO do app dele; a decisão de design é o valor, e
677
+ * era ela que morria: `!text-ink-900` chegava aqui inteiro, nenhum prefixo casava, e a camada
678
+ * sumia sem uma linha - com `--color-ink-900` declarado no CSS dele. 48 valores num censo real,
679
+ * 20 e 4 nas duas populações congeladas (25/08). Numa receita não há cascade para forçar, então
680
+ * despir o `!` não perde decisão nenhuma.
681
+ */
682
+ const utility = raw.replace(/^!/, "").replace(/!$/, "");
673
683
  /**
674
684
  * BEFORE THE ALPHA SPLIT, because the two share a slash and only one of them is a
675
685
  * colour. `w-1/2` was reaching the size reader as `w-1` and coming back `0.25rem` -
@@ -270,6 +270,67 @@ export function literalClasses(fragment) {
270
270
  }
271
271
  return out;
272
272
  }
273
+ /**
274
+ * OS TRECHOS DO ARQUIVO QUE CONSOMEM CLASSE - `className={…}` (e qualquer `*ClassName=`),
275
+ * e as chamadas que produzem classe (`cn`, `clsx`, `cva`, `tv`, …).
276
+ *
277
+ * Existe porque os três leitores de condição liam o arquivo INTEIRO como se toda string fosse
278
+ * classe: 531 de 1119 entradas de `rawLayers.classes` num censo real eram literais de ternário e
279
+ * de record que nunca foram classe - `gray`, `True`, `Find`, `✅`, `h1` - e o gradiente do Aurora
280
+ * deles chegava partido em `0%,rgba(…)` porque um VALOR de CSS foi partido no espaço como se
281
+ * fosse lista de classes (medido em 25/08). A forma sozinha não decide (`gray` e `hidden` têm a
282
+ * mesma forma); o consumo decide - a mesma doutrina que `looksLikeClassList` já escreveu.
283
+ */
284
+ const CLASS_ATTR = /(?:^|[^A-Za-z])[A-Za-z]*[Cc]lassName=\{/g;
285
+ const CLASS_CALL = /\b(?:cn|clsx|classNames|cx|twMerge|twJoin|classcat|cva|tv)\(/g;
286
+ export function classSpans(source) {
287
+ const spans = [];
288
+ for (const re of [CLASS_ATTR, CLASS_CALL]) {
289
+ re.lastIndex = 0;
290
+ for (const m of source.matchAll(re)) {
291
+ const open = (m.index ?? 0) + m[0].length - 1;
292
+ const end = endOf(source, open);
293
+ if (end !== -1)
294
+ spans.push([open, end]);
295
+ }
296
+ }
297
+ return spans;
298
+ }
299
+ function inSpan(spans, index) {
300
+ return spans.some(([open, end]) => index > open && index < end);
301
+ }
302
+ /**
303
+ * O RECORD É CONSUMIDO COMO CLASSE? - direto (`styles[size]` dentro de um contexto de classe) ou
304
+ * com um pulo (`const config = statusConfig[status]` e depois `config.dotClass` num `cn()`),
305
+ * inclusive desestruturado (`const { fontSize } = sizeConfig[size]`).
306
+ *
307
+ * Um pulo só, de propósito: seguir a cadeia inteira seria reimplementar um resolvedor de escopo, e
308
+ * os 8 de 8 casos medidos no repo real (dono, 06/08) cabem em um.
309
+ */
310
+ function consumedAsClass(source, spans, symbol) {
311
+ for (const m of source.matchAll(new RegExp(`\\b${symbol}\\s*\\[`, "g"))) {
312
+ const at = m.index ?? 0;
313
+ if (inSpan(spans, at))
314
+ return true;
315
+ const before = source.slice(Math.max(0, at - 120), at);
316
+ const alias = /([A-Za-z_$][\w$]*)\s*=\s*$/.exec(before)?.[1];
317
+ const destructured = /\{([^{}]*)\}\s*=\s*$/.exec(before)?.[1];
318
+ const names = destructured
319
+ ? destructured.split(",").map((s) => s.split(":")[0].trim())
320
+ : alias
321
+ ? [alias]
322
+ : [];
323
+ for (const name of names) {
324
+ if (!name || !/^[A-Za-z_$][\w$]*$/.test(name))
325
+ continue;
326
+ for (const use of source.matchAll(new RegExp(`\\b${name}\\b`, "g"))) {
327
+ if (inSpan(spans, use.index ?? 0))
328
+ return true;
329
+ }
330
+ }
331
+ }
332
+ return false;
333
+ }
273
334
  /**
274
335
  * Line and block comments removed, quotes respected.
275
336
  *
@@ -600,8 +661,15 @@ function enclosingCallClasses(source, at) {
600
661
  }
601
662
  export function readInlineConditions(source) {
602
663
  const layers = [];
664
+ const spans = classSpans(source);
603
665
  INLINE_COND.lastIndex = 0;
604
666
  for (const m of source.matchAll(INLINE_COND)) {
667
+ /**
668
+ * `cond && "string"` fora de um contexto de classe é quase sempre CONTEÚDO de JSX
669
+ * (`{loading && "Finding..."}`), e entrava como classe - o mesmo portão dos ternários.
670
+ */
671
+ if (!inSpan(spans, m.index ?? 0) && !looksLikeClassList(m[5] ?? m[9] ?? ""))
672
+ continue;
605
673
  const negated = (m[1] ?? m[6]) === "!";
606
674
  const prop = (m[2] ?? m[7] ?? "").split(".").pop() ?? "";
607
675
  const raw = m[5] ?? m[9] ?? "";
@@ -824,6 +892,7 @@ unattributed) {
824
892
  axisOf.set(m[1], m[2]);
825
893
  }
826
894
  const layers = [];
895
+ const spans = classSpans(clean);
827
896
  LOOKUP_RECORD.lastIndex = 0;
828
897
  for (const m of clean.matchAll(LOOKUP_RECORD)) {
829
898
  const symbol = m[1];
@@ -840,6 +909,16 @@ unattributed) {
840
909
  const options = entries.filter((e) => literalClasses(e.value).length > 0);
841
910
  if (options.length < 2)
842
911
  continue;
912
+ /**
913
+ * SÓ É ESTILO O QUE UM CONTEXTO DE CLASSE CONSOME - ou o que tem a forma inequívoca de uma
914
+ * lista de classes. Um record indexado é a forma de MUITAS coisas: `TIER_LABELS[tier]` guarda
915
+ * conteúdo, `AURORA_PALETTES[palette]` guarda um gradiente que vai para `style=`, e ambos
916
+ * entravam aqui como classe - 531 de 1119 entradas do censo real eram isso (25/08). O consumo
917
+ * era a única prova, e a mesma regra já valia para os campos de uma tabela por elemento.
918
+ */
919
+ if (!consumedAsClass(clean, spans, symbol) &&
920
+ !options.some((o) => looksLikeClassList(o.value)))
921
+ continue;
843
922
  if (axesOut) {
844
923
  const names = options.map((o) => o.key);
845
924
  axesOut[axis] = [...new Set([...(axesOut[axis] ?? []), ...names])];
@@ -916,8 +995,26 @@ unattributed) {
916
995
  const CLASS_TERNARY = /([!A-Za-z_$][\w$.!]*)\s*(?:===\s*["']([^"']+)["']\s*)?\?\s*["']([^"']*)["']\s*:\s*["']([^"']*)["']/g;
917
996
  export function readClassTernaries(source) {
918
997
  const layers = [];
998
+ const clean = stripComments(source);
999
+ const spans = classSpans(clean);
919
1000
  CLASS_TERNARY.lastIndex = 0;
920
- for (const m of stripComments(source).matchAll(CLASS_TERNARY)) {
1001
+ for (const m of clean.matchAll(CLASS_TERNARY)) {
1002
+ /**
1003
+ * UM TERNÁRIO ENTRE DUAS STRINGS É A FORMA DE QUALQUER COISA - `loading ? "Finding..." :
1004
+ * "Find"` é o rótulo de um botão, `large ? "h1" : "h2"` é uma tag. Fora de um contexto de
1005
+ * classe, só a forma inequívoca de lista de classes prova estilo; dentro, o consumo prova.
1006
+ * Sem este portão, os dois exemplos acima entravam no censo como classe (25/08).
1007
+ */
1008
+ const at = m.index ?? 0;
1009
+ if (!inSpan(spans, at)) {
1010
+ const before = clean.slice(Math.max(0, at - 120), at);
1011
+ const alias = /([A-Za-z_$][\w$]*)\s*=\s*$/.exec(before)?.[1];
1012
+ const aliased = alias &&
1013
+ /^[A-Za-z_$][\w$]*$/.test(alias) &&
1014
+ [...clean.matchAll(new RegExp(`\\b${alias}\\b`, "g"))].some((u) => inSpan(spans, u.index ?? 0));
1015
+ if (!aliased && !looksLikeClassList(m[3]) && !looksLikeClassList(m[4]))
1016
+ continue;
1017
+ }
921
1018
  const negated = m[1].startsWith("!");
922
1019
  const prop = m[1].replace(/^!/, "").split(".").pop() ?? "";
923
1020
  if (!prop)
@@ -941,6 +1038,100 @@ export function readClassTernaries(source) {
941
1038
  }
942
1039
  return layers;
943
1040
  }
1041
+ /** O valor quando ele é UMA string literal inteira - `null` para objeto, template com `${}`, etc. */
1042
+ function singleStringValue(value) {
1043
+ const trimmed = value.trim();
1044
+ if (!/^["'`]/.test(trimmed))
1045
+ return null;
1046
+ const closed = endOfString(trimmed);
1047
+ if (closed === -1 || trimmed.slice(closed).trim())
1048
+ return null;
1049
+ const inner = trimmed.slice(1, closed - 1);
1050
+ if (trimmed[0] === "`" && inner.includes("${"))
1051
+ return null;
1052
+ return inner;
1053
+ }
1054
+ /**
1055
+ * UM RECORD CONSUMIDO POR `style=` É ESTILO POR PROVA, NÃO POR FORMA - e o valor viaja INTEIRO.
1056
+ *
1057
+ * const AURORA_PALETTES: Record<AuroraPalette, string> = {
1058
+ * default: "linear-gradient(var(--aurora-angle,125deg),rgba(79,70,229,0.18) 0%,…)",
1059
+ * cool: "linear-gradient(…)",
1060
+ * };
1061
+ * style={{ backgroundImage: AURORA_PALETTES[palette], … }} // ou style={mergedStyle}
1062
+ *
1063
+ * O leitor de classes partia esse gradiente no espaço e o censo carregava `0%,rgba(192,132,252,0.18)`
1064
+ * como se fosse classe - 46 valores num censo real (25/08). A propriedade é a que ELE escreveu no
1065
+ * objeto de style (`backgroundImage`), o valor é o que ELE declarou no record, e o eixo é a prop que
1066
+ * indexa - transcrever não é inventar. Um nível de indireção é seguido (`const mergedStyle = {…}` e
1067
+ * `style={mergedStyle}`), que é como o componente real escreve.
1068
+ */
1069
+ export function readStyleRecords(source, axesOut) {
1070
+ const clean = stripComments(source);
1071
+ const axisOf = new Map();
1072
+ RECORD_INDEX.lastIndex = 0;
1073
+ for (const m of clean.matchAll(RECORD_INDEX)) {
1074
+ if (!axisOf.has(m[1]))
1075
+ axisOf.set(m[1], m[2]);
1076
+ }
1077
+ if (axisOf.size === 0)
1078
+ return [];
1079
+ // symbol → option → o valor inteiro, para os records cujos valores são strings.
1080
+ const records = new Map();
1081
+ LOOKUP_RECORD.lastIndex = 0;
1082
+ for (const m of clean.matchAll(LOOKUP_RECORD)) {
1083
+ if (!axisOf.has(m[1]))
1084
+ continue;
1085
+ const open = (m.index ?? 0) + m[0].length - 1;
1086
+ const end = endOf(clean, open);
1087
+ if (end === -1)
1088
+ continue;
1089
+ const options = topLevelEntries(clean.slice(open + 1, end - 1))
1090
+ .map((e) => ({ key: e.key, value: singleStringValue(e.value) }))
1091
+ .filter((e) => e.value != null);
1092
+ if (options.length >= 2)
1093
+ records.set(m[1], options);
1094
+ }
1095
+ if (records.size === 0)
1096
+ return [];
1097
+ // Os corpos que ALIMENTAM um style=: o objeto inline, e o const que um style={nome} aponta.
1098
+ const bodies = [];
1099
+ for (const m of clean.matchAll(/style=\{\{/g)) {
1100
+ const open = (m.index ?? 0) + m[0].length - 1;
1101
+ const end = endOf(clean, open);
1102
+ if (end !== -1)
1103
+ bodies.push(clean.slice(open + 1, end - 1));
1104
+ }
1105
+ for (const m of clean.matchAll(/style=\{\s*([A-Za-z_$][\w$]*)\s*\}/g)) {
1106
+ const named = new RegExp(`(?:const|let)\\s+${m[1]}\\b[^=]*=\\s*\\{`, "g").exec(clean);
1107
+ if (!named)
1108
+ continue;
1109
+ const open = named.index + named[0].length - 1;
1110
+ const end = endOf(clean, open);
1111
+ if (end !== -1)
1112
+ bodies.push(clean.slice(open + 1, end - 1));
1113
+ }
1114
+ const layers = [];
1115
+ for (const body of bodies) {
1116
+ for (const m of body.matchAll(/([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)\s*\[/g)) {
1117
+ const property = m[1];
1118
+ const options = records.get(m[2]);
1119
+ const axis = axisOf.get(m[2]);
1120
+ if (!options || !axis)
1121
+ continue;
1122
+ if (axesOut)
1123
+ axesOut[axis] = [
1124
+ ...new Set([...(axesOut[axis] ?? []), ...options.map((o) => o.key)]),
1125
+ ];
1126
+ for (const option of options)
1127
+ layers.push({
1128
+ when: { variant: { [axis]: option.key } },
1129
+ style: { [property]: option.value },
1130
+ });
1131
+ }
1132
+ }
1133
+ return layers;
1134
+ }
944
1135
  /**
945
1136
  * A LOOKUP INDEXED BY A TERNARY, which is how the same record serves a boolean.
946
1137
  *
@@ -977,6 +1168,12 @@ globals) {
977
1168
  ...readLookupRecords(source, fromRecords, unslotted),
978
1169
  ...readClassTernaries(source),
979
1170
  ];
1171
+ /**
1172
+ * O record que um `style=` consome - valores prontos, sem tabela de escala no meio.
1173
+ * Entram direto nas camadas finais (e registram o eixo), nunca em `raw`: `raw` é
1174
+ * matéria-prima de reinterpretação por classes, e estes já são o valor final.
1175
+ */
1176
+ const styleRecords = readStyleRecords(source, fromRecords);
980
1177
  const axes = {};
981
1178
  const defaults = {};
982
1179
  const allLayers = [];
@@ -1087,7 +1284,7 @@ globals) {
1087
1284
  reads,
1088
1285
  axes,
1089
1286
  defaults,
1090
- layers,
1287
+ layers: [...layers, ...styleRecords],
1091
1288
  raw: allLayers,
1092
1289
  base: withGlobal(transcribe(baseClasses, declared), baseClasses, globals),
1093
1290
  unslotted,
package/dist/index.js CHANGED
@@ -104,7 +104,9 @@ Options:
104
104
  --target <t> template/init target: next | general (default: next)
105
105
  --pages-dir <dir> init: folder for generated pages (default: app)
106
106
  --components-dir <dir> init: folder where components live (default: components)
107
- --styles <s> init: component code flavor: css | tailwind (default: css)
107
+ --styles <s> init: css | tailwind. Default: each component comes back in the
108
+ language it is already written in - pass one of these only if you
109
+ are migrating TO it
108
110
  --artifacts-only component: skip the .tsx materialization (recipe + css only)
109
111
  --interactive component: materialize the rich/behaving variant (join-field, streak, xp-bar)
110
112
  --replace <name> refit: replace an existing DS component (keeps its name)
@@ -128,7 +128,16 @@
128
128
  * é sempre o bump deste PR - nunca o número que o `package.json` já carrega, porque alguém pode
129
129
  * publicar no meio.
130
130
  */
131
- export const MATERIALISER_SINCE = "0.16.293";
131
+ /**
132
+ * 0.16.305 -> 0.16.306 em 25/08, e o SIM é sobre a FIAÇÃO que o `upgrade` regrava: o hook de
133
+ * `SessionStart` passa a ser pinado (`npx synthesisui@<versão> align`) em vez de `@latest`. O
134
+ * `@latest` nunca cacheia - round-trip no registro em TODA abertura de sessão, 2,92s medidos, contra
135
+ * 0,32s da versão fixada - e o trabalho do align em si custa 50ms. Um `upgrade` anterior deixa o
136
+ * `.claude/settings.json` pagando 2,9s por sessão; quem roda o upgrade novo recebe bytes diferentes
137
+ * ali, que é exatamente o que esta marca existe para dizer. Quem avisa versão nova passa a ser o
138
+ * próprio align, com o `cli` que o servidor manda no `?meta=1`.
139
+ */
140
+ export const MATERIALISER_SINCE = "0.16.306";
132
141
  /**
133
142
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
134
143
  *
@@ -253,7 +262,33 @@ export const CHECKER_SINCE = "0.16.250";
253
262
  * publicado antes deste código existir, e uma marca nele calaria o aviso para quem o instalou. Mesma
254
263
  * lição de algumas horas antes, na mesma sessão.
255
264
  */
256
- export const READER_SINCE = "0.16.299";
265
+ /**
266
+ * 0.16.299 -> 0.16.305 em 25/08: o censo passou a carregar `coverage.byComponent` - como CADA
267
+ * componente escreve estilo, e quantos utilitários ele usa.
268
+ *
269
+ * Campo novo, então um censo medido antes não o tem: `styles: "match"` cai no sabor do projeto
270
+ * inteiro, que é o que a versão anterior já fazia. Ninguém regride - mas quem não remede também não
271
+ * ganha, e num projeto híbrido o que ele ganha são mais de 200 componentes voltando na língua em
272
+ * que já estão escritos, em vez de na língua da maioria.
273
+ *
274
+ * É exatamente o que esta marca existe para dizer: o `align` avisa que vale remedir.
275
+ */
276
+ /**
277
+ * 0.16.305 -> 0.16.306 em 25/08: três leituras que devolvem estilo do cliente, medidas antes de
278
+ * escrever (itens 8, 9 e 10 do diagnóstico de 25/08):
279
+ *
280
+ * 1. O `!` de important deixa de matar o utilitário: `!text-ink-900` com `--color-ink-900`
281
+ * declarado agora resolve (48 valores no censo real; e o ledger passou a CONTAR um className
282
+ * de important, que antes nem entrava no denominador).
283
+ * 2. Um record consumido por `style=` viaja como o VALOR que ele é: o gradiente do Aurora chega
284
+ * inteiro em `layers`, em vez de partido em `0%,rgba(…)` como classe (46 valores).
285
+ * 3. Só é classe o que um contexto de classe consome: literais de ternário e de record de
286
+ * conteúdo (`Finding...`, `h1`, `✅`) saem de `rawLayers.classes` - eram 531 de 1119 entradas
287
+ * (47%) no censo real, poluindo numerador e denominador de toda métrica em cima.
288
+ *
289
+ * Um censo medido antes carrega a poluição e a perda; quem remede ganha os três de uma vez.
290
+ */
291
+ export const READER_SINCE = "0.16.306";
257
292
  /**
258
293
  * O QUE ESTÁ INSTALADO AQUI FICOU PARA TRÁS - e as DUAS condições que fazem isso ser verdade.
259
294
  *
package/dist/stack.js CHANGED
@@ -187,3 +187,59 @@ export async function detectStack(root) {
187
187
  stack.push("plain css");
188
188
  return stack;
189
189
  }
190
+ /**
191
+ * QUAL SABOR DE ESTILO O PROJETO DELE PEDE - derivado do que foi MEDIDO, nunca de um default nosso.
192
+ *
193
+ * O QUE ACONTECIA: `DEFAULT_CONFIG.styles` é `"css"`, e só virava `"tailwind"` se alguém passasse
194
+ * `--styles` no `init`. O import nunca pergunta e nunca grava esse campo. Resultado, no repositório
195
+ * do dono (24/08): um projeto com `tailwindcss ^4.3.0` receberia componentes em CSS, com um `.css`
196
+ * ao lado de cada um, num repositório onde todo o resto é Tailwind.
197
+ *
198
+ * E a informação para acertar já estava na mão: `detectStack` mede `tailwind` no segundo em que o
199
+ * censo é tirado. A plataforma media o fato e usava o oposto - a mesma família do prefixo `ds-` e
200
+ * das partes numeradas.
201
+ *
202
+ * A REGRA É A DELE, E O SILÊNCIO É NOSSO. Um projeto que declara Tailwind recebe Tailwind. Um que
203
+ * não declara recebe CSS, que é o que funciona em qualquer lugar - e aí o default é uma resposta
204
+ * honesta a uma pergunta sem dado, não um palpite sobre um dado que existe.
205
+ */
206
+ export function stylesFor(deps,
207
+ /**
208
+ * A FORMA DOMINANTE MEDIDA NO CÓDIGO DELE - `utility`, `modules` ou `global`.
209
+ *
210
+ * ELA VENCE A DEPENDÊNCIA, e o caso híbrido é a razão: um projeto pode ter `tailwindcss`
211
+ * instalado e escrever quase tudo em CSS Modules. Medido no `frontend-hub` (24/08):
212
+ * `@tailwindcss/postcss` e `@tailwindcss/vite` nas dependências, `classStyle: "modules"`, e o
213
+ * ledger conta 17 976 fragmentos `css` contra 2 620 de classe. Perguntar ao `package.json`
214
+ * responderia "tailwind" sobre um repositório que escreve CSS Modules.
215
+ *
216
+ * A dependência diz o que está INSTALADO; `classStyle` diz o que ele ESCREVE. A segunda é a
217
+ * pergunta - e é a mesma lei do resto da esteira: medir o fato, não o proxy.
218
+ *
219
+ * Ausente quando o censo não mediu (uma chamada mais velha, um projeto sem componente), e aí a
220
+ * dependência volta a ser a melhor pista que existe.
221
+ */
222
+ classStyle) {
223
+ if (classStyle === "utility")
224
+ return "tailwind";
225
+ if (classStyle === "modules" || classStyle === "global")
226
+ return "css";
227
+ return deps.tailwindcss ? "tailwind" : "css";
228
+ }
229
+ /**
230
+ * A VERSÃO MAIOR DO TAILWIND - e ela decide a sintaxe, não só a etiqueta.
231
+ *
232
+ * `@theme` e `@utility` existem na v4 e não na v3. Gerar a sintaxe de uma para um projeto da outra
233
+ * produz um arquivo que o build DELE não entende - e é o pior tipo de saída, porque parece certa
234
+ * até alguém compilar.
235
+ *
236
+ * `null` quando o pacote não está lá ou quando a faixa não diz um número - um intervalo que não se
237
+ * resolve é ausência de resposta, e supor a v4 seria escolher o mais novo por conveniência nossa.
238
+ */
239
+ export function tailwindMajor(deps) {
240
+ const raw = deps.tailwindcss;
241
+ if (!raw)
242
+ return null;
243
+ const m = /(\d+)\./.exec(raw.replace(/^[^0-9]*/, ""));
244
+ return m ? Number(m[1]) : null;
245
+ }
@@ -0,0 +1,121 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { resolveDeps, stylesFor } from "./stack.js";
4
+ /**
5
+ * QUANTOS UTILITÁRIOS BASTAM PARA DIZER QUE UM COMPONENTE ESCREVE UTILITÁRIOS.
6
+ *
7
+ * Um `p-4` solto num arquivo de CSS Modules é a exceção de alguém, não a língua do arquivo. Três
8
+ * separa as duas populações com folga na medição de 24/08: os componentes em CSS Modules ficam em
9
+ * 1% acima desse corte e os de lista de classes em 78%.
10
+ */
11
+ const ENOUGH_UTILITIES = 3;
12
+ /**
13
+ * O QUE A FORMA MEDIDA DECIDE SOZINHA - e `null` quando ela não decide.
14
+ *
15
+ * `module` decide: quem importa um `.module.css` escreve CSS, e não há segunda leitura. As outras
16
+ * formas não decidem pelo nome - `static` é `className="flex gap-2"` em 78% dos casos e
17
+ * `className="card-header"` nos outros 22% -, então quem responde é a contagem de utilitários.
18
+ *
19
+ * E quando nem ela responde, a resposta é `null`: o componente volta no sabor do PROJETO. Ausência
20
+ * de sinal devolve a decisão para quem tem mais dado, em vez de virar palpite aqui.
21
+ */
22
+ export function flavourOfShape(measured) {
23
+ if (!measured)
24
+ return null;
25
+ if (measured.shape === "module")
26
+ return "css";
27
+ if (measured.utilities >= ENOUGH_UTILITIES)
28
+ return "tailwind";
29
+ return null;
30
+ }
31
+ /**
32
+ * O SABOR DESTE COMPONENTE, com o do projeto como piso.
33
+ *
34
+ * `match` pergunta ao que foi medido e cai no do projeto quando a medida não decide - inclusive num
35
+ * censo tirado antes de 24/08, que não carrega medida nenhuma. Um ajuste explícito (`css` ou
36
+ * `tailwind`) vence sempre: ele é o destino que ele escolheu, e medida nenhuma sabe disso.
37
+ */
38
+ export function flavourFor(setting, project, component, byComponent) {
39
+ if (setting !== "match")
40
+ return setting;
41
+ return flavourOfShape(byComponent?.[component]) ?? project;
42
+ }
43
+ /**
44
+ * O CORTE DA MINORIA - abaixo disso a segunda forma é exceção, não migração.
45
+ *
46
+ * 10% dos componentes é o menor grupo que ainda dói se voltar na língua errada: num repositório de
47
+ * 605, são 60 arquivos. Abaixo disso a plataforma decide sozinha e não gasta a atenção dele.
48
+ */
49
+ const MINORITY = 0.1;
50
+ export function formMix(byComponent) {
51
+ const entries = Object.values(byComponent ?? {});
52
+ let css = 0;
53
+ let tailwind = 0;
54
+ for (const measured of entries) {
55
+ const flavour = flavourOfShape(measured);
56
+ if (flavour === "css")
57
+ css += 1;
58
+ else if (flavour === "tailwind")
59
+ tailwind += 1;
60
+ }
61
+ const decided = css + tailwind;
62
+ const smaller = Math.min(css, tailwind);
63
+ return {
64
+ css,
65
+ tailwind,
66
+ undecided: entries.length - decided,
67
+ mixed: decided > 0 && smaller / decided >= MINORITY,
68
+ };
69
+ }
70
+ /**
71
+ * O SABOR DE CADA COMPONENTE, RESOLVIDO UMA VEZ PARA O COMANDO INTEIRO.
72
+ *
73
+ * O QUE ELE EVITA: quatro comandos escrevem componentes - `component`, `generate`, `refit` e
74
+ * `upgrade` -, e cada um teria que ler o censo, ler o `package.json`, casar as duas medidas e
75
+ * decidir. Quatro cópias da mesma regra são quatro chances de duas delas discordarem, e aí o mesmo
76
+ * componente volta em CSS por um comando e em utilitários pelo outro.
77
+ *
78
+ * Devolve uma FUNÇÃO e não um valor, porque a resposta é por componente: `flavourOf("Card")` pode
79
+ * ser `css` e `flavourOf("Button")` `tailwind` na mesma corrida, que é exatamente o ponto.
80
+ *
81
+ * TUDO GUARDADO: um censo ilegível, um `package.json` ausente ou um projeto que nunca importou
82
+ * caem no sabor do projeto, e um projeto sem medida nenhuma cai em `css` - que é o que funciona em
83
+ * qualquer lugar. Nenhuma leitura aqui pode impedir alguém de gerar um componente.
84
+ */
85
+ export async function flavourResolver(root, setting) {
86
+ // Um ajuste explícito é o destino que ele escolheu: nada aqui precisa ser lido para honrá-lo.
87
+ if (setting !== "match")
88
+ return () => setting;
89
+ const { byComponent, classStyle } = await measuredStyle(root);
90
+ const project = stylesFor(await resolveDeps(root).catch(() => ({})), classStyle);
91
+ return (component) => flavourFor("match", project, component, byComponent);
92
+ }
93
+ /**
94
+ * O QUE O CENSO MEDIU SOBRE ESTILO - as duas respostas, numa leitura só.
95
+ *
96
+ * `byComponent` diz como CADA componente escreve; `classStyle` diz como o projeto escreve. A
97
+ * primeira decide o sabor de um componente e a segunda é o piso quando ela não decide, então elas
98
+ * andam juntas - e lê-las em dois lugares diferentes é abrir espaço para duas respostas.
99
+ *
100
+ * VAZIO em vez de erro quando não há censo, quando ele é ilegível, ou quando foi tirado antes de
101
+ * 24/08 e não carrega medida por componente: `init` roda antes de qualquer medição, e um censo
102
+ * quebrado não pode impedir alguém de configurar o projeto.
103
+ */
104
+ export async function measuredStyle(root) {
105
+ const raw = await readFile(join(root, "_synthesisui", "census.json"), "utf8").catch(() => "");
106
+ if (!raw)
107
+ return {};
108
+ try {
109
+ const census = JSON.parse(raw);
110
+ const kind = census.classStyle?.kind;
111
+ return {
112
+ byComponent: census.coverage?.byComponent,
113
+ classStyle: kind === "global" || kind === "modules" || kind === "utility"
114
+ ? kind
115
+ : undefined,
116
+ };
117
+ }
118
+ catch {
119
+ return {};
120
+ }
121
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.302",
3
+ "version": "0.16.306",
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": {