synthesisui 0.16.483 → 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.
@@ -21,6 +21,16 @@ import { body, bodyWrapped, paint } from "./output.js";
21
21
  * Vazio e não uma linha vazia: quem chama decide se imprime o título, e um bloco com cabeçalho e
22
22
  * nada embaixo é pior que bloco nenhum.
23
23
  */
24
+ /** "A and B", "A, B, C, D and 2 more" - quatro nomes cabem numa linha de terminal. */
25
+ function namesOf(names) {
26
+ const shown = names.slice(0, 4);
27
+ const rest = names.length - shown.length;
28
+ if (rest > 0)
29
+ return `${shown.join(", ")} and ${rest} more`;
30
+ return shown.length === 1
31
+ ? shown[0]
32
+ : `${shown.slice(0, -1).join(", ")} and ${shown[shown.length - 1]}`;
33
+ }
24
34
  export function craftLines(craft) {
25
35
  if (!craft)
26
36
  return [];
@@ -50,7 +60,9 @@ export function craftLines(craft) {
50
60
  * a segunda pergunta, e ela merece a segunda linha.
51
61
  */
52
62
  if (apart)
53
- lines.push(...bodyWrapped(`${apart} have no recipe yet: counted apart, never against you.`).map((l) => body(paint.dim(l))));
63
+ lines.push(...bodyWrapped(craft.unwrittenNames?.length
64
+ ? `${namesOf(craft.unwrittenNames)} have no recipe yet - nothing in their files paints them - so they are counted apart, never against you.`
65
+ : `${apart} have no recipe yet: counted apart, never against you.`).map((l) => body(paint.dim(l))));
54
66
  /**
55
67
  * OS RÓTULOS ALINHADOS PELO MAIS LONGO, e a largura é medida e não estimada: os nomes chegam do
56
68
  * servidor, então um padding fixo desalinharia no dia em que uma lente for renomeada lá.
package/dist/claude-md.js CHANGED
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { hasHook } from "./agent-wiring.js";
4
4
  import { declaredReference } from "./group-role.js";
5
5
  import { recallAvailable } from "./memory/availability.js";
6
+ import { readResponsiveForm, responsiveLine } from "./responsive-form.js";
6
7
  const START = "<!-- synthesisui:start -->";
7
8
  const END = "<!-- synthesisui:end -->";
8
9
  /** Reads the installed DSs from the .lock files in _synthesisui/ds/<slug>/. */
@@ -454,12 +455,19 @@ and task, call it once - an empty result means there is nothing more to retrieve
454
455
  tells you what was decided and why it matters; **the file on disk is still the authority for what is
455
456
  written now** - read the code before you edit it, never a remembered signature.`
456
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.";
457
465
  const rule = recall.available
458
466
  ? onlyAdopted
459
467
  ? `**These are true without asking anyone:** use the project's OWN custom properties, exactly as
460
468
  this system declares them. No raw colours, spacings or radii that a token already covers, and never a
461
469
  new token invented in silence - say so instead, because a new token is a decision for a person. There
462
- 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}
463
471
 
464
472
  **What the vocabulary IS is not written here.** Fetch only the piece the task requires:
465
473
  \`system_doctrine\` for the rules and the voice, \`find_token\` for a value you are about to write.${memoryLine}${selfCheck}`
@@ -471,7 +479,7 @@ is no component index for an adopted system: the tokens ARE the contract.
471
479
  - To override a style a component already sets, use this system's semantic role class - never \`!\`.
472
480
  If the override is ignored, regenerate that component: older ones predate the resolver.
473
481
  - Motion is selection, not improvisation. The base is quiet: nothing moves until a person asks, and
474
- 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}
475
483
 
476
484
  **When you are writing UI that belongs to one of the systems below, check its index first.** If an
477
485
  entry covers the purpose, do not write it from scratch - materialise it:
@@ -491,11 +499,11 @@ If nothing covers it, say which indexed entry you considered and why it did not
491
499
  project's OWN custom properties, exactly as the guide lists them. Do not write raw colours,
492
500
  spacings or radii that a token already covers, and do not invent a new token silently - say so
493
501
  instead, because a new token is a decision for a person to make. There is no component
494
- manifest for an adopted system - the tokens ARE the contract.${selfCheck}`
502
+ manifest for an adopted system - the tokens ARE the contract.${theirsLine}${selfCheck}`
495
503
  : `**When creating or editing components, read the system's GUIDE.md and follow it:** use the
496
504
  vocabulary it lists - every name in it is a name THIS project already declares, written exactly as
497
505
  you would write it. Do not use raw values outside the system's scale, and never a name from a
498
- stylesheet this project does not have.
506
+ stylesheet this project does not have.${theirsLine}
499
507
 
500
508
  **Before creating any UI element, look it up in the manifest below.** If something there
501
509
  already covers the purpose, do not write it from scratch - run
@@ -543,9 +551,15 @@ element. Write every user-facing string in that language - labels, empty states,
543
551
  \`alt\`. A screen reader pronounces \`aria-label\` using \`lang\`, so a mixed-language interface is
544
552
  worse than an untranslated one. If the attribute is wrong, change it rather than writing against
545
553
  it.`;
554
+ /**
555
+ * E COMO ELE FAZ O RESPONSIVO, medido como o idioma (item 19 da bateria, 29/09): no b9, 100% inline, o agente criou
556
+ * CSS Modules só para o ponto de quebra, porque ninguém tinha dito como aquele projeto faz.
557
+ */
558
+ const form = await readResponsiveForm(projectRoot).catch(() => null);
559
+ const responsive = form ? `\n\n${responsiveLine(form)}` : "";
546
560
  const body = `## Design Systems (via SynthesisUI)
547
561
 
548
- This project uses design system(s) tracked by the \`synthesisui\` CLI. ${rule}${language}
562
+ This project uses design system(s) tracked by the \`synthesisui\` CLI. ${rule}${language}${responsive}
549
563
 
550
564
  ${sections.join("\n")}
551
565
 
@@ -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.";