synthesisui 0.16.346 → 0.16.349

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.
@@ -3,9 +3,11 @@ import { join } from "node:path";
3
3
  import { syncClaudeMd } from "../claude-md.js";
4
4
  import { readProjectConfig, readToken, resolveRegistry } from "../config.js";
5
5
  import { customFontFamilies, googleFontsHref, nextFontSnippet, } from "../fonts.js";
6
+ import { globalSheetOf, prefixFrom } from "../global-sheet.js";
6
7
  import { lockReference } from "../group-role.js";
7
8
  import { buildGuide } from "../guide.js";
8
9
  import { censusScope } from "../measured-scope.js";
10
+ import { recallAvailable } from "../memory/availability.js";
9
11
  import { body as line, section, snippet } from "../output.js";
10
12
  import { fetchDesignSystem } from "../registry.js";
11
13
  import { repoStateOf } from "../repo-state.js";
@@ -13,6 +15,7 @@ import { describeFiltered, ruleApplies, rulesForProject, } from "../rule-filter.
13
15
  import { detectStack } from "../stack.js";
14
16
  import { onlyWhatMatched } from "../their-theme.js";
15
17
  import { pointTokensAtTheirNames } from "../their-vars.js";
18
+ import { tracksAnyOf } from "../tracked.js";
16
19
  /**
17
20
  * QUAL METADE DESTA PASTA UM TIME COMMITA.
18
21
  *
@@ -184,6 +187,16 @@ export async function detectAppDirs(root, pagesDir) {
184
187
  * stable root re-exports (tokens.css/theme.css) and a `.lock` at it, and updates
185
188
  * CLAUDE.md. Older version folders are kept for rollback/diff.
186
189
  */
190
+ /**
191
+ * ESTE REPOSITÓRIO VESTE SHADCN? - `components.json` é o arquivo que o próprio shadcn escreve.
192
+ *
193
+ * Uma pergunta, uma resposta: ela decide se o adaptador é ESCRITO e se ele é MENCIONADO. Enquanto
194
+ * eram duas leituras, o terminal dizia a verdade (só citava para quem tem) e a escrita plantava o
195
+ * arquivo em todo mundo - a pior combinação, porque a prosa parecia certa.
196
+ */
197
+ async function wearsShadcn(root) {
198
+ return access(join(root, "components.json")).then(() => true, () => false);
199
+ }
187
200
  export async function add(slug, opts) {
188
201
  const base = resolveRegistry(opts.registry);
189
202
  const projectRoot = opts.dir ?? process.cwd();
@@ -219,7 +232,26 @@ export async function add(slug, opts) {
219
232
  */
220
233
  const aligned = onlyWhatMatched(payload.artifacts["theme.css"] ?? "", new Set(theirVars.pairs.map((p) => p.ours)));
221
234
  // 1. server artifacts (tokens.css, theme.css, …) → pinned version folder
235
+ /**
236
+ * O ADAPTADOR DO SHADCN SÓ VIAJA PARA QUEM TEM SHADCN - e o sinal já existia.
237
+ *
238
+ * O QUE O CLIENTE PERCEBIA. `shadcn.css` caía na pasta de todo mundo, inclusive de quem nunca
239
+ * usou shadcn: um arquivo de uma stack que não é a dele, que ele não pediu e que nada no
240
+ * repositório dele importa. MEDIDO em 01/09 no `codelevel` - nenhum `@radix-ui`, nenhum
241
+ * `components/ui`, nenhum `components.json` - e os 2,3 KB estavam lá.
242
+ *
243
+ * `components.json` É O SINAL, e ele já decidia se o `add` MENCIONA o adaptador no terminal
244
+ * (28/07). Só a escrita não perguntava - então o produto dizia a verdade em voz alta e plantava o
245
+ * arquivo em silêncio. Agora a mesma pergunta responde às duas.
246
+ *
247
+ * É a régua que o compilador já aplica aos tokens (`reachableTokens` descarta o que nada
248
+ * referencia), levada ao arquivo inteiro: quem, NESTE repositório, lê isto?
249
+ */
250
+ const wantsShadcn = await wearsShadcn(projectRoot);
251
+ const skipped = !wantsShadcn && "shadcn.css" in payload.artifacts;
222
252
  for (const [filename, content] of Object.entries(payload.artifacts)) {
253
+ if (filename === "shadcn.css" && !wantsShadcn)
254
+ continue;
223
255
  await writeFile(join(versionDir, filename), filename === "tokens.css"
224
256
  ? theirVars.css
225
257
  : filename === "theme.css"
@@ -227,12 +259,35 @@ export async function add(slug, opts) {
227
259
  : content, "utf8");
228
260
  }
229
261
  // 2. canonical source of truth
230
- await writeFile(join(versionDir, "design-system.json"), `${JSON.stringify(payload.document, null, 2)}\n`, "utf8");
231
- // 3. guide for the agent (generated client-side from the document)
232
- await writeFile(join(versionDir, "GUIDE.md"), buildGuide(payload), "utf8");
262
+ /**
263
+ * O DOCUMENTO FICA - ele É a referência que o agente dele lê (decisão do dono, 01/09) - E ELE VAI
264
+ * COMPACTO.
265
+ *
266
+ * MEDIDO em 01/09 no `codelevel`, 62 componentes: `null, 2` custa **347 400 bytes**; sem a
267
+ * indentação são **213 470** - 134 KB de espaço em branco, 39% do arquivo, num arquivo que
268
+ * NENHUM humano abre para ler. Quem o consome são `loadSystem` e as quatro ferramentas do MCP, e
269
+ * `JSON.parse` não distingue os dois.
270
+ *
271
+ * O que o cliente ganha: o mesmo dado, com 39% a menos de diff em todo `upgrade` e 134 KB a menos
272
+ * no repositório dele. Quem quiser lê-lo com os olhos tem `describe_component`, que responde por
273
+ * componente em vez de por arquivo.
274
+ */
275
+ await writeFile(join(versionDir, "design-system.json"), `${JSON.stringify(payload.document)}\n`, "utf8");
276
+ /**
277
+ * 3. O GUIA - e o catálogo só é COPIADO onde não há caminho de volta.
278
+ *
279
+ * Com o servidor MCP registrado, `list_components` e `describe_component` servem o mesmo catálogo
280
+ * por componente e sob demanda, então copiá-lo inteiro é peso e duplicação. MEDIDO em 01/09 no
281
+ * `codelevel`: 923 das 1225 linhas do guia (75%) eram o catálogo, num arquivo de 113 KB.
282
+ *
283
+ * É a MESMA pergunta que o manifesto do `CLAUDE.md` já faz desde 20/08, e por isso a MESMA
284
+ * função responde: cortar sem caminho de volta trocaria contexto por ignorância.
285
+ */
286
+ const tools = await recallAvailable(projectRoot);
287
+ await writeFile(join(versionDir, "GUIDE.md"), buildGuide(payload, tools.available), "utf8");
233
288
  // 4. stable root re-exports for each CSS artifact → always the active version,
234
289
  // so the consumer's @import path never changes across updates
235
- const cssArtifacts = Object.keys(payload.artifacts).filter((f) => f.endsWith(".css"));
290
+ const cssArtifacts = Object.keys(payload.artifacts).filter((f) => f.endsWith(".css") && !(f === "shadcn.css" && !wantsShadcn));
236
291
  for (const filename of cssArtifacts) {
237
292
  await writeFile(join(slugDir, filename), `/* Active version (v${payload.version}). Managed by synthesisui - do not edit. */\n` +
238
293
  `@import "./v${payload.version}/${filename}";\n`, "utf8");
@@ -434,11 +489,34 @@ export async function add(slug, opts) {
434
489
  console.log(`↺ ${payload.name} active version set to v${v} (was v${prev.version})`);
435
490
  }
436
491
  const files = [
437
- ...Object.keys(payload.artifacts),
492
+ ...Object.keys(payload.artifacts).filter((f) => !(f === "shadcn.css" && skipped)),
438
493
  "design-system.json",
439
494
  "GUIDE.md",
440
495
  ];
441
496
  console.log(` v${v}/: ${files.join(", ")}`);
497
+ /**
498
+ * O QUE NÃO FOI ESCRITO, DITO EM VOZ ALTA - lei 8, e ela vale nos dois sentidos.
499
+ *
500
+ * Deixar de plantar um arquivo é melhor que plantá-lo, mas fazê-lo em SILÊNCIO é como o produto
501
+ * ganha a fama de esconder coisas: alguém que já viu `shadcn.css` num outro repositório procuraria
502
+ * aqui e concluiria que a instalação falhou. A frase diz o que não veio, por quê, e o que muda a
503
+ * resposta.
504
+ */
505
+ if (skipped)
506
+ console.log(` shadcn.css was not written: this repository has no \`components.json\`, so nothing here wears shadcn's variables. Add shadcn and run \`synthesisui add ${payload.slug}\` again to get the adapter.`);
507
+ /**
508
+ * O QUE FALTA PARA A PROMESSA DO `.gitignore` SER VERDADE - ver `tracksAnyOf`.
509
+ *
510
+ * O arquivo que a gente escreve dentro de `_synthesisui/` diz que a identidade e o CSS são
511
+ * commitados, e nenhum comando nunca verificou. MEDIDO em 01/09 no `codelevel`: zero arquivos
512
+ * rastreados depois de um `add` bem-sucedido.
513
+ *
514
+ * A frase lidera pelo EFEITO, porque é ele que faz alguém agir: "o build de um colega não tem
515
+ * tokens" move; "a pasta está untracked" não.
516
+ */
517
+ const tracked = await tracksAnyOf(projectRoot, `_synthesisui/ds/${payload.slug}`);
518
+ if (tracked === false)
519
+ console.log(` none of this is in version control yet - a colleague who clones this repository gets no tokens, and their build breaks. \`git add _synthesisui/ds\` commits the identity and the CSS; the measurement and the local record are already ignored for you.`);
442
520
  /**
443
521
  * A FOLHA NÃO É A QUE O SERVIDOR COMPILOU, e calar isso é o pior dos dois mundos: o cliente vê um
444
522
  * `var(--color-ink-500)` num arquivo "gerenciado pelo synthesisui" e não tem como saber de onde
@@ -519,7 +597,16 @@ export async function add(slug, opts) {
519
597
  /** Nenhuma pasta encontrada: os caminhos viram exemplo, e o texto abaixo diz isso. */
520
598
  const appDir = appDirs[0] ?? projectConfig.pagesDir;
521
599
  const appDirFound = appDirs.length > 0;
522
- const importPrefix = "../".repeat(appDir.split("/").length);
600
+ /**
601
+ * A FOLHA GLOBAL QUE ELE DE FATO CARREGA - ver `globalSheetOf`.
602
+ *
603
+ * A instrução mandava editar `<appDir>/globals.css`. MEDIDO em 01/09 no `codelevel`: os dois apps
604
+ * dele fazem `@import "@repo/ui/styles/globals.css"`, e o `globals.css` do app diz em comentário
605
+ * *"Do not redeclare tokens or fonts here"*. O arquivo certo era o do pacote, e a gente apontava
606
+ * para o do app - com o caminho relativo do app, que dali não resolve.
607
+ */
608
+ const sheet = await globalSheetOf(projectRoot, `${appDir}/globals.css`);
609
+ const importPrefix = prefixFrom(sheet);
523
610
  console.log(section("One-time setup (once per app)"));
524
611
  /**
525
612
  * "ONCE PER APP" É LITERAL NUM MONOREPO, e calar os outros apps entrega metade da fiação. Os
@@ -528,7 +615,9 @@ export async function add(slug, opts) {
528
615
  */
529
616
  if (appDirs.length > 1)
530
617
  console.log(line(`(${appDirs.length} apps here: ${appDirs.join(", ")} - the paths below are for ${appDir}; repeat for the others.)`));
531
- console.log(line(`1. Import the system in your GLOBAL stylesheet, e.g. ${appDir}/globals.css`));
618
+ console.log(line(sheet === `${appDir}/globals.css`
619
+ ? `1. Import the system in your GLOBAL stylesheet, e.g. ${sheet}`
620
+ : `1. Import the system in ${sheet} - that is the sheet your apps load (${appDir}/globals.css only re-exports it, so tokens put there would not reach the package's own components):`));
532
621
  console.log(line(` (the path is relative to that file - hence the leading ${importPrefix}):`));
533
622
  console.log("");
534
623
  // THE BRIDGE ONLY EXISTS FOR PEOPLE WHO ALREADY HAVE SHADCN, so it only
@@ -539,7 +628,8 @@ export async function add(slug, opts) {
539
628
  // can just build a design system with shadcn", with the answer sitting
540
629
  // unimported in his own repo. Naming it to everybody would be noise; naming it
541
630
  // to whoever has `components.json` is the whole feature arriving.
542
- const hasShadcn = await access(join(projectRoot, "components.json")).then(() => true, () => false);
631
+ /** A MESMA pergunta que decide a ESCRITA - ver `wantsShadcn`. Duas leituras seriam duas regras. */
632
+ const hasShadcn = await wearsShadcn(projectRoot);
543
633
  console.log(snippet([
544
634
  ...(hasTheme ? [`@import "tailwindcss";`] : []),
545
635
  `@import "${importPrefix}_synthesisui/ds/${payload.slug}/tokens.css";`,
@@ -21,6 +21,25 @@ import { resolveReadParts, siblingProjects, takeCensus, } from "./import.js";
21
21
  * after `closeRequest` runs here, so the person knows what happened and what
22
22
  * (if anything) is theirs to do next.
23
23
  */
24
+ /**
25
+ * ONDE O SYNC FOI PARAR, E O QUE FALTA PARA CHEGAR NO DISCO DELE.
26
+ *
27
+ * O `sync` escreve no RASCUNHO, e o registry serve a última PUBLICADA - por lei, desde 29/07: se o
28
+ * rascunho fluísse para o repo, o botão Publish não seguraria nada. Então uma medição bem-sucedida
29
+ * termina com o repositório dele **inalterado**, e nada dizia isso.
30
+ *
31
+ * MEDIDO em 01/09 no `codelevel`: o `sync` escreveu 62 de 62 componentes na v4 (o rascunho, com 1276
32
+ * revisões), o `add` tinha instalado a v3 (a última publicada), e o output mandou o cliente para o
33
+ * Studio - uma tela, não o passo que falta.
34
+ *
35
+ * É A MESMA LIÇÃO DE 13/08, uma superfície adiante - ver `decisionLine`, caso `authored`. Lá, "Get
36
+ * it: upgrade" mandava rodar um comando que responde `already at the latest version`: a pessoa
37
+ * seguia a instrução, não recebia nada, e ficava sem saber se errou ou se a ferramenta falhou. A
38
+ * frase de lá nomeia os DOIS passos na ordem, e o primeiro é dele - esta faz o mesmo.
39
+ */
40
+ export function draftLine(slug, base) {
41
+ return `this went into your DRAFT - your repository still has what you installed. It reaches the disk once you publish${base ? `: ${base}/dashboard/mine/${slug}/publish` : ""}\n then: npx synthesisui@latest upgrade ${slug}`;
42
+ }
24
43
  export function decisionLine(d, slug,
25
44
  /** Onde o sistema dele vive - o `publish` mora lá, e sem o endereço a frase manda procurar. */
26
45
  base) {
@@ -563,6 +582,27 @@ export async function remeasure(args) {
563
582
  console.log(body(paint.strong(said)));
564
583
  if (out.url)
565
584
  console.log(body(out.url));
585
+ /**
586
+ * ONDE ISSO FOI PARAR, E O QUE FALTA PARA CHEGAR NO DISCO DELE.
587
+ *
588
+ * O `sync` escreve no RASCUNHO, e o registry serve a última PUBLICADA - por lei, desde 29/07: se o
589
+ * rascunho fluísse para o repo, o botão Publish não seguraria nada. Então uma medição bem-sucedida
590
+ * termina com o repositório dele **inalterado**, e nada dizia isso.
591
+ *
592
+ * MEDIDO em 01/09 no `codelevel`: o `sync` escreveu 62 de 62 componentes na v4 (o rascunho, com
593
+ * 1276 revisões), o `add` tinha instalado a v3 (a última publicada), e o output mandou o cliente
594
+ * para o Studio - uma tela, não o passo que falta.
595
+ *
596
+ * É A MESMA LIÇÃO DE 13/08, uma superfície adiante. Lá, `authored` dizia "Get it: upgrade" e o
597
+ * `upgrade` respondia `already at the latest version`: a pessoa seguia a instrução, não recebia
598
+ * nada, e ficava sem saber se errou ou se a ferramenta falhou. A frase de lá nomeia os DOIS passos
599
+ * na ordem, e o primeiro é dele - esta faz o mesmo.
600
+ *
601
+ * Só aparece quando algo FOI escrito: um `sync` que não mudou nada não tem o que publicar, e
602
+ * cobrar um publish vazio é o aviso que ensina a ignorar o próximo.
603
+ */
604
+ if ((out.written ?? 0) > 0)
605
+ console.log(body(paint.faint(draftLine(slug, base))));
566
606
  /**
567
607
  * O QUE MUDOU DESDE A ÚLTIMA VEZ - a linha que só uma RE-medição pode dar.
568
608
  *
@@ -202,7 +202,17 @@ function declarations(body) {
202
202
  */
203
203
  let flat = body;
204
204
  for (let guard = 0; guard < 12; guard++) {
205
- const next = flat.replace(/\{[^{}]*\}/g, "");
205
+ /**
206
+ * O CABEÇALHO SAI JUNTO COM O BLOCO, e não depois - senão ele fica grudado na declaração
207
+ * seguinte e come a declaração inteira. Tirando só `{ … }`, o corpo de
208
+ *
209
+ * .panel { color: red; &:hover { color: blue; } padding: 24px; }
210
+ *
211
+ * virava `color: red; &:hover padding: 24px;`, e o pedaço do meio tem `:` no `:hover`:
212
+ * a propriedade lida era `&` e o `padding` do cartão dele MORRIA - a decisão estava no
213
+ * código, contada como interpretada, e não chegava à receita.
214
+ */
215
+ const next = flat.replace(/[^;{}]*\{[^{}]*\}/g, "");
206
216
  if (next === flat)
207
217
  break;
208
218
  flat = next;
@@ -197,6 +197,48 @@ function backtick(source, from) {
197
197
  * contagem: um global com regra de CLASSE é forma que nenhum leitor cobre hoje - 399 declarações
198
198
  * no repo dele, e a única lacuna que estava calada em vez de declarada (04/08).
199
199
  */
200
+ /**
201
+ * O SELETOR DE UMA DECLARAÇÃO, resolvido contra os blocos que a contêm.
202
+ *
203
+ * O QUE O CLIENTE GANHA: uma declaração que ele escreveu dentro de `&:hover` chega aqui como
204
+ * `.card:hover` - o nome inteiro, o mesmo que o navegador aplica. Sem isto ela chegava como
205
+ * `&:hover`, e um `&` sem pai não tem a quem se ligar: nenhum leitor deste lado ou do outro
206
+ * consegue dizer de que elemento aquela decisão é.
207
+ *
208
+ * UMA AT-RULE É CONTEXTO, NUNCA PAI. `@media (…) { .card { padding } }` é uma decisão do `.card`
209
+ * sob uma condição, e não de um elemento chamado `@media`. A at-rule só responde pelo seletor
210
+ * quando não há nenhum dentro dela - que é como `@utility text-grad { … }` do Tailwind 4 e o
211
+ * `0%` de um `@keyframes` seguem chegando exatamente como chegavam.
212
+ */
213
+ function selectorOf(open) {
214
+ let selector = "";
215
+ let atRule = "";
216
+ for (const block of open) {
217
+ if (!block)
218
+ continue;
219
+ if (block.startsWith("@")) {
220
+ if (!selector)
221
+ atRule = block;
222
+ continue;
223
+ }
224
+ selector = selector ? resolveNesting(block, selector) : block;
225
+ }
226
+ return selector || atRule;
227
+ }
228
+ /**
229
+ * O `&` DO FILHO TROCADO PELO PAI, e o filho sem `&` pendurado como descendente - as duas
230
+ * regras do CSS aninhado. Uma lista de pais vira `:is(a, b)`, que é o que o próprio CSS faz:
231
+ * escrever `a, b:hover` diria outra coisa, e diria errado.
232
+ */
233
+ function resolveNesting(child, parent) {
234
+ const base = parent.includes(",") ? `:is(${parent})` : parent;
235
+ return child
236
+ .split(",")
237
+ .map((part) => part.trim())
238
+ .filter(Boolean)
239
+ .map((part) => part.includes("&") ? part.replaceAll("&", base) : `${base} ${part}`)
240
+ .join(", ");
241
+ }
200
242
  export function fragmentsOfStylesheet(file, css,
201
243
  /**
202
244
  * COMO ESTA FOLHA CHEGA NO BUILD DELES, e isso vem do GRAFO DE IMPORTS e nunca do nome do
@@ -212,31 +254,54 @@ export function fragmentsOfStylesheet(file, css,
212
254
  */
213
255
  kind) {
214
256
  const out = [];
215
- let selector = "";
257
+ /**
258
+ * OS BLOCOS ABERTOS, E NÃO O ÚLTIMO SELETOR VISTO.
259
+ *
260
+ * O QUE O CLIENTE VIVIA COM UMA VARIÁVEL SÓ: depois de um bloco aninhado, TODA declaração que
261
+ * vinha a seguir ficava com o seletor do bloco que já tinha fechado. No `.card { color: red;
262
+ * &:hover { color: blue } padding: 8px }`, o `padding` dele era registrado como sendo do
263
+ * `:hover` - uma decisão de repouso lida como decisão de estado. Medido no repositório vivo em
264
+ * 01/09, sobre 724 folhas e 19 374 declarações: **440 (2,3%) ficavam com o seletor de um bloco
265
+ * já fechado, e 942 (4,9%) saíam com um `&` que não tinha pai a que se referir**.
266
+ */
267
+ const open = [];
216
268
  for (const [i, raw] of css.split("\n").entries()) {
217
269
  const line = raw.replace(/\/\*.*?\*\//g, "");
218
270
  for (const piece of line.matchAll(PIECES)) {
219
271
  const text = (piece[1] ?? "").trim();
220
272
  if (piece[2] === "{") {
221
- selector = text;
273
+ open.push(text);
222
274
  continue;
223
275
  }
276
+ /**
277
+ * O BLOCO SÓ FECHA DEPOIS QUE A DECLARAÇÃO DELE FOI LIDA. `.a { color: red }` numa linha
278
+ * só termina a declaração no `}`, sem `;` - desempilhar antes de ler perdia a última
279
+ * declaração de todo bloco escrito assim. Medido no repositório vivo quando aconteceu:
280
+ * 19 declarações a menos no censo do `frontend-hub`, num conserto que só devia mexer em
281
+ * QUAL seletor cada uma tem.
282
+ */
283
+ const closes = piece[2] === "}";
224
284
  /**
225
285
  * Sem terminador não há declaração: `padding: 8px` no fim de uma linha pode ser a primeira
226
286
  * metade de um valor que continua na próxima.
227
287
  */
228
- if (!piece[2] || !text)
288
+ if (!piece[2] || !text) {
289
+ if (closes)
290
+ open.pop();
229
291
  continue;
292
+ }
230
293
  const m = DECLARATION.exec(text);
231
- if (!m)
232
- continue;
233
294
  /**
234
295
  * UM TOKEN NÃO É UM FRAGMENTO DE COMPONENTE. `--color-ocean-500: #…` é a paleta, e ela tem
235
296
  * o seu próprio caminho e o seu próprio relatório; contá-la aqui inflaria o total com o que
236
297
  * já está coberto em outro lugar.
237
298
  */
238
- if (m[1].startsWith("--"))
299
+ if (!m || m[1].startsWith("--")) {
300
+ if (closes)
301
+ open.pop();
239
302
  continue;
303
+ }
304
+ const selector = selectorOf(open);
240
305
  out.push({
241
306
  shape: "css",
242
307
  file,
@@ -249,6 +314,8 @@ kind) {
249
314
  ? { reason: "sheet-not-imported" }
250
315
  : {}),
251
316
  });
317
+ if (closes)
318
+ open.pop();
252
319
  }
253
320
  }
254
321
  return out;
@@ -302,6 +369,24 @@ const STRUCTURE = new Set([
302
369
  function targetClass(text) {
303
370
  const brace = text.indexOf("{");
304
371
  const selector = brace === -1 ? text : text.slice(0, brace);
372
+ /**
373
+ * `@utility x` É A CLASSE `.x` - ela é a forma do Tailwind 4 de declarar um utilitário, e não
374
+ * traz o ponto que este casamento procura.
375
+ *
376
+ * O QUE ELE VIA SEM ISTO: a declaração CHEGAVA ao componente e o relatório dizia que não. O
377
+ * `readGlobalClasses` lê `@utility` desde 24/08 e a entrega no look de quem veste a classe -
378
+ * medido em 01/09 pela cadeia real, o `Scene` recebe `perspective: 900px` da `@utility
379
+ * scene-3d`. Mas o julgamento procurava um ponto no seletor, não achava, e mandava as mesmas
380
+ * declarações para o ledger como forma sem leitor. No censo do `codelevel`: **68 declarações
381
+ * em 22 utilities anunciadas como não interpretadas**, com as duas que um componente veste
382
+ * (`scene-3d` no `Scene`, `stage-3d` no `Stage`) já dentro da receita dele.
383
+ *
384
+ * Um número que acusa lacuna onde não há custa o mesmo que um que a esconde: manda alguém
385
+ * consertar o que não está quebrado. Quase custou um leitor inteiro escrito duas vezes.
386
+ */
387
+ const utility = /^@utility\s+([a-z][\w-]*)/i.exec(selector.trim())?.[1];
388
+ if (utility)
389
+ return utility;
305
390
  for (const group of selector.split(",")) {
306
391
  const classes = group.match(/\.[A-Za-z][\w-]*/g);
307
392
  /** Sem o ponto: o nome que `globalClasses` indexa. */
@@ -0,0 +1,103 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, join, posix, relative, resolve } from "node:path";
3
+ /**
4
+ * A FOLHA GLOBAL QUE OS APPS DELE REALMENTE CARREGAM - e num monorepo ela quase nunca é a do app.
5
+ *
6
+ * O QUE O CLIENTE PERCEBE SEM ISTO. O `add` manda importar os tokens em `apps/<x>/app/globals.css`.
7
+ * MEDIDO em 01/09 no `codelevel`: os DOIS apps dele fazem `@import "@repo/ui/styles/globals.css"`, e
8
+ * o `globals.css` do app diz, em comentário dele, *"Do not redeclare tokens or fonts here - extend in
9
+ * styles/"*. A instrução apontava para o arquivo errado, e a convenção certa estava escrita ali do
10
+ * lado.
11
+ *
12
+ * Ele seguiu a instrução no arquivo CERTO - o do pacote - e o caminho relativo que a gente imprimiu
13
+ * era o do app. Um monorepo com pacote de UI compartilhado não é exceção: é a forma comum, e a
14
+ * instrução tem que derivar de onde o CSS dele mora em vez de assumir.
15
+ *
16
+ * ─────────────────────────────────────────────────────────────────────────
17
+ * COMO A FOLHA REAL É ENCONTRADA, e nada aqui é palpite:
18
+ *
19
+ * 1. abre o `globals.css` do app
20
+ * 2. procura `@import "<spec>"` que aponte para um CSS DENTRO deste repositório
21
+ * 3. resolve o `<spec>`: caminho relativo, ou pacote do workspace pelo `exports`
22
+ * 4. repete na folha encontrada, até ela não re-exportar mais
23
+ *
24
+ * O QUE NÃO É SEGUIDO: `tailwindcss` e qualquer coisa que não resolva para um arquivo daqui. Um
25
+ * `@import "tailwindcss"` é a biblioteca, e mandar o cliente editá-la seria pior que a instrução que
26
+ * isto conserta.
27
+ */
28
+ /** Quantos saltos seguir antes de desistir - um ciclo de imports não pode travar um `add`. */
29
+ const MAX_HOPS = 5;
30
+ const IMPORT = /@import\s+["']([^"']+)["']/g;
31
+ /** `@repo/ui/styles/globals.css` → `packages/ui/src/styles/globals.css`, pelo `exports` dele. */
32
+ async function fromWorkspace(root, spec) {
33
+ for (const group of ["packages", "apps", "libs"]) {
34
+ let entries;
35
+ try {
36
+ const { readdir } = await import("node:fs/promises");
37
+ entries = await readdir(join(root, group));
38
+ }
39
+ catch {
40
+ continue;
41
+ }
42
+ for (const entry of entries) {
43
+ const pkgPath = join(root, group, entry, "package.json");
44
+ const raw = await readFile(pkgPath, "utf8").catch(() => null);
45
+ if (!raw)
46
+ continue;
47
+ let pkg;
48
+ try {
49
+ pkg = JSON.parse(raw);
50
+ }
51
+ catch {
52
+ continue;
53
+ }
54
+ if (!pkg.name || !spec.startsWith(`${pkg.name}/`))
55
+ continue;
56
+ const sub = `./${spec.slice(pkg.name.length + 1)}`;
57
+ const target = pkg.exports?.[sub];
58
+ if (typeof target !== "string")
59
+ continue;
60
+ return posix.join(group, entry, target.replace(/^\.\//, ""));
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+ /**
66
+ * A folha onde os tokens dele devem entrar, relativa à raiz - `appSheet` quando nada a re-exporta.
67
+ */
68
+ export async function globalSheetOf(root, appSheet) {
69
+ let current = appSheet;
70
+ for (let hop = 0; hop < MAX_HOPS; hop += 1) {
71
+ const raw = await readFile(join(root, current), "utf8").catch(() => null);
72
+ if (!raw)
73
+ return current;
74
+ let next = null;
75
+ for (const m of raw.matchAll(IMPORT)) {
76
+ const spec = m[1];
77
+ if (!spec.endsWith(".css"))
78
+ continue;
79
+ const candidate = spec.startsWith(".")
80
+ ? posix.normalize(posix.join(posix.dirname(current), spec))
81
+ : await fromWorkspace(root, spec);
82
+ if (!candidate)
83
+ continue;
84
+ /** Fora da raiz não é folha dele para editar - e um `..` demais sai do repositório. */
85
+ if (candidate.startsWith(".."))
86
+ continue;
87
+ const readable = await readFile(join(root, candidate), "utf8").catch(() => null);
88
+ if (readable === null)
89
+ continue;
90
+ next = candidate;
91
+ break;
92
+ }
93
+ if (!next || next === current)
94
+ return current;
95
+ current = next;
96
+ }
97
+ return current;
98
+ }
99
+ /** O `../` que leva daquela folha até a raiz do repositório - o prefixo do `@import`. */
100
+ export function prefixFrom(sheet) {
101
+ const up = relative(dirname(resolve("/r", sheet)), "/r");
102
+ return up === "" ? "./" : `${up.split(/[\\/]/).join("/")}/`;
103
+ }
package/dist/guide.js CHANGED
@@ -244,7 +244,42 @@ function componentEntry(cname, recipe) {
244
244
  * components with claude-code" work: the tokens alone are not enough, the agent
245
245
  * needs the rules and the real vocabulary (semantic token names and recipes).
246
246
  */
247
- export function buildGuide(payload) {
247
+ /**
248
+ * O QUE FICA NO LUGAR DO CATÁLOGO - e ele diz QUANTOS existem, nunca só que existem.
249
+ *
250
+ * Um guia que omite o catálogo em silêncio ensina o agente que o sistema não tem componente nenhum,
251
+ * que é o oposto do que ele precisa saber antes de escrever a primeira tela.
252
+ */
253
+ function toolsPointer(count) {
254
+ return `This system defines **${count} component${count === 1 ? "" : "s"}**, and they are NOT listed here on purpose: the tools answer the same
255
+ question with the piece you actually need, at the moment you need it.
256
+
257
+ - \`list_components\` - every name, with what each one is for. Call it BEFORE writing any UI
258
+ element from scratch; if one covers the purpose, use it instead of inventing another.
259
+ - \`describe_component { name }\` - one component in full: its parts, its axes, the libraries it
260
+ needs, and the rules that govern it.
261
+ - \`add_blueprint { name }\` - the same component as typed code in your repository.
262
+
263
+ A name alone does not tell you that an editor needs \`@tiptap/react\`, or that a card is built out
264
+ of a metric card - so read the component before you use it, never the list alone.`;
265
+ }
266
+ export function buildGuide(payload,
267
+ /**
268
+ * AS FERRAMENTAS ESTÃO AO ALCANCE DESTE REPOSITÓRIO? - ver `recallAvailable`.
269
+ *
270
+ * O QUE O CLIENTE GANHA. O catálogo inteiro deixa de ser copiado para o disco dele quando existe
271
+ * caminho de volta: `list_components` e `describe_component` servem a MESMA coisa, por componente
272
+ * e no momento em que o agente precisa. MEDIDO em 01/09 no `codelevel`: a seção do catálogo é
273
+ * **923 de 1225 linhas do guia (75%)**, e o arquivo inteiro pesa 113 KB no repositório dele.
274
+ *
275
+ * POR QUE É A MESMA RÉGUA DO MANIFESTO. O corte de 20/08 (`readManifest`) já usa exatamente este
276
+ * sinal, pelo mesmo motivo: cortar sem caminho de volta troca contexto por ignorância. Aqui a
277
+ * pergunta é idêntica, e por isso a resposta vem de fora - quem sabe é quem leu o `.mcp.json`.
278
+ *
279
+ * `false` mantém o guia inteiro, que é o comportamento de todo repositório sem o servidor
280
+ * registrado.
281
+ */
282
+ toolsReachable = false) {
248
283
  const { document: doc, slug, name, version } = payload;
249
284
  const { meta, foundations, motion, components } = doc;
250
285
  const semanticRoles = Object.keys(foundations.color.semantic);
@@ -360,6 +395,21 @@ ${depLines.join("\n")}
360
395
  const hasTailwind = "theme.css" in payload.artifacts;
361
396
  const hasParts = Object.values(components).some((r) => r.parts && Object.keys(r.parts).length > 0);
362
397
  const componentLines = Object.entries(components).map(([cname, recipe]) => componentEntry(cname, recipe));
398
+ /**
399
+ * O CORTE SÓ ACONTECE QUANDO ELE DE FATO CORTA - e este é o único jeito honesto de prometê-lo.
400
+ *
401
+ * O ponteiro para as ferramentas tem tamanho fixo. Num sistema de três componentes com descrições
402
+ * de uma linha, ele é MAIOR que o catálogo que substitui - e um guia que cresceu não pode ser
403
+ * anunciado como dieta de contexto. MEDIDO em 01/09: no `codelevel`, com 62 componentes, o corte é
404
+ * de 82% (114 431 -> 20 425 bytes); numa fixture de 3, o ponteiro custaria 7% a mais.
405
+ *
406
+ * Comparar os dois textos responde sozinho, em todo tamanho de sistema, sem limiar escrito à mão
407
+ * que envelheceria no dia em que o ponteiro mudasse de tamanho. Há 4 sistemas de 2 componentes em
408
+ * produção, e para eles copiar continua sendo o mais barato.
409
+ */
410
+ const catalogueText = componentLines.join("\n\n");
411
+ const pointerText = toolsPointer(componentLines.length);
412
+ const pointerBeatsCatalogue = pointerText.length < catalogueText.length;
363
413
  // Motion vocabulary: the system's keyframes, named and usable. Intent is
364
414
  // classified from the frames (same logic that compiled the animate-*
365
415
  // utilities into theme.css), so what this section PROMISES about a utility's
@@ -742,7 +792,7 @@ Each recipe becomes a \`.ds-<name>\` class (inside the \`[data-ds="${slug}"]\` s
742
792
  \`data-<axis>="<option>"\` attributes; states (hover/focus/active/disabled) ship in the CSS;
743
793
  multi-part components expose \`.ds-<name>-<part>\` classes (listed under each).
744
794
 
745
- ${componentLines.join("\n\n")}
795
+ ${toolsReachable && pointerBeatsCatalogue ? pointerText : catalogueText}
746
796
  ${blockEntries.length
747
797
  ? `
748
798
  ---
@@ -760,6 +810,9 @@ ${blockLines.join("\n\n")}
760
810
  : ""}
761
811
  ---
762
812
 
763
- _Full canonical source of truth (including values and keyframes) in \`design-system.json\`._
813
+ _Full canonical source of truth (including values and keyframes) in \`design-system.json\`._${toolsReachable && pointerBeatsCatalogue
814
+ ? `
815
+ _The component catalogue is served by the tools rather than copied here - see the section above._`
816
+ : ""}
764
817
  `;
765
818
  }
@@ -152,7 +152,7 @@
152
152
  * comentou vinha sendo escrito na pasta dele como componente de verdade. Medido em 14 arquivos
153
153
  * das duas populações o nó fantasma era a RAIZ - o elemento cujas classes viram a `base`.
154
154
  */
155
- export const MATERIALISER_SINCE = "0.16.345";
155
+ export const MATERIALISER_SINCE = "0.16.349";
156
156
  /**
157
157
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
158
158
  *
@@ -532,7 +532,7 @@ export const CHECKER_SINCE = "0.16.308";
532
532
  * si, o campo fica ausente, e ausente ali continua sendo a resposta certa - o `codelevel-ui` dá
533
533
  * 0 de 0 contra 145 arquivos de página do `frontend-hub`.
534
534
  */
535
- export const READER_SINCE = "0.16.346";
535
+ export const READER_SINCE = "0.16.348";
536
536
  /**
537
537
  * O QUE ESTÁ INSTALADO AQUI FICOU PARA TRÁS - e as DUAS condições que fazem isso ser verdade.
538
538
  *
@@ -0,0 +1,40 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const run = promisify(execFile);
4
+ /**
5
+ * ESTES ARQUIVOS ESTÃO NO CONTROLE DE VERSÃO DELE? - e a resposta muda o que o `add` diz no fim.
6
+ *
7
+ * O QUE O CLIENTE PERCEBE SEM ISTO. O `.gitignore` que NÓS geramos dentro de `_synthesisui/` promete,
8
+ * em comentário: *"a identidade e o CSS são commitados para que um clone fresco seja governado"*. Ele
9
+ * lista o que NÃO versionar, e daí em diante todo mundo assume que o resto está versionado. Ninguém
10
+ * nunca fez o `git add`, e nenhum comando checou.
11
+ *
12
+ * MEDIDO em 01/09 no `codelevel`, depois de um `add` bem-sucedido: `git ls-files _synthesisui/`
13
+ * devolve **0 arquivos**, e o status mostra `?? _synthesisui/`. A promessa não se cumpria em lugar
14
+ * nenhum, e o custo apareceu no mesmo dia - o dono apagou a pasta, ela não voltou do `git checkout`,
15
+ * e ele precisou de um `add` para recuperá-la. Um colega clonando o repositório teria um build sem
16
+ * tokens.
17
+ *
18
+ * ─────────────────────────────────────────────────────────────────────────
19
+ * LEITURA, NUNCA ESCRITA - e isso não é cautela, é o contrato.
20
+ *
21
+ * Rodar `git add` por conta própria mexeria no index dele: alguém no meio de um commit parcial
22
+ * encontraria arquivos nossos na área de stage sem ter pedido. O produto DIZ o que falta e por quê;
23
+ * quem versiona o repositório é ele.
24
+ *
25
+ * E ela CALA quando não sabe: fora de um repositório git, ou sem o binário, a pergunta não tem
26
+ * resposta - e um aviso sobre versionamento num diretório que não é versionado seria ruído puro.
27
+ */
28
+ export async function tracksAnyOf(root, path) {
29
+ try {
30
+ const { stdout } = await run("git", ["ls-files", "--", path], {
31
+ cwd: root,
32
+ timeout: 5000,
33
+ });
34
+ return stdout.trim().length > 0;
35
+ }
36
+ catch {
37
+ /** Não é repositório git, ou o git não está aqui: sem resposta, sem aviso. */
38
+ return null;
39
+ }
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.346",
3
+ "version": "0.16.349",
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": {