synthesisui 0.16.500 → 0.16.502

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/claude-md.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
3
  import { loadRoleRules, rolesContract } from "./role-rules.js";
3
4
  import { dirname, join } from "node:path";
@@ -5,6 +6,13 @@ import { hasHook } from "./agent-wiring.js";
5
6
  import { declaredReference } from "./group-role.js";
6
7
  import { recallAvailable } from "./memory/availability.js";
7
8
  import { readResponsiveForm, responsiveLine } from "./responsive-form.js";
9
+ /**
10
+ * A LEITURA - o caminho do sistema desde a fase 1 (18/10). Escrita uma vez e usada nos dois contratos: o de quem já tem
11
+ * sistema sem `rules.json` e o de quem chegou pela porta e ainda não tem sistema nenhum (fase 2).
12
+ */
13
+ const READ_THE_PAGE = "**Once the first screen is done, offer to read it - once.** If their next message does not take the offer up, do not offer it again; they can still ask. When the person asks you to read their page - or right after the creation of the first screen ends (with impeccable, once it has written `DESIGN.md`; never while it still asks for another round) - run `npx synthesisui read` with the dev server up, tell them in a few lines the system it found, then ask the questions in `_synthesisui/reading.json` ONE AT A TIME with your question tool (the first option is the default). Save their answers as `{ \"<question id>\": <option index> }` and run `npx synthesisui read --confirm <that file>`: their answers become the roles and breaks this project keeps.";
14
+ /** A frase do revisor depois da criação - ver o comentário em `renderRegion`, junto dos papéis. */
15
+ const REVIEWER_AFTER_CREATION = "\n\n**How a change closes here.** The visual world is decided - the roles and breaks above. From here on SynthesisUI's check closes a change (`check_file`, and the end-of-turn check). Impeccable's reviewer is for creating: spawn `impeccable-finish-reviewer` or run `impeccable detect` only when the person asks for a new visual direction or a large new surface with its own look - never as the routine close of a change, and never its degraded self-review in its place.";
8
16
  const START = "<!-- synthesisui:start -->";
9
17
  const END = "<!-- synthesisui:end -->";
10
18
  /** Reads the installed DSs from the .lock files in _synthesisui/ds/<slug>/. */
@@ -337,9 +345,10 @@ async function renderRegion(projectRoot, installed) {
337
345
  "This project has the synthesisui agent set up and **no design system yet** - so there is",
338
346
  "no token vocabulary to follow, and nothing here to obey.",
339
347
  "",
340
- "To turn this repository into one, run `/sui-import-ds` and I will read what is",
341
- "already in the code: the colours, the type, the shapes and the components. Nothing",
342
- "is invented, and nothing is copied out.",
348
+ READ_THE_PAGE,
349
+ "",
350
+ "A system kept somewhere else - a published one, or a repository's own - comes in with",
351
+ "`/sui-import-ds` instead: it reads what is already in the code. Nothing is invented.",
343
352
  "",
344
353
  "The `synthesisui` MCP server is registered, so ASK it instead of guessing - it",
345
354
  "answers what exists and what does not. This block fills itself in once a system is",
@@ -469,12 +478,18 @@ written now** - read the code before you edit it, never a remembered signature.`
469
478
  * livres passa pelo `landing` antes de qualquer linha - ele lista as seções do catálogo e devolve o prompt da pilha, com
470
479
  * as decisões tomadas. Sem servidor também: o `landing` fala com o registro direto.
471
480
  */
481
+ /**
482
+ * O REVISOR DA CAMADA DE ESTÉTICA SÓ NA CRIAÇÃO - decisão dele, 18/10 15:23 ("só na criação"). Antes da confirmação o
483
+ * impeccable segue as próprias instruções, revisor incluído: o mundo ainda está sendo criado. Com o sistema confirmado,
484
+ * fecha a mudança a nossa checagem. Pela instrução, nunca apagando o agente dele - compor, não forkar (15:25). A medida
485
+ * que motivou: no fluxo com o revisor em todo turno, 52 de 103 min e metade dos tokens (estudo de 18/10, seção 12).
486
+ */
472
487
  /** Os papéis e as quebras que a página dele já decidiu - ver `role-rules.ts` (18/10). Só existe com `rules.json`. */
473
488
  const roleRules = await loadRoleRules(projectRoot).catch(() => null);
474
489
  const rolesLine = roleRules
475
- ? rolesContract(roleRules)
490
+ ? rolesContract(roleRules) + (existsSync(join(projectRoot, ".claude", "skills", "impeccable")) ? REVIEWER_AFTER_CREATION : "")
476
491
  : /** Sem rules.json ainda: o agente oferece a leitura (jornada "a leitura no agente", E03, 18/10). */
477
- "\n\n**Once the first screen exists, offer to read it.** When the person asks you to read their page - or right after you build the first screen - run `npx synthesisui read` with the dev server up, tell them in a few lines the system it found, then ask the questions in `_synthesisui/reading.json` ONE AT A TIME with your question tool (the first option is the default). Save their answers as `{ \"<question id>\": <option index> }` and run `npx synthesisui read --confirm <that file>`: their answers become the roles and breaks this project keeps.";
492
+ `\n\n${READ_THE_PAGE}`;
478
493
  const landingLine = '\n\n**A landing asked in free words:** run `npx synthesisui@latest landing <slug> "<their words>"` first, and follow it.';
479
494
  const preferencesLine = recall.available
480
495
  ? `
@@ -81,6 +81,8 @@ const IGNORED = [
81
81
  * da mesma versão, em branches separadas, darem conflito numa linha só.
82
82
  */
83
83
  ".fetched-at.json",
84
+ /** A oferta da leitura, feita uma vez neste clone - ver `offerTheReading` (fase 2, 18/10). */
85
+ ".read-offered",
84
86
  ];
85
87
  const IGNORE_HEADER = "# Managed by synthesisui. The identity and the CSS are committed so a fresh\n" +
86
88
  "# clone is governed; the measurement and the local record are not, because\n" +
@@ -316,7 +316,10 @@ export async function connect(opts) {
316
316
  * A PERGUNTA SÓ EXISTE ONDE HÁ ALGUÉM PARA RESPONDER. Em `--ci`, num pipe ou dentro de um agente,
317
317
  * `ask` vai `null` e a pasta decide - e a linha DIZ que foi ela, porque silêncio nunca é escolha.
318
318
  */
319
- const interactive = !opts.ci && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
319
+ const interactive = !opts.ci &&
320
+ opts.interactive !== false &&
321
+ Boolean(process.stdin.isTTY) &&
322
+ Boolean(process.stdout.isTTY);
320
323
  const chosen = await chooseAgents({
321
324
  root,
322
325
  flag: opts.agents,
@@ -471,7 +474,7 @@ export async function connect(opts) {
471
474
  */
472
475
  if (want.hook && wired.hook !== "skipped") {
473
476
  const before = await readAutoSync(root).catch(() => null);
474
- const now = before ?? (await askToSendAtTurnEnd());
477
+ const now = before ?? (opts.interactive === false ? null : await askToSendAtTurnEnd());
475
478
  if (before === null && now !== null)
476
479
  await writeAutoSync(root, now);
477
480
  row(before === null, now === true
@@ -784,7 +787,8 @@ export async function connect(opts) {
784
787
  }
785
788
  }
786
789
  /** A mesma versão que a fiação do editor recebe - os dois pinam no mesmo número. */
787
- await offerShellHook(opts.shell === true, opts.version);
790
+ if (opts.interactive !== false)
791
+ await offerShellHook(opts.shell === true, opts.version);
788
792
  /**
789
793
  * O REINÍCIO SÓ QUANDO ELE É NECESSÁRIO - e pedi-lo sempre é o que fez o dono achar que reiniciar
790
794
  * o editor fazia parte do fluxo (07/08).
@@ -1,6 +1,6 @@
1
1
  import { readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { ourSpellingLines } from "../our-spelling.js";
3
- import { existsSync } from "node:fs";
3
+ import { existsSync, readdirSync, statSync } from "node:fs";
4
4
  import { dirname, join, relative, resolve } from "node:path";
5
5
  import { readAutoSync, startSync } from "../auto-sync.js";
6
6
  import { changedSince } from "../changed-files.js";
@@ -317,7 +317,7 @@ async function report(root, filePath, mode) {
317
317
  const ours = d.tokenUses;
318
318
  /** Os papéis e as quebras que a página dele já decidiu - ver `role-rules.ts` (18/10). */
319
319
  const roleRules = await loadRoleRules(root).catch(() => null);
320
- const roles = roleRules ? await onlyOnce(root, rel, checkRoles(rel, src, roleRules)) : [];
320
+ const roles = roleRules ? await onlyOnce(root, rel, checkRoles(rel, src, roleRules, root)) : [];
321
321
  // Every check leaves one line in the ledger - INCLUDING the clean ones,
322
322
  // because a fix is itself a write, so the clean re-check of a file that was
323
323
  // just flagged is the evidence the agent read the report and acted. Without
@@ -743,7 +743,11 @@ async function endOfTurn(root, input) {
743
743
  await startSync(root).catch(() => false);
744
744
  const out = (frame) => process.stdout.write(`${JSON.stringify(frame)}\n`);
745
745
  if (input.stop_hook_active) {
746
- out(pass());
746
+ /** A única exceção à passagem calada: a oferta que ainda não chegou à resposta final - ver `offerTheReading`. */
747
+ const again = existsSync(join(root, "_synthesisui", READ_OFFERED_FILE))
748
+ ? await offerTheReading(root, [], input.transcript_path).catch(() => null)
749
+ : null;
750
+ out(again ? { decision: "block", reason: again } : pass());
747
751
  return;
748
752
  }
749
753
  const clock = await turnClockOf(root);
@@ -763,10 +767,166 @@ async function endOfTurn(root, input) {
763
767
  const grown = await takeGrowthNote(root).catch(() => null);
764
768
  if (grown)
765
769
  said.push(grown);
770
+ const offer = await offerTheReading(root, changed, input.transcript_path, clock).catch(() => null);
766
771
  await moveTurnClock(root);
767
- out(said.length === 0
772
+ out(said.length === 0 && !offer
768
773
  ? pass()
769
- : { decision: "block", reason: [...said, CLOSE_THE_TURN].join("\n\n") });
774
+ : { decision: "block", reason: [...said, offer ?? CLOSE_THE_TURN].join("\n\n") });
775
+ }
776
+ /** O arquivo que lembra que a oferta já foi feita neste clone - ignorado pelo git (ver `add.ts`). */
777
+ export const READ_OFFERED_FILE = ".read-offered";
778
+ /** Uma tela: o que o agente escreve quando faz interface. CSS conta - a primeira tela do impeccable mexe no globals. */
779
+ const SCREEN = /\.(tsx|jsx|vue|svelte|astro|html|css|scss)$/;
780
+ /**
781
+ * A LEITURA OFERECIDA SOZINHA - E03 da jornada "um instalador, uma porta" (fase 2, 18/10). O dono: *"o agente oferece a
782
+ * leitura sozinho depois da primeira tela"*. A linha do contrato já dizia "depois da primeira tela, ofereça" - e o agente
783
+ * só oferecia quando a pessoa pedia (medido na fase 1). O fim do turno é o único momento que vê o turno inteiro, então é
784
+ * ele quem pede.
785
+ *
786
+ * QUANDO A CRIAÇÃO ACABOU, NÃO NO MEIO DELA - decisão dele em 19/10, depois de seis rodadas reais. O impeccable tem um
787
+ * fim: a última coisa que ele escreve é o `DESIGN.md` (1-2 min antes do fim nas 3 rodadas que terminaram, porta-1, 3 e 6;
788
+ * ausente nas 3 em que a criação ainda perguntava "outra rodada?", porta-2, 4 e 5). Oferecer antes disso punha duas
789
+ * perguntas na mesma resposta, e a do impeccable ganhava. Então: com o impeccable no projeto, o sinal é o `DESIGN.md`
790
+ * escrito; sem ele, a primeira tela.
791
+ *
792
+ * FEITA É QUANDO ESTÁ NA RESPOSTA FINAL. Enquanto a última resposta do agente não oferece a leitura, o fim do turno pede de
793
+ * novo - três vezes no máximo. Depois de oferecida, ela vale até a PESSOA falar: na porta-6 a oferta saiu certa e os
794
+ * subagentes do impeccable acordaram o agente mais 4 vezes, e a resposta final ficou sem ela. Enquanto a pessoa não falou
795
+ * e a última resposta não tem a oferta, ela volta no fim (cinco renovações no máximo, a trava contra laço). Quando a
796
+ * pessoa fala, acabou: a recusa não vira insistência.
797
+ *
798
+ * SÓ NA PORTA: projeto sem sistema nenhum. Quem já tem um (do catálogo, importado, ou o da leitura com `rules.json`)
799
+ * escolheu o caminho dele (achado na suíte, 18/10: o fecho do turno de um sistema instalado passou a terminar na oferta).
800
+ * Sem o registro da conversa não há como ler a resposta - e aí se pede uma vez só.
801
+ */
802
+ const MAX_OFFERS = 3;
803
+ const MAX_RENEWALS = 5;
804
+ export async function offerTheReading(root, changed, transcript, since = 0) {
805
+ const ds = join(root, "_synthesisui", "ds");
806
+ if (existsSync(ds) && readdirSync(ds).some((d) => statSync(join(ds, d)).isDirectory()))
807
+ return null;
808
+ const marker = join(root, "_synthesisui", READ_OFFERED_FILE);
809
+ const save = (s) => writeFile(marker, `${JSON.stringify(s)}\n`);
810
+ if (!existsSync(marker)) {
811
+ if (!(await creationDone(root, changed, since)))
812
+ return null;
813
+ await save({ count: 1, at: Date.now() });
814
+ return OFFER;
815
+ }
816
+ let state = null;
817
+ try {
818
+ state = JSON.parse(await readFile(marker, "utf8"));
819
+ }
820
+ catch {
821
+ return null;
822
+ }
823
+ if (!state || !transcript)
824
+ return null;
825
+ if (state.delivered) {
826
+ if ((state.renewals ?? 0) >= MAX_RENEWALS)
827
+ return null;
828
+ if (await lastReplyOffers(transcript))
829
+ return null;
830
+ if (typeof state.deliveredAt !== "number" || (await personSpokeAfter(transcript, state.deliveredAt))) {
831
+ await save({ ...state, renewals: MAX_RENEWALS });
832
+ return null;
833
+ }
834
+ await save({ ...state, renewals: (state.renewals ?? 0) + 1 });
835
+ return `You were woken up again after offering it, so it is no longer at the end of your reply. ${OFFER}`;
836
+ }
837
+ if ((state.count ?? MAX_OFFERS) >= MAX_OFFERS)
838
+ return null;
839
+ if (await lastReplyOffers(transcript)) {
840
+ await save({ ...state, delivered: true, deliveredAt: Date.now() });
841
+ return null;
842
+ }
843
+ const count = (state.count ?? 0) + 1;
844
+ await save({ ...state, count, at: Date.now() });
845
+ return `Your last reply did not offer it. ${OFFER}`;
846
+ }
847
+ /**
848
+ * A CRIAÇÃO ACABOU NESTE TURNO? Com o impeccable (a skill que o instalador DELE escreve), o `DESIGN.md` na raiz, e escrito
849
+ * ou acompanhado de uma tela neste turno - um `DESIGN.md` antigo, sem tela nova, não é fim de criação nenhuma. Sem o
850
+ * impeccable, a primeira tela.
851
+ */
852
+ async function creationDone(root, changed, since) {
853
+ const screen = changed.some((c) => SCREEN.test(c.rel));
854
+ if (!existsSync(join(root, ".claude", "skills", "impeccable")))
855
+ return screen;
856
+ const design = await stat(join(root, "DESIGN.md")).catch(() => null);
857
+ if (!design?.isFile())
858
+ return false;
859
+ return screen || design.mtimeMs >= since;
860
+ }
861
+ const OFFER = "The creation of this project's first screen is done, and it has no system yet. Answer anything above in a line or two, then the summary of what this turn did - and END your reply with ONE sentence offering to read the page and turn what it already decided into their design system (about a minute, a few questions). That sentence is the LAST line, after any question of your own. If they say yes, follow the contract: `npx synthesisui read` with the dev server up. Offer it ONCE: if their next message does not take it up, that is a no - never offer it again in later turns.";
862
+ /**
863
+ * A ÚLTIMA RESPOSTA DO AGENTE OFERECE A LEITURA? A última entrada `assistant` com texto, no registro. Nas quatro rodadas
864
+ * em que ele ofereceu, a frase tinha as duas coisas: ler a página (ou o site) e o sistema - "Want me to read the page and
865
+ * turn what it's already decided into your design system?". Na porta-7 (19/10) a oferta veio pelo nome do comando, sem
866
+ * "page": "I can run `npx synthesisui read` and ask you a few questions to lock in the design system".
867
+ */
868
+ export async function lastReplyOffers(transcript) {
869
+ const raw = await readFile(transcript, "utf8").catch(() => null);
870
+ if (raw === null)
871
+ return false;
872
+ for (const line of raw.split("\n").reverse()) {
873
+ if (!/"type":\s*"assistant"/.test(line))
874
+ continue;
875
+ let e;
876
+ try {
877
+ e = JSON.parse(line);
878
+ }
879
+ catch {
880
+ continue;
881
+ }
882
+ const c = e.message?.content;
883
+ const text = Array.isArray(c)
884
+ ? c.filter((x) => x?.type === "text").map((x) => x.text ?? "").join(" ")
885
+ : typeof c === "string" ? c : "";
886
+ if (!text.trim())
887
+ continue;
888
+ if (!/\bsystem\b/i.test(text))
889
+ return false;
890
+ return /\bread\b[^.?!]{0,60}\b(page|site|landing)\b/i.test(text) || /synthesisui read\b/i.test(text);
891
+ }
892
+ return false;
893
+ }
894
+ /**
895
+ * A PESSOA FALOU DEPOIS DE `at`? No registro, a fala dela é uma entrada `user` que não é meta, não é resultado de
896
+ * ferramenta, não é o retorno de um gancho e não é o aviso de um subagente - os três últimos também chegam como `user`.
897
+ * Sem o registro, conta como falou: na dúvida, não se insiste.
898
+ */
899
+ export async function personSpokeAfter(transcript, at) {
900
+ const raw = await readFile(transcript, "utf8").catch(() => null);
901
+ if (raw === null)
902
+ return true;
903
+ for (const line of raw.split("\n").reverse()) {
904
+ if (!/"type":\s*"user"/.test(line))
905
+ continue;
906
+ let e;
907
+ try {
908
+ e = JSON.parse(line);
909
+ }
910
+ catch {
911
+ continue;
912
+ }
913
+ if (e.type !== "user" || e.isMeta || !e.timestamp)
914
+ continue;
915
+ if (!(Date.parse(e.timestamp) > at))
916
+ return false;
917
+ const c = e.message?.content;
918
+ const text = typeof c === "string"
919
+ ? c
920
+ : Array.isArray(c) && !c.some((x) => x?.type === "tool_result")
921
+ ? c.map((x) => x?.text ?? "").join(" ")
922
+ : null;
923
+ if (text === null)
924
+ continue;
925
+ if (/^\s*(<task-notification>|<command-|<system-reminder>|Stop hook feedback)/.test(text))
926
+ continue;
927
+ return true;
928
+ }
929
+ return false;
770
930
  }
771
931
  /**
772
932
  * A ÚLTIMA MENSAGEM É O RESUMO, NÃO O RODAPÉ - bateria ampliada, item 19 (29/09). Este bloqueio faz o agente responder
@@ -2,10 +2,12 @@ import { readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { DEFAULT_CONFIG, resolveRegistry, writeProjectConfig, } from "../config.js";
4
4
  import { readProjectTheme, writeBesideTheirs, writeNewProjectRoot, writeNewProjectTheme, } from "../new-project-theme.js";
5
+ import { openTheDoor } from "../door.js";
5
6
  import { body, section, snippet } from "../output.js";
6
7
  import { fetchDesignSystem } from "../registry.js";
7
8
  import { resolveDeps, stylesFor, tailwindMajor } from "../stack.js";
8
9
  import { formMix, measuredStyle } from "../styles-flavour.js";
10
+ import { desligarCaptura, ligarCaptura } from "../transcript.js";
9
11
  import { add } from "./add.js";
10
12
  const whereOf = (answer) => answer === "file" || answer === "1"
11
13
  ? "file"
@@ -57,6 +59,14 @@ export async function init(opts) {
57
59
  */
58
60
  if (opts.ds)
59
61
  await fetchDesignSystem(resolveRegistry(opts.registry), opts.ds);
62
+ /**
63
+ * SEM `--ds`, O `init` É A PORTA (fase 2, 18/10) - ver `door.ts`. O que ele imprimia sobre o config continua sendo
64
+ * calculado igual, só que guardado: a porta fecha com um resumo de cinco linhas, e o detalhe do config é o que menos
65
+ * importa para quem acabou de chegar.
66
+ */
67
+ const door = !opts.ds;
68
+ if (door)
69
+ ligarCaptura();
60
70
  const target = opts.target === "general" ? "general" : "next";
61
71
  const config = {
62
72
  target,
@@ -270,11 +280,14 @@ export async function init(opts) {
270
280
  console.log(body("first clean file and goes quiet after that."));
271
281
  return;
272
282
  }
273
- console.log("");
274
- console.log("Next steps:");
275
- console.log(" • synthesisui connect wire your agent to the system");
276
- console.log(" • synthesisui add <slug> bring a design system in");
277
- console.log(" • synthesisui template <slug> <name> materialize a full page");
283
+ await openTheDoor({
284
+ root,
285
+ version: opts.cli ?? "latest",
286
+ ...(opts.registry ? { registry: opts.registry } : {}),
287
+ ...(opts.pkg ? { pkg: opts.pkg } : {}),
288
+ ...(opts.aesthetic ? { aesthetic: opts.aesthetic } : {}),
289
+ configLines: desligarCaptura(),
290
+ });
278
291
  }
279
292
  /**
280
293
  * O QUE O `init` ACABOU DE DECLARAR VAI PARA O `.lock` (jornada "o pedido livre sai mastigado", 15/10).
@@ -1,4 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { desligarCaptura, ligarCaptura } from "../transcript.js";
3
+ import { adopt } from "./adopt.js";
2
4
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
5
  import { createRequire } from "node:module";
4
6
  import { homedir } from "node:os";
@@ -6,7 +8,7 @@ import { join, relative } from "node:path";
6
8
  import { classify, pixelRatio } from "../reading/classify.js";
7
9
  import { PAGE_SCRIPT } from "../reading/page-script.js";
8
10
  import { decodePng } from "../reading/png.js";
9
- import { pxOf } from "../role-rules.js";
11
+ import { globalClassOf, globalStyles, moduleClassName, pxOf } from "../role-rules.js";
10
12
  import { agentsToMaintain } from "../agents-chosen.js";
11
13
  import { syncClaudeMd } from "../claude-md.js";
12
14
  import { projectRootFrom } from "./hook.js";
@@ -112,12 +114,46 @@ function sources(root) {
112
114
  return out;
113
115
  }
114
116
  /** Onde o texto mora no código: o componente (React) -> o arquivo que o declara -> a linha da classe; senão, pela classe. */
117
+ /**
118
+ * A CLASSE DE UM CSS MODULE NO DOM - `page-module___8aEwW__h2` (o Turbopack: arquivo, hash, nome local). Achado na rodada
119
+ * real da porta (18/10): sem isto, a página que o impeccable escreveu com `styles.h2` saía com 5 papéis lidos e 0
120
+ * gravados. A extensão vem do import que o código escreve.
121
+ */
122
+ function moduleOf(cls, files) {
123
+ /* a do próprio elemento primeiro; sem ela, a do título e a do pai (a capa da porta-2: `<span>` sem classe dentro de
124
+ `<p className={styles.batch}>`, com o tamanho em `.batch span`) */
125
+ for (const tok of cls.split(" | ").flatMap((part) => part.split(/\s+/))) {
126
+ const m = tok.match(/^([A-Za-z0-9-]+?)-module__.+?__([A-Za-z0-9_-]+)$/);
127
+ if (!m)
128
+ continue;
129
+ const stem = m[1];
130
+ const local = m[2];
131
+ const imp = new RegExp(`["'][^"']*\\b${stem}\\.module\\.(s?css)["']`);
132
+ const ext = files.map((f) => readFileSync(f, "utf8").match(imp)?.[1]).find(Boolean) ?? "css";
133
+ return { name: moduleClassName(`${stem}.module.${ext}`, local), local };
134
+ }
135
+ return null;
136
+ }
137
+ let stylesOf = null;
115
138
  function homeOf(root, files, t) {
116
- const cls = sizeClassOf(t.cls);
139
+ const sized = sizeClassOf(t.cls);
140
+ /* a classe global (o juízo dele, 19/10): `className="section-title"` com o tamanho no globals.css - a do elemento
141
+ primeiro, depois a do pai (o `<h3>` sem classe dentro de `.route`, medido em `.route h3`) */
142
+ if (!sized && stylesOf?.root !== root)
143
+ stylesOf = { root, styles: globalStyles(root) };
144
+ const glob = sized || moduleOf(t.cls, files) ? null
145
+ : globalClassOf(t.cls.split(" | ").flatMap((part) => part.split(/\s+/)), stylesOf?.styles ?? []);
146
+ const mod = sized ? null : (moduleOf(t.cls, files) ?? (glob ? { name: glob.name, local: glob.local } : null));
147
+ const cls = sized ?? mod?.name ?? null;
117
148
  const lineOf = (f) => {
118
149
  if (!cls)
119
150
  return null;
120
- const i = readFileSync(f, "utf8").split("\n").findIndex((l) => l.includes(cls));
151
+ const lines = readFileSync(f, "utf8").split("\n");
152
+ const i = mod
153
+ ? lines.findIndex((l) => glob
154
+ ? new RegExp(`className=[^>]*?(?<![\\w-])${mod.local.replace(/-/g, "\\-")}(?![\\w-])`).test(l)
155
+ : new RegExp(`\\b\\w+(?:\\.${mod.local}\\b|\\[["']${mod.local}["']\\])`).test(l))
156
+ : lines.findIndex((l) => l.includes(cls));
121
157
  return i >= 0 ? i + 1 : null;
122
158
  };
123
159
  if (t.comp) {
@@ -126,7 +162,7 @@ function homeOf(root, files, t) {
126
162
  if (f)
127
163
  return { file: relative(root, f), line: lineOf(f), cls, via: "component", component: t.comp };
128
164
  }
129
- if (cls) {
165
+ if (cls && !mod) {
130
166
  const f = files.find((x) => readFileSync(x, "utf8").includes(cls));
131
167
  if (f)
132
168
  return { file: relative(root, f), line: lineOf(f), cls, via: "class", component: t.comp };
@@ -194,9 +230,21 @@ export async function confirm(opts) {
194
230
  const answers = opts.answers ? JSON.parse(readFileSync(opts.answers, "utf8")) : {};
195
231
  const pick = (id) => answers[id] ?? 0;
196
232
  const dsDir = join(root, "_synthesisui", "ds");
197
- const slug = existsSync(dsDir) ? readdirSync(dsDir).find((d) => statSync(join(dsDir, d)).isDirectory()) : undefined;
233
+ const firstSystem = () => existsSync(dsDir) ? readdirSync(dsDir).find((d) => statSync(join(dsDir, d)).isDirectory()) : undefined;
234
+ /* O SISTEMA NASCE AQUI quando o projeto chegou pela porta (fase 2, 18/10): o `init` não adota, porque num projeto vazio
235
+ os únicos tokens são os de exemplo do create-next-app. Agora a página já decidiu - os tokens que ela tem são os dela. */
236
+ if (!firstSystem()) {
237
+ ligarCaptura();
238
+ try {
239
+ await adopt({ dir: root, write: true });
240
+ }
241
+ finally {
242
+ desligarCaptura();
243
+ }
244
+ }
245
+ const slug = firstSystem();
198
246
  if (!slug) {
199
- say("This project has no adopted system yet - run `synthesisui adopt --write` first.");
247
+ say("No tokens found in this project's CSS, so there is no system to write the answers into - check that the page's stylesheet declares them in :root or @theme.");
200
248
  process.exitCode = 1;
201
249
  return;
202
250
  }
@@ -222,6 +270,10 @@ export async function confirm(opts) {
222
270
  writeFileSync(join(dsDir, slug, "rules.json"), `${JSON.stringify(rules, null, 2)}\n`);
223
271
  const contract = await syncClaudeMd(root, await agentsToMaintain(root));
224
272
  say(`Your system: ${roles.length} roles and ${breaks.length} breaks, written to _synthesisui/ds/${slug}/rules.json.`);
273
+ /* NUNCA CALADO (o juízo dele, 19/10): um papel lido sem a classe que o identifica no código fica fora - e isso se diz */
274
+ const unplaced = R.roles.filter((r) => !r.home?.cls);
275
+ if (unplaced.length > 0)
276
+ say(`Left out: ${unplaced.map((r) => `${r.role.split(" ")[0]} ${r.px}px`).join(", ")} - the page shows ${unplaced.length === 1 ? "it" : "them"}, but no class in the code says where ${unplaced.length === 1 ? "it is" : "they are"}, so the check could not hold ${unplaced.length === 1 ? "it" : "them"}.`);
225
277
  say(contract ? "The contract your agent reads now lists them - every heading is checked against its role at the end of each turn." : "Run `synthesisui connect` so your agent reads and checks it.");
226
278
  }
227
279
  export async function read(opts) {
package/dist/door.js ADDED
@@ -0,0 +1,176 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { connect } from "./commands/connect.js";
6
+ import { MCP_TOOL_COUNT } from "./commands/mcp.js";
7
+ import { body, paint, section, snippet } from "./output.js";
8
+ import { SKILLS } from "./skills.js";
9
+ import { desligarCaptura, ligarCaptura } from "./transcript.js";
10
+ /**
11
+ * A CAMADA DE ESTÉTICA PADRÃO, na versão que a régua do conjunto provou (18/10: corte, linhas A-C, relógios). O impeccable
12
+ * muda rápido; subir este número pede a régua de novo (D10 do mapa), não só o publish dele.
13
+ */
14
+ export const IMPECCABLE_VERSION = "4.6.0";
15
+ /** O impeccable que já está no projeto, pela skill que o instalador DELE escreve - nunca por um arquivo nosso. */
16
+ export function impeccableIn(root) {
17
+ const skill = join(root, ".claude", "skills", "impeccable", "SKILL.md");
18
+ if (!existsSync(skill))
19
+ return null;
20
+ try {
21
+ const head = readFileSync(skill, "utf8").slice(0, 4000);
22
+ return /^version:\s*(\S+)/m.exec(head)?.[1] ?? "?";
23
+ }
24
+ catch {
25
+ return "?";
26
+ }
27
+ }
28
+ /** Uma pergunta, o sim como padrão - e só num terminal de verdade. */
29
+ async function askAesthetic() {
30
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
31
+ return null;
32
+ const { createInterface } = await import("node:readline/promises");
33
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
34
+ try {
35
+ const answer = await rl.question(` Add the aesthetic layer - impeccable ${IMPECCABLE_VERSION}, by its own installer? It creates the first look; SynthesisUI keeps every screen after it consistent. [Y/n] `);
36
+ return !/^n/i.test(answer.trim());
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ finally {
42
+ rl.close();
43
+ }
44
+ }
45
+ /**
46
+ * COMPOR, NUNCA FORKAR (decisão dele, 18/10 15:25): o SynthesisUI é o ecossistema e o impeccable é uma camada. Quem
47
+ * instala é o instalador DELE, chamado por nós; nenhum arquivo dele é escrito, copiado ou apagado aqui. Já instalado,
48
+ * fica como está.
49
+ */
50
+ async function aestheticLayer(root, wanted) {
51
+ const found = impeccableIn(root);
52
+ if (found)
53
+ return { kind: "found", version: found };
54
+ if (wanted === "none")
55
+ return { kind: "declined", asked: false };
56
+ if (wanted !== "impeccable") {
57
+ const yes = await askAesthetic();
58
+ if (yes === false)
59
+ return { kind: "declined", asked: true };
60
+ }
61
+ try {
62
+ execFileSync("npx", ["-y", `impeccable@${IMPECCABLE_VERSION}`, "install", "-y", "--providers=claude", "--project"], { cwd: root, stdio: "pipe" });
63
+ return { kind: "installed", version: impeccableIn(root) ?? IMPECCABLE_VERSION };
64
+ }
65
+ catch (e) {
66
+ const err = e;
67
+ return {
68
+ kind: "failed",
69
+ why: err.stderr?.toString().trim().split("\n").filter(Boolean).pop() ??
70
+ err.message ??
71
+ "unknown",
72
+ };
73
+ }
74
+ }
75
+ /** O gerenciador que o projeto já usa, pelo lockfile - instalar com outro deixaria dois lockfiles brigando. */
76
+ export function packageManagerOf(root) {
77
+ if (existsSync(join(root, "pnpm-lock.yaml")))
78
+ return "pnpm";
79
+ if (existsSync(join(root, "yarn.lock")))
80
+ return "yarn";
81
+ if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock")))
82
+ return "bun";
83
+ return "npm";
84
+ }
85
+ export function addDevArgs(pm, spec) {
86
+ if (pm === "npm")
87
+ return ["install", "--save-dev", "--no-audit", "--no-fund", spec];
88
+ if (pm === "bun")
89
+ return ["add", "--dev", spec];
90
+ return ["add", "--save-dev", spec];
91
+ }
92
+ /**
93
+ * O CLI COMO DEPENDÊNCIA DO PROJETO - é o que faz o fim do turno rodar a versão dele, e ~10x mais rápido que o `npx`
94
+ * (medido em 27/07, ver `hookCommand`). O `connect` escolhe sozinho o local quando ele está em `node_modules`, então
95
+ * esta instalação vem ANTES dele.
96
+ *
97
+ * `spec` existe para provar uma versão antes do publish (um tarball do `npm pack`); o padrão é a versão que roda.
98
+ */
99
+ async function installLocalCli(root, spec) {
100
+ const pm = packageManagerOf(root);
101
+ try {
102
+ execFileSync(pm, addDevArgs(pm, spec), { cwd: root, stdio: "pipe" });
103
+ return { ok: true, pm };
104
+ }
105
+ catch (e) {
106
+ const err = e;
107
+ const why = err.stderr?.toString().trim().split("\n").filter(Boolean).pop() ??
108
+ err.message ??
109
+ "unknown";
110
+ return { ok: false, pm, why };
111
+ }
112
+ }
113
+ export async function openTheDoor(opts) {
114
+ const { root, version } = opts;
115
+ const pkgPath = join(root, "package.json");
116
+ const hasPackage = existsSync(pkgPath);
117
+ const local = hasPackage
118
+ ? await installLocalCli(root, opts.pkg ?? `synthesisui@${version}`)
119
+ : null;
120
+ const layer = await aestheticLayer(root, opts.aesthetic);
121
+ ligarCaptura();
122
+ let failed = null;
123
+ try {
124
+ await connect({
125
+ dir: root,
126
+ version,
127
+ ...(opts.registry ? { registry: opts.registry } : {}),
128
+ hook: true,
129
+ mcp: true,
130
+ agents: "claude",
131
+ interactive: false,
132
+ });
133
+ }
134
+ catch (e) {
135
+ failed = e;
136
+ }
137
+ const connectLines = desligarCaptura();
138
+ if (failed) {
139
+ for (const line of [...opts.configLines, ...connectLines])
140
+ console.log(line);
141
+ throw failed;
142
+ }
143
+ const installed = hasPackage
144
+ ? (JSON.parse(await readFile(pkgPath, "utf8")).devDependencies?.synthesisui ?? null)
145
+ : null;
146
+ console.log(section("SynthesisUI is set up"));
147
+ console.log("");
148
+ console.log(body(local?.ok
149
+ ? `✓ the CLI synthesisui ${version} in your devDependencies (${local.pm}) - the check runs this one`
150
+ : local
151
+ ? `· the CLI not installed (${local.why}) - the check runs it through npx, slower`
152
+ : "· the CLI no package.json here - the check runs it through npx, slower"));
153
+ console.log(body(`✓ your agent Claude Code: the check at the end of each turn, ${MCP_TOOL_COUNT} tools, ${SKILLS.length} skills`));
154
+ console.log(body(layer.kind === "found"
155
+ ? `✓ aesthetic impeccable ${layer.version} was already here - left exactly as it is`
156
+ : layer.kind === "installed"
157
+ ? `✓ aesthetic impeccable ${layer.version}, by its own installer - it creates the first look`
158
+ : layer.kind === "failed"
159
+ ? `· aesthetic impeccable did not install (${layer.why}) - run init again to retry`
160
+ : "· aesthetic none - the first look comes from your agent alone, with no design direction behind it"));
161
+ if (layer.kind === "declined")
162
+ console.log(body(paint.faint(" add it later: npx synthesisui init --aesthetic impeccable")));
163
+ if (layer.kind === "installed" && opts.aesthetic !== "impeccable" && !process.stdin.isTTY)
164
+ console.log(body(paint.faint(" installed by default - `--aesthetic none` leaves it out")));
165
+ console.log(body("✓ no account nothing here needs one"));
166
+ if (installed && local?.ok === false)
167
+ console.log(body(paint.faint(` (package.json already lists synthesisui ${installed})`)));
168
+ console.log("");
169
+ console.log(section("Next"));
170
+ console.log("");
171
+ console.log(body("Open your agent here and ask for your first screen:"));
172
+ console.log("");
173
+ console.log(snippet(["claude", '"make a landing page for <what you are building>"']));
174
+ console.log("");
175
+ console.log(body("When the first screen is done, the agent offers to read it and turn what it decided into your system."));
176
+ }
package/dist/index.js CHANGED
@@ -50,7 +50,8 @@ const HELP = `synthesisui - bring SynthesisUI design systems into your project
50
50
  Usage - deterministic, FREE:
51
51
  synthesisui login [options] connect the CLI to your account (device-flow; --force to switch accounts)
52
52
  synthesisui logout sign out of this machine (removes the saved token)
53
- synthesisui init [options] write _synthesisui/config.json (target, dirs); --ds to bring one in
53
+ synthesisui init [options] start here: the CLI in your devDependencies, your agent wired, no account;
54
+ --ds <slug> brings a published system in instead
54
55
  synthesisui list [options] list the published design systems
55
56
  synthesisui list --mine your own systems, with their group
56
57
  synthesisui add <slug> [options] materialize a DS into _synthesisui/ds/<slug>/
@@ -125,6 +126,8 @@ Options:
125
126
  --dir <path> consumer project root (default: current directory)
126
127
  --version <n> install a specific version (default: latest)
127
128
  --ds <slug> init: bring this DS in right away · generate: target DS (default: installed)
129
+ --package <spec> init: what to add to devDependencies (default: synthesisui@<this version>)
130
+ --aesthetic <a> init: impeccable | none - the layer that creates the first look (default: impeccable, asked in a terminal)
128
131
  --name <name> import: the NAME of the system, which is what its install slug
129
132
  comes from - and the slug never changes. Also the preferred
130
133
  component name for generate.
@@ -165,7 +168,7 @@ Examples:
165
168
  synthesisui adopt # you already have a system: start here
166
169
  synthesisui import --dry # see the census before anything is sent
167
170
  synthesisui login
168
- synthesisui init --target next
171
+ synthesisui init # an empty project: one command, then ask your agent
169
172
  synthesisui init --target next --ds halogen bootstrap + bring a system in
170
173
  synthesisui doctor
171
174
  synthesisui doctor apps/web packages/ui # scope the read in a monorepo
@@ -574,6 +577,8 @@ async function main() {
574
577
  ...(typeof flags["theme-in"] === "string"
575
578
  ? { themeIn: flags["theme-in"] }
576
579
  : {}),
580
+ ...(typeof flags.package === "string" ? { pkg: flags.package } : {}),
581
+ ...(typeof flags.aesthetic === "string" ? { aesthetic: flags.aesthetic } : {}),
577
582
  cli: CLI_VERSION,
578
583
  });
579
584
  break;
@@ -350,7 +350,12 @@
350
350
  * sem eles, a linha que ensina o agente a oferecer a leitura (`synthesisui read`) e a confirmar (`read --confirm`). Uma
351
351
  * pasta escrita antes não fala de papéis nem da leitura.
352
352
  */
353
- export const MATERIALISER_SINCE = "0.16.500";
353
+ /**
354
+ * 0.16.500 -> 0.16.501 em 18/10, e o passo 1 dá **SIM** (jornada "um instalador, uma porta"): num projeto sem sistema, o
355
+ * bloco do `CLAUDE.md` ensina a leitura da primeira tela em vez do `/sui-import-ds`; com o `rules.json` e o impeccable no
356
+ * projeto, diz que o revisor dele é só da criação. Uma pasta escrita antes manda o agente importar e não fala do revisor.
357
+ */
358
+ export const MATERIALISER_SINCE = "0.16.501";
354
359
  /**
355
360
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
356
361
  *
@@ -574,7 +579,13 @@ export const COUNTED_DIFFERENTLY = "this run counts a value as named only when Y
574
579
  * vez) e acha a raiz pelo projeto - não pela pasta onde o terminal do agente parou, que deixava turnos inteiros sem
575
580
  * conferência. Um hook pinado antes não confere papel nenhum.
576
581
  */
577
- export const CHECKER_SINCE = "0.16.500";
582
+ /**
583
+ * 0.16.500 -> 0.16.501 em 18/10, e o passo 1 dá **SIM** (jornada "um instalador, uma porta"): o fim do turno pede ao agente,
584
+ * uma vez, que ofereça a leitura quando a primeira tela aparece num projeto sem sistema nenhum. Um hook pinado antes nunca
585
+ * oferece. E a checagem por papel lê CSS Modules (`className={styles.h2}`, o tamanho no `.module.css`): um hook anterior
586
+ * não confere papel nenhum numa página escrita assim.
587
+ */
588
+ export const CHECKER_SINCE = "0.16.501";
578
589
  /**
579
590
  * A ÚLTIMA VERSÃO EM QUE OS LEITORES PASSARAM A PRODUZIR UM CENSO DIFERENTE.
580
591
  *
@@ -1,5 +1,6 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
1
2
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
- import { basename, extname, join } from "node:path";
3
+ import { basename, dirname, extname, join } from "node:path";
3
4
  export async function loadRoleRules(root) {
4
5
  const ds = join(root, "_synthesisui", "ds");
5
6
  const slugs = await readdir(ds).catch(() => []);
@@ -44,6 +45,116 @@ function sizeClass(attrs) {
44
45
  return null;
45
46
  return found.sort((a, b) => (pxOf(b) ?? 0) - (pxOf(a) ?? 0))[0] ?? null;
46
47
  }
48
+ /**
49
+ * A CLASSE DE UM CSS MODULE - achado na rodada real da porta (18/10, `regua/rodadas/porta-2`): o impeccable escreveu a
50
+ * landing com `className={styles.h2}` (117 usos, 4 `.module.css`), a leitura achou 5 papéis e o `--confirm` gravou ZERO,
51
+ * porque a identidade de um papel era só a classe de tamanho do Tailwind. Uma de três criações medidas foi assim.
52
+ *
53
+ * A identidade passa a ser o ARQUIVO e o nome local - `page.module.css .h2` -, que é o que a pessoa acha no código e o
54
+ * que o hash do build não muda. O apelido do import (`styles`, `s`) não entra: dois arquivos podem chamar o mesmo módulo
55
+ * de jeitos diferentes.
56
+ */
57
+ export function moduleClassName(file, local) {
58
+ return `${file} .${local}`;
59
+ }
60
+ /** Os imports de CSS Module de um arquivo: apelido -> o caminho como foi escrito. */
61
+ function moduleImports(src) {
62
+ const out = new Map();
63
+ for (const m of src.matchAll(/import\s+(\w+)\s+from\s+["']([^"']+\.module\.s?css)["']/g))
64
+ out.set(m[1], m[2]);
65
+ return out;
66
+ }
67
+ /** As classes de módulo dentro de um `className={...}`, na ordem em que aparecem. */
68
+ function moduleClasses(attrs, imports) {
69
+ const expr = attrs.match(/className=\{([^}]*(?:\}[^}]*)?)\}/)?.[1] ?? "";
70
+ const out = [];
71
+ for (const m of expr.matchAll(/\b(\w+)(?:\.([A-Za-z_][\w-]*)|\[["']([\w-]+)["']\])/g)) {
72
+ const path = imports.get(m[1]);
73
+ const local = (m[2] ?? m[3]);
74
+ if (path && local)
75
+ out.push({ name: moduleClassName(basename(path), local), path, local });
76
+ }
77
+ return out;
78
+ }
79
+ /** As classes escritas como texto em `className="a b"` (ou num template sem expressão no meio do nome). */
80
+ function plainClasses(attrs) {
81
+ const lit = attrs.match(/className=\{?\s*["'`]([^"'`]*)["'`]/)?.[1] ?? "";
82
+ return lit.split(/\s+/).filter((t) => /^[A-Za-z_][\w-]*$/.test(t));
83
+ }
84
+ /** O `font-size` de uma classe dentro do `.module.css` - o maior de um `clamp()` (o desktop), `rem` a 16px. */
85
+ export function modulePx(css, local) {
86
+ let best = null;
87
+ const esc = local.replace(/[-]/g, "\\-");
88
+ for (const m of css.matchAll(new RegExp(`([^{}]*\\.${esc}(?![\\w-])[^{}]*)\\{([^}]*)\\}`, "g"))) {
89
+ const v = m[2].match(/font-size\s*:\s*([^;]+)/)?.[1]?.trim();
90
+ if (!v)
91
+ continue;
92
+ const parts = v.startsWith("clamp(") ? v.slice(6, -1).split(",") : [v];
93
+ for (const p of parts) {
94
+ const n = p.trim().match(/^([\d.]+)(px|rem)$/);
95
+ if (!n)
96
+ continue;
97
+ const px = Number(n[1]) * (n[2] === "rem" ? 16 : 1);
98
+ if (best === null || px > best)
99
+ best = px;
100
+ }
101
+ }
102
+ return best;
103
+ }
104
+ /**
105
+ * A CLASSE GLOBAL - achado no juízo dele (19/10, a landing da Naiker, `synthesisui-tests/juizo-porta`): o impeccable
106
+ * escreveu os títulos com classes comuns, `className="section-title"`, e o tamanho no `globals.css`. A leitura achou os
107
+ * papéis (h1 180, h2 70,4, h3 28,8) e o `--confirm` gravou ZERO, calado - o mesmo defeito do CSS Modules (porta-2) numa
108
+ * terceira forma de escrever estilo.
109
+ *
110
+ * A identidade é a mesma forma do módulo: o arquivo e a classe, `globals.css .section-title`. Vale a classe que tem um
111
+ * `font-size` numa folha de estilo comum do projeto (não `.module.css`), na ordem em que vem - a do próprio elemento
112
+ * primeiro.
113
+ */
114
+ export function globalStyles(root) {
115
+ const out = [];
116
+ const walk = (d) => {
117
+ let names = [];
118
+ try {
119
+ names = readdirSync(d);
120
+ }
121
+ catch {
122
+ return;
123
+ }
124
+ for (const n of names) {
125
+ if (n === "node_modules" || n.startsWith(".") || n === "_synthesisui" || n === "public")
126
+ continue;
127
+ const p = join(d, n);
128
+ let dir = false;
129
+ try {
130
+ dir = statSync(p).isDirectory();
131
+ }
132
+ catch {
133
+ continue;
134
+ }
135
+ if (dir)
136
+ walk(p);
137
+ else if (/\.s?css$/.test(n) && !/\.module\.s?css$/.test(n))
138
+ out.push({ file: n, css: readFileSync(p, "utf8") });
139
+ }
140
+ };
141
+ walk(existsSync(join(root, "src")) ? join(root, "src") : root);
142
+ if (existsSync(join(root, "app")) && existsSync(join(root, "src")))
143
+ walk(join(root, "app"));
144
+ return out;
145
+ }
146
+ export function globalClassOf(tokens, styles) {
147
+ for (const tok of tokens) {
148
+ if (!/^[A-Za-z_][\w-]*$/.test(tok))
149
+ continue;
150
+ for (const st of styles) {
151
+ const px = modulePx(st.css, tok);
152
+ if (px !== null)
153
+ return { name: moduleClassName(st.file, tok), local: tok, px };
154
+ }
155
+ }
156
+ return null;
157
+ }
47
158
  /** Dentro de um `.map(` ainda aberto = o título se repete = é de cartão. A mesma pergunta que a régua faz ao DOM. */
48
159
  function insideMap(src, at) {
49
160
  const before = src.slice(0, at);
@@ -56,16 +167,31 @@ function insideMap(src, at) {
56
167
  return depth > 0;
57
168
  }
58
169
  const near = (a, b) => Math.abs(a - b) / Math.max(a, b) < 0.03;
59
- export function checkRoles(rel, src, rules) {
170
+ export function checkRoles(rel, src, rules, root) {
60
171
  if (rules.enforce === false || !/\.(tsx|jsx)$/.test(rel))
61
172
  return [];
173
+ const imports = moduleImports(src);
174
+ const cssOf = new Map();
175
+ const pxOfModule = (path, local) => {
176
+ if (!root)
177
+ return null;
178
+ const file = join(root, dirname(rel), path);
179
+ if (!cssOf.has(file))
180
+ cssOf.set(file, existsSync(file) ? readFileSync(file, "utf8") : "");
181
+ return modulePx(cssOf.get(file), local);
182
+ };
183
+ /** As folhas de estilo comuns do projeto, lidas uma vez e só se algum título usar classe sem tamanho do Tailwind. */
184
+ let globals = null;
185
+ const styles = () => (globals ??= root ? globalStyles(root) : []);
62
186
  const component = basename(rel, extname(rel));
63
187
  const known = new Set((rules.knownDrift ?? []).filter((d) => d.at.startsWith(rel)).map((d) => d.class));
64
188
  const section = rules.roles.find((r) => r.element === "h2" && r.context === "section");
65
189
  const out = [];
66
190
  for (const m of src.matchAll(/<(h1|h2|h3)\b([^>]*)>/g)) {
67
191
  const el = m[1];
68
- const cls = sizeClass(m[2]);
192
+ const mod = sizeClass(m[2]) ? null : (moduleClasses(m[2], imports)[0] ?? null);
193
+ const glob = sizeClass(m[2]) || mod ? null : globalClassOf(plainClasses(m[2]), styles());
194
+ const cls = sizeClass(m[2]) ?? mod?.name ?? glob?.name ?? null;
69
195
  if (!cls)
70
196
  continue;
71
197
  const line = src.slice(0, m.index).split("\n").length;
@@ -78,7 +204,7 @@ export function checkRoles(rel, src, rules) {
78
204
  continue;
79
205
  if (known.has(bare))
80
206
  continue;
81
- const px = pxOf(bare);
207
+ const px = mod ? pxOfModule(mod.path, mod.local) : glob ? glob.px : pxOf(bare);
82
208
  const role = mine.find((r) => r.context === context) ?? mine[0];
83
209
  const brk = rules.breaks.find((b) => b.class === bare);
84
210
  if (brk) {
@@ -111,6 +237,8 @@ export function checkRoles(rel, src, rules) {
111
237
  * quebra fora do componente dela. A cópia com OUTRO tamanho (41,6 no lugar de 57,6) não aparece no código - é a régua
112
238
  * da página renderizada que vê ("o mesmo motivo"), no fecho do turno.
113
239
  */
240
+ /* duas quebras podem morar na mesma classe (a capa da porta-2: "Batch" e "Nº 01" em `.batch span`) - uma linha basta */
241
+ const said = new Set();
114
242
  for (const b of rules.breaks) {
115
243
  if (!b.class || b.allowedIn === component)
116
244
  continue;
@@ -118,9 +246,14 @@ export function checkRoles(rel, src, rules) {
118
246
  if (/^h[1-3]$/.test(m[1]))
119
247
  continue;
120
248
  const cls = m[2].match(/className=\{?\s*["'`]([^"'`]*)["'`]/)?.[1] ?? "";
121
- if (!` ${cls} `.includes(` ${b.class} `))
249
+ const mods = moduleClasses(m[2], imports).map((c) => c.name);
250
+ const globs = b.class.includes(" .") && !b.class.includes(".module.") ? cls.split(/\s+/).filter(Boolean).map((t) => globalClassOf([t], styles())?.name) : [];
251
+ if (!` ${cls} `.includes(` ${b.class} `) && !mods.includes(b.class) && !globs.includes(b.class))
122
252
  continue;
123
253
  const line = src.slice(0, m.index).split("\n").length;
254
+ if (said.has(`${line} ${b.class}`))
255
+ continue;
256
+ said.add(`${line} ${b.class}`);
124
257
  out.push(` line ${line} <${m[1]}> ${b.class} is the break "${b.rule}" the person chose for ${b.allowedIn} (${b.home}). Reuse that component here instead of copying the motif, or ask whether this page gets its own.`);
125
258
  }
126
259
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.500",
3
+ "version": "0.16.502",
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": {