synthesisui 0.16.401 → 0.16.402

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.
@@ -8,6 +8,7 @@ import { lockReference } from "../group-role.js";
8
8
  import { buildGuide } from "../guide.js";
9
9
  import { censusScope } from "../measured-scope.js";
10
10
  import { recallAvailable } from "../memory/availability.js";
11
+ import { nextFontWeights } from "../next-font-weights.js";
11
12
  import { body as line, section, snippet } from "../output.js";
12
13
  import { fetchDesignSystem } from "../registry.js";
13
14
  import { repoStateOf } from "../repo-state.js";
@@ -233,7 +234,16 @@ export async function add(slug, opts) {
233
234
  * função responde: cortar sem caminho de volta trocaria contexto por ignorância.
234
235
  */
235
236
  const tools = await recallAvailable(projectRoot);
236
- await writeFile(join(versionDir, "GUIDE.md"), buildGuide(payload, tools.available), "utf8");
237
+ /**
238
+ * OS PESOS DAS FAMÍLIAS, LIDOS UMA VEZ - ver `next-font-weights.ts`.
239
+ *
240
+ * Aqui, e não junto do passo 3 do setup, porque o GUIA também imprime o bloco do `next/font`
241
+ * para o cliente colar: dois leitores do mesmo manifesto poderiam discordar, e o guia é o
242
+ * arquivo que o agente dele lê antes de escrever. `null` em projeto que não é Next é a resposta
243
+ * certa, não uma falha - lá não existe `next/font` para ter peso nenhum.
244
+ */
245
+ const fontWeights = await nextFontWeights(projectRoot);
246
+ await writeFile(join(versionDir, "GUIDE.md"), buildGuide(payload, tools.available, fontWeights), "utf8");
237
247
  // 4. stable root re-exports for each CSS artifact → always the active version,
238
248
  // so the consumer's @import path never changes across updates
239
249
  const cssArtifacts = Object.keys(payload.artifacts).filter((f) => f.endsWith(".css") && !(f === "shadcn.css" && !wantsShadcn));
@@ -671,9 +681,71 @@ export async function add(slug, opts) {
671
681
  // "blink". The Google Fonts <link> stays as the framework-agnostic path.
672
682
  const families = payload.document.foundations.typography.families;
673
683
  const fontsHref = googleFontsHref(families);
674
- const nextFonts = projectConfig.target === "next"
675
- ? nextFontSnippet(families, payload.slug, appDir, payload.fontSeam)
684
+ /**
685
+ * OS PESOS QUE AS FAMÍLIAS TÊM NO PROJETO DELE - ver `next-font-weights.ts`.
686
+ *
687
+ * Lido UMA vez e passado adiante: o manifesto tem 1942 famílias, e reabri-lo por app de um
688
+ * monorepo seria a mesma leitura repetida com liberdade de discordar de si mesma.
689
+ */
690
+ const fontPlan = projectConfig.target === "next"
691
+ ? nextFontSnippet({
692
+ families,
693
+ slug: payload.slug,
694
+ appDir,
695
+ seam: payload.fontSeam,
696
+ weights: fontWeights,
697
+ declaredWeights: payload.document.foundations.typography.weights,
698
+ })
676
699
  : null;
700
+ const nextFonts = fontPlan?.ok ? fontPlan : null;
701
+ /**
702
+ * A LACUNA DE PESO, DITA EM VOZ ALTA (lei 8) - e ela é o oposto de um arquivo escrito errado.
703
+ *
704
+ * Sem o manifesto do `next/font` ao alcance, emitir `weight` seria adivinhar, e a adivinhação
705
+ * derrubou o `next build` de um projeto real em 08/09. Então o arquivo não sai, e o cliente
706
+ * recebe o motivo mais o caminho que funciona.
707
+ */
708
+ /**
709
+ * A LACUNA DE FONTE, DITA COM A CAUSA CERTA (lei 8) - e as duas causas pedem frases diferentes.
710
+ *
711
+ * Sem manifesto, não sabemos os pesos de NENHUMA família e emiti-los seria adivinhar - a
712
+ * adivinhação derrubou o `next build` de um projeto real em 08/09. Com manifesto legível e
713
+ * família desconhecida, o diagnóstico é outro: aquela fonte não é do Google Fonts, e nem o
714
+ * `next/font/google` nem o `<link>` a servem. Dizer a primeira frase no segundo caso é um
715
+ * diagnóstico falso, e manda a pessoa para um caminho que não existe para ela.
716
+ *
717
+ * SEM NÚMERO, de propósito: o passo 3 é impresso logo abaixo, e dois blocos numerados "3." na
718
+ * mesma tela é a família de defeito que este PR conserta.
719
+ */
720
+ if (fontPlan && !fontPlan.ok && fontPlan.why === "manifest-unreadable") {
721
+ console.log("");
722
+ console.log(line(`next/font: ${appDir}/fonts.ts was NOT written - this project's next/font manifest`));
723
+ console.log(line(`is not readable from here, so the weights ${fontPlan.families.join(", ")} actually`));
724
+ console.log(line(`${fontPlan.families.length === 1 ? "ships" : "ship"} cannot be read - and asking for a weight a family does not ship fails`));
725
+ console.log(line(`\`next build\`. Step 3 below works everywhere; add next/font yourself if you`));
726
+ console.log(line(`want it self-hosted.`));
727
+ }
728
+ if (fontPlan && !fontPlan.ok && fontPlan.why === "family-unknown") {
729
+ console.log("");
730
+ console.log(line(`next/font: ${appDir}/fonts.ts was NOT written - Google Fonts has no ${fontPlan.families.join(", ")},`));
731
+ console.log(line(`so neither next/font/google nor the link below can serve ${fontPlan.families.length === 1 ? "it" : "them"}. ${fontPlan.families.length === 1 ? "This is" : "These are"} yours to`));
732
+ console.log(line(`load - a licensed or self-hosted face, most likely - and the token names are`));
733
+ console.log(line(`already in tokens.css waiting for ${fontPlan.families.length === 1 ? "it" : "them"}.`));
734
+ }
735
+ /**
736
+ * E A FAMÍLIA QUE FICOU DE FORA DO ARQUIVO É NOMEADA - ver `unknown` em `fonts.ts`.
737
+ *
738
+ * Com duas famílias e só uma no manifesto, a segunda saía do `fonts.ts`, do `layout` e do mapa de
739
+ * CSS sem uma palavra: o token dela continua no `tokens.css` e o texto sai no fallback do
740
+ * navegador. Este módulo já dizia isso sobre o caso vizinho - *"um token que aponta para uma
741
+ * fonte que ninguém carregou é pior que um token ausente"*.
742
+ */
743
+ if (nextFonts && nextFonts.unknown.length > 0) {
744
+ console.log("");
745
+ console.log(line(`next/font: ${nextFonts.unknown.join(", ")} ${nextFonts.unknown.length === 1 ? "is" : "are"} NOT in ${appDir}/fonts.ts - Google Fonts`));
746
+ console.log(line(`has no such family, so ${nextFonts.unknown.length === 1 ? "it is" : "they are"} yours to load. The token${nextFonts.unknown.length === 1 ? "" : "s"} for ${nextFonts.unknown.length === 1 ? "it" : "them"} ${nextFonts.unknown.length === 1 ? "is" : "are"}`));
747
+ console.log(line(`already in tokens.css, waiting.`));
748
+ }
677
749
  if (nextFonts) {
678
750
  /**
679
751
  * UM `fonts.ts` POR APP, e não só no primeiro - lacuna do próprio conserto de 22/08.
@@ -694,9 +766,17 @@ export async function add(slug, opts) {
694
766
  continue;
695
767
  if (!(await exists(join(projectRoot, ...dir.split("/")))))
696
768
  continue;
697
- const snippet = nextFontSnippet(families, payload.slug, dir, payload.fontSeam);
698
- if (!snippet)
769
+ const plan = nextFontSnippet({
770
+ families,
771
+ slug: payload.slug,
772
+ appDir: dir,
773
+ seam: payload.fontSeam,
774
+ weights: fontWeights,
775
+ declaredWeights: payload.document.foundations.typography.weights,
776
+ });
777
+ if (!plan.ok)
699
778
  continue;
779
+ const snippet = plan;
700
780
  const header = [
701
781
  `// Self-hosted type for the "${payload.slug}" design system (via next/font -`,
702
782
  `// preloaded, no font flash). Generated by \`synthesisui add\`; edit freely.`,
@@ -2,8 +2,9 @@ import { existsSync } from "node:fs";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { emitCn, readVocabulary } from "../cn-codegen.js";
5
- import { generateComponentFiles } from "../component-codegen.js";
5
+ import { generateComponentFiles, } from "../component-codegen.js";
6
6
  import { readProjectConfig, resolveRegistry } from "../config.js";
7
+ import { detectAppDirs } from "../global-sheet.js";
7
8
  import { hasInteractiveTemplate, interactiveTemplate, } from "../interactive-templates.js";
8
9
  import { body, section, snippet } from "../output.js";
9
10
  import { findCollision, installedThemeVars, reactMajorOf, readInstalledConvention, readInstalledScheme, } from "../project-facts.js";
@@ -12,6 +13,7 @@ import { installedThemeCss, whatOnlyTheSheetResolves, } from "../sheet-needed.js
12
13
  import { flavourResolver } from "../styles-flavour.js";
13
14
  import { inTheirTongue, projectTongue, sumSpoken, } from "../their-tongue.js";
14
15
  import { readCensus, unreadComment, unreadForComponent, } from "../unread-for-component.js";
16
+ import { readWiring } from "../wiring-read.js";
15
17
  import { editedSinceWritten, readWritten, recordWritten } from "../written.js";
16
18
  /**
17
19
  * Writes the shared `cn.ts` next to the components, built from THIS project's
@@ -69,6 +71,18 @@ export function localName(blueprint, asked) {
69
71
  * The component's styles reference the DS tokens, so the system itself must be
70
72
  * installed (`synthesisui add <slug>`) for `tokens.css`/`theme.css` to resolve.
71
73
  */
74
+ /**
75
+ * O QUE NESTE ARQUIVO PRECISA DA FOLHA, dito uma vez.
76
+ *
77
+ * A frase aparece nos três desfechos do bloco de setup (está aí · está no repo · falta), e três
78
+ * redações da mesma medição são três respostas no dia em que uma for editada.
79
+ */
80
+ function whatNeedsIt(need) {
81
+ const some = (list) => `${list.slice(0, 3).join(", ")}${list.length > 3 ? `, +${list.length - 3}` : ""}`;
82
+ return need.variables.length > 0
83
+ ? some(need.variables)
84
+ : `the ${some(need.classes)} ${need.classes.length === 1 ? "utility" : "utilities"}`;
85
+ }
72
86
  export async function component(slug, name, opts) {
73
87
  const base = resolveRegistry(opts.registry);
74
88
  const root = opts.dir ?? process.cwd();
@@ -118,6 +132,14 @@ export async function component(slug, name, opts) {
118
132
  * recipe, ou sobre o css antes da tradução, seria responder sobre um arquivo que ninguém abriu.
119
133
  */
120
134
  let writtenSource = "";
135
+ /**
136
+ * O VEREDITO DA RAIZ - ver `GeneratedRoot` em `component-codegen.ts`.
137
+ *
138
+ * `null` no caminho do template interativo, e isso é uma resposta: ali o `.tsx` é curado à mão e
139
+ * já nasce com o elemento operável certo, então não há nada a avisar. Um `false` inventado aqui
140
+ * faria o comando calar por engano no caminho que ele deveria cobrir.
141
+ */
142
+ let root_ = null;
121
143
  const artifactsAreTheProduct = opts.artifactsOnly === true || config.target !== "next";
122
144
  if (artifactsAreTheProduct) {
123
145
  const dir = join(root, "_synthesisui", "ds", slug, "components");
@@ -332,7 +354,7 @@ export async function component(slug, name, opts) {
332
354
  filenames = written.map((f) => f.filename);
333
355
  }
334
356
  else {
335
- const { files, spoken: said } = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, flavour, await reactMajorOf(root),
357
+ const { files, spoken: said, root: generatedRoot, } = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, flavour, await reactMajorOf(root),
336
358
  /**
337
359
  * THE SPELLING, FROM THE VERSION WE JUST FETCHED.
338
360
  *
@@ -353,6 +375,7 @@ export async function component(slug, name, opts) {
353
375
  */
354
376
  local, await readInstalledScheme(root, slug), tongue, await installedThemeVars(root, slug));
355
377
  spoken = said;
378
+ root_ = generatedRoot;
356
379
  /**
357
380
  * AS DECLARAÇÕES DO ARQUIVO DELE QUE O INTERPRETADOR NÃO LEU - ver `unread-for-component.ts`.
358
381
  *
@@ -449,7 +472,50 @@ export async function component(slug, name, opts) {
449
472
  * junto a única linha que diz COMO importar o componente. Dizer "está isolado" e sonegar o
450
473
  * `import { ButtonSample }` troca um defeito por outro.
451
474
  */
452
- if (need.needed) {
475
+ /**
476
+ * A FIAÇÃO JÁ ESTÁ AQUI? - ver `wiring-read.ts`, a MESMA leitura que o `doctor` usa.
477
+ *
478
+ * MEDIDO em 08/09, nos dois apps do ato 2B: com os dois `@import` colados e o
479
+ * `data-ds="<slug>"` no `<body>`, este comando ainda imprimia os passos 1 e 2 para colar de
480
+ * novo. Ele perguntava se o arquivo PRECISA da folha e nunca se a folha já estava lá.
481
+ *
482
+ * As duas perguntas continuam sendo feitas, e nesta ordem: `need` decide se o setup é
483
+ * necessário, `wiring` decide se ele ainda está pendente.
484
+ */
485
+ const wiring = need.needed ? await readWiring(root, slug) : null;
486
+ /**
487
+ * O ADAPTADOR CONTA quando o que precisa da folha são UTILITÁRIOS - eles só existem pelo
488
+ * `@theme` do `theme.css`. Dizer "está tudo aí" com o `tokens.css` sozinho declararia resolvido
489
+ * exatamente o que continua faltando.
490
+ */
491
+ const sheetsThere = wiring?.imported === true && (!tailwind || wiring?.themed === true);
492
+ /**
493
+ * E A RESPOSTA É DO PROJETO, NÃO DO APP - então num repositório com mais de um app ela não
494
+ * decide, e o comando diz isso em vez de afirmar (`INV-VOLTA-12`: monorepo é a forma comum).
495
+ *
496
+ * `readWiring` varre a partir da raiz e responde "existe, em algum lugar daqui". Com dois apps
497
+ * servidos, um fiado e o outro não, um responderia pelo outro - e o arquivo que este comando
498
+ * acabou de escrever vai para uma pasta só de componentes, que não desambigua qual app o usa.
499
+ */
500
+ const appDirs = await detectAppDirs(root, config.pagesDir ?? "app");
501
+ const oneApp = appDirs.length <= 1;
502
+ const alreadyWired = sheetsThere && wiring?.scoped === true;
503
+ if (need.needed && alreadyWired && !oneApp) {
504
+ console.log(section(`This file needs the sheet - and this repo has it`));
505
+ console.log(body(`What needs it: ${whatNeedsIt(need)}.`));
506
+ console.log(body(`Measured across this repository, not per app: ${slug}'s sheet is imported and`));
507
+ console.log(body(`data-ds="${slug}" is set somewhere in it. There ${appDirs.length === 2 ? "are two apps" : `are ${appDirs.length} apps`} here (${appDirs.slice(0, 2).join(", ")}${appDirs.length > 2 ? ", …" : ""}),`));
508
+ console.log(body(`so if this file lands in one that does not carry them, run \`doctor\` inside it.`));
509
+ }
510
+ if (need.needed && alreadyWired && oneApp) {
511
+ console.log(section(`This file needs the sheet - and it is already there`));
512
+ console.log(body(`What needs it: ${whatNeedsIt(need)}.`));
513
+ console.log(body(`This project already imports ${slug}'s ${tailwind ? "tokens.css and theme.css" : "tokens.css"} and carries data-ds="${slug}",`));
514
+ console.log(body(wiring?.fontsMapped
515
+ ? `and its type is mapped onto the system's family tokens. Nothing to set up.`
516
+ : `so nothing to set up here. \`doctor\` says whether the type is mapped too.`));
517
+ }
518
+ if (need.needed && !alreadyWired) {
453
519
  console.log(section(`One-time setup (once per app, for "${slug}")`));
454
520
  console.log(body(need.variables.length > 0
455
521
  ? `(what still needs the sheet here: ${need.variables.slice(0, 3).join(", ")}${need.variables.length > 3 ? `, +${need.variables.length - 3}` : ""} - your code names no value for ${need.variables.length === 1 ? "it" : "them"})`
@@ -466,6 +532,40 @@ export async function component(slug, name, opts) {
466
532
  console.log("");
467
533
  console.log(body(`(If you haven't installed the system yet, run: synthesisui add ${slug})`));
468
534
  }
535
+ /**
536
+ * O ELEMENTO QUE SAIU NÃO ATIVA O QUE A RECEITA PEDE - e isso se diz, em vez de sumir (lei 8).
537
+ *
538
+ * O QUE O CLIENTE VIA (medido em 08/09): `<Button>Ship it</Button>` renderizou um `<div>`. Sem
539
+ * foco, sem teclado, sem `role` - e as declarações de `:focus-visible` e `:disabled` da própria
540
+ * receita inertes, porque um `div` não recebe foco nem fica desabilitado.
541
+ *
542
+ * A RAIZ NÃO-OPERÁVEL ESTÁ CORRETA: a receita não declara papel de ação, e a lei 10 manda não
543
+ * inventar semântica que o contrato dele não tem - um `role="button"` + `tabIndex` supostos
544
+ * seriam acessibilidade que a gente adivinhou, o que é pior que não fazer. O que faltava era a
545
+ * segunda metade: DIZER.
546
+ *
547
+ * E A MEDIÇÃO VEM DE QUEM DECIDIU - ver `GeneratedRoot`. A primeira versão deste bloco lia o
548
+ * texto emitido com um regex (`const Tag = (as ?? "div|span")` + `focus-visible:` na fonte), e a
549
+ * revisão mediu as duas cegueiras: a grafia de variante do Tailwind não existe numa folha, então
550
+ * o aviso morria em `styles: css`; e `(div|span)` não alcançava o `article` que o guard da lei 10
551
+ * devolve quando o topo tem heading - o caso mais citado neste repositório.
552
+ */
553
+ const inertRoot = root_ != null && !root_.operable;
554
+ /**
555
+ * OS ESTADOS COM O NOME QUE O CSS DELE USA - o contrato os guarda em camelCase (`focusVisible`),
556
+ * e é `:focus-visible` que ele vai procurar no arquivo. A conversão é de LEITURA, para o texto
557
+ * falar a língua da folha e não a do nosso schema.
558
+ */
559
+ const interactiveStates = (root_?.interactiveStates ?? []).map((state) => state.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`));
560
+ if (interactiveStates.length > 0 && inertRoot) {
561
+ console.log(section("It renders a container, not a control"));
562
+ console.log(body(`This recipe declares no action role, so the root is a <${root_?.tag}> - we do not`));
563
+ console.log(body(`invent semantics your contract does not have. But it styles ${interactiveStates.join(", ")},`));
564
+ console.log(body(`and a <${root_?.tag}> reaches none of them: no focus, no keyboard, nothing announced.`));
565
+ console.log("");
566
+ console.log(body(`If it is interactive, the file takes the element as a prop - \`as="button"\`,`));
567
+ console.log(body(`\`as="a"\`, whatever it really is. Which one it is, is yours to say.`));
568
+ }
469
569
  console.log(section("Use it"));
470
570
  if (!opts.artifactsOnly && config.target === "next") {
471
571
  /**
@@ -16,7 +16,6 @@ import { diagnose, nameToWrite, scanSource, siblingTokens, } from "../doctor/sca
16
16
  import { findSelfConflicts, forbiddenProps, isReset, propMatchesLabel, } from "../doctor/self-conflict.js";
17
17
  import { withTheirNames } from "../doctor/their-names.js";
18
18
  import { buildTable, EMPTY_TABLE, nearestToken, } from "../doctor/tokens.js";
19
- import { FAMILY_SEAM_PREFIX } from "../fonts.js";
20
19
  import { detectAppDirs, globalSheetOf, prefixFrom } from "../global-sheet.js";
21
20
  import { groupRole } from "../group-role.js";
22
21
  import { actingSlug, describeScope, measuredScope, scopePaths, } from "../measured-scope.js";
@@ -26,6 +25,7 @@ import { installedThemeCss, whatOnlyTheSheetResolves, } from "../sheet-needed.js
26
25
  import { resolveDeps } from "../stack.js";
27
26
  import { projectTongue } from "../their-tongue.js";
28
27
  import { danglingTheirVars } from "../their-vars.js";
28
+ import { readWiring } from "../wiring-read.js";
29
29
  /**
30
30
  * `synthesisui doctor` - the check nobody else ships.
31
31
  *
@@ -542,78 +542,6 @@ function verdict(d, hasSystem, overruled, conflicts) {
542
542
  }
543
543
  return lines;
544
544
  }
545
- /**
546
- * Wiring is a property of the PROJECT, never of the scope being read.
547
- *
548
- * This ran inside the scoped walk, so `doctor app/login` read only that folder
549
- * - and the import lives in `app/globals.css`. It concluded "installed, but not
550
- * wired up yet" about a project that was perfectly wired (my-test4, 27/07).
551
- *
552
- * Which would be a mild annoyance, except the managed block now tells the agent
553
- * to run `doctor <the file you just wrote>` after every edit. The false alarm
554
- * fires on every loop, and an agent that believes the system is unwired starts
555
- * repairing wiring that was already correct - editing the one file we most need
556
- * it to leave alone.
557
- *
558
- * The system itself was always resolved from the root. So is this now.
559
- */
560
- async function readWiring(root, slug) {
561
- const w = {
562
- imported: false,
563
- scoped: false,
564
- /** `init` wrote a fonts file for this project (Next targets only). */
565
- fontsWritten: false,
566
- /** …and the stylesheet actually maps it onto the system's type tokens. */
567
- fontsMapped: false,
568
- /**
569
- * THE FEATURE NOBODY KNEW WE SHIPPED.
570
- *
571
- * Every system compiles a `shadcn.css` mapping shadcn's whole variable
572
- * contract onto its own tokens, so shadcn components wear the system instead
573
- * of shadcn's defaults. It has been generated into every project since it
574
- * was built, and until 28/07 nothing mentioned it: not `add`, not the
575
- * managed block, not the GUIDE except as a filename in a list. The only
576
- * documentation was a comment inside the file, which is the same as none.
577
- *
578
- * The person it mattered most to is the one who asked "I don't get the use
579
- * case, I can just build a design system with shadcn" - and the answer was
580
- * sitting unimported in his own repo.
581
- *
582
- * Reported only when the project HAS shadcn. Telling everybody about a
583
- * bridge they will never use is how a report earns the skim.
584
- */
585
- hasShadcn: false,
586
- bridged: false,
587
- };
588
- if (!slug)
589
- return w;
590
- w.hasShadcn = await readFile(join(root, "components.json"), "utf8").then(() => true, () => false);
591
- for await (const file of walk(root)) {
592
- const src = await readFile(file, "utf8").catch(() => "");
593
- if (!src)
594
- continue;
595
- if (src.includes(`_synthesisui/ds/${slug}/tokens.css`))
596
- w.imported = true;
597
- if (src.includes(`_synthesisui/ds/${slug}/shadcn.css`))
598
- w.bridged = true;
599
- if (src.includes(`data-ds="${slug}"`))
600
- w.scoped = true;
601
- // The requirement that stayed invisible. `init` writes a fonts file and
602
- // asks for two more edits; an agent told to check only the first two did
603
- // exactly that, stopped, and left the project rendering in the framework's
604
- // default face (my-test2, 27/07). What the checker checks is what gets done.
605
- if (src.includes("--font-ds-") && /next\/font/.test(src))
606
- w.fontsWritten = true;
607
- /** O prefixo vem da const única da costura - um literal aqui divergiria em silêncio (26/08). */
608
- if (new RegExp(`${FAMILY_SEAM_PREFIX}\\w+\\s*:\\s*var\\(\\s*--font-ds-`).test(src))
609
- w.fontsMapped = true;
610
- // The bridge joins the early exit, otherwise the walk can stop before the
611
- // stylesheet that imports it and report a wired project as unbridged.
612
- if (w.imported && w.scoped && w.fontsMapped && (!w.hasShadcn || w.bridged))
613
- break;
614
- }
615
- return w;
616
- }
617
545
  /** Onde o retrato mora por default: dentro do que a esteira já escreve, e commitável. */
618
546
  const DEFAULT_BASELINE = "_synthesisui/drift-baseline.json";
619
547
  /**
@@ -22,6 +22,8 @@ export async function list(opts) {
22
22
  }
23
23
  console.log("Your design systems:\n");
24
24
  const width = Math.max(...mine.map((s) => s.slug.length));
25
+ /** Algum sistema tem rascunho à frente do que o `add` entrega? Só então a legenda tem serventia. */
26
+ let anyDraftAhead = false;
25
27
  for (const s of mine) {
26
28
  /**
27
29
  * O GRUPO ENTRE PARÊNTESES, e "invited" quando ele não é dela - as duas
@@ -30,7 +32,27 @@ export async function list(opts) {
30
32
  const group = s.group
31
33
  ? `${s.group}${s.mine ? "" : ", invited"}`
32
34
  : "no group";
33
- console.log(` ${s.slug.padEnd(width)} ${s.name} (v${s.version}) · ${group}`);
35
+ /**
36
+ * O NÚMERO QUE ELE RECEBE VEM PRIMEIRO, e o rascunho aparece como rascunho.
37
+ *
38
+ * O DEFEITO (medido em 08/09, num projeto novo): esta linha imprimia `codelevel (v3)` e o
39
+ * `add` instalava `CodeLevel v2`. A listagem mostrava a linha mais nova, que é o rascunho do
40
+ * Studio; o `add` serve a última publicada. Duas verdades sobre coisas diferentes, e nenhuma
41
+ * palavra separando as duas - então o cliente lia um número e recebia outro.
42
+ *
43
+ * Sem `installable` (registry antigo) nada muda: um número só, como antes.
44
+ */
45
+ const installable = s.installable ?? s.version;
46
+ const draftAhead = s.version > installable;
47
+ if (draftAhead)
48
+ anyDraftAhead = true;
49
+ const versions = draftAhead
50
+ ? `v${installable} · v${s.version} draft`
51
+ : `v${installable}`;
52
+ console.log(` ${s.slug.padEnd(width)} ${s.name} (${versions}) · ${group}`);
53
+ }
54
+ if (anyDraftAhead) {
55
+ console.log("\n`add` installs the published version; the draft is what the Studio is editing.");
34
56
  }
35
57
  console.log('\nWork in one with: synthesisui use <slug> "<what you are building>"');
36
58
  return;
@@ -1,6 +1,25 @@
1
1
  import { emittableAttrs } from "./attr-shape.js";
2
2
  import { inTheirTongue, sumSpoken, } from "./their-tongue.js";
3
3
  import { PHRASING_FORMS, } from "./types.js";
4
+ /**
5
+ * OS ELEMENTOS QUE SÃO OPERÁVEIS POR NATUREZA - a lista vem do HTML, e é por isso que ela é uma
6
+ * regra e não uma foto: nenhum repositório de cliente a muda.
7
+ *
8
+ * A pergunta que ela responde é a do avesso: uma tag FORA desta lista não recebe foco de teclado
9
+ * nem fica desabilitada, então toda declaração de `:focus-visible` e `:disabled` que a receita
10
+ * carrega fica inerte nela.
11
+ */
12
+ const OPERABLE_ELEMENTS = new Set([
13
+ "button",
14
+ "a",
15
+ "input",
16
+ "select",
17
+ "textarea",
18
+ "summary",
19
+ "details",
20
+ "label",
21
+ "option",
22
+ ]);
4
23
  export const DEFAULT_CONVENTION = {
5
24
  prefix: "ds-",
6
25
  partSeparator: "-",
@@ -49,6 +68,32 @@ const camel = (name) => {
49
68
  export function partIsInteractive(part) {
50
69
  return Object.keys(part.states ?? {}).some((s) => /^(focus|focusVisible|focus-visible|disabled)$/i.test(s));
51
70
  }
71
+ /**
72
+ * OS ESTADOS OPERÁVEIS QUE A RECEITA DECLARA - nas DUAS formas em que uma receita os declara.
73
+ *
74
+ * O DEFEITO QUE ISTO CONSERTA, medido no `button` do `codelevel` em 08/09: a primeira versão lia
75
+ * `recipe.states`, que é a forma das receitas que a PLATAFORMA GERA. Numa receita LIDA do
76
+ * repositório do cliente o mesmo fato mora em `layers[].when.state` - `recipe.states` vem vazio -,
77
+ * então o aviso deixou de sair exatamente no caso que o originou.
78
+ *
79
+ * É a mesma lição que `rootAxes` já carrega: *"axesOf alone keeps an axis only when the option's
80
+ * own block carries declarations - true for a recipe we generated, and false for every recipe we
81
+ * READ"*. Um leitor que conhece uma forma só atende metade da população, e a metade que ele perde é
82
+ * justamente a do cliente.
83
+ */
84
+ export function interactiveStatesOf(recipe) {
85
+ const OPERABLE_STATE = /^(focus|focusVisible|focus-visible|disabled)$/i;
86
+ const found = new Set();
87
+ for (const state of Object.keys(recipe.states ?? {}))
88
+ if (OPERABLE_STATE.test(state))
89
+ found.add(state);
90
+ for (const layer of recipe.layers ?? []) {
91
+ const state = layer.when?.state;
92
+ if (state && OPERABLE_STATE.test(state))
93
+ found.add(state);
94
+ }
95
+ return [...found];
96
+ }
52
97
  /** Intrinsic element + extra attrs per component, chosen like the platform
53
98
  * renderer does (name first, then preview.kind). Fallback: div + children. */
54
99
  function elementFor(name, recipe) {
@@ -941,6 +986,30 @@ function treeParts(nodes, out = []) {
941
986
  }
942
987
  return out;
943
988
  }
989
+ /**
990
+ * A ÁRVORE JÁ TEM ONDE O CONTEÚDO DELE ENTRA?
991
+ *
992
+ * O QUE O CLIENTE VIA (medido em 08/09, num `create-next-app` com o `codelevel`): ele escreveu
993
+ * `<Button>Ship it</Button>` e a tela mostrou **"Ship it" duas vezes** - duas ocorrências dentro do
994
+ * `<main>` servido, não uma.
995
+ *
996
+ * A CAUSA: a árvore do recipe tem um nó `as: "slot"`, e `emitTree` emite `{children}` nele - é o
997
+ * lugar do conteúdo, dentro da parte que o desenha. A raiz emitia `{children}` OUTRA VEZ, logo
998
+ * depois da árvore. Não é um caso do `button`: vale para toda receita cuja árvore tenha slot.
999
+ *
1000
+ * ENTÃO A PERGUNTA PASSA A SER FEITA. Com slot na árvore, o conteúdo já tem endereço e a raiz não
1001
+ * o repete; sem slot, a raiz continua sendo o único lugar onde ele pode entrar - e sonegá-lo ali
1002
+ * seria trocar um defeito por outro, um componente que ignora o que o cliente escreveu dentro.
1003
+ */
1004
+ function treeHasSlot(nodes) {
1005
+ for (const node of nodes ?? []) {
1006
+ if (node.as === "slot")
1007
+ return true;
1008
+ if (node.children && treeHasSlot(node.children))
1009
+ return true;
1010
+ }
1011
+ return false;
1012
+ }
944
1013
  /**
945
1014
  * THE COMPOSITION, EMITTED AS JSX RATHER THAN DESCRIBED IN A COMMENT.
946
1015
  *
@@ -1114,8 +1183,13 @@ localName = name) {
1114
1183
  "...props",
1115
1184
  ].join(", ");
1116
1185
  const rootOpen = ` <${el.jsxTag}${el.jsxAttrs}\n className={${joinCls([`"${elementClass(name, convention)}"`, "className"])}}\n${dataAttrLines(axes)}${axes.length ? "\n" : ""} {...props}\n `;
1186
+ /**
1187
+ * O CONTEÚDO DELE ENTRA UMA VEZ - ver `treeHasSlot`. Com slot na árvore, o `{children}` da raiz
1188
+ * duplicava na tela o que o cliente escreveu.
1189
+ */
1190
+ const rootTakesChildren = hasShape && !treeHasSlot(tree);
1117
1191
  const rootJsx = hasShape
1118
- ? `${rootOpen}>\n${emitTree(tree, comp, " ")}\n {children}\n </${el.jsxTag}>`
1192
+ ? `${rootOpen}>\n${emitTree(tree, comp, " ")}${rootTakesChildren ? "\n {children}" : ""}\n </${el.jsxTag}>`
1119
1193
  : `${rootOpen}/>`;
1120
1194
  // Tree order first, because that is the order somebody reads them in the file;
1121
1195
  // anything the tree does not mention still gets its component.
@@ -1296,8 +1370,7 @@ ${el.setup} return (
1296
1370
  ${hasShape
1297
1371
  ? `${rootOpen} {...props}
1298
1372
  >
1299
- ${emitTree(tree, comp, " ")}
1300
- {children}
1373
+ ${emitTree(tree, comp, " ")}${treeHasSlot(tree) ? "" : "\n {children}"}
1301
1374
  </${el.jsxTag}>`
1302
1375
  : `${rootOpen} {...props}
1303
1376
  />`}
@@ -1461,7 +1534,20 @@ themeVars) {
1461
1534
  filename: "index.ts",
1462
1535
  code: `export * from "./${localName}";\n`,
1463
1536
  });
1464
- return speak(files, tongue, styles === "tailwind" ? "tailwind-class" : "stylesheet");
1537
+ /**
1538
+ * O VEREDITO DA RAIZ SAI DAQUI, de `elementFor` e do contrato - ver `GeneratedRoot`.
1539
+ *
1540
+ * `interactiveStates` lê `recipe.states`, que é o mesmo dado nas duas línguas; a alternativa que
1541
+ * a revisão derrubou lia a grafia de variante do Tailwind na fonte emitida e ficava cega em
1542
+ * `styles: css`.
1543
+ */
1544
+ const rootEl = elementFor(name, recipe);
1545
+ const root = {
1546
+ tag: rootEl.tag,
1547
+ interactiveStates: interactiveStatesOf(recipe),
1548
+ operable: OPERABLE_ELEMENTS.has(rootEl.tag),
1549
+ };
1550
+ return speak(files, tongue, styles === "tailwind" ? "tailwind-class" : "stylesheet", root);
1465
1551
  }
1466
1552
  /**
1467
1553
  * A TRADUÇÃO, SOBRE OS BYTES, UMA VEZ - e o destino é de quem GEROU o texto, não da extensão.
@@ -1475,14 +1561,14 @@ themeVars) {
1475
1561
  * `index.ts` é um re-export e não carrega estilo, mas passa pela mesma porta: um arquivo isento por
1476
1562
  * lista é a lista que alguém esquece de atualizar.
1477
1563
  */
1478
- function speak(files, tongue, tsxDestination) {
1564
+ function speak(files, tongue, tsxDestination, root) {
1479
1565
  if (!tongue)
1480
- return { files, spoken: null };
1566
+ return { files, spoken: null, root };
1481
1567
  const reports = [];
1482
1568
  const said = files.map((file) => {
1483
1569
  const spoken = inTheirTongue(file.code, tongue, file.filename.endsWith(".css") ? "stylesheet" : tsxDestination);
1484
1570
  reports.push(spoken);
1485
1571
  return { filename: file.filename, code: spoken.css };
1486
1572
  });
1487
- return { files: said, spoken: sumSpoken(reports) };
1573
+ return { files: said, spoken: sumSpoken(reports), root };
1488
1574
  }
package/dist/fonts.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { weightsToEmit } from "./next-font-weights.js";
1
2
  // Fallbacks genéricos do CSS - não são webfonts.
2
3
  const GENERIC_FAMILIES = new Set([
3
4
  "sans-serif",
@@ -87,42 +88,69 @@ export function googleFontsHref(families) {
87
88
  * batiza em `fontSeamOf`. Payload novo dispensa a convenção: o dado vence.
88
89
  */
89
90
  export const FAMILY_SEAM_PREFIX = "--ds-typography-families-";
90
- export function nextFontSnippet(families, slug, appDir = "app",
91
- /**
92
- * A COSTURA SERVIDA PELO PAYLOAD (papel → nome do token no tokens.css) - ver `fontSeam` em
93
- * `types.ts`. Presente, o bloco de mapa fala os nomes que o ARTEFATO declara, por construção;
94
- * ausente (payload antigo), a composição por convenção continua valendo.
95
- */
96
- seam) {
91
+ export function nextFontSnippet(input) {
92
+ const { families, slug, appDir = "app", seam, weights, declaredWeights, } = input;
93
+ /**
94
+ * A ORDEM É DETERMINÍSTICA e os duplicados saem: isto vira uma linha de arquivo gerado, e um
95
+ * conjunto que muda de ordem entre rodadas produz diff onde nada mudou.
96
+ */
97
+ const wanted = declaredWeights
98
+ ? [...new Set(Object.values(declaredWeights).map(String))].sort()
99
+ : ["400", "500", "600"];
97
100
  /** Toda vaga que o documento tem, não só as nossas três - ver `customFontFamilies`. */
98
101
  const roles = familySlots(families).filter((role) => {
99
102
  const name = firstFamily(families[role] ?? "");
100
103
  return name && !GENERIC_FAMILIES.has(name.toLowerCase());
101
104
  });
105
+ const named = [
106
+ ...new Set(roles.map((role) => firstFamily(families[role] ?? ""))),
107
+ ];
102
108
  if (roles.length === 0)
103
- return null;
109
+ return { ok: false, why: "generic-only", families: [] };
110
+ /**
111
+ * SEM MANIFESTO, NADA É EMITIDO - e o motivo viaja para quem chama.
112
+ *
113
+ * A alternativa seria emitir os três degraus "porque quase sempre existem", que é exatamente a
114
+ * suposição que derrubou o build de um projeto real em 08/09. Um arquivo não escrito custa uma
115
+ * frase ao cliente; um arquivo escrito errado custa o build dele.
116
+ */
117
+ if (!weights)
118
+ return { ok: false, why: "manifest-unreadable", families: named };
104
119
  const importName = (name) => firstFamily(name).replace(/ /g, "_");
105
120
  const seen = new Map(); // family name -> const name
106
121
  const importNames = [];
107
122
  const consts = [];
123
+ /** Família cujos pesos o manifesto não conhece: não se inventa peso para ela. */
124
+ const unknown = [];
108
125
  for (const role of roles) {
109
126
  const name = firstFamily(families[role] ?? "");
110
127
  if (!seen.has(name)) {
128
+ const available = weights(name);
129
+ const emit = available ? weightsToEmit(available, wanted) : [];
130
+ if (emit.length === 0) {
131
+ unknown.push(name);
132
+ continue;
133
+ }
111
134
  seen.set(name, role);
112
135
  importNames.push(importName(name));
113
136
  consts.push(`export const ${seen.get(name)} = ${importName(name)}({`, ` subsets: ["latin"],`,
114
- // WEIGHTS ARE ALWAYS SPELLED OUT, because next/font only allows
115
- // omitting them for VARIABLE fonts - and plenty of Google families
116
- // ship as static cuts (IBM Plex Mono, for one). The generated file
117
- // assumed variable and broke the consumer's first `next build`; an
118
- // agent caught it in the field (test03, 29/07) and had to repair our
119
- // output by hand. Explicit weights are valid for BOTH kinds, so
120
- // emitting them always can never break - and these three are the
121
- // steps every generated system actually uses (regular/medium/
122
- // semibold), so no static cut is downloaded for nothing.
123
- ` weight: ["400", "500", "600"],`, ` variable: "--font-ds-${seen.get(name)}",`, `});`);
137
+ /**
138
+ * OS PESOS SÃO OS QUE A FAMÍLIA TEM - ver `next-font-weights.ts`.
139
+ *
140
+ * Escrevê-los é obrigatório: `next/font` só aceita omissão para fonte VARIÁVEL, e muita
141
+ * família do Google é entregue em cortes estáticos (IBM Plex Mono, entre outras) - o
142
+ * arquivo gerado assumia variável e quebrava o primeiro `next build` do cliente (29/07).
143
+ *
144
+ * E escrevê-los FIXOS quebra o outro lado: `Instrument Serif` tem um único peso, e o
145
+ * `["400","500","600"]` de antes derrubou um `create-next-app` inteiro em 08/09. Os dois
146
+ * defeitos são o mesmo erro - afirmar sobre a fonte dele em vez de ler.
147
+ */
148
+ ` weight: [${emit.map((w) => `"${w}"`).join(", ")}],`, ` variable: "--font-ds-${seen.get(name)}",`, `});`);
124
149
  }
125
150
  }
151
+ /** Nenhuma família sobreviveu: o manifesto existe e não conhece nenhuma delas. */
152
+ if (importNames.length === 0)
153
+ return { ok: false, why: "family-unknown", families: unknown };
126
154
  const fontsFile = [
127
155
  `// ${appDir}/fonts.ts`,
128
156
  `import { ${importNames.join(", ")} } from "next/font/google";`,
@@ -132,16 +160,31 @@ seam) {
132
160
  const name = firstFamily(families[role] ?? "");
133
161
  return `--font-ds-${seen.get(name)}`;
134
162
  };
163
+ /**
164
+ * SÓ AS VAGAS QUE VIRARAM CÓDIGO - uma família descartada por peso desconhecido não pode aparecer
165
+ * no `layout` nem no mapa de CSS: o import citaria uma const que o `fonts.ts` não exporta, e o
166
+ * projeto dele deixaria de compilar por causa da linha que a gente mandou colar.
167
+ */
168
+ const emitted = roles.filter((role) => seen.has(firstFamily(families[role] ?? "")));
135
169
  const layout = [
136
170
  `// ${appDir}/layout.tsx`,
137
- `import { ${[...new Set(roles.map((r) => seen.get(firstFamily(families[r] ?? ""))))].join(", ")} } from "./fonts";`,
138
- `<body data-ds="${slug}" className={\`${[...new Set(roles.map((r) => `\${${seen.get(firstFamily(families[r] ?? ""))}.variable}`))].join(" ")}\`}>`,
171
+ `import { ${[...new Set(emitted.map((r) => seen.get(firstFamily(families[r] ?? ""))))].join(", ")} } from "./fonts";`,
172
+ `<body data-ds="${slug}" className={\`${[...new Set(emitted.map((r) => `\${${seen.get(firstFamily(families[r] ?? ""))}.variable}`))].join(" ")}\`}>`,
139
173
  ];
140
174
  const css = [
141
175
  `/* ${appDir}/globals.css - AFTER the tokens.css import */`,
142
176
  `[data-ds="${slug}"] {`,
143
- ...roles.map((role) => ` ${seam?.[role] ?? `${FAMILY_SEAM_PREFIX}${role}`}: var(${roleVar(role)});`),
177
+ ...emitted.map((role) => ` ${seam?.[role] ?? `${FAMILY_SEAM_PREFIX}${role}`}: var(${roleVar(role)});`),
144
178
  `}`,
145
179
  ];
146
- return { fontsFile, layout, css };
180
+ /**
181
+ * E A FAMÍLIA QUE FICOU DE FORA VIAJA - lei 8, e este módulo já dizia isto sobre o caso vizinho:
182
+ * *"um token que aponta para uma fonte que ninguém carregou é pior que um token ausente - o
183
+ * ausente pelo menos aparece na conta"*.
184
+ *
185
+ * Com duas famílias e só uma no manifesto, a outra saía do `fonts.ts`, do `layout` e do mapa de
186
+ * CSS sem uma palavra: o token dela continua no `tokens.css`, e o texto sai no fallback do
187
+ * navegador sem ninguém saber por quê.
188
+ */
189
+ return { ok: true, fontsFile, layout, css, unknown };
147
190
  }
package/dist/guide.js CHANGED
@@ -279,7 +279,21 @@ export function buildGuide(payload,
279
279
  * `false` mantém o guia inteiro, que é o comportamento de todo repositório sem o servidor
280
280
  * registrado.
281
281
  */
282
- toolsReachable = false) {
282
+ toolsReachable = false,
283
+ /**
284
+ * OS PESOS QUE AS FAMÍLIAS TÊM NO PROJETO DELE - ver `next-font-weights.ts`.
285
+ *
286
+ * Vem de fora pela mesma razão que `toolsReachable`: quem sabe é quem leu o disco dele. `null`
287
+ * significa "não deu para ler", e o guia então OMITE o bloco do `next/font` em vez de imprimir
288
+ * pesos supostos - um trecho colado com peso que a família não tem faz o `next build` falhar.
289
+ *
290
+ * OBRIGATÓRIO, e a razão é o `fixReach`: um argumento opcional aqui é um argumento que um
291
+ * chamador novo esquece, e o esquecimento faz o bloco do `next/font` DESAPARECER do único
292
+ * GUIDE.md do produto - em silêncio, com tsc, vitest e build passando. `nextFontSnippet` fechou
293
+ * essa porta; deixá-la entreaberta um nível acima seria vigiá-la em vez de fechá-la
294
+ * (CLAUDE.md, Parte I, seção 6).
295
+ */
296
+ fontWeights) {
283
297
  const { document: doc, slug, name, version } = payload;
284
298
  const { meta, foundations, motion, components } = doc;
285
299
  const semanticRoles = Object.keys(foundations.color.semantic);
@@ -316,7 +330,22 @@ toolsReachable = false) {
316
330
  .join("&")}&display=swap`
317
331
  : null;
318
332
  /** A costura vem do payload quando o servidor a fala - o dado vence a convenção (T18-par). */
319
- const nextFonts = nextFontSnippet(foundations.typography.families, slug, undefined, payload.fontSeam);
333
+ /**
334
+ * OS PESOS VÊM DE FORA (ver `next-font-weights.ts`), porque quem sabe é quem leu o projeto dele.
335
+ *
336
+ * `null` aqui NÃO é um esquecimento: o guia é escrito no `add`, que já leu o manifesto. Sem ele,
337
+ * o bloco do `next/font` não entra - um trecho para colar com um peso que a família não tem
338
+ * derruba o `next build` de quem colar, e o `<link>` abaixo funciona em qualquer framework.
339
+ */
340
+ const fontPlan = nextFontSnippet({
341
+ families: foundations.typography.families,
342
+ slug,
343
+ seam: payload.fontSeam,
344
+ weights: fontWeights,
345
+ /** Os pesos que ELE declara - ver `declaredWeights`. O guia é o arquivo que o agente dele cola. */
346
+ declaredWeights: foundations.typography.weights,
347
+ });
348
+ const nextFonts = fontPlan.ok ? fontPlan : null;
320
349
  const fontsSection = fontsHref
321
350
  ? `
322
351
  ## Fonts
@@ -170,7 +170,7 @@
170
170
  * O que o cliente ganha ao rodar `upgrade`: o agente dele no Codex passa a poder PERGUNTAR ao
171
171
  * sistema, em vez de só receber as regras e adivinhar o resto.
172
172
  */
173
- export const MATERIALISER_SINCE = "0.16.390";
173
+ export const MATERIALISER_SINCE = "0.16.402";
174
174
  /**
175
175
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
176
176
  *
@@ -0,0 +1,114 @@
1
+ /**
2
+ * OS PESOS QUE CADA FAMÍLIA REALMENTE TEM - lidos do projeto DELE, nunca de uma tabela nossa.
3
+ *
4
+ * O QUE O CLIENTE GANHA: o `npm run build` dele para de falhar por causa de um arquivo que a
5
+ * plataforma escreveu.
6
+ *
7
+ * O DEFEITO, medido em 08/09 num `create-next-app` recém-criado com o `codelevel` instalado pelo
8
+ * binário 0.16.401:
9
+ *
10
+ * Unknown weight 500 for font Instrument Serif.
11
+ * Available weights: 400
12
+ * Import trace: ./app/fonts.ts -> ./app/layout.tsx
13
+ *
14
+ * `next build` terminou em **EXIT=1**. O app não compila, e o arquivo que o derruba foi escrito
15
+ * pelo `add`. É o mesmo desfecho de `wired-slugs.ts`: a esteira existe para devolver o código dele
16
+ * funcionando, e entregou um repositório que não constrói.
17
+ *
18
+ * A CAUSA ERA UMA SUPOSIÇÃO ESCRITA EM COMENTÁRIO. O gerador fixava `weight: ["400","500","600"]`
19
+ * para TODA família, e o comentário afirmava que emitir sempre *"can never break"*. A afirmação
20
+ * nasceu do conserto de 29/07 - antes disso o arquivo omitia os pesos e quebrava para as fontes
21
+ * estáticas - e ela cobre metade do problema: omitir quebra para a fonte estática, e afirmar
22
+ * quebra para a fonte que não tem aquele corte. `Instrument Serif` tem exatamente um peso.
23
+ *
24
+ * ONDE MORA A RESPOSTA, e por isso ela não é nossa: o próprio `next` que o projeto dele instalou
25
+ * carrega o manifesto que o `next/font` usa para VALIDAR. Medido no mesmo projeto: **1942
26
+ * famílias**, cada uma com os pesos e estilos que existem. Ler dali significa que o que a gente
27
+ * emite é exatamente o que o compilador dele aceita - e que uma família nova do Google chega junto
28
+ * com o `npm update` dele, sem release nosso.
29
+ *
30
+ * `null` QUANDO NÃO DÁ PARA SABER. Sem manifesto legível, este módulo não adivinha: quem chama
31
+ * decide, e a decisão do produto é não escrever o arquivo e dizer o motivo (lei 8). O `<link>` do
32
+ * Google Fonts continua sendo o caminho que funciona - medido em 08/09, ele responde HTTP 200
33
+ * mesmo quando a URL pede um peso que a família não tem, porque aquela API ignora o que não existe.
34
+ */
35
+ import { readFile } from "node:fs/promises";
36
+ import { createRequire } from "node:module";
37
+ import { dirname, join } from "node:path";
38
+ /**
39
+ * Os lugares onde o manifesto do `next/font` já morou. Mais de um de propósito: o caminho é interno
40
+ * ao Next, então uma versão que o mova não pode virar um build quebrado - vira `null`, e o produto
41
+ * diz que não conseguiu ler.
42
+ */
43
+ const MANIFEST_PATHS = [
44
+ "dist/compiled/@next/font/dist/google/font-data.json",
45
+ "dist/compiled/@next/font/google/font-data.json",
46
+ "font/dist/google/font-data.json",
47
+ ];
48
+ /**
49
+ * O manifesto do projeto DELE, resolvido pela mesma regra que o `import` dele usa.
50
+ *
51
+ * `createRequire` a partir da raiz do projeto respeita o hoisting do monorepo: o `next` de um app
52
+ * pode estar na raiz do workspace, e uma busca por caminho fixo em `node_modules/` acharia nada
53
+ * exatamente nos repositórios grandes, que são os que mais precisam disto.
54
+ */
55
+ async function loadManifest(projectRoot) {
56
+ for (const pkg of ["next/package.json", "@next/font/package.json"]) {
57
+ let base;
58
+ try {
59
+ const require = createRequire(join(projectRoot, "package.json"));
60
+ base = dirname(require.resolve(pkg));
61
+ }
62
+ catch {
63
+ continue;
64
+ }
65
+ for (const rel of MANIFEST_PATHS) {
66
+ try {
67
+ const raw = await readFile(join(base, ...rel.split("/")), "utf8");
68
+ const parsed = JSON.parse(raw);
69
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
70
+ return parsed;
71
+ }
72
+ catch {
73
+ // próximo caminho - um manifesto ausente não é erro, é uma resposta
74
+ }
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+ /**
80
+ * O leitor de pesos deste projeto, ou `null` quando o manifesto não está ao alcance.
81
+ *
82
+ * A busca do nome é case-insensitive porque o documento guarda o nome como ele aparece no CSS dele
83
+ * (`"instrument serif"` de um `font-family` em minúsculas é a mesma família), e o manifesto guarda
84
+ * a grafia do Google.
85
+ */
86
+ export async function nextFontWeights(projectRoot) {
87
+ const manifest = await loadManifest(projectRoot);
88
+ if (!manifest)
89
+ return null;
90
+ const byLower = new Map();
91
+ for (const [family, entry] of Object.entries(manifest)) {
92
+ const weights = (entry?.weights ?? []).filter((w) => /^\d+$/.test(w));
93
+ byLower.set(family.toLowerCase(), weights);
94
+ }
95
+ return (family) => byLower.get(family.trim().toLowerCase()) ?? null;
96
+ }
97
+ /**
98
+ * OS PESOS A EMITIR para uma família, dados os que o gerador quer e os que existem.
99
+ *
100
+ * `wanted` são os três degraus que todo sistema gerado usa de fato - regular, medium, semibold. A
101
+ * saída é a interseção, na ordem dos disponíveis, e nunca o conjunto vazio: uma família que não tem
102
+ * nenhum dos três (só 200/300, por exemplo) recebe o peso mais próximo de 400 que ela tem, porque
103
+ * emitir lista vazia é `weight: []`, que o `next/font` recusa tanto quanto o peso inexistente.
104
+ */
105
+ export function weightsToEmit(available, wanted = ["400", "500", "600"]) {
106
+ const have = available.filter((w) => /^\d+$/.test(w));
107
+ if (have.length === 0)
108
+ return [];
109
+ const kept = have.filter((w) => wanted.includes(w));
110
+ if (kept.length > 0)
111
+ return kept;
112
+ const nearest = [...have].sort((a, b) => Math.abs(Number(a) - 400) - Math.abs(Number(b) - 400))[0];
113
+ return [nearest];
114
+ }
@@ -0,0 +1,113 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { walk } from "./commands/doctor.js";
4
+ import { FAMILY_SEAM_PREFIX } from "./fonts.js";
5
+ /**
6
+ * A FIAÇÃO DESTE PROJETO, MEDIDA - e a mesma medição para todos os comandos que a citam.
7
+ *
8
+ * O QUE O CLIENTE VIA (medido em 08/09, nos dois apps do ato 2B): ele colou os dois `@import` no
9
+ * `globals.css`, pôs `data-ds="codelevel"` no `<body>`, mapeou as quatro famílias - e o
10
+ * `component` terminou imprimindo **One-time setup**, com os passos 1 e 2 para colar de novo. O
11
+ * comando media "este arquivo PRECISA da folha?" (`sheet-needed.ts`) e nunca "a folha JÁ está
12
+ * aqui?".
13
+ *
14
+ * As duas perguntas são diferentes e as duas importam: a primeira decide se o setup é necessário, e
15
+ * a segunda decide se ele ainda está PENDENTE. Cobrar o que já foi feito gasta a confiança de quem
16
+ * fez - ele fica sem saber se errou algo, e o texto que deveria orientar passa a ser ruído.
17
+ *
18
+ * POR QUE ESTE MÓDULO EXISTE, em vez de o `component` medir por conta: esta leitura já existia
19
+ * dentro do `doctor`, com o histórico inteiro dela. Uma segunda medição da mesma coisa é uma
20
+ * segunda medição livre de discordar da primeira - dois comandos dizendo coisas opostas sobre o
21
+ * mesmo repositório é exatamente o defeito que este PR conserta.
22
+ */
23
+ /**
24
+ * Wiring is a property of the PROJECT, never of the scope being read.
25
+ *
26
+ * This ran inside the scoped walk, so `doctor app/login` read only that folder
27
+ * - and the import lives in `app/globals.css`. It concluded "installed, but not
28
+ * wired up yet" about a project that was perfectly wired (my-test4, 27/07).
29
+ *
30
+ * Which would be a mild annoyance, except the managed block now tells the agent
31
+ * to run `doctor <the file you just wrote>` after every edit. The false alarm
32
+ * fires on every loop, and an agent that believes the system is unwired starts
33
+ * repairing wiring that was already correct - editing the one file we most need
34
+ * it to leave alone.
35
+ *
36
+ * The system itself was always resolved from the root. So is this now.
37
+ */
38
+ export async function readWiring(root, slug) {
39
+ const w = {
40
+ imported: false,
41
+ /**
42
+ * O ADAPTADOR DO TAILWIND, e ele é uma pergunta SEPARADA de `imported`.
43
+ *
44
+ * O DEFEITO QUE ISTO CONSERTA, apontado na revisão deste PR: `imported` olha só o
45
+ * `tokens.css`, e o bloco que consulta esta leitura fala dos UTILITÁRIOS - que só o `@theme`
46
+ * do `theme.css` gera. Num projeto com um import e não o outro, o comando nomeava a falta
47
+ * (`what needs it: the bg-canvas utility`) e na linha seguinte declarava que não faltava nada.
48
+ *
49
+ * Um falso silêncio é pior que o falso alarme que este PR veio consertar: o cliente cola o
50
+ * componente, vê um bloco sem tipografia e sem sombra, e o comando acabou de dizer que estava
51
+ * tudo certo.
52
+ */
53
+ themed: false,
54
+ scoped: false,
55
+ /** `init` wrote a fonts file for this project (Next targets only). */
56
+ fontsWritten: false,
57
+ /** …and the stylesheet actually maps it onto the system's type tokens. */
58
+ fontsMapped: false,
59
+ /**
60
+ * THE FEATURE NOBODY KNEW WE SHIPPED.
61
+ *
62
+ * Every system compiles a `shadcn.css` mapping shadcn's whole variable
63
+ * contract onto its own tokens, so shadcn components wear the system instead
64
+ * of shadcn's defaults. It has been generated into every project since it
65
+ * was built, and until 28/07 nothing mentioned it: not `add`, not the
66
+ * managed block, not the GUIDE except as a filename in a list. The only
67
+ * documentation was a comment inside the file, which is the same as none.
68
+ *
69
+ * The person it mattered most to is the one who asked "I don't get the use
70
+ * case, I can just build a design system with shadcn" - and the answer was
71
+ * sitting unimported in his own repo.
72
+ *
73
+ * Reported only when the project HAS shadcn. Telling everybody about a
74
+ * bridge they will never use is how a report earns the skim.
75
+ */
76
+ hasShadcn: false,
77
+ bridged: false,
78
+ };
79
+ if (!slug)
80
+ return w;
81
+ w.hasShadcn = await readFile(join(root, "components.json"), "utf8").then(() => true, () => false);
82
+ for await (const file of walk(root)) {
83
+ const src = await readFile(file, "utf8").catch(() => "");
84
+ if (!src)
85
+ continue;
86
+ if (src.includes(`_synthesisui/ds/${slug}/tokens.css`))
87
+ w.imported = true;
88
+ if (src.includes(`_synthesisui/ds/${slug}/theme.css`))
89
+ w.themed = true;
90
+ if (src.includes(`_synthesisui/ds/${slug}/shadcn.css`))
91
+ w.bridged = true;
92
+ if (src.includes(`data-ds="${slug}"`))
93
+ w.scoped = true;
94
+ // The requirement that stayed invisible. `init` writes a fonts file and
95
+ // asks for two more edits; an agent told to check only the first two did
96
+ // exactly that, stopped, and left the project rendering in the framework's
97
+ // default face (my-test2, 27/07). What the checker checks is what gets done.
98
+ if (src.includes("--font-ds-") && /next\/font/.test(src))
99
+ w.fontsWritten = true;
100
+ /** O prefixo vem da const única da costura - um literal aqui divergiria em silêncio (26/08). */
101
+ if (new RegExp(`${FAMILY_SEAM_PREFIX}\\w+\\s*:\\s*var\\(\\s*--font-ds-`).test(src))
102
+ w.fontsMapped = true;
103
+ // The bridge joins the early exit, otherwise the walk can stop before the
104
+ // stylesheet that imports it and report a wired project as unbridged.
105
+ if (w.imported &&
106
+ w.themed &&
107
+ w.scoped &&
108
+ w.fontsMapped &&
109
+ (!w.hasShadcn || w.bridged))
110
+ break;
111
+ }
112
+ return w;
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.401",
3
+ "version": "0.16.402",
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": {