synthesisui 0.16.401 → 0.16.403

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 { nextFontFacts } 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 fontFacts = await nextFontFacts(projectRoot);
246
+ await writeFile(join(versionDir, "GUIDE.md"), buildGuide(payload, tools.available, fontFacts), "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
+ facts: fontFacts,
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
+ facts: fontFacts,
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
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * UM `FontFacts` COMPLETO A PARTIR DO QUE VOCÊ QUER DIZER - a porta que faltava fechar.
3
+ *
4
+ * O SUFIXO `.spec-input` DECLARA A INTENÇÃO NO PONTO DE IMPORTAÇÃO - ver `reachable.spec.ts`: este
5
+ * módulo existe só para alimentar spec, e é uma CATEGORIA, não uma órfã que alguém esqueceu de
6
+ * ligar. Ele nunca entra em código de produto.
7
+ *
8
+ * `buildGuide` e `nextFontSnippet` fecharam "esquecer o ARGUMENTO"; ficou aberto "esquecer um CAMPO
9
+ * dele", e isso custou 35 testes: quando `subsetsOf` nasceu, as fixtures escritas à mão em dez
10
+ * lugares não a tinham, o `guide.spec.ts` parou de CARREGAR, e a suíte seguiu verde com o arquivo
11
+ * inteiro fora da contagem. Só o `tsc` pegava, e o `tsc` dos specs não é o portão de cada rodada.
12
+ *
13
+ * Quem escreve um teste diz só o que importa para ele; os outros campos vêm completos por
14
+ * construção, e um campo novo neste tipo passa a chegar em toda fixture sozinho.
15
+ */
16
+ export function factsOf(over = {}) {
17
+ const weights = over.weights ?? ["400", "500", "600"];
18
+ const subsets = over.subsets ?? ["latin"];
19
+ return {
20
+ weightsOf: () => weights,
21
+ nameOf: (family) => over.names?.[family] ?? family,
22
+ subsetsOf: () => subsets,
23
+ };
24
+ }
package/dist/fonts.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { subsetsToEmit, 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,61 +88,156 @@ 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, facts, 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;
104
- const importName = (name) => firstFamily(name).replace(/ /g, "_");
105
- const seen = new Map(); // family name -> const name
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 (!facts)
118
+ return { ok: false, why: "manifest-unreadable", families: named };
119
+ /**
120
+ * O IDENTIFICADOR SAI DA GRAFIA DO MANIFESTO, nunca de uma regra nossa - ver `FontFacts.nameOf`.
121
+ *
122
+ * A versão anterior fazia `replace(/ /g, "_")` sobre o nome como o documento DELE o escreve, e um
123
+ * `font-family: instrument serif` produzia `import { instrument_serif }`, que não existe. Uma
124
+ * capitalização "por palavra" trocaria esse defeito por outro: `IBM Plex Mono` sairia
125
+ * `Ibm_Plex_Mono`.
126
+ *
127
+ * E NÃO HÁ FALLBACK, de propósito. Uma família sem grafia conhecida é uma família que o manifesto
128
+ * não conhece, e ela segue o mesmo caminho de sempre: sai em `unknown`, sem linha escrita. Manter
129
+ * a heurística como último recurso deixaria a porta aberta para reescrever exatamente o import
130
+ * que quebra o build dele - vigiar em vez de fechar.
131
+ */
132
+ const importName = (canonical) => canonical.replace(/ /g, "_");
133
+ /**
134
+ * A DEDUPLICAÇÃO É PELA GRAFIA CANÔNICA, e não pelo texto dele - a porta que a canonização
135
+ * ABRIU, achada pela revisão deste PR.
136
+ *
137
+ * Medido: um documento com `display: "Geist"` e `body: "geist, sans-serif"` - dois papéis
138
+ * chegando à mesma família com caixa diferente, que é o normal quando o CSS dele foi escrito por
139
+ * mãos e épocas distintas - produzia
140
+ *
141
+ * import { Geist, Geist } from "next/font/google";
142
+ * SyntaxError: Identifier 'Geist' has already been declared
143
+ *
144
+ * Enquanto o identificador era composto do texto dele, as duas grafias davam dois nomes
145
+ * diferentes (`geist` e `Geist`) - inválidos, mas distintos. Canonizar o nome sem canonizar a
146
+ * CHAVE trocou "dois imports errados" por "um arquivo que não parseia": o mesmo EXIT=1 da
147
+ * `INV-VOLTA-16`, pela porta que este próprio conserto criou.
148
+ *
149
+ * A chave é a grafia do manifesto - a mesma resposta para as duas escritas dele.
150
+ */
151
+ const seen = new Map(); // canonical family -> const name
106
152
  const importNames = [];
107
153
  const consts = [];
154
+ /** Família cujos pesos o manifesto não conhece: não se inventa peso para ela. */
155
+ const unknown = [];
108
156
  for (const role of roles) {
109
157
  const name = firstFamily(families[role] ?? "");
110
- if (!seen.has(name)) {
111
- seen.set(name, role);
112
- importNames.push(importName(name));
113
- 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)}",`, `});`);
158
+ const canonical = facts.nameOf(name);
159
+ if (!canonical || !seen.has(canonical)) {
160
+ const available = facts.weightsOf(name);
161
+ const emit = available ? weightsToEmit(available, wanted) : [];
162
+ if (emit.length === 0 || !canonical) {
163
+ if (!unknown.includes(name))
164
+ unknown.push(name);
165
+ continue;
166
+ }
167
+ /**
168
+ * OS SUBSETS TAMBÉM SÃO LIDOS - ver `subsetsToEmit`. Era `["latin"]` fixo, e o `next` reprova
169
+ * subset inexistente na MESMA função em que reprova peso inexistente: 121 das 1911 famílias
170
+ * do manifesto não têm `latin`, então a terceira porta deste módulo levava ao mesmo EXIT=1.
171
+ *
172
+ * Lista vazia significa que a família não declara subset nenhum, e aí a chave não é escrita:
173
+ * o `next` desliga o preload sozinho, e escrever `["latin"]` seria afirmar o que ela não tem.
174
+ */
175
+ const subsets = subsetsToEmit(facts.subsetsOf(name) ?? []);
176
+ seen.set(canonical, role);
177
+ importNames.push(importName(canonical));
178
+ consts.push(`export const ${role} = ${importName(canonical)}({`, ...(subsets.length > 0
179
+ ? [` subsets: [${subsets.map((x) => `"${x}"`).join(", ")}],`]
180
+ : []),
181
+ /**
182
+ * OS PESOS SÃO OS QUE A FAMÍLIA TEM - ver `next-font-weights.ts`.
183
+ *
184
+ * Escrevê-los é obrigatório: `next/font` só aceita omissão para fonte VARIÁVEL, e muita
185
+ * família do Google é entregue em cortes estáticos (IBM Plex Mono, entre outras) - o
186
+ * arquivo gerado assumia variável e quebrava o primeiro `next build` do cliente (29/07).
187
+ *
188
+ * E escrevê-los FIXOS quebra o outro lado: `Instrument Serif` tem um único peso, e o
189
+ * `["400","500","600"]` de antes derrubou um `create-next-app` inteiro em 08/09. Os dois
190
+ * defeitos são o mesmo erro - afirmar sobre a fonte dele em vez de ler.
191
+ */
192
+ ` weight: [${emit.map((w) => `"${w}"`).join(", ")}],`, ` variable: "--font-ds-${role}",`, `});`);
124
193
  }
125
194
  }
195
+ /** Nenhuma família sobreviveu: o manifesto existe e não conhece nenhuma delas. */
196
+ if (importNames.length === 0)
197
+ return { ok: false, why: "family-unknown", families: unknown };
126
198
  const fontsFile = [
127
199
  `// ${appDir}/fonts.ts`,
128
200
  `import { ${importNames.join(", ")} } from "next/font/google";`,
129
201
  ...consts,
130
202
  ];
131
- const roleVar = (role) => {
203
+ /**
204
+ * A CONST QUE ESTE PAPEL USA - uma resposta só, e é o que impede as três leituras de divergirem.
205
+ *
206
+ * O `layout`, o mapa de CSS e o `roleVar` recompunham a chave cada um do seu jeito a partir do
207
+ * texto dele; com a deduplicação passando a ser canônica, três recomposições são três chances de
208
+ * uma delas citar uma const que o arquivo não exporta.
209
+ */
210
+ const constOf = (role) => {
132
211
  const name = firstFamily(families[role] ?? "");
133
- return `--font-ds-${seen.get(name)}`;
212
+ const canonical = facts.nameOf(name);
213
+ return canonical ? seen.get(canonical) : undefined;
134
214
  };
215
+ const roleVar = (role) => `--font-ds-${constOf(role)}`;
216
+ /**
217
+ * SÓ AS VAGAS QUE VIRARAM CÓDIGO - uma família descartada por peso desconhecido não pode aparecer
218
+ * no `layout` nem no mapa de CSS: o import citaria uma const que o `fonts.ts` não exporta, e o
219
+ * projeto dele deixaria de compilar por causa da linha que a gente mandou colar.
220
+ */
221
+ const emitted = roles.filter((role) => constOf(role) !== undefined);
135
222
  const layout = [
136
223
  `// ${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(" ")}\`}>`,
224
+ `import { ${[...new Set(emitted.map(constOf))].join(", ")} } from "./fonts";`,
225
+ `<body data-ds="${slug}" className={\`${[...new Set(emitted.map((r) => `\${${constOf(r)}.variable}`))].join(" ")}\`}>`,
139
226
  ];
140
227
  const css = [
141
228
  `/* ${appDir}/globals.css - AFTER the tokens.css import */`,
142
229
  `[data-ds="${slug}"] {`,
143
- ...roles.map((role) => ` ${seam?.[role] ?? `${FAMILY_SEAM_PREFIX}${role}`}: var(${roleVar(role)});`),
230
+ ...emitted.map((role) => ` ${seam?.[role] ?? `${FAMILY_SEAM_PREFIX}${role}`}: var(${roleVar(role)});`),
144
231
  `}`,
145
232
  ];
146
- return { fontsFile, layout, css };
233
+ /**
234
+ * E A FAMÍLIA QUE FICOU DE FORA VIAJA - lei 8, e este módulo já dizia isto sobre o caso vizinho:
235
+ * *"um token que aponta para uma fonte que ninguém carregou é pior que um token ausente - o
236
+ * ausente pelo menos aparece na conta"*.
237
+ *
238
+ * Com duas famílias e só uma no manifesto, a outra saía do `fonts.ts`, do `layout` e do mapa de
239
+ * CSS sem uma palavra: o token dela continua no `tokens.css`, e o texto sai no fallback do
240
+ * navegador sem ninguém saber por quê.
241
+ */
242
+ return { ok: true, fontsFile, layout, css, unknown };
147
243
  }
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
+ fontFacts) {
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
+ facts: fontFacts,
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.403";
174
174
  /**
175
175
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
176
176
  *
@@ -0,0 +1,140 @@
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. *MEDIÇÃO, e a população é a versão do
26
+ * `next` que o projeto instalou: **1942 famílias** no `create-next-app` de 08/09, **1911** no
27
+ * `next` 16.2.0 deste repositório.* Cada uma com os pesos, estilos e subsets que existem. Ler dali significa que o que a gente
28
+ * emite é exatamente o que o compilador dele aceita - e que uma família nova do Google chega junto
29
+ * com o `npm update` dele, sem release nosso.
30
+ *
31
+ * `null` QUANDO NÃO DÁ PARA SABER. Sem manifesto legível, este módulo não adivinha: quem chama
32
+ * decide, e a decisão do produto é não escrever o arquivo e dizer o motivo (lei 8). O `<link>` do
33
+ * Google Fonts continua sendo o caminho que funciona - medido em 08/09, ele responde HTTP 200
34
+ * mesmo quando a URL pede um peso que a família não tem, porque aquela API ignora o que não existe.
35
+ */
36
+ import { readFile } from "node:fs/promises";
37
+ import { createRequire } from "node:module";
38
+ import { dirname, join } from "node:path";
39
+ /**
40
+ * Os lugares onde o manifesto do `next/font` já morou. Mais de um de propósito: o caminho é interno
41
+ * ao Next, então uma versão que o mova não pode virar um build quebrado - vira `null`, e o produto
42
+ * diz que não conseguiu ler.
43
+ */
44
+ const MANIFEST_PATHS = [
45
+ "dist/compiled/@next/font/dist/google/font-data.json",
46
+ "dist/compiled/@next/font/google/font-data.json",
47
+ "font/dist/google/font-data.json",
48
+ ];
49
+ /**
50
+ * O manifesto do projeto DELE, resolvido pela mesma regra que o `import` dele usa.
51
+ *
52
+ * `createRequire` a partir da raiz do projeto respeita o hoisting do monorepo: o `next` de um app
53
+ * pode estar na raiz do workspace, e uma busca por caminho fixo em `node_modules/` acharia nada
54
+ * exatamente nos repositórios grandes, que são os que mais precisam disto.
55
+ */
56
+ async function loadManifest(projectRoot) {
57
+ for (const pkg of ["next/package.json", "@next/font/package.json"]) {
58
+ let base;
59
+ try {
60
+ const require = createRequire(join(projectRoot, "package.json"));
61
+ base = dirname(require.resolve(pkg));
62
+ }
63
+ catch {
64
+ continue;
65
+ }
66
+ for (const rel of MANIFEST_PATHS) {
67
+ try {
68
+ const raw = await readFile(join(base, ...rel.split("/")), "utf8");
69
+ const parsed = JSON.parse(raw);
70
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
71
+ return parsed;
72
+ }
73
+ catch {
74
+ // próximo caminho - um manifesto ausente não é erro, é uma resposta
75
+ }
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ /**
81
+ * O leitor de pesos deste projeto, ou `null` quando o manifesto não está ao alcance.
82
+ *
83
+ * A busca do nome é case-insensitive porque o documento guarda o nome como ele aparece no CSS dele
84
+ * (`"instrument serif"` de um `font-family` em minúsculas é a mesma família), e o manifesto guarda
85
+ * a grafia do Google.
86
+ */
87
+ export async function nextFontFacts(projectRoot) {
88
+ const manifest = await loadManifest(projectRoot);
89
+ if (!manifest)
90
+ return null;
91
+ const weights = new Map();
92
+ const names = new Map();
93
+ const subsets = new Map();
94
+ for (const [family, entry] of Object.entries(manifest)) {
95
+ const key = family.toLowerCase();
96
+ weights.set(key, (entry?.weights ?? []).filter((w) => /^\d+$/.test(w)));
97
+ names.set(key, family);
98
+ subsets.set(key, entry?.subsets ?? []);
99
+ }
100
+ const key = (family) => family.trim().toLowerCase();
101
+ return {
102
+ weightsOf: (family) => weights.get(key(family)) ?? null,
103
+ nameOf: (family) => names.get(key(family)) ?? null,
104
+ subsetsOf: (family) => subsets.get(key(family)) ?? null,
105
+ };
106
+ }
107
+ /**
108
+ * OS PESOS A EMITIR para uma família, dados os que o gerador quer e os que existem.
109
+ *
110
+ * `wanted` são os três degraus que todo sistema gerado usa de fato - regular, medium, semibold. A
111
+ * saída é a interseção, na ordem dos disponíveis, e nunca o conjunto vazio: uma família que não tem
112
+ * nenhum dos três (só 200/300, por exemplo) recebe o peso mais próximo de 400 que ela tem, porque
113
+ * emitir lista vazia é `weight: []`, que o `next/font` recusa tanto quanto o peso inexistente.
114
+ */
115
+ export function weightsToEmit(available, wanted = ["400", "500", "600"]) {
116
+ const have = available.filter((w) => /^\d+$/.test(w));
117
+ if (have.length === 0)
118
+ return [];
119
+ const kept = have.filter((w) => wanted.includes(w));
120
+ if (kept.length > 0)
121
+ return kept;
122
+ const nearest = [...have].sort((a, b) => Math.abs(Number(a) - 400) - Math.abs(Number(b) - 400))[0];
123
+ return [nearest];
124
+ }
125
+ /**
126
+ * OS SUBSETS A PEDIR para uma família, dados os que ela tem.
127
+ *
128
+ * `latin` quando ela o tem - é o que o texto dele usa, e pedir mais baixaria arquivo para nada.
129
+ * Sem `latin` mas com outros, os que ela tem: as 11 famílias nessa situação têm exatamente um
130
+ * (`Chenla` e `Content` são `["khmer"]`), então pedir a lista dela é pedir o único que existe.
131
+ *
132
+ * `[]` quando ela não tem subset nenhum, e aí a chave NÃO é escrita: o `next` desliga o preload
133
+ * sozinho nesse caso, e escrever `["latin"]` seria afirmar sobre a fonte dele um subset que ela não
134
+ * declara - a afirmação que este módulo existe para não fazer.
135
+ */
136
+ export function subsetsToEmit(available) {
137
+ if (available.includes("latin"))
138
+ return ["latin"];
139
+ return [...available];
140
+ }
@@ -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.403",
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": {