synthesisui 0.16.235 → 0.16.239

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
@@ -186,9 +186,9 @@ session. It costs nothing when nothing changed: the tool holds both versions and
186
186
  refuses politely when there is nothing to refresh, so calling it is cheaper than
187
187
  deciding whether to.
188
188
 
189
- It re-measures this repo with the current reader, reuses the reading that was already
190
- authored, and sends it - a newer reader sees styles the old one could not, and until
191
- it runs, this project's system is missing them.
189
+ It re-measures this repo with the current reader, reuses what was already written
190
+ about this app, and sends it - a newer reader sees styles the old one could not, and
191
+ until it runs, this project's system is missing them.
192
192
 
193
193
  It never asks anyone anything, and it never overwrites a recipe a person wrote on
194
194
  the screen: when a send would rewrite one, it refuses and names them. If it says
@@ -196,8 +196,8 @@ nothing changed, nothing changed - carry on.
196
196
 
197
197
  **And when you describe this system, open with the age of the measurement:** the
198
198
  \`ledger.cli\` and the date in \`_synthesisui/census.json\`. A long, correct report
199
- about a census taken by an older reader is the worst way to be right - every absence
200
- in it may have been filled since, and the reader who trusts it has no way to know.
199
+ about a measurement taken by an older reader is the worst way to be right - every
200
+ absence in it may have been filled since, and the reader who trusts it has no way to know.
201
201
 
202
202
  **And if it says the design system needs updating, do these three things in order:**
203
203
  say the one line, ask, and run it.
@@ -334,7 +334,8 @@ extend that. The person who asked you for a feature should never have to know th
334
334
  names or type them; finding the right component is your job, not theirs.
335
335
 
336
336
  Overriding a style a component already sets just works - write
337
- \`className="text-info"\`, with no \`!\`. The generated components resolve your class
337
+ \`className="text-<role>"\` with one of this system's own colour roles (the GUIDE
338
+ lists them), with no \`!\`. The generated components resolve your class
338
339
  against their own, so the last one written wins, which is what the call site reads like.
339
340
  If an override is ignored, that component predates the resolver: regenerate it with
340
341
  \`npx synthesisui@latest component <slug> <name>\` rather than reaching for \`!\`.
@@ -250,15 +250,13 @@ export async function add(slug, opts) {
250
250
  await writeFile(join(slugDir, "requires.json"), `${JSON.stringify(required, null, 2)}\n`, "utf8");
251
251
  }
252
252
  /** A filosofia continua sendo CONTADA na saída, e ela vive no documento - ver abaixo. */
253
- const philosophy = payload.document.philosophy;
254
- const sections = philosophy?.sections ?? [];
253
+ const voice = payload.voice ?? { hasContext: false, sections: 0 };
255
254
  /**
256
- * 5c. A FILOSOFIA NÃO É MATERIALIZADA - ela já está aqui.
257
- *
258
- * `philosophy` é campo do DOCUMENTO versionado (`design-system.ts`), e o `design-system.json`
259
- * escrito no passo 2 a carrega inteira. O `philosophy.md` era uma segunda cópia do mesmo dado, no
260
- * mesmo commit, que só era reescrita no upgrade seguinte - dois caminhos para uma verdade é como
261
- * um deles começa a discordar do outro. O `system_doctrine` lê do documento.
255
+ * 5c. A VOZ NÃO É MATERIALIZADA - nem no `.md` (aposentado acima) nem no
256
+ * `design-system.json` (R2, 16/08: o payload viaja sem `philosophy`). Ela
257
+ * mora na plataforma e o `system_doctrine` a busca servida - sempre a
258
+ * versão atual, nada em texto plano no repo. As REGRAS continuam locais
259
+ * (`doctrine.json`): o doctor e o hook precisam delas offline.
262
260
  */
263
261
  // 6. discovery by the agent
264
262
  const claudeMd = await syncClaudeMd(projectRoot);
@@ -288,8 +286,8 @@ export async function add(slug, opts) {
288
286
  */
289
287
  const doctrine = [
290
288
  ...(rules.length > 0 ? [`${rules.length} rule(s)`] : []),
291
- ...(sections.length > 0 || philosophy?.context
292
- ? [`${sections.length} section(s) of philosophy`]
289
+ ...(voice.sections > 0 || voice.hasContext
290
+ ? [`the voice (${voice.sections} section(s), served from the platform)`]
293
291
  : []),
294
292
  ];
295
293
  if (doctrine.length > 0)
@@ -134,6 +134,14 @@ async function findTokens(root, only) {
134
134
  /** The contract. Names THEIR tokens - there is no translation layer. */
135
135
  function guide(name, slug, tokens) {
136
136
  const lines = [...tokens.entries()].map(([k, v]) => ` ${k}: ${v};`);
137
+ /**
138
+ * O EXEMPLO É UM TOKEN REAL DELE. A promessa três linhas abaixo é "YOUR tokens, under YOUR
139
+ * names" - e o exemplo inventava `--<slug>-color-primary`, um prefixo derivado por nós + um papel
140
+ * nosso, que pode não existir no CSS de ninguém. O primeiro token de cor DELE cumpre a promessa.
141
+ */
142
+ const example = [...tokens.entries()].find(([, v]) => /^#|^(rgb|hsl|oklch|color)\(/i.test(v.trim()))?.[0] ??
143
+ [...tokens.keys()][0] ??
144
+ `--${slug}-color-primary`;
137
145
  return `# ${name} - the contract your agent follows
138
146
 
139
147
  Adopted from this repository by \`synthesisui adopt\`. These are YOUR tokens,
@@ -145,7 +153,7 @@ When writing or editing UI in this project, use these custom properties.
145
153
  Do not write raw colours, spacings or radii that a token already covers.
146
154
 
147
155
  ✗ background: #3b82f6
148
- ✓ background: var(--${slug}-color-primary)
156
+ ✓ background: var(${example})
149
157
 
150
158
  If a value has no token, say so instead of inventing one silently - a new
151
159
  token is a decision for a person to make.
@@ -59,14 +59,21 @@ export async function component(slug, name, opts) {
59
59
  if (!SAFE_NAME.test(res.name)) {
60
60
  throw new RegistryError(`Registry returned an unsafe component name.`);
61
61
  }
62
- const dir = join(root, "_synthesisui", "ds", slug, "components");
63
- await mkdir(dir, { recursive: true });
64
- await writeFile(join(dir, `${res.name}.json`), `${JSON.stringify(res.recipe, null, 2)}\n`, "utf8");
65
- await writeFile(join(dir, `${res.name}.css`), `${res.css}\n`, "utf8");
66
- console.log(`✓ ${res.name} → _synthesisui/ds/${slug}/components/${res.name}.{json,css} (${slug} v${res.version})`);
62
+ // R3 (dono, 16/08): as cópias .json/.css em _synthesisui só nascem quando
63
+ // são o PRODUTO do comando - `--artifacts-only` e targets não-Next. Quando
64
+ // o .tsx é materializado logo abaixo, ninguém as lê depois (medido na
65
+ // auditoria de 16/08), e cada arquivo a mais no repo dele é superfície.
66
+ const config = await readProjectConfig(root);
67
+ const artifactsAreTheProduct = opts.artifactsOnly === true || config.target !== "next";
68
+ if (artifactsAreTheProduct) {
69
+ const dir = join(root, "_synthesisui", "ds", slug, "components");
70
+ await mkdir(dir, { recursive: true });
71
+ await writeFile(join(dir, `${res.name}.json`), `${JSON.stringify(res.recipe, null, 2)}\n`, "utf8");
72
+ await writeFile(join(dir, `${res.name}.css`), `${res.css}\n`, "utf8");
73
+ console.log(`✓ ${res.name} → _synthesisui/ds/${slug}/components/${res.name}.{json,css} (${slug} v${res.version})`);
74
+ }
67
75
  // 2. YOUR component - a real, importable `export function <Pascal>()` in the
68
76
  // project's flavor (config: styles css|tailwind), under componentsDir.
69
- const config = await readProjectConfig(root);
70
77
  const wantInteractive = opts.interactive && hasInteractiveTemplate(res.name);
71
78
  if (!opts.artifactsOnly && config.target === "next") {
72
79
  /**
@@ -1179,17 +1179,21 @@ export async function doctor(opts) {
1179
1179
  const w = Math.max(...repeats.map((r) => r.literal.length));
1180
1180
  for (const r of repeats) {
1181
1181
  const where = `${r.count}\u00d7 in ${r.files} file${r.files === 1 ? "" : "s"}`;
1182
- const near = nameToWrite(r) ? null : nearestToken(table, r.literal);
1182
+ const near = nameToWrite(r)
1183
+ ? null
1184
+ : nearestToken(table, r.literal, r.kind);
1183
1185
  /**
1184
1186
  * COINCIDÊNCIA NÃO É RESPOSTA - ver `crossFamily` em `scan.ts`. Para aquele `kind` o
1185
1187
  * sistema não tem nome; o valor mora noutra família, e dizer só a seta afirma o contrário.
1188
+ * MAS quando o REPO DELE nomeia o valor (`theirToken`), esse nome vale - `apply-fix.ts:140`
1189
+ * e a classificação de sugestão já tratam assim; só a impressão tinha ficado para trás.
1186
1190
  */
1187
- const named = r.crossFamily
1191
+ const named = r.crossFamily && !r.theirToken
1188
1192
  ? ` → no ${r.kind} named for it · the value lives as ${r.token}`
1189
1193
  : nameToWrite(r)
1190
1194
  ? ` → ${nameToWrite(r)}`
1191
1195
  : near
1192
- ? ` → nearest is ${near.name} (${near.value})`
1196
+ ? ` → nearest is ${near.theirs ?? near.name} (${near.value})`
1193
1197
  : "";
1194
1198
  say(` ${r.literal.padEnd(w)} ${where}${named}`);
1195
1199
  }
@@ -1205,13 +1209,15 @@ export async function doctor(opts) {
1205
1209
  for (const x of shown) {
1206
1210
  // A dead end with a neighbour is not a dead end. Only for lengths -
1207
1211
  // "nearly the same blue" is the guess this tool must never make.
1208
- const near = nameToWrite(x) ? null : nearestToken(table, x.literal);
1209
- const named = x.crossFamily
1212
+ const near = nameToWrite(x)
1213
+ ? null
1214
+ : nearestToken(table, x.literal, x.kind);
1215
+ const named = x.crossFamily && !x.theirToken
1210
1216
  ? `→ no ${x.kind} named for it · the value lives as ${x.token}`
1211
1217
  : nameToWrite(x)
1212
1218
  ? `→ ${nameToWrite(x)}`
1213
1219
  : near
1214
- ? `→ nearest is ${near.name} (${near.value})`
1220
+ ? `→ nearest is ${near.theirs ?? near.name} (${near.value})`
1215
1221
  : "→ no token holds this value yet";
1216
1222
  say(` ${String(x.line).padStart(4)} ${x.literal} ${named}`);
1217
1223
  }
@@ -1308,13 +1314,14 @@ export async function doctor(opts) {
1308
1314
  if (frozen.length > 0) {
1309
1315
  say(section("These will not follow your other scheme"));
1310
1316
  say(body(frozen.length === 1
1311
- ? "One recipe names a primitive where a SURFACE role of yours holds the same value."
1312
- : `${frozen.length} recipes name a primitive where a SURFACE role of yours holds the same value.`));
1317
+ ? "One recipe pins a fixed shade where a surface colour of this system holds the same value."
1318
+ : `${frozen.length} recipes pin a fixed shade where a surface colour of this system holds the same value.`));
1313
1319
  say("");
1314
1320
  for (const f of frozen) {
1315
1321
  say(body(`ds-${f.component} · ${f.where}`));
1316
1322
  say(` binds ${f.wrote}`);
1317
- say(` {color.semantic.${f.role}} holds that, and becomes ${f.becomes}`);
1323
+ /** A grafia que EXISTE no repo dele - o CSS compilado carrega esta variável; `{color.semantic.*}` é formato nosso e não sai. */
1324
+ say(` var(--ds-color-semantic-${f.role}) holds that, and becomes ${f.becomes}`);
1318
1325
  say("");
1319
1326
  }
1320
1327
  say(body("The value resolves and the CSS compiles, so nothing"));
@@ -1557,7 +1564,7 @@ export async function doctor(opts) {
1557
1564
  });
1558
1565
  }
1559
1566
  for (const r of unnamed.slice(0, migrating ? 3 : 2)) {
1560
- const near = nearestToken(table, r.literal);
1567
+ const near = nearestToken(table, r.literal, r.kind);
1561
1568
  plan.push({
1562
1569
  rank: migrating ? 3 : 4,
1563
1570
  /**
@@ -1567,7 +1574,7 @@ export async function doctor(opts) {
1567
1574
  * fila da anterior: aquela tem nome esperando, esta precisa de um.
1568
1575
  */
1569
1576
  what: near
1570
- ? `${r.literal} - name it, or snap to ${near.name}`
1577
+ ? `${r.literal} - name it, or snap to ${near.theirs ?? near.name}`
1571
1578
  : r.crossFamily
1572
1579
  ? `${r.literal} - no ${r.kind} named for it, and the value lives as ${r.token} in another family`
1573
1580
  : `${r.literal} - no name for it, here or in your CSS`,
@@ -1753,7 +1760,8 @@ export async function doctor(opts) {
1753
1760
  const verdict = compareToBaseline(d, base, {
1754
1761
  ...(relScopes.length ? { scope: relScopes.join(",") } : {}),
1755
1762
  });
1756
- console.log(section(verdict.worse ? "Drift went up" : "Ratchet"));
1763
+ /** "Drift" é a nossa palavra e não sai (ver a tradução da seção principal); aqui idem. */
1764
+ console.log(section(verdict.worse ? "Hand-written values went up" : "Holding the line"));
1757
1765
  for (const line of describeVerdict(verdict))
1758
1766
  console.log(body(line));
1759
1767
  if (verdict.worse)
@@ -22,7 +22,7 @@ export async function gaps(opts) {
22
22
  const raw = await readFile(path, "utf8").catch(() => null);
23
23
  console.log(section("What this pipeline did not read"));
24
24
  if (!raw) {
25
- console.log(body(`No census at ${path}. Run \`synthesisui import --dry\` first - that is what measures your files, and it writes the census there.`));
25
+ console.log(body(`No measurement at ${path}. Run \`synthesisui import --dry\` first - that is what measures your files, and it writes the result there.`));
26
26
  return;
27
27
  }
28
28
  let ledger;
@@ -39,7 +39,7 @@ export async function gaps(opts) {
39
39
  * contar.
40
40
  */
41
41
  if (!ledger) {
42
- console.log(body(`This census was written by a CLI that did not count style fragments yet, so the numbers do not exist rather than being zero. Run \`synthesisui import --dry\` again with ${opts.cli} to measure.`));
42
+ console.log(body(`This measurement was written by a tool version that did not count style fragments yet, so the numbers do not exist rather than being zero. Run \`synthesisui import --dry\` again with ${opts.cli} to measure.`));
43
43
  return;
44
44
  }
45
45
  for (const line of describeTriage(triageLedger(ledger, opts.cli)))
@@ -55,17 +55,21 @@ export async function generate(description, opts) {
55
55
  }
56
56
  console.log(`→ generating a component for "${slug}" at ${base} …`);
57
57
  const res = await postGenerate(base, { slug, description, name: opts.name });
58
- const dir = join(root, "_synthesisui", "ds", slug, "generated");
59
- await mkdir(dir, { recursive: true });
60
- await writeFile(join(dir, `${res.name}.json`), `${JSON.stringify(res.recipe, null, 2)}\n`, "utf8");
61
- await writeFile(join(dir, `${res.name}.css`), `${res.css}\n`, "utf8");
62
58
  const tries = `${res.tries} ${res.tries === 1 ? "try" : "tries"}`;
63
59
  console.log(`✓ ${res.name} generated (${res.model}, ${tries})`);
64
- console.log(` → _synthesisui/ds/${slug}/generated/${res.name}.{json,css}`);
65
60
  // Materialize YOUR component (.tsx) too - the SAME codegen `component` uses -
66
61
  // so a generated component is as usable as a brought-in one, not just a
67
62
  // recipe you have to wire by hand.
68
63
  const config = await readProjectConfig(root);
64
+ // R3 (dono, 16/08): as cópias .json/.css só quando são o produto - num
65
+ // target não-Next o .tsx abaixo não nasce, e aí elas são a entrega.
66
+ if (config.target !== "next") {
67
+ const dir = join(root, "_synthesisui", "ds", slug, "generated");
68
+ await mkdir(dir, { recursive: true });
69
+ await writeFile(join(dir, `${res.name}.json`), `${JSON.stringify(res.recipe, null, 2)}\n`, "utf8");
70
+ await writeFile(join(dir, `${res.name}.css`), `${res.css}\n`, "utf8");
71
+ console.log(` → _synthesisui/ds/${slug}/generated/${res.name}.{json,css}`);
72
+ }
69
73
  let materialized = false;
70
74
  if (config.target === "next") {
71
75
  const version = await readActiveVersion(root, slug);
@@ -3113,7 +3113,7 @@ export async function runImport(opts) {
3113
3113
  if (creds && !sameRegistry(creds.registry, base)) {
3114
3114
  console.log("");
3115
3115
  console.log(body(`You are logged in to ${paint.strong(creds.registry ?? "another registry")}, and this would go to ${paint.strong(base)}.`));
3116
- console.log(body("The census is on disk. Either name the one you meant:"));
3116
+ console.log(body(`Your measurement is saved at ${out}. Either name the one you meant:`));
3117
3117
  console.log("");
3118
3118
  console.log(body(` synthesisui import --census ${out} --registry ${creds.registry}`));
3119
3119
  console.log(body("or log in to this one:"));
@@ -3143,7 +3143,7 @@ export async function runImport(opts) {
3143
3143
  // which of the two it was (dono, 31/07). A stale token gets past the
3144
3144
  // `readToken` guard above and only fails here.
3145
3145
  if (res?.status === 401) {
3146
- console.log(body("Your session has expired. The census is on disk."));
3146
+ console.log(body(`Your session has expired. Your measurement is saved at ${out}.`));
3147
3147
  console.log("");
3148
3148
  console.log(body(` ${paint.strong("synthesisui login")}`));
3149
3149
  console.log(body(` synthesisui import --census ${out}${chosen ? ` --name "${chosen}"` : ""}`));
@@ -3152,7 +3152,7 @@ export async function runImport(opts) {
3152
3152
  }
3153
3153
  console.log(body(res
3154
3154
  ? `The registry refused it (HTTP ${res.status})${detail ? `: ${detail}` : "."}`
3155
- : "Could not reach the registry. The census is on disk - try again later."));
3155
+ : `Could not reach the registry. Your measurement is saved at ${out} - try again later.`));
3156
3156
  console.log("");
3157
3157
  return;
3158
3158
  }
@@ -6,7 +6,7 @@ import { readToken, resolveRegistry } from "../config.js";
6
6
  import { readEvents } from "../doctor/ledger.js";
7
7
  import { fileRequest } from "../doctor/requests.js";
8
8
  import { diagnose, nameToWrite, scanSource } from "../doctor/scan.js";
9
- import { nearestToken, tokenFor } from "../doctor/tokens.js";
9
+ import { nearestToken, normalizeValue, tokenFor } from "../doctor/tokens.js";
10
10
  import { repoStateOf } from "../repo-state.js";
11
11
  import { component } from "./component.js";
12
12
  import { loadSystem, walkAll } from "./doctor.js";
@@ -109,6 +109,25 @@ const TOOLS = [
109
109
  required: ["name"],
110
110
  },
111
111
  },
112
+ {
113
+ name: "playbook",
114
+ description: "The skill playbooks (init, import, adapt) - SERVED, not shipped, so they are always current and your context only carries the step you are on. Call with { skill } to get the framing and a table of contents; then fetch ONLY the chapter for your current step with { skill, section }. Never fetch more than the step needs.",
115
+ inputSchema: {
116
+ type: "object",
117
+ properties: {
118
+ skill: {
119
+ type: "string",
120
+ enum: ["init", "import", "adapt"],
121
+ description: "Which playbook.",
122
+ },
123
+ section: {
124
+ type: "string",
125
+ description: "A chapter id from the toc. Omit to get the framing + toc.",
126
+ },
127
+ },
128
+ required: ["skill"],
129
+ },
130
+ },
112
131
  {
113
132
  name: "recipe_vocabulary",
114
133
  description: "What a recipe CAN hold: every state that compiles, every preview form, the floor per kind, and the rules a media region needs. Call this BEFORE writing a recipe, not after - the reader that skipped it sent a `dark:` inside a variant and a state the compiler cannot spell, and both were dropped in silence. Served from the catalogue, so it grows as the contract grows.",
@@ -271,12 +290,29 @@ async function findToken(root, value) {
271
290
  const { table } = await loadSystem(root);
272
291
  if (table.byName.size === 0)
273
292
  return "No design system installed here.";
293
+ /**
294
+ * O NOME DELE PRIMEIRO, como no doctor (`nameToWrite`): `loadSystem` já devolve a tabela COM os
295
+ * aliases dele (`withTheirNames`) e este era o único leitor que nunca os consultava. Sem o `kind`
296
+ * do contexto, a busca varre as famílias - um alias em qualquer uma delas é o nome que o repo
297
+ * DELE dá àquele valor.
298
+ */
299
+ const theirNameFor = (v) => {
300
+ const norm = normalizeValue(v, table.rootPx);
301
+ for (const [key, name] of table.aliases)
302
+ if (key.endsWith(`:${norm}`))
303
+ return name;
304
+ return null;
305
+ };
274
306
  const exact = tokenFor(table, value);
275
- if (exact)
276
- return `${value} is ${exact} in this system. Use var(${exact}).`;
307
+ if (exact) {
308
+ const shown = theirNameFor(value) ?? exact;
309
+ return `${value} is ${shown} in this project. Use var(${shown}).`;
310
+ }
277
311
  const near = nearestToken(table, value);
278
- if (near)
279
- return `No token holds ${value}. The closest is ${near.name} at ${near.value}. If that is what you meant, use it - if it genuinely is not, say so rather than inventing a token.`;
312
+ if (near) {
313
+ const shown = theirNameFor(near.value) ?? near.name;
314
+ return `No token holds ${value}. The closest is ${shown} at ${near.value}. If that is what you meant, use it - if it genuinely is not, say so rather than inventing a token.`;
315
+ }
280
316
  // Same words the managed block uses. A refusal is only useful if it is the
281
317
  // same refusal every time.
282
318
  return `No token in this system holds ${value}, and nothing is close. Do NOT invent one. Say which value you need and what you would call it, and let a person decide.`;
@@ -297,18 +333,55 @@ async function findToken(root, value) {
297
333
  * palavra que a pessoa já tem. Sem rede, a doutrina responde igual e a checagem é que se cala: o
298
334
  * dado é local justamente para não depender de servidor nenhum.
299
335
  */
336
+ /** A voz do sistema, servida da plataforma - ver o comentário no chamador. */
337
+ async function fetchVoice(root) {
338
+ const slug = await installedSlug(root);
339
+ const token = await readToken();
340
+ if (!slug || !token)
341
+ return { kind: "unreachable" };
342
+ const res = await fetch(`${resolveRegistry()}/api/registry/ds/${slug}?voice=1`, { headers: { Authorization: `Bearer ${token}` } }).catch(() => null);
343
+ if (!res?.ok)
344
+ return { kind: "unreachable" };
345
+ const body = (await res.json().catch(() => null));
346
+ if (!body)
347
+ return { kind: "unreachable" };
348
+ return {
349
+ kind: "ok",
350
+ voice: {
351
+ context: body.context ?? undefined,
352
+ sections: body.sections ?? [],
353
+ },
354
+ };
355
+ }
300
356
  async function systemDoctrine(root) {
301
357
  const { documents, doctrines } = await loadSystem(root);
302
358
  const rules = doctrines.flatMap((d) => d.rules);
303
359
  const parts = [];
304
360
  const pinned = doctrines[0]?.version;
305
- for (const doc of documents) {
306
- const p = doc.philosophy;
307
- if (p?.context)
361
+ /**
362
+ * A VOZ É SERVIDA, NÃO LIDA DO DISCO (R2, dono 16/08). As REGRAS continuam
363
+ * locais - o doctor e o hook precisam delas offline, e essa decisão está
364
+ * declarada três vezes. A voz nunca foi lida por nenhum loop local: só esta
365
+ * ferramenta a renderiza, e ela agora pergunta à plataforma - sempre a
366
+ * versão atual, e nada dela em texto plano no repo. Sem rede, as regras
367
+ * respondem igual e a voz se declara indisponível em vez de sumir calada.
368
+ *
369
+ * Installs antigos (design-system.json com `philosophy` dentro) continuam
370
+ * funcionando: o disco é o fallback quando a rede não responde.
371
+ */
372
+ const localVoice = documents.map((doc) => doc.philosophy ?? {});
373
+ const served = await fetchVoice(root);
374
+ const voices = served.kind === "ok"
375
+ ? [served.voice]
376
+ : localVoice.filter((p) => p.context || (p.sections?.length ?? 0) > 0);
377
+ for (const p of voices) {
378
+ if (p.context)
308
379
  parts.push(`## What this product is\n\n${p.context}`);
309
- for (const sec of p?.sections ?? [])
380
+ for (const sec of p.sections ?? [])
310
381
  parts.push(`## ${sec.title}\n\n${sec.body}`);
311
382
  }
383
+ if (served.kind === "unreachable" && voices.length === 0)
384
+ parts.push("## Voice\n\nThe voice is served from the platform and could not be reached right now. The rules above still apply in full - build with them, and fetch the voice again before writing user-facing copy.");
312
385
  if (rules.length === 0 && parts.length === 0)
313
386
  return "This system carries no rules and no philosophy yet. Nothing here overrides your judgement - build with its tokens and its components, and say what you needed and could not find.";
314
387
  const head = [
@@ -408,6 +481,28 @@ async function listComponents(root) {
408
481
  * properties need to be dynamic - the more we map, the more precision". A list written
409
482
  * into a published package is a list that stops matching the contract.
410
483
  */
484
+ /**
485
+ * O PLAYBOOK, na fatia pedida (R1, dono 16/08): sem `section`, a moldura + o
486
+ * sumário; com, UM capítulo. O corpo inteiro nunca viaja - a dieta de contexto
487
+ * é servida por construção.
488
+ */
489
+ async function playbook(skill, section) {
490
+ const answer = await askCatalogue("playbook", undefined, section ? { skill, section } : { skill });
491
+ if (!answer.ok)
492
+ return answer.because;
493
+ const body = answer.body;
494
+ if ("error" in body)
495
+ return [body.error, body.hint].filter(Boolean).join("\n");
496
+ if ("sections" in body) {
497
+ return [
498
+ body.intro,
499
+ "",
500
+ "## Chapters - fetch ONLY the one for your current step",
501
+ ...body.sections.map((c) => `- ${c.id} - ${c.title}`),
502
+ ].join("\n");
503
+ }
504
+ return body.body;
505
+ }
411
506
  async function recipeVocabulary() {
412
507
  const answer = await askCatalogue("vocabulary");
413
508
  if (!answer.ok)
@@ -769,7 +864,9 @@ cli) {
769
864
  }
770
865
  })();
771
866
  }
772
- async function askCatalogue(want, post) {
867
+ async function askCatalogue(want, post,
868
+ /** Parâmetros extras do GET (ex.: o playbook e o capítulo pedidos). */
869
+ query) {
773
870
  const token = await readToken();
774
871
  if (!token) {
775
872
  return {
@@ -779,7 +876,8 @@ async function askCatalogue(want, post) {
779
876
  }
780
877
  const base = resolveRegistry();
781
878
  try {
782
- const res = await fetch(`${base}/api/catalogue${post ? "" : `?want=${want}`}`, post
879
+ const extra = query ? `&${new URLSearchParams(query).toString()}` : "";
880
+ const res = await fetch(`${base}/api/catalogue${post ? "" : `?want=${want}${extra}`}`, post
783
881
  ? {
784
882
  method: "POST",
785
883
  headers: {
@@ -966,6 +1064,11 @@ cli) {
966
1064
  case "add_component":
967
1065
  countAgentRead(root, String(args.name ?? ""), "add", cli);
968
1066
  return text(await addComponent(root, String(args.name ?? "")));
1067
+ case "playbook": {
1068
+ const skill = String(args?.skill ?? "");
1069
+ const section = args?.section ? String(args.section) : undefined;
1070
+ return text(await playbook(skill, section));
1071
+ }
969
1072
  case "recipe_vocabulary":
970
1073
  return text(await recipeVocabulary());
971
1074
  case "validate_recipe": {
@@ -123,12 +123,15 @@ export async function refit(file, opts) {
123
123
  recipe: res.recipe,
124
124
  });
125
125
  console.log(`✓ saved into "${slug}" (draft v${saved.version} - ships with your next publish)`);
126
- // 5. materialize back into the project: artifacts + YOUR typed component
127
- const artifactsDir = join(root, "_synthesisui", "ds", slug, "components");
128
- await mkdir(artifactsDir, { recursive: true });
129
- await writeFile(join(artifactsDir, `${res.name}.json`), `${JSON.stringify(res.recipe, null, 2)}\n`, "utf8");
130
- await writeFile(join(artifactsDir, `${res.name}.css`), `${res.css}\n`, "utf8");
126
+ // 5. materialize back into the project: YOUR typed component - e as cópias
127
+ // .json/.css só quando são o produto (target não-Next; R3, dono 16/08).
131
128
  const config = await readProjectConfig(root);
129
+ if (config.target !== "next") {
130
+ const artifactsDir = join(root, "_synthesisui", "ds", slug, "components");
131
+ await mkdir(artifactsDir, { recursive: true });
132
+ await writeFile(join(artifactsDir, `${res.name}.json`), `${JSON.stringify(res.recipe, null, 2)}\n`, "utf8");
133
+ await writeFile(join(artifactsDir, `${res.name}.css`), `${res.css}\n`, "utf8");
134
+ }
132
135
  let materialized = false;
133
136
  if (config.target === "next") {
134
137
  const compDir = join(root, config.componentsDir, res.name);
@@ -269,9 +269,9 @@ export async function remeasure(args) {
269
269
  }
270
270
  }
271
271
  console.log(section("Measuring your repository again"));
272
- console.log(body(paint.faint(`${scope ? `scope ${scope}` : "the whole repo"}${local.system && local.system !== stored.scope
273
- ? " · read from your own census, which is where the last measurement recorded it"
274
- : ""} · the reading you already authored travels unchanged`)));
272
+ console.log(body(paint.faint(`${scope ? `reading ${scope}` : "the whole repo"}${local.system && local.system !== stored.scope
273
+ ? " · read from _synthesisui/census.json, where the last measurement recorded it"
274
+ : ""} · what you already wrote about your app travels unchanged`)));
275
275
  const census = await takeCensus(scope ? join(root, scope) : root, {
276
276
  ...(scope ? { scopeLabel: scope } : {}),
277
277
  ...(args.cli ? { cli: args.cli } : {}),
@@ -333,7 +333,7 @@ export async function remeasure(args) {
333
333
  * repositório inteiro e o rascunho curado de 37 vira 339).
334
334
  */
335
335
  if (!fresh.scope && local.system)
336
- console.log(body(`⚠ the census was written without a scope while "${local.system}" is the one recorded - the next re-measure would read the whole repo. Please report this: it is a state we have not reproduced.`));
336
+ console.log(body(`⚠ the last measurement was saved without the folder it read, while "${local.system}" is the one recorded - the next one would read the whole repo. Please report this: it is a state we have not reproduced.`));
337
337
  if (usage.length > 0)
338
338
  fresh.usage = usage;
339
339
  for (const key of ["scheme", "reading"])
@@ -57,14 +57,43 @@ export async function use(slug, intent, opts) {
57
57
  : "",
58
58
  `- ${base}/v${version}/GUIDE.md - how to apply the system, the recipes and the token vocabulary`,
59
59
  ].filter(Boolean);
60
+ /**
61
+ * OS EXEMPLOS SÃO DO SISTEMA DELE, não uma lista nossa de cor. `bg-primary`/`p-md` cravados
62
+ * ensinavam classes que um sistema importado pode nem compilar - os nomes reais moram no
63
+ * `design-system.json` instalado, e é dele que os exemplos saem. Ilegível, ficam os genéricos.
64
+ */
65
+ let utilities = "`bg-primary`, `text-foreground`, `p-md`, `rounded-lg`, `font-display`";
66
+ try {
67
+ const doc = JSON.parse(await readFile(join(slugDir, `v${version}`, "design-system.json"), "utf8"));
68
+ const roles = Object.keys(doc.foundations?.color?.semantic ?? {});
69
+ const spacing = Object.keys(doc.foundations?.spacing ?? {});
70
+ const radius = Object.keys(doc.foundations?.radius ?? {});
71
+ const parts = [
72
+ roles.length > 0
73
+ ? `\`bg-${roles.includes("primary") ? "primary" : roles[0]}\``
74
+ : null,
75
+ roles.includes("foreground")
76
+ ? "`text-foreground`"
77
+ : roles[1]
78
+ ? `\`text-${roles[1]}\``
79
+ : null,
80
+ spacing[0] ? `\`p-${spacing[0]}\`` : null,
81
+ radius[0] ? `\`rounded-${radius[0]}\`` : null,
82
+ "`font-display`",
83
+ ].filter(Boolean);
84
+ if (parts.length >= 3)
85
+ utilities = parts.join(", ");
86
+ }
87
+ catch {
88
+ // sem manifesto legível, os exemplos genéricos ficam - pior seria nenhum
89
+ }
60
90
  // The styling contract differs by target: Next projects in this product use
61
91
  // Tailwind v4 backed by the DS; the "general" target is framework-agnostic CSS.
62
92
  const stylingRule = config.target === "general"
63
93
  ? `- Style with the design system only: reuse the \`.ds-*\` recipe classes and the ` +
64
94
  `\`var(--ds-*)\` custom properties. Never use raw hex/px outside the system's scale.`
65
95
  : `- Style with the design system only: reuse the \`.ds-*\` recipe classes and the ` +
66
- `DS-backed Tailwind utilities (\`bg-primary\`, \`text-foreground\`, \`p-md\`, \`rounded-lg\`, ` +
67
- `\`font-display\`…). Never use raw hex/px outside the system's scale.`;
96
+ `DS-backed Tailwind utilities (${utilities}…). Never use raw hex/px outside the system's scale.`;
68
97
  const task = intent.trim() || "build the UI I describe next";
69
98
  const prompt = [
70
99
  `Use the "${name}" design system (slug: ${slug}, v${version}) to: ${task}`,
@@ -79,12 +79,20 @@ export function compareToBaseline(d, base, now) {
79
79
  : {}),
80
80
  };
81
81
  }
82
- /** O que a mensagem de uma anotação diz, e é a mesma frase nos dois formatos. */
82
+ /**
83
+ * O que a mensagem de uma anotação diz, e é a mesma frase nos dois formatos.
84
+ *
85
+ * O NOME DELE PRIMEIRO, como em todo o resto do doctor (`nameToWrite`): este era o único caminho
86
+ * que ignorava `their-names` por completo - e é o que aparece anotado na linha do PR de todo o
87
+ * time dele. Um `theirToken` também desarma o caso `crossFamily`: o repo DELE nomeia o valor, então
88
+ * a troca existe (mesma regra de `apply-fix.ts`).
89
+ */
83
90
  function messageFor(f) {
84
- return f.token
85
- ? f.crossFamily
91
+ const shown = f.theirToken ?? f.token;
92
+ return shown
93
+ ? f.crossFamily && !f.theirToken
86
94
  ? `${f.literal} is written by hand, and this project's system has no ${f.kind} named for it. The value does exist elsewhere in the system - as \`${f.token}\`, in another family - so this is a decision to make, not a swap: name a ${f.kind}, or leave it.`
87
- : `${f.literal} is written by hand, and this project's system already names it: var(${f.token}). Use the token.`
95
+ : `${f.literal} is written by hand, and this project already names it: var(${shown}). Use the name.`
88
96
  : `${f.literal} is a ${f.kind} value written by hand, and the system has no name for it yet. Name it, or file a token request: npx synthesisui request token`;
89
97
  }
90
98
  /**
@@ -192,7 +200,8 @@ export function describeVerdict(v) {
192
200
  lines.push(`Baseline not comparable: ${v.incomparable}.`);
193
201
  return lines;
194
202
  }
195
- lines.push(`Drift ${v.drift.was} → ${v.drift.now}${v.phantoms.was || v.phantoms.now ? ` · phantom tokens ${v.phantoms.was} → ${v.phantoms.now}` : ""}.`);
203
+ /** "Drift"/"phantom" são palavras nossas - a linha fala do que ELE vê: valores à mão e nomes que não resolvem. */
204
+ lines.push(`Hand-written values ${v.drift.was} → ${v.drift.now}${v.phantoms.was || v.phantoms.now ? ` · names that resolve to nothing ${v.phantoms.was} → ${v.phantoms.now}` : ""}.`);
196
205
  if (v.improved > 0)
197
206
  lines.push(`${v.improved} file${v.improved === 1 ? "" : "s"} improved since the baseline.`);
198
207
  if (v.regressed.length === 0) {
@@ -467,7 +467,9 @@ export function buildTable(input) {
467
467
  * Deliberately narrow: same unit, within 25% or 8px, and never for a colour -
468
468
  * "nearly the same blue" is exactly the guess this tool must not make.
469
469
  */
470
- export function nearestToken(table, literal) {
470
+ export function nearestToken(table, literal,
471
+ /** A família do achado - com ela, o vizinho também é oferecido pelo NOME DELE quando o repo o nomeia. */
472
+ kind) {
471
473
  const m = /^(-?\d*\.?\d+)(px|rem)$/.exec(literal.trim().toLowerCase());
472
474
  if (!m)
473
475
  return null;
@@ -490,7 +492,17 @@ export function nearestToken(table, literal) {
490
492
  if (!best)
491
493
  return null;
492
494
  const limit = unit === "rem" ? Math.max(n * 0.25, 0.5) : Math.max(n * 0.25, 8);
493
- return best.delta <= limit ? best : null;
495
+ if (best.delta > limit)
496
+ return null;
497
+ /**
498
+ * O VIZINHO PELO NOME DELE - a mesma chave que `theirNames` construiu (`kind:valor`), porque o
499
+ * valor sozinho não decide a família. Só a tabela nossa era consultada aqui, então a oferta saía
500
+ * sempre no nosso vocabulário mesmo quando o repo dele nomeia o mesmo valor.
501
+ */
502
+ const theirs = kind
503
+ ? table.aliases.get(`${kind}:${normalizeValue(best.value, table.rootPx)}`)
504
+ : undefined;
505
+ return theirs ? { ...best, theirs } : best;
494
506
  }
495
507
  /**
496
508
  * The family a token belongs to, from the drift it was found in.