synthesisui 0.16.484 → 0.16.485

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.
package/dist/claude-md.js CHANGED
@@ -455,12 +455,19 @@ and task, call it once - an empty result means there is nothing more to retrieve
455
455
  tells you what was decided and why it matters; **the file on disk is still the authority for what is
456
456
  written now** - read the code before you edit it, never a remembered signature.`
457
457
  : "";
458
+ /**
459
+ * O TEMA É DELE - bateria ampliada, item 22 (29/09). Em c01, c02, c05, c17 e c23 o agente mexeu no tema do projeto
460
+ * sem perguntar: `--text-xl` no globals.css para "casar com a escala", 17 valores no tailwind.config.js, três cores
461
+ * de status no custom.scss, um `app/tokens.css` com os nomes `--ds-*` da plataforma. Cada um parecia ajudar, e cada
462
+ * um é uma decisão da pessoa tomada por outra pessoa.
463
+ */
464
+ const theirsLine = "\n\n**The project's theme is the person's.** Do not add, change or remove a token, a theme config value (tailwind.config, theme.ts, uno.config) or the stylesheet that declares them unless they ask. A value you need and the theme does not have is ONE question with your proposal - not an edit.";
458
465
  const rule = recall.available
459
466
  ? onlyAdopted
460
467
  ? `**These are true without asking anyone:** use the project's OWN custom properties, exactly as
461
468
  this system declares them. No raw colours, spacings or radii that a token already covers, and never a
462
469
  new token invented in silence - say so instead, because a new token is a decision for a person. There
463
- is no component index for an adopted system: the tokens ARE the contract.
470
+ is no component index for an adopted system: the tokens ARE the contract.${theirsLine}
464
471
 
465
472
  **What the vocabulary IS is not written here.** Fetch only the piece the task requires:
466
473
  \`system_doctrine\` for the rules and the voice, \`find_token\` for a value you are about to write.${memoryLine}${selfCheck}`
@@ -472,7 +479,7 @@ is no component index for an adopted system: the tokens ARE the contract.
472
479
  - To override a style a component already sets, use this system's semantic role class - never \`!\`.
473
480
  If the override is ignored, regenerate that component: older ones predate the resolver.
474
481
  - Motion is selection, not improvisation. The base is quiet: nothing moves until a person asks, and
475
- what moves comes from this system's own vocabulary - never hand-rolled \`@keyframes\` or raw durations.
482
+ what moves comes from this system's own vocabulary - never hand-rolled \`@keyframes\` or raw durations.${theirsLine}
476
483
 
477
484
  **When you are writing UI that belongs to one of the systems below, check its index first.** If an
478
485
  entry covers the purpose, do not write it from scratch - materialise it:
@@ -492,11 +499,11 @@ If nothing covers it, say which indexed entry you considered and why it did not
492
499
  project's OWN custom properties, exactly as the guide lists them. Do not write raw colours,
493
500
  spacings or radii that a token already covers, and do not invent a new token silently - say so
494
501
  instead, because a new token is a decision for a person to make. There is no component
495
- manifest for an adopted system - the tokens ARE the contract.${selfCheck}`
502
+ manifest for an adopted system - the tokens ARE the contract.${theirsLine}${selfCheck}`
496
503
  : `**When creating or editing components, read the system's GUIDE.md and follow it:** use the
497
504
  vocabulary it lists - every name in it is a name THIS project already declares, written exactly as
498
505
  you would write it. Do not use raw values outside the system's scale, and never a name from a
499
- stylesheet this project does not have.
506
+ stylesheet this project does not have.${theirsLine}
500
507
 
501
508
  **Before creating any UI element, look it up in the manifest below.** If something there
502
509
  already covers the purpose, do not write it from scratch - run
@@ -870,56 +870,80 @@ export async function add(slug, opts) {
870
870
  * `fonts.ts`. Um arquivo compartilhado atravessaria a fronteira do workspace.
871
871
  */
872
872
  const wroteIn = [];
873
- for (const dir of appDirs.length > 0 ? appDirs : [appDir]) {
874
- const fontsPath = join(projectRoot, ...dir.split("/"), "fonts.ts");
875
- /** Arquivo dele que já existe nunca é reescrito - o setup impresso diz o que ele deve exportar. */
876
- if (await exists(fontsPath))
877
- continue;
878
- if (!(await exists(join(projectRoot, ...dir.split("/")))))
879
- continue;
880
- const plan = nextFontSnippet({
881
- families,
882
- appDir: dir,
883
- facts: fontFacts,
884
- declaredWeights: payload.document.foundations.typography.weights,
885
- });
886
- if (!plan.ok)
887
- continue;
888
- const snippet = plan;
889
- const header = [
890
- `// Self-hosted type for the "${payload.slug}" design system (via next/font -`,
891
- `// preloaded, no font flash). Generated by \`synthesisui add\`; edit freely.`,
892
- ];
893
- await writeFile(fontsPath, `${[...header, ...snippet.fontsFile.slice(1)].join("\n")}\n`, "utf8");
894
- wroteIn.push(`${dir}/fonts.ts`);
895
- }
896
- const wroteFonts = wroteIn.length > 0;
897
- console.log("");
898
- if (wroteFonts) {
899
- console.log(line(`3. ✓ wrote ${wroteIn.join(", ")} - self-hosted type via next/font (preloaded, no font flash).`));
900
- console.log(line(" Finish the wiring with two small edits:"));
901
- }
902
- else {
903
- console.log(line(appDirFound
904
- ? `3. Load the type via next/font (${appDir}/fonts.ts already exists - left untouched; it should export:)`
905
- : /**
906
- * A FRASE DIZIA "already exists" PARA DOIS ESTADOS DIFERENTES, e um deles é o contrário
907
- * disso: não achei a pasta do app. No monorepo dele o arquivo não existia e o terminal
908
- * dizia que existia - o cliente não tem como saber que os caminhos acima são chute.
909
- */
910
- `3. Load the type via next/font (I could not find your app folder from here, so the paths above are examples - create ${appDir}/fonts.ts wherever your app router lives):`));
873
+ /**
874
+ * NO IMPORT, NENHUM ARQUIVO MORTO - bateria ampliada, item 20 (29/09). O c01 declara Inter e Space Grotesk e não as
875
+ * carrega; o import escrevia `app/fonts.ts` e deixava "two small edits" que ninguém fez - um arquivo que nada
876
+ * importa, e as fontes do sistema sem aparecer na tela. No projeto de origem o código é dele: o passo diz em uma
877
+ * linha o que acontece hoje e mostra a ligação inteira, para ele fazer (ou pedir ao agente) se quiser.
878
+ */
879
+ const named = [
880
+ ...new Set(Object.values(families)
881
+ .filter((f) => typeof f === "string")
882
+ .map((f) => f.split(",")[0]?.trim().replace(/^["']|["']$/g, "") ?? "")
883
+ .filter(Boolean)),
884
+ ];
885
+ if (opts.fromImport) {
886
+ console.log("");
887
+ console.log(line(`3. Your code names ${named.join(" and ") || "its fonts"} and nothing here loads ${named.length === 1 ? "it" : "them"} - today the text falls back to the browser's font. Nothing was written. To load ${named.length === 1 ? "it" : "them"}, these three edits do it:`));
911
888
  console.log("");
912
889
  console.log(snippet(nextFonts.fontsFile));
913
890
  console.log("");
914
- console.log(line(" Then finish the wiring:"));
891
+ console.log(snippet(nextFonts.layout));
892
+ console.log("");
893
+ console.log(snippet(nextFonts.css));
915
894
  }
916
- console.log("");
917
- console.log(snippet(nextFonts.layout));
918
- console.log("");
919
- console.log(snippet(nextFonts.css));
920
- if (fontsHref) {
895
+ else {
896
+ for (const dir of appDirs.length > 0 ? appDirs : [appDir]) {
897
+ const fontsPath = join(projectRoot, ...dir.split("/"), "fonts.ts");
898
+ /** Arquivo dele que já existe nunca é reescrito - o setup impresso diz o que ele deve exportar. */
899
+ if (await exists(fontsPath))
900
+ continue;
901
+ if (!(await exists(join(projectRoot, ...dir.split("/")))))
902
+ continue;
903
+ const plan = nextFontSnippet({
904
+ families,
905
+ appDir: dir,
906
+ facts: fontFacts,
907
+ declaredWeights: payload.document.foundations.typography.weights,
908
+ });
909
+ if (!plan.ok)
910
+ continue;
911
+ const snippet = plan;
912
+ const header = [
913
+ `// Self-hosted type for the "${payload.slug}" design system (via next/font -`,
914
+ `// preloaded, no font flash). Generated by \`synthesisui add\`; edit freely.`,
915
+ ];
916
+ await writeFile(fontsPath, `${[...header, ...snippet.fontsFile.slice(1)].join("\n")}\n`, "utf8");
917
+ wroteIn.push(`${dir}/fonts.ts`);
918
+ }
919
+ const wroteFonts = wroteIn.length > 0;
920
+ console.log("");
921
+ if (wroteFonts) {
922
+ console.log(line(`3. ✓ wrote ${wroteIn.join(", ")} - self-hosted type via next/font (preloaded, no font flash).`));
923
+ console.log(line(" Finish the wiring with two small edits:"));
924
+ }
925
+ else {
926
+ console.log(line(appDirFound
927
+ ? `3. Load the type via next/font (${appDir}/fonts.ts already exists - left untouched; it should export:)`
928
+ : /**
929
+ * A FRASE DIZIA "already exists" PARA DOIS ESTADOS DIFERENTES, e um deles é o contrário
930
+ * disso: não achei a pasta do app. No monorepo dele o arquivo não existia e o terminal
931
+ * dizia que existia - o cliente não tem como saber que os caminhos acima são chute.
932
+ */
933
+ `3. Load the type via next/font (I could not find your app folder from here, so the paths above are examples - create ${appDir}/fonts.ts wherever your app router lives):`));
934
+ console.log("");
935
+ console.log(snippet(nextFonts.fontsFile));
936
+ console.log("");
937
+ console.log(line(" Then finish the wiring:"));
938
+ }
939
+ console.log("");
940
+ console.log(snippet(nextFonts.layout));
921
941
  console.log("");
922
- console.log(line(` Quick alternative (works anywhere, may flash on cold loads): <link rel="stylesheet" href="${fontsHref}" /> in the <head>.`));
942
+ console.log(snippet(nextFonts.css));
943
+ if (fontsHref) {
944
+ console.log("");
945
+ console.log(line(` Quick alternative (works anywhere, may flash on cold loads): <link rel="stylesheet" href="${fontsHref}" /> in the <head>.`));
946
+ }
923
947
  }
924
948
  }
925
949
  else if (fontsHref) {
@@ -77,6 +77,17 @@ export async function component(slug, name, opts) {
77
77
  if (!SAFE_NAME.test(slug)) {
78
78
  throw new RegistryError(`Invalid slug "${slug}".`);
79
79
  }
80
+ /**
81
+ * FORA DE REACT, O COMANDO DIZ E PARA - bateria ampliada, item 15 (29/09). Em Astro, Svelte, Vue, Angular e num site
82
+ * HTML (c11 a c14, c28) ele escrevia um `.tsx` React, e o agente traduzia à mão: no Vue sobraram `cn.ts` e
83
+ * `sidebar.tsx` esquecidos no projeto. A promessa é React e Next; o que não cabe nela se diz antes de escrever.
84
+ */
85
+ const other = await notReact(root);
86
+ if (other) {
87
+ console.log(`This project is written in ${other}, and \`synthesisui component\` writes React components, so nothing was written. Build it in ${other === "plain HTML" ? "your own HTML and CSS" : other} with the system's values: \`synthesisui use ${slug} "<what you want>"\` prints a prompt your agent can follow, and the MCP tools answer every value.`);
88
+ process.exitCode = 1;
89
+ return;
90
+ }
80
91
  console.log(`→ fetching "${name}" from "${slug}" …`);
81
92
  const res = await fetchComponent(base, slug, name, opts.version);
82
93
  // The server should only ever return a kebab-case name, but never trust a
@@ -632,3 +643,30 @@ export async function component(slug, name, opts) {
632
643
  }
633
644
  console.log("");
634
645
  }
646
+ /** O framework que não é React, pelo `package.json` - ou "plain HTML" quando não há `package.json` e há um `.html`. */
647
+ async function notReact(root) {
648
+ const raw = await readFile(join(root, "package.json"), "utf8").catch(() => null);
649
+ if (raw == null) {
650
+ const { readdir } = await import("node:fs/promises");
651
+ const names = await readdir(root).catch(() => []);
652
+ return names.some((n) => /\.html?$/i.test(n)) ? "plain HTML" : null;
653
+ }
654
+ try {
655
+ const pkg = JSON.parse(raw);
656
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
657
+ if (deps.react || deps.next || deps["react-dom"])
658
+ return null;
659
+ if (deps.nuxt || deps.vue)
660
+ return "Vue";
661
+ if (deps["@sveltejs/kit"] || deps.svelte)
662
+ return "Svelte";
663
+ if (deps.astro)
664
+ return "Astro";
665
+ if (deps["@angular/core"])
666
+ return "Angular";
667
+ return null;
668
+ }
669
+ catch {
670
+ return null;
671
+ }
672
+ }
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { readStyleSheet, scssVariables } from "../doctor/scss-lens.js";
3
- import { tsTokenObjects, tsTokensAsVars } from "../doctor/ts-tokens-lens.js";
3
+ import { THEME_SOURCE, tsTokenObjects, tsTokensAsVars } from "../doctor/ts-tokens-lens.js";
4
4
  import { dirname, join, relative, resolve } from "node:path";
5
5
  import { describeIntent, intentOf, readProjectConfig } from "../config.js";
6
6
  import { describeFix, planFix, readerFor, writeFix, } from "../doctor/apply-fix.js";
@@ -472,7 +472,7 @@ measured) {
472
472
  /** E o objeto de tokens em TypeScript - ver `TokenTable.tsNames` (27/09). */
473
473
  const tsNames = new Map();
474
474
  for await (const file of walkAll(roots))
475
- if (/\.(ts|tsx)$/.test(file) && !/\.d\.ts$/.test(file))
475
+ if (THEME_SOURCE.test(file) && !/\.d\.ts$/.test(file))
476
476
  for (const o of tsTokenObjects(await readFile(file, "utf8").catch(() => "")))
477
477
  for (const [n, v] of o.values)
478
478
  if (!tsNames.has(n))
@@ -774,7 +774,7 @@ export async function doctor(opts) {
774
774
  /** Os objetos de tokens em TypeScript, para a lente da varredura - lidos da RAIZ, como o vocabulário. */
775
775
  const tsObjects = [];
776
776
  for await (const file of walkAll([root]))
777
- if (/\.(ts|tsx)$/.test(file) && !/\.d\.ts$/.test(file))
777
+ if (THEME_SOURCE.test(file) && !/\.d\.ts$/.test(file))
778
778
  tsObjects.push(...tsTokenObjects(await readFile(file, "utf8").catch(() => "")));
779
779
  const tally = emptyTally();
780
780
  const internalSpecs = await internalSpecifiers(root);
@@ -425,7 +425,14 @@ async function report(root, filePath, mode) {
425
425
  /** O QUE FOI CORTADO É DITO - medido num `.css` real: 20 de 252, e as 232 sumiam caladas. */
426
426
  ...(unnamed.length > 20
427
427
  ? [` … and ${unnamed.length - 20} more in this file`]
428
- : []), "", "This system declares no name for them, so there is nothing to replace them with.", "Leave them as they are, or tell the person the value and what THEY would call it.");
428
+ : []), "", "This system declares no name for them, so there is nothing to replace them with.",
429
+ /**
430
+ * UMA PERGUNTA COM A PROPOSTA, NÃO UMA LISTA PARA NOMEAR - bateria ampliada, item 18 (29/09). Em 17 dos 29
431
+ * projetos o turno terminava em "tell me what to call them" com 5 a 43 valores: a tela pronta e o trabalho
432
+ * aberto, esperando a pessoa inventar nomes. Quem escolhe continua sendo ela; o que muda é que ela recebe a
433
+ * sugestão pronta, no estilo que o projeto já usa, e responde uma vez.
434
+ */
435
+ "Do not add a name on your own. For the values that repeat, propose names in this project's own style (next to the names it already declares) as ONE question the person answers with a yes - then add them and swap the values only after that yes. Leave one-off values as they are, and do not list every value back to the person.");
429
436
  }
430
437
  if (phantoms.length > 0) {
431
438
  lines.push("", "Names this system does not declare. These look tokenized and apply nothing at all:", ...phantoms.slice(0, 20).map((p) => ` line ${p.line} ${p.name}`), "", "Use a name the system has, or say which value you need and what you would call it. Do NOT invent a token.");
@@ -613,9 +620,17 @@ round) {
613
620
  if (src == null || !table)
614
621
  continue;
615
622
  measured += 1;
616
- const n = diagnose([scanSource(rel, src, table)]).findings.length;
617
- if (n > 0)
618
- dirty.push(`${rel} (${n} by hand)`);
623
+ /**
624
+ * O QUE SAI DA ESCALA CONTA AQUI TAMBÉM - bateria ampliada, item 14 (29/09). No c06 o turno mudou 18 arquivos, e
625
+ * três deles tinham `@media (max-width: 767px)`, fora da escala (o md é 768). A régua de 28/09 achava; esta
626
+ * linha só somava os valores à mão, e o turno terminava em "checked too - nothing to name".
627
+ */
628
+ const report = scanSource(rel, src, table);
629
+ const n = diagnose([report]).findings.length;
630
+ const off = report.offScale?.length ?? 0;
631
+ const what = [n > 0 ? `${n} by hand` : "", off > 0 ? `${off} off your scale` : ""].filter(Boolean).join(", ");
632
+ if (what)
633
+ dirty.push(`${rel} (${what})`);
619
634
  }
620
635
  const unmeasured = rest.length - measured;
621
636
  said.push([
@@ -634,6 +649,34 @@ round) {
634
649
  : "Run `npx synthesisui doctor` to see them, or write one of them again and this will check it.",
635
650
  ].join("\n"));
636
651
  }
652
+ /**
653
+ * O QUE PASSOU DE PROPÓSITO, DITO UMA VEZ POR TURNO - a régua de 28/09 ("a checagem diz isso uma vez"), bateria
654
+ * ampliada, item 14. Sem esta linha, "nothing to name" ao lado de um `1px` ou de um `100dvh` parecia que a checagem
655
+ * não tinha visto, e o agente do c06 e do c18 foi conferir à mão. Só no fim do turno: um comando de shell não repete.
656
+ */
657
+ // Só pega carona: um turno limpo continua calado - bloquear o fim do turno para dizer que passou seria ruído.
658
+ if (round === "turn" && changed.length > 0 && said.length > 0) {
659
+ const { table } = await loadSystem(root).catch(() => ({ table: null }));
660
+ const passes = new Map();
661
+ if (table)
662
+ for (const { rel } of changed.slice(0, REFUSAL_CEILING)) {
663
+ const src = await readFile(join(root, rel), "utf8").catch(() => null);
664
+ if (src == null)
665
+ continue;
666
+ for (const a of scanSource(rel, src, table).setAside ?? []) {
667
+ if (!/passes$|no token holds$|on your system's scale$/.test(a.reason))
668
+ continue;
669
+ const files = passes.get(a.reason) ?? new Set();
670
+ files.add(rel);
671
+ passes.set(a.reason, files);
672
+ }
673
+ }
674
+ if (passes.size > 0)
675
+ said.push(`Passed on purpose, not missed: ${[...passes]
676
+ .slice(0, 6)
677
+ .map(([why, files]) => `${why} (${files.size} file${files.size === 1 ? "" : "s"})`)
678
+ .join("; ")}.`);
679
+ }
637
680
  return { said, block };
638
681
  }
639
682
  /**
@@ -686,5 +729,12 @@ async function endOfTurn(root, input) {
686
729
  await moveTurnClock(root);
687
730
  out(said.length === 0
688
731
  ? pass()
689
- : { decision: "block", reason: said.join("\n\n") });
732
+ : { decision: "block", reason: [...said, CLOSE_THE_TURN].join("\n\n") });
690
733
  }
734
+ /**
735
+ * A ÚLTIMA MENSAGEM É O RESUMO, NÃO O RODAPÉ - bateria ampliada, item 19 (29/09). Este bloqueio faz o agente responder
736
+ * mais uma vez, e essa resposta vira a última coisa que a pessoa lê: em quase todos os 30 projetos, um turno de três a
737
+ * cinco minutos terminava numa nota sobre a checagem ("One more note from the style checker…"), e o que foi feito
738
+ * ficava no meio da conversa.
739
+ */
740
+ export const CLOSE_THE_TURN = "Answer this in a line or two, then END your reply with the summary of what this turn did for the person - that last message is the one they read.";
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { importMark, writeSyncMark } from "../last-sync.js";
3
3
  import { readStyleSheet } from "../doctor/scss-lens.js";
4
- import { tsTokenObjects, tsTokensAsVars } from "../doctor/ts-tokens-lens.js";
4
+ import { THEME_SOURCE, tsTokenObjects, tsTokensAsVars } from "../doctor/ts-tokens-lens.js";
5
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
6
6
  import { anatomyFromSketch } from "../anatomy-from-sketch.js";
7
7
  import { applyAnatomyPatch, hasEdits, } from "../anatomy-patch.js";
@@ -36,6 +36,9 @@ import { aliasesOf, describeReachability, edgesIn, reachabilityOf, } from "../do
36
36
  import { rootGroundOf } from "../doctor/root-ground.js";
37
37
  import { diagnose, scanSource } from "../doctor/scan.js";
38
38
  import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
39
+ import { attributeFaces } from "../doctor/scheme-scope.js";
40
+ import { cssImportLists, resolveSheetConflicts, unloadedSheets } from "../doctor/sheet-order.js";
41
+ import { unreadComponentFiles } from "../doctor/other-frameworks.js";
39
42
  import { describeSignals, emptySignals, finishSignals, readSignalsInto, } from "../doctor/signals.js";
40
43
  import { sketchOf } from "../doctor/sketch.js";
41
44
  import { describeForeign, readStyleIslands, } from "../doctor/style-island.js";
@@ -282,6 +285,34 @@ alsoGlobalsFrom) {
282
285
  const body = await readStyleSheet(file);
283
286
  sheets.push({ file, body });
284
287
  }
288
+ /**
289
+ * O QUE UMA FOLHA DELE IMPORTA DE DENTRO DE UMA PASTA QUE A VARREDURA PULA - bateria ampliada, 29/09.
290
+ *
291
+ * O Style Dictionary gera `build/css/variables.css` e o `globals.css` o importa. A varredura pula `build/`
292
+ * (lá costuma morar saída de build, e ler aquilo seria ler o bundle), então as 14 variáveis dele nunca
293
+ * entravam, e o import dizia "your code names no token yet" a quem tinha o sistema mais organizado do kit.
294
+ *
295
+ * A pasta continua pulada; o que muda é que uma folha que ELE importa entra, porque o `@import` é a palavra
296
+ * dele dizendo que aquilo faz parte do CSS dele. Só dentro do escopo medido.
297
+ */
298
+ const lidas = new Set(sheets.map((s) => s.file));
299
+ for (let i = 0; i < sheets.length; i++) {
300
+ for (const spec of sheetImports(sheets[i].body)) {
301
+ for (const candidate of partialCandidates(spec)) {
302
+ const alvo = join(dirname(sheets[i].file), candidate);
303
+ if (lidas.has(alvo))
304
+ break;
305
+ if (!alvo.startsWith(`${root}${sep}`))
306
+ break;
307
+ const body = await readStyleSheet(alvo);
308
+ if (!body)
309
+ continue;
310
+ lidas.add(alvo);
311
+ sheets.push({ file: alvo, body });
312
+ break;
313
+ }
314
+ }
315
+ }
285
316
  /**
286
317
  * AS FOLHAS DO RESTO DO REPOSITÓRIO - lidas com a MESMA régua, e nunca com uma segunda.
287
318
  * `deForaDoEscopo` marca quais são, porque elas só podem virar classe global: nem `css`, nem
@@ -564,13 +595,45 @@ export async function takeCensus(root, opts) {
564
595
  */
565
596
  const tsObjects = [];
566
597
  for await (const file of walkAll([root]))
567
- if (/\.(ts|tsx)$/.test(file) && !/\.d\.ts$/.test(file))
598
+ if (THEME_SOURCE.test(file) && !/\.d\.ts$/.test(file))
568
599
  tsObjects.push(...tsTokenObjects(await readFile(file, "utf8").catch(() => "")));
569
600
  const tsRoot = tsObjects
570
601
  .flatMap((o) => [...o.values].map(([n, v]) => `${n}: ${v};`))
571
602
  .join(" ");
572
- const table = buildTable({ css: tsRoot ? `${css}\n:root { ${tsRoot} }` : css, source: "yours" });
573
- const schemes = parseSchemeBlocks(css);
603
+ /**
604
+ * O MESMO TOKEN EM DUAS FOLHAS: fica o que a TELA mostra - ver `sheet-order.ts` (bateria ampliada, c20). O leitor de
605
+ * raiz fica com a primeira declaração; aqui a folha que o código importa por último vem primeiro, só para os tokens.
606
+ * As classes continuam lendo `css` na ordem de sempre.
607
+ */
608
+ const codeFiles = [];
609
+ const citingFiles = [];
610
+ for await (const file of walkAll([root])) {
611
+ if (/\.(tsx|ts|jsx|js|mjs)$/.test(file) && !/\.d\.ts$/.test(file))
612
+ codeFiles.push(file);
613
+ if (/\.(tsx|ts|jsx|js|mjs|cjs|vue|svelte|astro|html|json)$/.test(file) && !/\.d\.ts$/.test(file))
614
+ citingFiles.push(file);
615
+ }
616
+ // A varredura não entra em `.json` nem em `.html`, e é na RAIZ que eles dizem que folhas carregam: `angular.json`,
617
+ // `index.html` (Vite, site estático). Sem isto o `styles.scss` do Angular parecia não carregado.
618
+ for (const name of await readdir(root).catch(() => []))
619
+ if (/\.(json|html)$/.test(name) && name !== "package-lock.json")
620
+ citingFiles.push(join(root, name));
621
+ const sheetsInScope = globalSheets
622
+ .filter((g) => !g.outside)
623
+ .map((g) => ({ file: resolve(root, g.file), body: g.body }));
624
+ /** A folha que ninguém carrega não dá token - ver `unloadedSheets` (bateria ampliada, c17 e c18). */
625
+ const unloaded = new Set(await unloadedSheets(sheetsInScope, citingFiles));
626
+ const loaded = sheetsInScope.filter((g) => !unloaded.has(g.file));
627
+ const { ordered, conflicts: declaredConflicts } = resolveSheetConflicts(loaded, await cssImportLists(codeFiles));
628
+ const tokenCss = unloaded.size > 0 || declaredConflicts.some((c) => c.decidedBy) ? ordered.map((g) => g.body).join("\n") : css;
629
+ const declaredUnloaded = [...unloaded];
630
+ /** Componentes que esta versão não lê (Vue, Svelte, Astro, Angular) - ver `other-frameworks.ts`. */
631
+ const everyFile = (await readdir(root, { recursive: true }).catch(() => []))
632
+ .filter((f) => !/(^|[/\\])(node_modules|\.git|\.next|\.nuxt|\.svelte-kit|\.astro|\.angular|dist|build|_synthesisui)([/\\]|$)/.test(f))
633
+ .map((f) => join(root, f));
634
+ const unreadComponents = await unreadComponentFiles(everyFile);
635
+ const table = buildTable({ css: tsRoot ? `${tokenCss}\n:root { ${tsRoot} }` : tokenCss, source: "yours" });
636
+ const schemes = parseSchemeBlocks(tokenCss);
574
637
  const reports = [];
575
638
  const tally = emptyTally();
576
639
  const internal = await internalSpecifiers(root);
@@ -2469,6 +2532,7 @@ export async function takeCensus(root, opts) {
2469
2532
  globalSheets
2470
2533
  .filter((g) => !g.outside)
2471
2534
  .map((g) => ({ file: g.file, css: g.body })));
2535
+ const declaredBrands = attributeFaces(css, new Set(schemes.base.keys()));
2472
2536
  const declaredSchemes = {
2473
2537
  base: Object.fromEntries(schemes.base),
2474
2538
  light: Object.fromEntries(schemes.light),
@@ -2761,6 +2825,10 @@ export async function takeCensus(root, opts) {
2761
2825
  ? { declaredAlt: Object.fromEntries(schemes.dark) }
2762
2826
  : {}),
2763
2827
  declaredSchemes,
2828
+ ...(declaredBrands.length > 0 ? { declaredBrands } : {}),
2829
+ ...(declaredConflicts.length > 0 ? { declaredConflicts } : {}),
2830
+ ...(declaredUnloaded.length > 0 ? { declaredUnloaded } : {}),
2831
+ ...(unreadComponents.length > 0 ? { unreadComponents } : {}),
2764
2832
  observed: distinctValues(d),
2765
2833
  ...(components.length > 0 ? { components } : {}),
2766
2834
  ...(defined.length > 0 ? { defined } : {}),
@@ -3944,6 +4012,11 @@ export function defaultScheme(declared) {
3944
4012
  * transcrito em vez de serem reescritas: a régua que as produziu é a mesma, então a tela nunca
3945
4013
  * diverge do relatório.
3946
4014
  */
4015
+ /** O caminho a partir da pasta do projeto, que é como ele o reconhece. */
4016
+ function relativeish(file) {
4017
+ const cwd = process.cwd();
4018
+ return file.startsWith(`${cwd}${sep}`) ? file.slice(cwd.length + 1) : file;
4019
+ }
3947
4020
  function mostrarLeitura(c, secoes, titulos = DECIDEM) {
3948
4021
  const valores = c.values?.classes;
3949
4022
  const decisoes = valores ? valores.seen - valores.structure : 0;
@@ -3956,6 +4029,22 @@ function mostrarLeitura(c, secoes, titulos = DECIDEM) {
3956
4029
  console.log(body(`${paint.strong(`${lidos} of ${total}`)} components came out with a blueprint${pct === null
3957
4030
  ? ""
3958
4031
  : `, and ${paint.strong(`${pct}%`)} of your ${decisoes} style decisions carry your own vocabulary`}.`));
4032
+ for (const u of c.unreadComponents ?? []) {
4033
+ const ext = { Vue: ".vue", Svelte: ".svelte", Astro: ".astro", Angular: "Angular" }[u.framework] ?? u.framework;
4034
+ console.log(body(`Your components are written in ${u.framework} (${u.files} ${ext === "Angular" ? "Angular component" : ext} file${u.files === 1 ? "" : "s"}), and this version reads components written in React only. Your colours, spacing and type came in; your components did not - the system starts without them.`));
4035
+ }
4036
+ for (const f of c.declaredUnloaded ?? [])
4037
+ console.log(body(`${relativeish(f)} declares variables, and nothing in this project loads it - no import, no link, no config. They were left out of this system.`));
4038
+ for (const k of c.declaredConflicts ?? []) {
4039
+ const onde = k.values.map((v) => `${v.value} in ${relativeish(v.file)}`).join(", ");
4040
+ console.log(body(k.decidedBy
4041
+ ? `${k.name} is declared twice (${onde}). Your screen shows ${k.shown}, the one ${relativeish(k.decidedBy)} imports last, and that is the value this system keeps.`
4042
+ : `${k.name} is declared twice (${onde}), and nothing here says which one loads last. This system keeps ${k.shown} - check which one your screen shows.`));
4043
+ }
4044
+ if (c.declaredBrands?.length) {
4045
+ const outras = c.declaredBrands.map((b) => b.selector).join(", ");
4046
+ console.log(body(`Your code names ${c.declaredBrands.length === 1 ? "one more brand" : `${c.declaredBrands.length} more brands`} with the same names (${outras}). This system is the one in :root; ${c.declaredBrands.length === 1 ? "the other was" : "the others were"} read and left out, not mixed in.`));
4047
+ }
3959
4048
  /**
3960
4049
  * TODAS as ocorrências, e não a primeira: um import com dois escopos declara a organização de
3961
4050
  * CADA um, e `find` mostraria `packages/ui` e engoliria `packages/charts` - a segunda lacuna
@@ -5364,6 +5453,8 @@ export async function runImport(opts) {
5364
5453
  * `import-installs.spec.ts` provava que a chamada existe e não que ela leva o que precisa.
5365
5454
  */
5366
5455
  cli: opts.cli,
5456
+ /** O projeto é o de origem: nenhum `fonts.ts` morto no código dele - ver `AddOptions.fromImport`. */
5457
+ fromImport: true,
5367
5458
  });
5368
5459
  }
5369
5460
  catch (error) {
@@ -6,6 +6,8 @@ import { readFile } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
  import { readProjectTheme, writeBesideTheirs, writeNewProjectRoot, writeNewProjectTheme } from "../new-project-theme.js";
8
8
  import { add } from "./add.js";
9
+ import { resolveRegistry } from "../config.js";
10
+ import { fetchDesignSystem } from "../registry.js";
9
11
  const whereOf = (answer) => answer === "file" || answer === "1"
10
12
  ? "file"
11
13
  : answer === "separate" || answer === "2"
@@ -48,6 +50,14 @@ async function askWhere(tokensFile, separate, entry, intro) {
48
50
  */
49
51
  export async function init(opts) {
50
52
  const root = opts.dir ?? process.cwd();
53
+ /**
54
+ * O SISTEMA EXISTE ANTES DE QUALQUER ARQUIVO - bateria ampliada, item 21 (29/09). `init --ds teste-nunca-existiu`
55
+ * recusava bem ("No design system ... that this machine can see"), mas só depois de escrever `_synthesisui/config.json`,
56
+ * o `ledger.jsonl` e a statusline: o projeto ficava com o rastro de uma instalação que não aconteceu. A pergunta
57
+ * vai ao registry primeiro; a recusa é a mesma, e agora nada fica para trás.
58
+ */
59
+ if (opts.ds)
60
+ await fetchDesignSystem(resolveRegistry(opts.registry), opts.ds);
51
61
  const target = opts.target === "general" ? "general" : "next";
52
62
  const config = {
53
63
  target,
@@ -72,8 +72,11 @@ const SHAPES = [
72
72
  * Next (app e pages) são do framework: dizem onde a tela mora, não onde um componente nasce.
73
73
  */
74
74
  const ROUTE_FILE = /(^|\/)(page|layout|loading|error|global-error|not-found|template|default|route|head|_app|_document|_error)\.(tsx|jsx|ts|js)$/i;
75
+ /** E todo arquivo de `pages/` do pages router - bateria ampliada, c30: `index.tsx` e `about.tsx` são rotas, não
76
+ * componentes "ao lado da rota". */
77
+ const PAGES_DIR = /^(src\/)?pages\//i;
75
78
  export function countsAsOrganization(rel) {
76
- return /\.(tsx|jsx|vue|svelte)$/i.test(rel) && !ROUTE_FILE.test(rel);
79
+ return /\.(tsx|jsx|vue|svelte)$/i.test(rel) && !ROUTE_FILE.test(rel) && !PAGES_DIR.test(rel.replace(/\\/g, "/"));
77
80
  }
78
81
  /**
79
82
  * O MESMO NOME, MÓDULO CAIXA E SEPARADOR - a única normalização deste arquivo.
@@ -30,6 +30,8 @@ import { localOnlyNames } from "./components-scan.js";
30
30
  * so a file called `page.tsx` IS a route in every Next codebase there is.
31
31
  */
32
32
  const RESERVED_FILE = /(^|[/\\])(page|layout|template|default|error|global-error|not-found|loading|route|middleware|instrumentation|sitemap|robots|opengraph-image|icon|apple-icon|manifest)\.[jt]sx?$/;
33
+ /** `pages/…` na raiz ou em `src/` - o roteador por arquivo do Next antigo (e as telas de um app Vite). */
34
+ const PAGES_ROUTER = /^(src[/\\])?pages[/\\].+\.[jt]sx?$/;
33
35
  /** Nuxt, SvelteKit and Remix equivalents, for the same exact reason. */
34
36
  const RESERVED_FILE_OTHER = /(^|[/\\])(\+page|\+layout|\+server|_app|_document|_layout)\.[a-z]+$/;
35
37
  /**
@@ -229,6 +231,18 @@ export function gateComponent(input) {
229
231
  because: `\`${file}\` is the root the app mounts - the whole screen, not a component somebody can import`,
230
232
  };
231
233
  }
234
+ /**
235
+ * O PAGES ROUTER - bateria ampliada, c30 (29/09). No Next antigo cada arquivo de `pages/` É uma rota
236
+ * (`pages/index.tsx` é `/`), e o nome dele não é reservado: o census contava 0 páginas, e o import chamava
237
+ * `pages/` de "componentes ao lado da rota". Em um app Vite, `src/pages/Home.tsx` também é uma tela inteira.
238
+ */
239
+ if (PAGES_ROUTER.test(file)) {
240
+ return {
241
+ ok: false,
242
+ why: "route",
243
+ because: `\`${file}\` lives in pages/ - each file there is a route, not a component somebody can import`,
244
+ };
245
+ }
232
246
  if (RESERVED_FILE.test(file) || RESERVED_FILE_OTHER.test(file)) {
233
247
  return {
234
248
  ok: false,
@@ -348,6 +348,15 @@ internal = []) {
348
348
  return;
349
349
  const owners = importedNames(source, internal);
350
350
  const private_ = localOnlyNames(source);
351
+ /**
352
+ * `function App({ Component, pageProps })` - A TELA QUE CHEGA COMO PROP (bateria ampliada, c30, 29/09). O `_app.tsx`
353
+ * do pages router renderiza `<Component {...pageProps} />`, e `Component` entrava no sistema como um componente
354
+ * dele. Um nome com maiúscula que o arquivo recebe como parâmetro desestruturado é fiação, igual a um local privado.
355
+ */
356
+ for (const m of source.matchAll(/\(\s*\{([^}]*)\}\s*(?::[^)]*)?\)/g))
357
+ for (const part of m[1].split(","))
358
+ for (const id of /^\s*(?:[\w$]+\s*:\s*)?([A-Z][\w$]*)\b/.exec(part)?.slice(1) ?? [])
359
+ private_.add(id);
351
360
  /**
352
361
  * Abertura por regex, corpo por SCANNER - ver `bodyOf`. A regex só encontra `<Nome`; onde a
353
362
  * tag termina é uma pergunta que só um scanner com estado responde.
@@ -0,0 +1,26 @@
1
+ import { readFile } from "node:fs/promises";
2
+ export async function unreadComponentFiles(files) {
3
+ const count = { Vue: 0, Svelte: 0, Astro: 0, Angular: 0 };
4
+ for (const f of files) {
5
+ if (/\.vue$/i.test(f)) {
6
+ if (!/(^|\/)(pages|layouts)\//.test(f) && !/(^|\/)app\.vue$/i.test(f))
7
+ count.Vue += 1;
8
+ }
9
+ else if (/\.svelte$/i.test(f) &&
10
+ !/(^|\/)\+(page|layout|error)\.svelte$/.test(f))
11
+ count.Svelte += 1;
12
+ else if (/\.astro$/i.test(f) && !/(^|\/)pages\//.test(f))
13
+ count.Astro += 1;
14
+ else if (/\.ts$/i.test(f) && !/\.(spec|test)\.ts$/.test(f)) {
15
+ const src = await readFile(f, "utf8").catch(() => "");
16
+ const root = /(^|\/)app(\.component)?\.ts$/.test(f);
17
+ if (!root &&
18
+ /@Component\s*\(\s*\{/.test(src) &&
19
+ /from\s+["']@angular\/core["']/.test(src))
20
+ count.Angular += 1;
21
+ }
22
+ }
23
+ return Object.entries(count)
24
+ .filter(([, n]) => n > 0)
25
+ .map(([framework, n]) => ({ framework, files: n }));
26
+ }
@@ -12,6 +12,7 @@
12
12
  import { propertyAt, roleFits } from "./their-names.js";
13
13
  import { utilitiesOn } from "./idiom-names.js";
14
14
  import { normalizeValue, tokenMatch } from "./tokens.js";
15
+ import { THEME_SOURCE, tsTokenObjects } from "./ts-tokens-lens.js";
15
16
  /**
16
17
  * O NOME QUE SE ESCREVE NO ARQUIVO DELE - e ele é SEMPRE do vocabulário dele (`INV-GERAL-13`).
17
18
  *
@@ -488,7 +489,74 @@ export function scanSource(file, source, table) {
488
489
  : null),
489
490
  };
490
491
  }
491
- return scanCore(file, source, table);
492
+ return stretchedStack(file, source, themeDefinitionsAside(file, source, scanCore(file, source, table)));
493
+ }
494
+ /**
495
+ * A GRADE QUE DIVIDE A ALTURA DA TELA - bateria ampliada, item 17 (29/09).
496
+ *
497
+ * Em 11 dos 28 projetos com tela, o celular abria com meia tela vazia antes do conteúdo, e o agente dizia "works on
498
+ * mobile" nos 11. A causa é uma só, escrita pelo próprio agente na casca: `display: grid` com `min-height: 100dvh` e as
499
+ * colunas empilhando em `1fr` no celular. Sem `grid-template-rows`, as duas linhas automáticas dividem a altura - a da
500
+ * barra lateral cresce até a metade da tela. O conserto é uma linha (`grid-template-rows: auto 1fr`, ou
501
+ * `align-content: start`), e é dito aqui porque o agente não vê o celular.
502
+ */
503
+ function stretchedStack(file, source, report) {
504
+ if (!/\.(css|scss|sass|less|tsx|jsx)$/i.test(file))
505
+ return report;
506
+ const stacks = /grid-template-columns\s*:\s*1fr\s*[;}]|gridTemplateColumns\s*:[^,}\n]*["']1fr["']/.test(source);
507
+ if (!stacks)
508
+ return report;
509
+ const found = [];
510
+ const ROWS = /grid-template-rows|gridTemplateRows|align-content|alignContent|place-content|placeContent/;
511
+ /** O seletor que recebe a regra de linhas em QUALQUER bloco (o `@media` do c06 põe `grid-template-rows` ali). */
512
+ const settled = new Set();
513
+ const blocks = /([^{};]*)\{([^{}]*)\}/g;
514
+ for (const m of source.matchAll(blocks))
515
+ if (ROWS.test(m[2]))
516
+ settled.add(m[1].trim());
517
+ for (const m of source.matchAll(blocks)) {
518
+ const body = m[2];
519
+ const selector = m[1].trim();
520
+ const grid = /display\s*:\s*["']?grid\b/.test(body);
521
+ const tall = /min-?[hH]eight\s*:\s*["']?100(dvh|svh|lvh|vh)\b/.exec(body);
522
+ if (!grid || !tall || ROWS.test(body) || (selector.startsWith(".") && settled.has(selector)))
523
+ continue;
524
+ const at = source.slice(0, (m.index ?? 0) + m[1].length + 1 + body.indexOf(tall[0])).split("\n").length;
525
+ found.push({ line: at, literal: `min-height: 100${tall[1]}`, why: "stretch" });
526
+ }
527
+ if (found.length === 0)
528
+ return report;
529
+ return { ...report, offScale: [...(report.offScale ?? []), ...found] };
530
+ }
531
+ /**
532
+ * O ARQUIVO ONDE O TEMA NASCE NÃO É DERIVA - bateria ampliada, item 13 (29/09).
533
+ *
534
+ * A checagem do fim do turno dizia "11 hand-written values in src/theme.ts" (c03), 18 no do Emotion (c04), 13 no
535
+ * `theme.css.ts` (c05), 18 no `uno.config.ts` (c15) e 7 no `custom.scss` do Bootstrap (c17). São as DEFINIÇÕES
536
+ * dele: o único lugar onde um valor pode e deve estar escrito. O agente gastava o fim de cada turno explicando isso.
537
+ *
538
+ * Postos de lado com o motivo, como os de fixture: não acusados e não creditados. Num `.ts`/`.js` com objeto de tema
539
+ * (o mesmo leitor de `ts-tokens-lens`), o arquivo todo; num `.scss`, as linhas `$nome: valor`.
540
+ */
541
+ function themeDefinitionsAside(file, source, report) {
542
+ if (report.findings.length === 0)
543
+ return report;
544
+ let aside = [];
545
+ if (THEME_SOURCE.test(file) && !/\.(tsx|jsx)$/i.test(file) && tsTokenObjects(source).length > 0)
546
+ aside = report.findings;
547
+ else if (/\.(scss|sass)$/i.test(file)) {
548
+ const lines = source.split("\n");
549
+ // `$primary: #7c2d12;` - a definição simples. Um mapa (`$r: (radius: 6px, …)`) segue lido como antes.
550
+ aside = report.findings.filter((f) => /^\s*\$[\w-]+\s*:\s*[^(]*;?\s*$/.test(lines[f.line - 1] ?? ""));
551
+ }
552
+ if (aside.length === 0)
553
+ return report;
554
+ const kept = report.findings.filter((f) => !aside.includes(f));
555
+ return {
556
+ ...report,
557
+ findings: kept,
558
+ setAside: [...(report.setAside ?? []), { reason: "your theme is defined here - these are its definitions", count: aside.length }],
559
+ };
492
560
  }
493
561
  /**
494
562
  * OS SELETORES QUE DECLARAM VOCABULÁRIO - `@theme`, `:root` e a redefinição por tema (bateria, 28/09: b5 escreve
@@ -1072,7 +1140,9 @@ context = []) {
1072
1140
  }
1073
1141
  const MEDIA_WIDTH = /\b(?:(?:min|max)-)?width\s*(?::|[<>]=?)\s*([\d.]+)(px|rem|em)\b/g;
1074
1142
  const TOKEN_ARITHMETIC = /(?:\$\{\s*|:\s*)([A-Za-z_$][\w$]*(?:\.[\w$]+)*\.(?:[\w$]*(?:space|spacing|gap|radius|size|font)[\w$]*)(?:\.[\w$]+)*)\s*([+\-*/])\s*(\d+(?:\.\d+)?)\b/gi;
1075
- const BORDER_WIDTH = /\bborder(?:-(?:top|right|bottom|left))?(?:-width)?\s*:\s*(\d+(?:\.\d+)?px)\b/g;
1143
+ const BORDER_WIDTH = /\b(?:border(?:-(?:top|right|bottom|left|inline|block)(?:-(?:start|end))?)?|outline)(?:-width|-offset)?\s*:\s*(\d+(?:\.\d+)?px)\b/g;
1144
+ /** `100dvh`, `50vw` - uma medida da janela, que nenhum token guarda. */
1145
+ const VIEWPORT = /(?<![\w.-])(\d+(?:\.\d+)?(?:dvh|svh|lvh|vh|dvw|svw|lvw|vw))\b/g;
1076
1146
  const BORDER_NAME = /border|line|hairline|stroke|rule|divider|thin/i;
1077
1147
  /**
1078
1148
  * FORA DA ESCALA, linha a linha - ver `OffScale`. `setAside` recebe o que foi medido e está certo: o ponto de quebra
@@ -1105,7 +1175,14 @@ function offScaleOf(file, line, table, setAside) {
1105
1175
  const theirs = [table.aliases.get(`spacing:${v}`), table.aliases.get(`radius:${v}`)].find((a) => a && BORDER_NAME.test(a.name));
1106
1176
  if (theirs)
1107
1177
  out.push({ literal: m[1], why: "named", nearest: `var(${theirs.name})` });
1178
+ /**
1179
+ * SEM NOME DELE, PASSA - E É DITO (a régua de 28/09, "a checagem diz isso uma vez"; bateria ampliada, item 14). O
1180
+ * fim do turno do c06 dizia "nothing to name" com 1px e 2px nos arquivos, e o agente os achava à mão.
1181
+ */ else if (/^[12]px$/.test(v))
1182
+ setAside(`a ${v} border or outline - nothing in this project names a border width, so it passes`);
1108
1183
  }
1184
+ for (const m of line.matchAll(VIEWPORT))
1185
+ setAside(`${m[1]} - a size of the window, which no token holds`);
1109
1186
  return out;
1110
1187
  }
1111
1188
  /** As linhas que o `check_file` e o fim do turno dizem sobre `OffScale` - uma frase por caso, a mesma nos dois. */
@@ -1114,6 +1191,8 @@ export function describeOffScale(files) {
1114
1191
  ? ` ${f.file}:${o.line} ${o.literal} - a breakpoint off your system's widths; the nearest is ${o.nearest} (a media query cannot use a token, so match the width)`
1115
1192
  : o.why === "arithmetic"
1116
1193
  ? ` ${f.file}:${o.line} ${o.literal} - arithmetic on a token lands between the steps; use a step the system has, or ask for one`
1117
- : ` ${f.file}:${o.line} ${o.literal} - this repo calls it ${o.nearest}`));
1194
+ : o.why === "stretch"
1195
+ ? ` ${f.file}:${o.line} ${o.literal} on a grid that stacks to one column - on a phone its rows split the screen's height, so the bar above the content grows to half the screen; add grid-template-rows: auto 1fr (or align-content: start)`
1196
+ : ` ${f.file}:${o.line} ${o.literal} - this repo calls it ${o.nearest}`));
1118
1197
  return rows.length > 0 ? ["", "Off this system's scale:", ...rows] : [];
1119
1198
  }
@@ -33,6 +33,8 @@ const DARK_MARK = /(\.dark\b|\.theme-dark\b|\[data-(?:theme|scheme|mode)\s*[~^|*
33
33
  const LIGHT_MARK = /(\.light\b|\.theme-light\b|\[data-(?:theme|scheme|mode)\s*[~^|*$]?=\s*["']?light["']?\s*\])/i;
34
34
  /** As raízes do documento - os lugares onde alguém escreve o vocabulário do projeto. */
35
35
  const ROOT_MARK = /(^|[\s>+~,])(:root|html|:host|body)\b/gi;
36
+ /** Um seletor que é SÓ atributo, sem elemento nem classe: `[data-brand="acme"]`. */
37
+ const DOCUMENT_ATTRIBUTE = /^(\[[\w-]+(?:\s*[~^|*$]?=\s*(?:"[^"]*"|'[^']*'|[\w-]+))?\s*\])+$/;
36
38
  /** `@theme` do Tailwind v4 é raiz por definição: é onde a paleta inteira é declarada. */
37
39
  const AT_THEME = /^@theme\b/i;
38
40
  /**
@@ -71,7 +73,22 @@ function isDocumentScoped(selector) {
71
73
  // real virava local, e com ele os dois tokens que o comentário de `tokens.ts` cita
72
74
  // por nome (`--loader-color`, `--color-track`).
73
75
  const flat = selector.replace(/:(?:where|is)\s*\(/gi, "").replace(/\)/g, "");
74
- return flat.split(",").every((one) => {
76
+ const parts = flat.split(",");
77
+ /**
78
+ * `:root, [data-brand="acme"] { … }` - A MARCA PADRÃO DITA DUAS VEZES (bateria ampliada, c07, 29/09).
79
+ *
80
+ * O projeto multimarca declara a marca padrão na raiz E sob o atributo que a troca, na mesma regra. O
81
+ * atributo sozinho, sem elemento, é o que ele põe no `<html>`; ao lado de uma raiz na MESMA lista, ele fala
82
+ * do documento, e não de um componente. Sem isto a regra inteira caía em `other`, as 5 variáveis dele não
83
+ * entravam, e o sistema nascia misturando as duas marcas pelo "the colour your code paints most".
84
+ */
85
+ const rootInList = parts.some((one) => {
86
+ ROOT_MARK.lastIndex = 0;
87
+ return ROOT_MARK.test(one);
88
+ });
89
+ return parts.every((one) => {
90
+ if (rootInList && DOCUMENT_ATTRIBUTE.test(one.trim()))
91
+ return true;
75
92
  SCHEME_MARK.lastIndex = 0;
76
93
  const scoped = SCHEME_MARK.test(one);
77
94
  ROOT_MARK.lastIndex = 0;
@@ -270,3 +287,24 @@ export function scopedBlocks(css) {
270
287
  * consumo.
271
288
  */
272
289
  const NAME = /^(--[a-z0-9_-]+|-{0,2}[a-z][a-z0-9-]*)$/i;
290
+ /**
291
+ * AS OUTRAS MARCAS - `[data-brand="globex"] { --brand: …; }` redeclarando os nomes da raiz (bateria ampliada, c07).
292
+ *
293
+ * Um bloco só de atributo que redeclara pelo menos dois nomes da raiz é outra marca (ou outro tema) do mesmo
294
+ * sistema, e não vocabulário novo. Ele não entra na base - a base é a marca da raiz -, mas a pessoa precisa saber
295
+ * que ele foi visto e deixado de fora, e não misturado nem esquecido.
296
+ */
297
+ export function attributeFaces(css, baseNames) {
298
+ const out = new Map();
299
+ for (const block of scopedBlocks(css)) {
300
+ if (block.scope !== "other" || block.stack.length !== 1)
301
+ continue;
302
+ const selector = block.stack[0].trim();
303
+ if (!DOCUMENT_ATTRIBUTE.test(selector))
304
+ continue;
305
+ const same = block.declarations.filter((d) => baseNames.has(d.name)).length;
306
+ if (same >= 2)
307
+ out.set(selector, (out.get(selector) ?? 0) + same);
308
+ }
309
+ return [...out].map(([selector, names]) => ({ selector, names }));
310
+ }
@@ -0,0 +1,99 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { parseRootTokens } from "./tokens.js";
4
+ const CSS_IMPORT = /import\s+(?:[\w*{}\s,]+\s+from\s+)?["']([^"']+\.(?:css|scss|sass|less))["']/g;
5
+ /** Os `import "…css"` de cada arquivo de código, resolvidos, na ordem em que aparecem. */
6
+ export async function cssImportLists(codeFiles) {
7
+ const out = new Map();
8
+ for (const file of codeFiles) {
9
+ const src = await readFile(file, "utf8").catch(() => "");
10
+ if (!src.includes(".css") && !src.includes(".scss"))
11
+ continue;
12
+ const list = [];
13
+ for (const m of src.matchAll(CSS_IMPORT)) {
14
+ const spec = m[1];
15
+ if (!spec.startsWith("."))
16
+ continue;
17
+ list.push(join(dirname(file), spec));
18
+ }
19
+ if (list.length > 1)
20
+ out.set(file, list);
21
+ }
22
+ return out;
23
+ }
24
+ /**
25
+ * Os conflitos entre folhas, e a ORDEM das folhas para o leitor de raiz (que fica com a primeira): quem a tela mostra
26
+ * vem antes. Folha que não está em conflito nenhum mantém o lugar.
27
+ */
28
+ export function resolveSheetConflicts(sheets, importLists) {
29
+ const perSheet = sheets.map((s) => ({
30
+ ...s,
31
+ tokens: parseRootTokens(s.body),
32
+ }));
33
+ const byName = new Map();
34
+ for (const s of perSheet)
35
+ for (const [name, value] of s.tokens) {
36
+ const list = byName.get(name) ?? [];
37
+ list.push({ file: s.file, value });
38
+ byName.set(name, list);
39
+ }
40
+ const conflicts = [];
41
+ /** Folha -> quantas folhas ela vence. A que vence mais sobe na ordem. */
42
+ const wins = new Map();
43
+ for (const [name, values] of byName) {
44
+ const distinct = new Set(values.map((v) => v.value.trim().toLowerCase()));
45
+ if (values.length < 2 || distinct.size < 2)
46
+ continue;
47
+ let decidedBy = null;
48
+ let winner = null;
49
+ for (const [importer, list] of importLists) {
50
+ const positions = values.map((v) => list.indexOf(v.file));
51
+ if (positions.some((p) => p === -1))
52
+ continue;
53
+ const last = positions.indexOf(Math.max(...positions));
54
+ winner = values[last] ?? null;
55
+ decidedBy = importer;
56
+ break;
57
+ }
58
+ if (winner)
59
+ wins.set(winner.file, (wins.get(winner.file) ?? 0) + 1);
60
+ conflicts.push({
61
+ name,
62
+ shown: (winner ?? values[0])?.value ?? "",
63
+ values,
64
+ decidedBy,
65
+ });
66
+ }
67
+ const ordered = [...sheets].sort((a, b) => (wins.get(b.file) ?? 0) - (wins.get(a.file) ?? 0));
68
+ return { ordered, conflicts };
69
+ }
70
+ /**
71
+ * A FOLHA QUE NINGUÉM CARREGA - bateria ampliada, c17 e c18 (29/09).
72
+ *
73
+ * O create-next-app deixa um `app/globals.css` com `--foreground: #ededed`, e quem troca por outra folha (o
74
+ * `vendor.min.css` do c18, o `custom.scss` do Bootstrap no c17) costuma esquecer o arquivo. Nenhuma página o importa,
75
+ * ele não pinta nada, e a cor do texto do sistema saía dele.
76
+ *
77
+ * Uma folha só dá token quando algum arquivo a cita pelo nome: um `import` no código, um `<link>` no HTML, a lista de
78
+ * estilos de uma config (`angular.json`, `nuxt.config.ts`) ou um `@import` de outra folha. Quando nada no projeto cita
79
+ * folha nenhuma, a pergunta não tem resposta, e nada é tirado.
80
+ */
81
+ export async function unloadedSheets(sheets, textFiles) {
82
+ if (sheets.length < 2)
83
+ return [];
84
+ const texts = await Promise.all(textFiles.map((f) => readFile(f, "utf8").catch(() => "")));
85
+ const all = [...texts, ...sheets.map((s) => s.body)].join("\n");
86
+ const base = (f) => f.slice(f.lastIndexOf("/") + 1);
87
+ const cited = (f) => {
88
+ const name = base(f);
89
+ const stem = name.replace(/\.(css|scss|sass|less)$/, "");
90
+ return all.includes(name) || new RegExp(`["'/]_?${stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`).test(all);
91
+ };
92
+ const citedAny = sheets.some((s) => cited(s.file));
93
+ if (!citedAny)
94
+ return [];
95
+ return sheets
96
+ .filter((s) => !/\.module\./.test(s.file) && !base(s.file).startsWith("_"))
97
+ .filter((s) => parseRootTokens(s.body).size > 0 && !cited(s.file))
98
+ .map((s) => s.file);
99
+ }
@@ -1,5 +1,13 @@
1
1
  const kebab = (k) => k.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
2
- /** Um literal de objeto a partir de `{`, ou `null` quando aparece algo que não é literal. */
2
+ /**
3
+ * Um literal de objeto a partir de `{`, ou `null` quando a própria estrutura não fecha.
4
+ *
5
+ * UMA ENTRADA ILEGÍVEL CUSTA SÓ ELA - bateria ampliada, 29/09. O tema do styled-components tinha
6
+ * `space: [0, 4, 8, 16, 24, 32]` e o do Emotion `spacing: (n) => \`${n * 4}px\``, e o objeto inteiro caía
7
+ * por causa de uma chave: o import dizia "your code names no token yet" com seis cores nomeadas ao lado. Agora
8
+ * um array vira escala (números) ou pilha de fonte (textos), e o que não é literal (função, chamada, spread,
9
+ * referência) fica sem leitura SÓ naquela chave. Uma expressão sobre token continua sem leitura, como antes.
10
+ */
3
11
  function parseObject(s, start) {
4
12
  let i = start + 1;
5
13
  const tree = {};
@@ -26,61 +34,168 @@ function parseObject(s, start) {
26
34
  i = end + 1;
27
35
  return out;
28
36
  };
37
+ /** Anda até a próxima `,` ou `}` do MESMO nível, atravessando parênteses, colchetes, chaves e textos. */
38
+ const skip = () => {
39
+ let depth = 0;
40
+ while (i < s.length) {
41
+ const c = s[i];
42
+ if (c === '"' || c === "'" || c === "`") {
43
+ const end = s.indexOf(c, i + 1);
44
+ if (end === -1)
45
+ return false;
46
+ i = end + 1;
47
+ continue;
48
+ }
49
+ if (c === "(" || c === "[" || c === "{")
50
+ depth += 1;
51
+ else if (c === ")" || c === "]" || c === "}") {
52
+ if (depth === 0)
53
+ return c === "}";
54
+ depth -= 1;
55
+ }
56
+ else if (c === "," && depth === 0)
57
+ return true;
58
+ i += 1;
59
+ }
60
+ return false;
61
+ };
62
+ /** `[0, 4, 8]` -> escala; `["Inter", "sans-serif"]` -> uma pilha de fonte. Outra coisa: `undefined`. */
63
+ const array = () => {
64
+ const close = s.indexOf("]", i);
65
+ if (close === -1)
66
+ return undefined;
67
+ const inner = s.slice(i + 1, close);
68
+ if (/[[{(]/.test(inner))
69
+ return undefined;
70
+ const items = inner
71
+ .split(",")
72
+ .map((x) => x.trim())
73
+ .filter((x) => x !== "");
74
+ if (items.length === 0)
75
+ return undefined;
76
+ if (items.every((x) => /^-?\d+(\.\d+)?$/.test(x))) {
77
+ i = close + 1;
78
+ return Object.fromEntries(items.map((x, n) => [String(n), Number(x)]));
79
+ }
80
+ if (items.every((x) => /^(["'])[^"']*\1$/.test(x))) {
81
+ i = close + 1;
82
+ return items.map((x) => x.slice(1, -1)).join(", ");
83
+ }
84
+ return undefined;
85
+ };
29
86
  for (;;) {
30
87
  ws();
31
88
  if (s[i] === "}")
32
89
  return { tree, end: i + 1 };
90
+ if (i >= s.length)
91
+ return null;
33
92
  let key = str();
34
93
  if (key === null) {
35
94
  const m = /^[A-Za-z_$][\w$]*|^\d+/.exec(s.slice(i));
36
- if (!m)
37
- return null;
95
+ if (!m) {
96
+ // `...spread` ou chave computada: a entrada fica sem leitura, o resto do objeto segue
97
+ if (!skip())
98
+ return s[i] === "}" ? { tree, end: i + 1 } : null;
99
+ if (s[i] === ",")
100
+ i += 1;
101
+ continue;
102
+ }
38
103
  key = m[0];
39
104
  i += key.length;
40
105
  }
41
106
  ws();
42
- if (s[i] !== ":")
43
- return null;
107
+ if (s[i] !== ":") {
108
+ // `{ primary, secondary }` (atalho) ou método: sem valor literal para ler
109
+ if (!skip())
110
+ return s[i] === "}" ? { tree, end: i + 1 } : null;
111
+ if (s[i] === ",")
112
+ i += 1;
113
+ continue;
114
+ }
44
115
  i += 1;
45
116
  ws();
117
+ let value;
46
118
  if (s[i] === "{") {
47
119
  const inner = parseObject(s, i);
48
- if (!inner)
49
- return null;
50
- tree[key] = inner.tree;
51
- i = inner.end;
120
+ if (inner) {
121
+ value = inner.tree;
122
+ i = inner.end;
123
+ }
52
124
  }
125
+ else if (s[i] === "[")
126
+ value = array();
53
127
  else {
54
128
  const text = str();
55
129
  if (text !== null)
56
- tree[key] = text;
130
+ value = text;
57
131
  else {
58
- const m = /^-?\d+(\.\d+)?/.exec(s.slice(i));
59
- if (!m)
60
- return null;
61
- tree[key] = Number(m[0]);
62
- i += m[0].length;
132
+ const m = /^-?\d+(\.\d+)?(?=\s*[,}\n])/.exec(s.slice(i));
133
+ if (m) {
134
+ value = Number(m[0]);
135
+ i += m[0].length;
136
+ }
63
137
  }
64
138
  }
139
+ if (value === undefined) {
140
+ if (!skip())
141
+ return s[i] === "}" ? { tree, end: i + 1 } : null;
142
+ }
143
+ else
144
+ tree[key] = value;
65
145
  ws();
66
146
  if (s[i] === ",")
67
147
  i += 1;
68
148
  }
69
149
  }
70
150
  const DESIGN = /^(#[0-9a-f]{3,8}|rgba?\(|hsla?\(|oklch\(|cubic-bezier\(|-?\d+(\.\d+)?(px|rem|em|%|ms|s)\b)|,|['"]/i;
151
+ /** Número que NÃO é medida: peso, camada, altura de linha, opacidade. Ganha `px` só o resto. */
152
+ const UNITLESS = /(font-?weight|weight|z-?index|line-?height|opacity|flex|order)$/i;
153
+ /**
154
+ * INVÓLUCROS DE CONFIG - `theme.extend.colors` do Tailwind 3, `theme.tokens.colors` do Chakra. Eles dizem ONDE o
155
+ * vocabulário mora na config, e não fazem parte do nome dele: `--theme-extend-colors-brand` seria um nome que ele
156
+ * nunca escreveu. Só saem no começo do caminho.
157
+ */
158
+ const WRAPPER = new Set(["theme", "extend", "tokens"]);
71
159
  function flatten(tree, prefix, out) {
72
160
  for (const [k, v] of Object.entries(tree)) {
73
- const path = [...prefix, kebab(k)];
74
- if (typeof v === "object")
161
+ if (prefix.length === 0 && ["content", "plugins", "presets", "dark-mode", "darkMode", "safelist"].includes(k))
162
+ continue;
163
+ const seg = kebab(k);
164
+ if (prefix.every((p) => WRAPPER.has(p)) && WRAPPER.has(seg) && typeof v === "object") {
165
+ flatten(v, [...prefix, seg], out);
166
+ continue;
167
+ }
168
+ // `brand: { DEFAULT: "#0f766e", soft: … }` - no Tailwind o DEFAULT É o `brand`, e não um tom chamado "default"
169
+ const path = [...prefix.filter((p) => !WRAPPER.has(p)), ...(k === "DEFAULT" ? [] : [seg])];
170
+ if (typeof v === "object") {
171
+ // `{ value: "#1e3a8a" }` - a forma de token do Chakra e do Style Dictionary: o nome é o de cima
172
+ const keys = Object.keys(v);
173
+ if (keys.length === 1 && keys[0] === "value" && typeof v.value !== "object") {
174
+ out.set(`--${path.join("-")}`, typeof v.value === "number" ? `${v.value}px` : v.value);
175
+ continue;
176
+ }
75
177
  flatten(v, path, out);
178
+ }
76
179
  else
77
- out.set(`--${path.join("-")}`, typeof v === "number" ? `${v}px` : v);
180
+ out.set(`--${path.join("-")}`, typeof v === "number" ? (UNITLESS.test(k) ? String(v) : `${v}px`) : v);
78
181
  }
79
182
  }
80
- /** Os objetos exportados que seguram valores de design - um objeto de rotas ou de textos não conta. */
183
+ /**
184
+ * ONDE UM OBJETO DE TOKENS COMEÇA - bateria ampliada, 29/09. Antes só `export const x = {`, e por isso nenhum dos
185
+ * temas escritos como a biblioteca manda entrava: `createTheme({...})` (MUI, vanilla-extract), `defineConfig({...})`
186
+ * (Chakra, UnoCSS), `module.exports = {...}` (tailwind.config.js), `export default {...}`. Uma const não exportada só
187
+ * conta quando a chamada é de tema, porque `const style = {` dentro de um componente não é o sistema dele.
188
+ */
189
+ const THEME_CALL = /^(createTheme|defineConfig|createSystem|extendTheme|defineTokens|createGlobalTheme|defineTheme)$/;
190
+ const OPENERS = /(?:(export\s+)?(?:const|let)\s+(\[[^\]]*\]|[A-Za-z_$][\w$]*)\s*(?::[^=]+)?=|export\s+default|module\.exports\s*=)\s*(?:([A-Za-z_$][\w$.]*)\s*\(\s*(?:[A-Za-z_$][\w$]*\s*,\s*)?)?\{/g;
191
+ /** Os objetos que seguram valores de design - um objeto de rotas ou de textos não conta. */
81
192
  export function tsTokenObjects(source) {
82
193
  const out = [];
83
- for (const m of source.matchAll(/export\s+const\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*\{/g)) {
194
+ for (const m of source.matchAll(OPENERS)) {
195
+ const exported = /^(export|module)/.test(m[0]) || m[1] !== undefined;
196
+ const call = (m[3] ?? "").split(".").pop() ?? "";
197
+ if (!exported && !THEME_CALL.test(call))
198
+ continue;
84
199
  const parsed = parseObject(source, (m.index ?? 0) + m[0].length - 1);
85
200
  if (!parsed)
86
201
  continue;
@@ -88,8 +203,9 @@ export function tsTokenObjects(source) {
88
203
  flatten(parsed.tree, [], values);
89
204
  const leaves = [...values.values()];
90
205
  const design = leaves.filter((v) => DESIGN.test(v) || /^\d+(\.\d+)?px$/.test(v)).length;
206
+ const name = (m[2] ?? "default").replace(/^\[\s*|\s*\]$/g, "").split(",").pop()?.trim() || "default";
91
207
  if (leaves.length >= 4 && design * 10 >= leaves.length * 6)
92
- out.push({ name: m[1], values });
208
+ out.push({ name, values });
93
209
  }
94
210
  return out;
95
211
  }
@@ -135,3 +251,9 @@ export function tsTokensAsVars(source, objects) {
135
251
  });
136
252
  return out;
137
253
  }
254
+ /**
255
+ * OS ARQUIVOS ONDE UM TEMA PODE MORAR - TypeScript e também JavaScript, porque o `tailwind.config.js` do Tailwind 3 é
256
+ * o sistema inteiro de quem o usa (bateria ampliada, c02). O filtro de valor de design de `tsTokenObjects` é o que
257
+ * separa o tema de um `next.config.js`.
258
+ */
259
+ export const THEME_SOURCE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
@@ -509,7 +509,13 @@ export const COUNTED_DIFFERENTLY = "this run counts a value as named only when Y
509
509
  * 0.16.475 -> 0.16.478 em 28/09, e o passo 1 dá **SIM** (etapa 02, "a nossa grafia conta como nossa"): o fim do turno diz
510
510
  * quando o arquivo usa `var(--ds-*)` - o valor do sistema com o NOSSO nome. Um hook anterior cala sobre esse arquivo.
511
511
  */
512
- export const CHECKER_SINCE = "0.16.478";
512
+ /**
513
+ * 0.16.478 -> 0.16.485 em 29/09, e o passo 1 dá **SIM** (a bateria ampliada, itens 13, 14, 17, 18 e 19): o fim do turno
514
+ * deixa de acusar o arquivo onde o tema é definido, nomeia o que sai da escala depois do corte, diz uma vez o que passou
515
+ * de propósito, avisa a grade que divide a altura da tela no celular, propõe os nomes numa pergunta só e termina pedindo
516
+ * o resumo por último. Um hook anterior diz outra coisa sobre o mesmo turno.
517
+ */
518
+ export const CHECKER_SINCE = "0.16.485";
513
519
  /**
514
520
  * A ÚLTIMA VERSÃO EM QUE OS LEITORES PASSARAM A PRODUZIR UM CENSO DIFERENTE.
515
521
  *
@@ -979,7 +985,13 @@ export const CHECKER_SINCE = "0.16.478";
979
985
  * mão o que um `:root { --a: x; }` ou um `[data-theme="dark"] { … }` de UMA linha atribui a uma variável - a definição do
980
986
  * token. Quatro apps do corpus moveram. Um censo lido antes guardou essas declarações como drift.
981
987
  */
982
- export const READER_SINCE = "0.16.473";
988
+ /**
989
+ * 0.16.473 -> 0.16.485 em 29/09, e o passo 1 dá **SIM** (a bateria ampliada, itens 1 a 11): o censo lê a folha que ele
990
+ * importa de dentro de build/, a marca da raiz do multimarca, o tema em JS/TS de sete bibliotecas, o token que a tela
991
+ * mostra quando duas folhas brigam, deixa de fora a folha que ninguém carrega, conta o pages router como rota e diz os
992
+ * componentes em Vue, Svelte, Astro e Angular. Um censo lido antes guardou "names no token yet" onde havia tokens.
993
+ */
994
+ export const READER_SINCE = "0.16.485";
983
995
  /**
984
996
  * O QUE ESTÁ INSTALADO AQUI FICOU PARA TRÁS - e as DUAS condições que fazem isso ser verdade.
985
997
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.484",
3
+ "version": "0.16.485",
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": {