synthesisui 0.16.421 → 0.16.423

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/guide.js CHANGED
@@ -293,8 +293,49 @@ toolsReachable = false,
293
293
  * essa porta; deixá-la entreaberta um nível acima seria vigiá-la em vez de fechá-la
294
294
  * (CLAUDE.md, Parte I, seção 6).
295
295
  */
296
- fontFacts) {
296
+ fontFacts,
297
+ /**
298
+ * O VOCABULÁRIO DESTE REPOSITÓRIO - `tongueFromArtifacts`, ou `null` quando não há folha para ler.
299
+ *
300
+ * O QUE ISTO CONSERTA, e é a razão de existir desta etapa: este arquivo é o que o agente DELE lê
301
+ * antes de escrever, e ele ensinava a nossa grafia. *"Color: `var(--ds-color-semantic-<role>)`"*
302
+ * é uma instrução para escrever a NOSSA variável no código dele - e ela só resolve com a nossa
303
+ * folha carregada, que é o que `INV-GERAL-13` proíbe. Um guia que ensina isso produz a violação
304
+ * sem que ninguém decida nada: o agente obedece.
305
+ *
306
+ * Com o vocabulário em mão, cada linha abaixo diz o nome que o código DELE dá àquele valor - ou o
307
+ * valor, quando o código dele não nomeia nenhum.
308
+ *
309
+ * OBRIGATÓRIO, pelo mesmo argumento de `fontFacts` logo acima: um argumento opcional aqui é um
310
+ * argumento que um chamador novo esquece, e o esquecimento devolve a nossa grafia ao arquivo que
311
+ * mais a propaga.
312
+ */
313
+ tongue) {
297
314
  const { document: doc, slug, name, version } = payload;
315
+ /**
316
+ * COMO ISTO SE ESCREVE NO CÓDIGO DELE - a mesma resposta que a materialização usa.
317
+ *
318
+ * `var(--nome-dele)` quando o repositório dele nomeia aquele valor; o valor literal quando não.
319
+ * Sem vocabulário (projeto que nunca buildou, folha ausente) a resposta é `null`, e quem chama
320
+ * DIZ isso em vez de imprimir uma grafia que ninguém pode escrever.
321
+ */
322
+ const saysAs = (ours) => {
323
+ if (!tongue)
324
+ return null;
325
+ const theirs = tongue.names.get(ours);
326
+ if (theirs)
327
+ return `var(${theirs})`;
328
+ return tongue.values.get(ours) ?? null;
329
+ };
330
+ /** `spacing` -> `md → var(--gap-md)`, para cada degrau que o vocabulário alcança. */
331
+ const family = (prefix, keys) => {
332
+ const said = keys
333
+ .map((k) => ({ k, as: saysAs(`--ds-${prefix}-${kebab(k)}`) }))
334
+ .filter((x) => x.as !== null);
335
+ return said.length > 0
336
+ ? said.map((x) => `\`${x.k}\` → \`${x.as}\``).join(", ")
337
+ : list(keys);
338
+ };
298
339
  const { meta, foundations, motion, components } = doc;
299
340
  const semanticRoles = Object.keys(foundations.color.semantic);
300
341
  /**
@@ -339,8 +380,6 @@ fontFacts) {
339
380
  */
340
381
  const fontPlan = nextFontSnippet({
341
382
  families: foundations.typography.families,
342
- slug,
343
- seam: payload.fontSeam,
344
383
  facts: fontFacts,
345
384
  /** Os pesos que ELE declara - ver `declaredWeights`. O guia é o arquivo que o agente dele cola. */
346
385
  declaredWeights: foundations.typography.weights,
@@ -548,7 +587,10 @@ It writes a **deterministic scaffold**: the page uses this DS's \`.ds-*\` recipe
548
587
  path-classes, paired with a co-located scoped CSS (Next target) that carries the **responsive** media
549
588
  queries and the **CSS-only hamburger** - so the page is mobile-ready out of the box. **Refine it in
550
589
  place** - wire real data, split into components, swap the chart/icon/media placeholders - but keep the
551
- \`data-ds="${slug}"\` wrapper and the \`.ds-*\` / layout classes so it stays on-system. Run
590
+ wrapper and the \`.ds-*\` / layout classes: the stylesheet next to the page carries the rules for the
591
+ ones it wears, scoped to that wrapper - the command says how many came along, so the page stands on
592
+ its own with nothing imported. A \`.ds-*\` you ADD while refining has no rule there; bring that piece
593
+ in with \`synthesisui component\` instead. Run
552
594
  \`synthesisui init\` once to set the target (next/general) and the output folder.
553
595
 
554
596
  ## Single components as YOUR code
@@ -560,8 +602,8 @@ synthesisui component ${slug} button
560
602
  It writes \`<componentsDir>/button/\` with \`button.tsx\` (+ colocated \`button.css\` in the default
561
603
  \`styles: "css"\` flavor, or Tailwind utilities inline with \`styles: "tailwind"\` - set once via
562
604
  \`synthesisui init --styles tailwind\`). Import and render: \`import { Button } from "components/button"\`.
563
- The boundary: tokens are global (\`tokens.css\` + the \`data-ds\` root attribute, once per app);
564
- everything a component owns lives in its own folder.
605
+ The boundary: everything a component owns lives in its own folder, and every value in it is a name
606
+ YOUR code declares or the value itself - there is nothing global to set up.
565
607
 
566
608
  **A component's conditional classes are decided at the call site, not in the stylesheet.** A recipe
567
609
  carries the look of a state as a rule keyed on a data attribute (\`[data-size="lg"]\`), and the caller
@@ -623,80 +665,73 @@ ${meta.narrative}
623
665
  ${rulesNote}
624
666
  ## How to apply
625
667
 
626
- 1. Import the system once in your project's global CSS, using a path **relative to that CSS
627
- file** - from \`app/globals.css\` in a Next App Router project that means a leading \`../\`:
628
- \`\`\`css
629
- @import "../_synthesisui/ds/${slug}/tokens.css";
630
- \`\`\`
631
- (drop the \`../\` if your global CSS sits at the project root.)${hasTailwind
632
- ? `\n **Using Tailwind (this project's default)? You also need \`theme.css\` right after \`tokens.css\` -\n without it the DS-backed utilities (\`bg-primary\`, \`p-md\`…) aren't generated and the UI renders\n unstyled. See "Styling with Tailwind v4" below for the full import block.**`
633
- : ""}
668
+ **Nothing here is imported into your app.** \`_synthesisui/ds/${slug}/\` is the reference this guide,
669
+ the check, the doctor and the MCP server read to interpret your design - your code never points at
670
+ it, and nothing we write into this repository needs it at runtime.
634
671
 
635
- 2. Wrap the tree that should use the system with the scope attribute:
636
- \`\`\`html
637
- <div data-ds="${slug}">…your UI here…</div>
638
- \`\`\`
639
- All \`--ds-*\` custom properties and \`.ds-*\` classes only apply inside that scope.
640
- Applying \`data-ds="${slug}"\` at the app root (e.g. \`<body>\` or the root layout)
641
- is the simplest choice - the whole app then wears the system.
642
- ${hasAlt
643
- ? `
644
- 3. Light/dark: an ancestor with \`data-scheme="${altScheme}"\` switches the neutral roles to the opposite mode.
645
- \`\`\`tsx
646
- <div data-scheme="${altScheme}"><div data-ds="${slug}">…</div></div>
672
+ 1. Ask for a component and it arrives as YOUR code:
647
673
  \`\`\`
648
- A theme toggle just adds/removes that attribute on the scope element:
649
- \`\`\`tsx
650
- root.toggleAttribute("data-scheme"); // present = ${altScheme}, absent = ${meta.scheme}
674
+ npx synthesisui component ${slug} <name>
651
675
  \`\`\`
652
- `
676
+ It lands in your components directory speaking the names your own code declares - or carrying the
677
+ value itself, where your code names none. No import, no scope attribute, nothing at runtime.
678
+
679
+ 2. Writing something by hand? Use the vocabulary below. Every name in it is a name **your own code
680
+ declares**, written exactly as you would write it.
681
+ ${hasAlt
682
+ ? `\n3. Light/dark: this system's roles have two readings, and a component that arrives from
683
+ \`component\` carries both. Which one shows is your app's own scheme switch - we do not add one.\n`
653
684
  : ""}${fontsSection}${depsSection}${hasTailwind
654
685
  ? `
655
686
  ## Styling with Tailwind v4 (preferred in this project)
656
687
 
657
- Import \`theme.css\` after \`tailwindcss\` and \`tokens.css\`:
658
- \`\`\`css
659
- @import "tailwindcss";
660
- @import "./_synthesisui/ds/${slug}/tokens.css";
661
- @import "./_synthesisui/ds/${slug}/theme.css";
662
- \`\`\`
663
- This maps the DS tokens onto Tailwind's theme, so inside \`[data-ds="${slug}"]\` you get utilities
664
- backed by the design system: \`bg-*\`/\`text-*\`/\`border-*\` (semantic colors), \`p-*\`/\`m-*\`/\`gap-*\`
665
- (spacing), \`rounded-*\`, \`shadow-*\`, \`font-*\` (families **and** weights), \`text-*\` (type scale), \`ease-*\`${seriesKeys.length > 0
666
- ? `, \`bg-series-*\`/\`text-series-*\`/\`fill-series-*\` (data-viz series)`
667
- : ""}.
688
+ Your own \`@theme\` is what backs the utilities, and it is the one that decides. Where it declares a
689
+ name, the utility already paints your value and it is the most readable code you can write; where it
690
+ does not, write the value.
668
691
 
669
- **Prefer these utilities for layout and new composition** - they are this project's idiom and read
670
- far better than inline \`style\`. Reach for inline \`var(--ds-*)\` only when no utility fits.
692
+ **Prefer utilities for layout and new composition** - they are this project's idiom and read far
693
+ better than inline \`style\`.
671
694
 
672
695
  \`\`\`tsx
673
- // ✅ preferred - Tailwind utilities backed by the DS
674
- <main className="bg-canvas text-foreground p-2xl flex flex-col gap-md">
675
- <button className="ds-button" data-intent="primary">Save</button>
696
+ // preferred - your project's own utilities
697
+ <main className="p-2xl flex flex-col gap-md">
698
+ <button className="rounded-md px-md py-2xs">Save</button>
676
699
  </main>
700
+ \`\`\`
701
+
702
+ To give a utility the system's value, declare the name in **your own** \`@theme\` - it is your
703
+ stylesheet, your namespace, and nothing of ours is involved:
677
704
 
678
- // ❌ avoid - inline styles with raw var() when a utility exists
679
- <main style={{ background: "var(--ds-color-semantic-canvas)", padding: "var(--ds-spacing-2xl)" }}>
705
+ \`\`\`css
706
+ @theme {
707
+ --radius-md: <the value you want rounded-md to paint>;
708
+ }
680
709
  \`\`\`
681
710
 
711
+ Do that and \`synthesisui component\` starts writing \`rounded-md\` instead of the raw value, because
712
+ the name now exists in your project.
713
+
682
714
  ---
683
715
  `
684
716
  : ""}
685
- This is **v${version}**. The stable entrypoints at \`_synthesisui/ds/${slug}/\` (the
686
- \`tokens.css\`/\`theme.css\` re-exports, plus \`.lock\`) always point at the active version - import
687
- those, not the versioned ones. The pinned files for this version - ${artifactList},
717
+ This is **v${version}**. The pinned files for this version - ${artifactList},
688
718
  \`design-system.json\` (canonical source of truth), \`GUIDE.md\` (this file) - live in
689
- \`_synthesisui/ds/${slug}/v${version}/\`.
719
+ \`_synthesisui/ds/${slug}/v${version}/\`, and they are OURS to read: the check, the doctor and the MCP
720
+ server use them to interpret your design. Your app imports none of them.
690
721
 
691
722
  ---
692
723
  ${pagesSection}
693
724
  ## Building with the system
694
725
 
695
726
  **This system is for building real product UI** - pages, layouts, dashboards, whole flows.
696
- Compose the \`.ds-*\` recipes (and their parts) together with the DS-backed utilities to assemble
697
- actual screens. There is **no "samples only" rule**: build the real app. An
698
- \`app/synthesisui-samples/<component>/\` page is a fine *optional* scratch space to eyeball a single
699
- component, but it is never required.
727
+ Bring the recipes in as YOUR code (\`npx synthesisui component ${slug} <name>\`) and compose them with
728
+ your own utilities to assemble actual screens. There is **no "samples only" rule**: build the real
729
+ app. An \`app/synthesisui-samples/<component>/\` page is a fine *optional* scratch space to eyeball a
730
+ single component, but it is never required.
731
+
732
+ The \`.ds-*\` class names below describe the recipe's SHAPE - which parts it has and which
733
+ \`data-*\` each one takes. They are what the materialized component wears, and the stylesheet that
734
+ declares them comes with it.
700
735
 
701
736
  ### Layout & composition
702
737
  The system defines the scale; these are sensible defaults for spending it:
@@ -728,10 +763,9 @@ part classes and their \`data-*\` are listed per component below. Example - a ta
728
763
  `
729
764
  : ""}
730
765
  ### Overlays & portals
731
- Dialogs, menus and toasts are often rendered through a portal at the end of \`<body>\` - **outside**
732
- your \`data-ds\` scope. Since \`.ds-*\`/\`--ds-*\` only resolve inside the scope, wrap any portalled UI
733
- in its own \`<div data-ds="${slug}"${hasAlt ? ` data-scheme="…"` : ""}>\`, or apply \`data-ds\` at the
734
- app root so everything (portals included) inherits it. Behavior (open/close, focus trap, positioning,
766
+ Dialogs, menus and toasts are often rendered through a portal at the end of \`<body>\` - outside
767
+ the tree they were written in. A component brought in by \`synthesisui component\` carries its own
768
+ stylesheet and works anywhere, portal included. Behavior (open/close, focus trap, positioning,
735
769
  keyboard) is yours to wire - the system ships the **looks**, not the JavaScript.
736
770
 
737
771
  ### Interactive recipes - the behavior contract
@@ -756,34 +790,47 @@ styles it, you make it work.
756
790
  ## Rules (follow them when creating components)
757
791
  ${hasTailwind
758
792
  ? `
759
- - **Styling mechanism:** prefer Tailwind utilities backed by the DS (\`bg-primary\`, \`p-md\`,
760
- \`font-display\`, \`font-medium\`, …) for layout and new composition, and reuse the \`.ds-*\` recipes
761
- for components the DS already covers. Use inline \`style\` with \`var(--ds-*)\` only as a last resort.
762
- The token names below are the source vocabulary - every utility derives from them.`
793
+ - **Styling mechanism:** prefer your project's own Tailwind utilities for layout and new
794
+ composition, and bring the recipes in as code (\`synthesisui component ${slug} <name>\`) for the
795
+ components this system already covers. Where no utility fits, write the name from the vocabulary
796
+ below - it is a name your own code declares.`
763
797
  : ""}
764
- - **Always use semantic tokens**, never raw values nor primitives directly.
765
- Color: \`var(--ds-color-semantic-<role>)\`${hasTailwind ? " (utility: `bg-<role>`/`text-<role>`)" : ""}. The roles are: ${list(semanticRoles.map((r) => {
798
+ - **Always use the system's vocabulary**, never raw values picked by eye. Every name below is a
799
+ name **your own code declares** - written exactly as you would write it. Nothing here comes from
800
+ a stylesheet of ours, and nothing needs one.
801
+ Color${hasTailwind ? " (utility: `bg-<role>`/`text-<role>`)" : ""}: ${semanticRoles
802
+ .map((r) => {
766
803
  const theirs = theirRole(r);
767
- return theirs === r ? r : `${r} (${theirs} in your code)`;
768
- }))}.
769
- - Primitives (\`--ds-color-<palette>-<step>\`) exist but should **not** be referenced directly -
770
- they feed the semantic roles.${namedColours.length > 0
771
- ? `\n- **Yours by name** → \`var(--ds-color-<name>)\`: ${namedColours.length} colour${namedColours.length === 1 ? "" : "s"} your code names by PURPOSE rather than by step, so no scale could hold ${namedColours.length === 1 ? "it" : "them"} - ${list(namedColours.slice(0, 8))}${namedColours.length > 8 ? ", …" : ""}. These are yours: reach for them when the purpose matches, and prefer a semantic role when it does not.`
804
+ /** O papel com o nome que ELE dá a ele - e o parêntese só sai quando os dois diferem. */
805
+ const named = theirs === r ? `\`${r}\`` : `\`${r} (${theirs} in your code)\``;
806
+ const as = saysAs(`--ds-color-semantic-${kebab(r)}`);
807
+ return as ? `${named} → \`${as}\`` : named;
808
+ })
809
+ .join(", ")}.${namedColours.length > 0
810
+ ? `\n- **Yours by name**: ${namedColours.length} colour${namedColours.length === 1 ? "" : "s"} your code names by PURPOSE rather than by step, so no scale could hold ${namedColours.length === 1 ? "it" : "them"} - ${family("color", namedColours.slice(0, 8))}${namedColours.length > 8 ? ", …" : ""}. These are yours: reach for them when the purpose matches, and prefer a semantic role when it does not.`
772
811
  : ""}${seriesKeys.length > 0
773
- ? `\n- Data-viz → \`var(--ds-color-series-<n>)\`${hasTailwind ? " (utility: `bg-series-<n>`/`text-series-<n>`/`fill-series-<n>`)" : ""}: categorical chart/series colors, ${seriesKeys.length} of them (${list(seriesKeys)}). Use them in order for multi-series charts; they re-paint with the system.`
812
+ ? `\n- Data-viz${hasTailwind ? " (utility: `bg-series-<n>`/`text-series-<n>`/`fill-series-<n>`)" : ""}: categorical chart/series colors, ${seriesKeys.length} of them - ${family("color-series", seriesKeys)}. Use them in order for multi-series charts; they re-paint with the system.`
774
813
  : ""}
775
- - Spacing → \`var(--ds-spacing-<key>)\`: ${list(Object.keys(foundations.spacing))}.
776
- - Radius → \`var(--ds-radius-<key>)\`: ${list(Object.keys(foundations.radius))}.
777
- - Shadow → \`var(--ds-shadow-<key>)\`: ${list(Object.keys(foundations.shadow))}.
814
+ - Spacing → ${family("spacing", Object.keys(foundations.spacing))}.
815
+ - Radius → ${family("radius", Object.keys(foundations.radius))}.
816
+ - Shadow → ${family("shadow", Object.keys(foundations.shadow))}.
778
817
  - Typography: families ${familySlots(foundations.typography.families)
779
- .map((slot) => `\`${payload.fontSeam?.[slot] ?? `${FAMILY_SEAM_PREFIX}${slot}`}\` (${foundations.typography.families[slot]})`)
818
+ .map((slot) => {
819
+ const as = saysAs(`${FAMILY_SEAM_PREFIX}${kebab(slot)}`);
820
+ const face = foundations.typography.families[slot];
821
+ return as ? `\`${slot}\` → \`${as}\` (${face})` : `\`${slot}\` (${face})`;
822
+ })
780
823
  .join(", ")};
781
824
  weights${hasTailwind ? " (utility: `font-<key>`)" : ""}: ${list(weights)};
782
- scale \`--ds-typography-scale-<key>-font-size\`${hasTailwind ? " (utility: `text-<key>`)" : ""}: ${list(Object.keys(foundations.typography.scale))}.
783
- - Motion: durations \`--ds-motion-durations-<key>\` (${list(Object.keys(motion.durations))}) and
784
- easings \`--ds-motion-easings-<key>\` (${list(Object.keys(motion.easings))}). Use them on
785
- \`transition\` (e.g. \`transition: color var(--ds-motion-durations-fast) var(--ds-motion-easings-standard)\`)
786
- so timing stays on-brand. For ANIMATION, this system ships a named vocabulary - see
825
+ scale${hasTailwind ? " (utility: `text-<key>`)" : ""}: ${Object.keys(foundations.typography.scale)
826
+ .map((k) => {
827
+ const as = saysAs(`--ds-typography-scale-${kebab(k)}-font-size`);
828
+ return as ? `\`${k}\` → \`${as}\`` : `\`${k}\``;
829
+ })
830
+ .join(", ")}.
831
+ - Motion: durations ${family("motion-durations", Object.keys(motion.durations))}
832
+ and easings ${family("motion-easings", Object.keys(motion.easings))}. Use them on
833
+ \`transition\` so timing stays on-brand. For ANIMATION, this system ships a named vocabulary - see
787
834
  **Motion vocabulary** below; never hand-roll \`@keyframes\` or raw durations.
788
835
  - When **creating a new component** the DS does not cover yet: compose it from these semantic
789
836
  tokens to inherit the system's identity; do not invent colors/measures outside the scale.
@@ -817,7 +864,8 @@ tell the person what you chose from the vocabulary instead.`
817
864
 
818
865
  ## Ready-made components
819
866
 
820
- Each recipe becomes a \`.ds-<name>\` class (inside the \`[data-ds="${slug}"]\` scope). Variants are
867
+ Each recipe has a \`.ds-<name>\` class - the SHAPE it wears when \`synthesisui component\` writes it
868
+ into your project, with the stylesheet that declares it alongside. Variants are
821
869
  \`data-<axis>="<option>"\` attributes; states (hover/focus/active/disabled) ship in the CSS;
822
870
  multi-part components expose \`.ds-<name>-<part>\` classes (listed under each).
823
871
 
@@ -832,7 +880,7 @@ A small gamification library the AI advisor (\`synthesisui advise\`) can propose
832
880
  \`.ds-<name>\` recipe shape as the components above, token-only so they wear the system. Use them
833
881
  **only where they fit the product** (progress, retention, recognition); they're a library to compose
834
882
  from, not a default - and lean against over-gamifying a serious B2B product. Each is a \`.ds-<name>\`
835
- class inside the \`[data-ds="${slug}"]\` scope; multi-part ones expose \`.ds-<name>-<part>\`.
883
+ recipe; multi-part ones expose \`.ds-<name>-<part>\`.
836
884
 
837
885
  ${blockLines.join("\n\n")}
838
886
  `
@@ -235,7 +235,7 @@
235
235
  * chama `wireAgent`, então rodar o comando é o caminho de volta - e ele só alarga a string que era
236
236
  * nossa, nunca um filtro que uma pessoa escreveu.
237
237
  */
238
- export const MATERIALISER_SINCE = "0.16.421";
238
+ export const MATERIALISER_SINCE = "0.16.423";
239
239
  /**
240
240
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
241
241
  *
@@ -343,8 +343,8 @@ export const MATERIALISER_SINCE = "0.16.421";
343
343
  * A frase viaja ao lado da versão porque as duas são uma coisa só: quem move a marca troca a
344
344
  * explicação no mesmo lugar, em vez de deixar o CI citando a causa da marca anterior.
345
345
  */
346
- export const COUNTED_DIFFERENTLY_SINCE = "0.16.421";
347
- export const COUNTED_DIFFERENTLY = "this run counts the utilities of your own theme - that version counted only `var(--token)`";
346
+ export const COUNTED_DIFFERENTLY_SINCE = "0.16.422";
347
+ export const COUNTED_DIFFERENTLY = "this run counts a value as named only when YOUR code names it - that version also counted the ones only the system named";
348
348
  /**
349
349
  * 0.16.408 -> 0.16.413 em 10/09: o hook passa a DIZER o valor que nada nomeia. Ele silenciava toda
350
350
  * deriva sem token de destino - a decisão estava escrita como "não vale interromper, porque o único
@@ -366,7 +366,7 @@ export const COUNTED_DIFFERENTLY = "this run counts the utilities of your own th
366
366
  * utility, um arquivo escrito inteiro no vocabulário do sistema recebia zero. A contagem em si é a
367
367
  * outra marca - ver `COUNTED_DIFFERENTLY_SINCE`, que sobe no mesmo diff.
368
368
  */
369
- export const CHECKER_SINCE = "0.16.421";
369
+ export const CHECKER_SINCE = "0.16.422";
370
370
  /**
371
371
  * A ÚLTIMA VERSÃO EM QUE OS LEITORES PASSARAM A PRODUZIR UM CENSO DIFERENTE.
372
372
  *
@@ -0,0 +1,257 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /** A forma mínima que faz de um objeto um design system - abaixo disto não há o que comparar. */
4
+ function shapeOf(value) {
5
+ if (!value || typeof value !== "object")
6
+ return "it is not an object";
7
+ const doc = value;
8
+ if (!doc.meta || typeof doc.meta !== "object")
9
+ return "it has no `meta` - a design system always carries its name and slug";
10
+ if (!doc.foundations || typeof doc.foundations !== "object")
11
+ return "it has no `foundations` - a design system always carries its tokens";
12
+ return null;
13
+ }
14
+ async function readDocument(path) {
15
+ let raw;
16
+ try {
17
+ raw = await readFile(path, "utf8");
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(raw);
25
+ }
26
+ catch (error) {
27
+ return {
28
+ ok: false,
29
+ why: error instanceof Error ? error.message : "it is not valid JSON",
30
+ };
31
+ }
32
+ const wrong = shapeOf(parsed);
33
+ if (wrong)
34
+ return { ok: false, why: wrong };
35
+ return { ok: true, document: parsed };
36
+ }
37
+ /**
38
+ * QUAL VERSÃO ESTÁ MATERIALIZADA NESTE REPOSITÓRIO - a que o `.lock` registra.
39
+ *
40
+ * Ler a pasta `v*` mais alta seria adivinhar: um `add --version N` deixa as duas no disco, e o
41
+ * `.lock` é o que diz qual delas o repositório está usando. `null` quando não há install.
42
+ */
43
+ export async function installedVersion(root, slug) {
44
+ try {
45
+ const raw = await readFile(join(root, "_synthesisui", "ds", slug, ".lock"), "utf8");
46
+ const lock = JSON.parse(raw);
47
+ return typeof lock.version === "number" ? lock.version : null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /** O par que a comparação precisa: o que foi instalado, e o que está no disco agora. */
54
+ export async function readInstalledPair(root, slug, version) {
55
+ const dir = join(root, "_synthesisui", "ds", slug, `v${version}`);
56
+ const local = await readDocument(join(dir, "design-system.json"));
57
+ if (local === null)
58
+ return { kind: "not-installed" };
59
+ if (!local.ok)
60
+ return {
61
+ kind: "unreadable",
62
+ where: join(dir, "design-system.json"),
63
+ why: local.why,
64
+ };
65
+ const installed = await readDocument(join(dir, ".installed.json"));
66
+ if (installed === null)
67
+ return {
68
+ kind: "no-baseline",
69
+ /**
70
+ * O CAMINHO DE SAÍDA AVISA DO RISCO ANTES DO COMANDO.
71
+ *
72
+ * `add` reescreve `design-system.json` sem comparar nada. Mandar rodá-lo sem dizer isso
73
+ * apagaria, em silêncio, exatamente a edição que este recurso existe para preservar - e a
74
+ * pessoa teria seguido o passo que a plataforma recomendou.
75
+ */
76
+ why: `this install predates the file that tracks your edits, and \`add\` rewrites design-system.json. If you already edited it, copy it first (cp _synthesisui/ds/${slug}/v<n>/design-system.json /tmp/${slug}-before-add.json), then run \`npx synthesisui@latest add ${slug}\` once - every edit made after that comes up with the next sync.`,
77
+ };
78
+ if (!installed.ok)
79
+ return {
80
+ kind: "unreadable",
81
+ where: join(dir, ".installed.json"),
82
+ why: installed.why,
83
+ };
84
+ return { kind: "pair", installed: installed.document, local: local.document };
85
+ }
86
+ /** Achata fundação e motion em `caminho → valor`, na mesma gramática do documento. */
87
+ function flatten(value, prefix, into) {
88
+ if (!value || typeof value !== "object")
89
+ return;
90
+ for (const [key, inner] of Object.entries(value)) {
91
+ const path = prefix ? `${prefix}.${key}` : key;
92
+ if (typeof inner === "string" || typeof inner === "number")
93
+ into.set(path, String(inner));
94
+ else
95
+ flatten(inner, path, into);
96
+ }
97
+ }
98
+ /**
99
+ * O QUE NÃO VIAJA, DITO EM VOZ ALTA - lei 8, e a lista é curta de propósito.
100
+ *
101
+ * `meta` é a identidade do sistema (nome, slug, tagline): mudá-la é ato dela na plataforma, não
102
+ * do agente dele. `philosophy` e `analyses` são texto que a plataforma escreve e nenhum prompt
103
+ * pede para editar. Os três ficam fora porque não são decisão do código dele - e é por isso que
104
+ * a ausência deles aqui é uma escolha, e não um esquecimento.
105
+ */
106
+ export const NOT_FROM_HIS_FILES = ["meta", "philosophy", "analyses"];
107
+ const RECIPE_MAPS = [
108
+ ["components", "component"],
109
+ ["blocks", "block"],
110
+ ["layouts", "layout"],
111
+ ["charts", "chart"],
112
+ ];
113
+ /**
114
+ * A PROCEDÊNCIA NÃO É UM TOKEN, e o mesmo defeito já custou uma sessão do outro lado.
115
+ *
116
+ * `foundations.source` guarda quem escreveu cada caminho. Sem esta linha, um carimbo mudado por
117
+ * um comando nosso apareceria como "o agente dele trocou um valor de design".
118
+ */
119
+ function tokensOf(doc) {
120
+ const out = new Map();
121
+ const { source: _source, ...rest } = doc.foundations;
122
+ flatten(rest, "", out);
123
+ /**
124
+ * E TODO EIXO DE RAIZ QUE NÃO É RECEITA, varrido pelo que o documento TEM - e não por uma
125
+ * lista escrita à mão.
126
+ *
127
+ * A primeira escrita enumerava `foundations` e `motion`, e deixava de fora `icons` e
128
+ * `globals` - este último carrega a folha de projeto inteira, 107 declarações num cliente
129
+ * real. O agente editava, o `sync` dizia "nothing new", e a alteração morria no disco.
130
+ * Varrer o que existe faz um eixo NOVO do contrato entrar sozinho, em vez de esperar alguém
131
+ * lembrar.
132
+ */
133
+ const root = doc;
134
+ const recipes = new Set(RECIPE_MAPS.map(([key]) => key));
135
+ for (const key of Object.keys(root)) {
136
+ if (key === "foundations")
137
+ continue;
138
+ if (recipes.has(key))
139
+ continue;
140
+ if (NOT_FROM_HIS_FILES.includes(key))
141
+ continue;
142
+ flatten(root[key], key, out);
143
+ }
144
+ return out;
145
+ }
146
+ function fileOf(recipe) {
147
+ return recipe?.source?.file;
148
+ }
149
+ /**
150
+ * O QUE MUDOU ENTRE O QUE FOI INSTALADO E O QUE ESTÁ NO DISCO.
151
+ *
152
+ * Puro sobre os dois documentos. Uma receita conta como UMA alteração, e não uma por declaração:
153
+ * o que o cliente lê no terminal é "o agente mexeu no Button", e a lista de declarações mora no
154
+ * documento que sobe junto.
155
+ */
156
+ export function localEdits(installed, local) {
157
+ const out = [];
158
+ const was = tokensOf(installed);
159
+ const now = tokensOf(local);
160
+ for (const path of [...new Set([...was.keys(), ...now.keys()])].sort()) {
161
+ const from = was.get(path);
162
+ const to = now.get(path);
163
+ if (from === to)
164
+ continue;
165
+ out.push({
166
+ what: path,
167
+ of: "token",
168
+ kind: from === undefined ? "added" : to === undefined ? "removed" : "changed",
169
+ ...(from !== undefined ? { from } : {}),
170
+ ...(to !== undefined ? { to } : {}),
171
+ });
172
+ }
173
+ for (const [key, of_] of RECIPE_MAPS) {
174
+ const before = installed[key];
175
+ const after = local[key];
176
+ const names = [
177
+ ...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]),
178
+ ].sort();
179
+ for (const name of names) {
180
+ const from = before?.[name];
181
+ const to = after?.[name];
182
+ if (from === undefined && to === undefined)
183
+ continue;
184
+ if (from !== undefined && to !== undefined) {
185
+ if (JSON.stringify(from) === JSON.stringify(to))
186
+ continue;
187
+ const file = fileOf(to) ?? fileOf(from);
188
+ out.push({
189
+ what: name,
190
+ of: of_,
191
+ kind: "changed",
192
+ ...(file ? { file } : {}),
193
+ });
194
+ continue;
195
+ }
196
+ const file = fileOf(to ?? from);
197
+ out.push({
198
+ what: name,
199
+ of: of_,
200
+ kind: from === undefined ? "added" : "removed",
201
+ ...(file ? { file } : {}),
202
+ });
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+ /**
208
+ * QUANTAS LINHAS O TERMINAL NOMEIA ANTES DE DIZER QUANTAS SOBRARAM.
209
+ *
210
+ * Doze é o que cabe numa tela sem rolagem depois do resto da saída do `sync`, e o que não couber
211
+ * é DITO - uma lista cortada em silêncio faz doze alterações parecerem doze quando são quarenta.
212
+ */
213
+ const NAMED = 12;
214
+ /**
215
+ * O QUE O `sync` DIZ SOBRE O QUE ELE ENCONTROU NOS ARQUIVOS - nunca um "ok" mudo, e nunca uma
216
+ * afirmação de sucesso antes de o servidor responder.
217
+ *
218
+ * A primeira escrita dizia *"N changes from your files WENT UP"*, e ela era montada antes do
219
+ * envio: quando o servidor recusava, a saída afirmava sucesso numa linha e fracasso na seguinte,
220
+ * na mesma tela. Quem lê rápido carrega a primeira. Esta frase relata o que foi ENCONTRADO; quem
221
+ * diz que chegou é `landedLine`, depois da resposta.
222
+ */
223
+ export function sentLine(edits, slug) {
224
+ if (edits.length === 0)
225
+ return `nothing new in ${slug} - the design system on this machine matches what the platform has`;
226
+ const head = `${edits.length} change${edits.length === 1 ? "" : "s"} found in your files, from ${slug}:`;
227
+ const lines = edits.slice(0, NAMED).map((edit) => {
228
+ const where = edit.file ? ` · ${edit.file}` : "";
229
+ const move = edit.from !== undefined && edit.to !== undefined
230
+ ? ` ${edit.from} → ${edit.to}`
231
+ : edit.kind === "added"
232
+ ? " (new)"
233
+ : edit.kind === "removed"
234
+ ? " (gone)"
235
+ : "";
236
+ return ` ${edit.what}${move}${where}`;
237
+ });
238
+ const rest = edits.length > NAMED ? [` and ${edits.length - NAMED} more`] : [];
239
+ return [head, ...lines, ...rest].join("\n");
240
+ }
241
+ /**
242
+ * DEPOIS DE SUBIR, O BASELINE PASSA A SER O QUE ESTÁ NO DISCO - a metade que fecha o ciclo.
243
+ *
244
+ * Sem isto, todo `sync` reenviaria a mesma alteração para sempre e o documento na plataforma
245
+ * ganharia uma revisão nova a cada rodada, sem nada ter mudado. É a outra metade de A4, e ela
246
+ * mora aqui - e não no corpo do comando - para poder ser exercida por spec sem subir nada.
247
+ *
248
+ * SÓ DEPOIS DE O SERVIDOR TER ACEITADO: uma recusa mantém o baseline velho, para a próxima
249
+ * rodada tentar de novo em vez de esquecer o que ele fez.
250
+ */
251
+ /** E a confirmação, depois de o servidor ter aceitado - a única frase no passado. */
252
+ export function landedLine(applied, slug) {
253
+ return `${applied} change${applied === 1 ? "" : "s"} from your files went up to ${slug}`;
254
+ }
255
+ export async function settleBaseline(root, slug, version, document) {
256
+ await writeFile(join(root, "_synthesisui", "ds", slug, `v${version}`, ".installed.json"), `${JSON.stringify(document)}\n`, "utf8");
257
+ }