synthesisui 0.16.482 → 0.16.484

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.
@@ -21,6 +21,16 @@ import { body, bodyWrapped, paint } from "./output.js";
21
21
  * Vazio e não uma linha vazia: quem chama decide se imprime o título, e um bloco com cabeçalho e
22
22
  * nada embaixo é pior que bloco nenhum.
23
23
  */
24
+ /** "A and B", "A, B, C, D and 2 more" - quatro nomes cabem numa linha de terminal. */
25
+ function namesOf(names) {
26
+ const shown = names.slice(0, 4);
27
+ const rest = names.length - shown.length;
28
+ if (rest > 0)
29
+ return `${shown.join(", ")} and ${rest} more`;
30
+ return shown.length === 1
31
+ ? shown[0]
32
+ : `${shown.slice(0, -1).join(", ")} and ${shown[shown.length - 1]}`;
33
+ }
24
34
  export function craftLines(craft) {
25
35
  if (!craft)
26
36
  return [];
@@ -50,7 +60,9 @@ export function craftLines(craft) {
50
60
  * a segunda pergunta, e ela merece a segunda linha.
51
61
  */
52
62
  if (apart)
53
- lines.push(...bodyWrapped(`${apart} have no recipe yet: counted apart, never against you.`).map((l) => body(paint.dim(l))));
63
+ lines.push(...bodyWrapped(craft.unwrittenNames?.length
64
+ ? `${namesOf(craft.unwrittenNames)} have no recipe yet - nothing in their files paints them - so they are counted apart, never against you.`
65
+ : `${apart} have no recipe yet: counted apart, never against you.`).map((l) => body(paint.dim(l))));
54
66
  /**
55
67
  * OS RÓTULOS ALINHADOS PELO MAIS LONGO, e a largura é medida e não estimada: os nomes chegam do
56
68
  * servidor, então um padding fixo desalinharia no dia em que uma lente for renomeada lá.
package/dist/claude-md.js CHANGED
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { hasHook } from "./agent-wiring.js";
4
4
  import { declaredReference } from "./group-role.js";
5
5
  import { recallAvailable } from "./memory/availability.js";
6
+ import { readResponsiveForm, responsiveLine } from "./responsive-form.js";
6
7
  const START = "<!-- synthesisui:start -->";
7
8
  const END = "<!-- synthesisui:end -->";
8
9
  /** Reads the installed DSs from the .lock files in _synthesisui/ds/<slug>/. */
@@ -543,9 +544,15 @@ element. Write every user-facing string in that language - labels, empty states,
543
544
  \`alt\`. A screen reader pronounces \`aria-label\` using \`lang\`, so a mixed-language interface is
544
545
  worse than an untranslated one. If the attribute is wrong, change it rather than writing against
545
546
  it.`;
547
+ /**
548
+ * E COMO ELE FAZ O RESPONSIVO, medido como o idioma (item 19 da bateria, 29/09): no b9, 100% inline, o agente criou
549
+ * CSS Modules só para o ponto de quebra, porque ninguém tinha dito como aquele projeto faz.
550
+ */
551
+ const form = await readResponsiveForm(projectRoot).catch(() => null);
552
+ const responsive = form ? `\n\n${responsiveLine(form)}` : "";
546
553
  const body = `## Design Systems (via SynthesisUI)
547
554
 
548
- This project uses design system(s) tracked by the \`synthesisui\` CLI. ${rule}${language}
555
+ This project uses design system(s) tracked by the \`synthesisui\` CLI. ${rule}${language}${responsive}
549
556
 
550
557
  ${sections.join("\n")}
551
558
 
@@ -204,3 +204,55 @@ export function agentPrompt(plan) {
204
204
  "Change a value only where it is used as the line says. Leave every other colour as it is, and do not add or invent a name or a colour - these six were chosen by the person.",
205
205
  ].join("\n");
206
206
  }
207
+ /**
208
+ * O UPGRADE TRAZ AS SEIS CORES NOVAS - etapa 01 da jornada "cada um sabe se o seu código está em dia", 29/09.
209
+ *
210
+ * Só o `colours` escrevia o bloco, então quem recebia a versão nova de um colega pelo `upgrade` ficava com os valores
211
+ * velhos no CSS global (medido no teste-e09: `--color-primary: #0070f3` com o sistema em `#2e5078`). Aqui o bloco que
212
+ * é NOSSO - entre as marcas - tem os valores trocados pelos da versão nova; nada fora dele é tocado.
213
+ *
214
+ * O papel de cada linha: pela grafia (`--<prefixo>-<palavra>`) e, quando o nome é o DELE (`--accent`), pela cor de
215
+ * antes - é a cor que a versão instalada dava àquele papel.
216
+ */
217
+ export function refreshColourBlock(css, slug, before, after) {
218
+ const hit = blockMarks.END_RE(slug).exec(css);
219
+ if (!hit)
220
+ return { css, changes: [] };
221
+ const low = (x) => (x ?? "").trim().toLowerCase();
222
+ const changes = [];
223
+ const taken = new Set();
224
+ const block = hit[0].replace(/^(\s*)(--[a-z0-9-]+)(\s*:\s*)([^;]+);/gim, (line, pad, name, colon, value) => {
225
+ const byWord = SIX_ROLES.find((r) => !taken.has(r) &&
226
+ new RegExp(`^--[a-z0-9]+-${WORD[r].word}$`, "i").test(name));
227
+ const role = byWord ??
228
+ SIX_ROLES.find((r) => !taken.has(r) && before[r] && low(before[r]) === low(value));
229
+ if (!role)
230
+ return line;
231
+ taken.add(role);
232
+ const to = after[role];
233
+ if (!to || low(to) === low(value))
234
+ return line;
235
+ changes.push({ role, name, from: value.trim(), to });
236
+ return `${pad}${name}${colon}${to};`;
237
+ });
238
+ if (changes.length === 0)
239
+ return { css, changes };
240
+ return {
241
+ css: css.slice(0, hit.index) + block + css.slice(hit.index + hit[0].length),
242
+ changes,
243
+ };
244
+ }
245
+ /** O mesmo, no disco: acha o CSS global dele e reescreve só se algum valor mudou. */
246
+ export async function refreshColourBlockOnDisk(root, slug, before, after) {
247
+ const v = await readVocabulary(root, slug);
248
+ if (!v.global)
249
+ return { file: null, changes: [] };
250
+ const path = join(root, v.global);
251
+ const css = await readFile(path, "utf8").catch(() => null);
252
+ if (css == null)
253
+ return { file: null, changes: [] };
254
+ const r = refreshColourBlock(css, slug, before, after);
255
+ if (r.changes.length > 0)
256
+ await writeFile(path, r.css, "utf8");
257
+ return { file: v.global, changes: r.changes };
258
+ }
@@ -1,6 +1,5 @@
1
- import { neverMeasured } from "../never-measured.js";
2
1
  import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
2
+ import { dirname, join } from "node:path";
4
3
  import { agentsToMaintain } from "../agents-chosen.js";
5
4
  import { syncClaudeMd } from "../claude-md.js";
6
5
  import { readProjectConfig, readToken, resolveRegistry } from "../config.js";
@@ -9,18 +8,19 @@ import { customFontFamilies, googleFontsHref, nextFontSnippet, } from "../fonts.
9
8
  import { detectAppDirs } from "../global-sheet.js";
10
9
  import { lockReference } from "../group-role.js";
11
10
  import { buildGuide } from "../guide.js";
11
+ import { nextInstalledAt, readInstalledAt, writeInstalledAt, } from "../installed-at.js";
12
12
  import { censusScope } from "../measured-scope.js";
13
13
  import { recallAvailable } from "../memory/availability.js";
14
+ import { neverMeasured } from "../never-measured.js";
14
15
  import { nextFontFacts } from "../next-font-weights.js";
15
16
  import { body as line, section, snippet } from "../output.js";
16
17
  import { fetchDesignSystem } from "../registry.js";
17
18
  import { repoStateOf } from "../repo-state.js";
18
19
  import { describeFiltered, ruleApplies, rulesForProject, } from "../rule-filter.js";
19
- import { detectStack } from "../stack.js";
20
+ import { detectStack, resolveDeps } from "../stack.js";
20
21
  import { onlyWhatMatched } from "../their-theme.js";
21
22
  import { tongueOf } from "../their-tongue.js";
22
23
  import { pointTokensAtTheirNames } from "../their-vars.js";
23
- import { resolveDeps } from "../stack.js";
24
24
  import { tracksAnyOf } from "../tracked.js";
25
25
  /**
26
26
  * QUAL METADE DESTA PASTA UM TIME COMMITA.
@@ -75,6 +75,11 @@ const IGNORED = [
75
75
  * turno de quem instalasse faria um arquivo nosso aparecer no `git status` dele.
76
76
  */
77
77
  ".turn-clock",
78
+ /**
79
+ * A HORA EM QUE A VERSÃO ATERROU NESTE CLONE - ver `installed-at.ts` (29/09). Dentro do `.lock` ela fazia dois upgrades
80
+ * da mesma versão, em branches separadas, darem conflito numa linha só.
81
+ */
82
+ ".fetched-at.json",
78
83
  ];
79
84
  const IGNORE_HEADER = "# Managed by synthesisui. The identity and the CSS are committed so a fresh\n" +
80
85
  "# clone is governed; the measurement and the local record are not, because\n" +
@@ -196,7 +201,10 @@ async function familiesHeNames(root, families) {
196
201
  const kept = Object.entries(families).filter(([, v]) => {
197
202
  if (typeof v !== "string")
198
203
  return true;
199
- const first = v.split(",")[0]?.trim().replace(/^["']|["']$/g, "") ?? "";
204
+ const first = v
205
+ .split(",")[0]
206
+ ?.trim()
207
+ .replace(/^["']|["']$/g, "") ?? "";
200
208
  return !first || /^var\(/.test(first) || css.includes(first.toLowerCase());
201
209
  });
202
210
  return Object.fromEntries(kept);
@@ -392,7 +400,9 @@ export async function add(slug, opts) {
392
400
  * E O SISTEMA QUE NUNCA FOI MEDIDO NUM CÓDIGO já nasce assim - ver `neverMeasured` (rodada 5, 27/09):
393
401
  * o fork do leigo-5 terminava o `connect` mandando rodar `sync` para uma pasta que não existe.
394
402
  */
395
- ...(prev?.reading === "none" || neverMeasured(payload.document) ? { reading: "none" } : {}),
403
+ ...(prev?.reading === "none" || neverMeasured(payload.document)
404
+ ? { reading: "none" }
405
+ : {}),
396
406
  /** Ver `RegistryPayload.compiler`: é o que faz um conserto de CSS chegar a um install. */
397
407
  ...(payload.compiler != null ? { compiler: payload.compiler } : {}),
398
408
  ...(payload.rulesStamp ? { rules: payload.rulesStamp } : {}),
@@ -428,17 +438,21 @@ export async function add(slug, opts) {
428
438
  /** A memória do que NÓS escrevemos atravessa a re-materialização - ver `RootLock.written`. */
429
439
  ...(prev?.written ? { written: prev.written } : {}),
430
440
  };
431
- const landed = (() => {
432
- if (!prev?.fetchedAt)
433
- return true;
434
- const { fetchedAt: _was, ...before } = prev;
435
- return JSON.stringify(before) !== JSON.stringify(identity);
436
- })();
437
- const lock = {
438
- ...identity,
439
- fetchedAt: landed || !prev?.fetchedAt ? new Date().toISOString() : prev.fetchedAt,
440
- };
441
+ /**
442
+ * O `.lock` SEM A HORA - ver `installed-at.ts` (29/09): dois clones na mesma versão escrevem o mesmo arquivo, e a hora
443
+ * de cada um vai para `.fetched-at.json`, que o git ignora.
444
+ */
445
+ const slugDirOfLock = dirname(rootLockPath);
446
+ const installedAt = nextInstalledAt({
447
+ prevLock: prev ??
448
+ null,
449
+ prevAt: await readInstalledAt(slugDirOfLock, null),
450
+ identity: identity,
451
+ now: new Date().toISOString(),
452
+ });
453
+ const lock = { ...identity };
441
454
  await writeFile(rootLockPath, `${JSON.stringify(lock, null, 2)}\n`, "utf8");
455
+ await writeInstalledAt(slugDirOfLock, installedAt);
442
456
  /**
443
457
  * O MAPA SOBE AGORA, e é o único momento em que ele é novo.
444
458
  *
@@ -637,7 +651,9 @@ export async function add(slug, opts) {
637
651
  * `@theme` - vinte linhas depois de "100% of your 59 style decisions carry your own vocabulary".
638
652
  */
639
653
  const wearsTailwind = await projectWearsTailwind(projectRoot);
640
- if (wearsTailwind && !opts.themeFollows && (aligned.dropped.length > 0 || aligned.own.length > 0)) {
654
+ if (wearsTailwind &&
655
+ !opts.themeFollows &&
656
+ (aligned.dropped.length > 0 || aligned.own.length > 0)) {
641
657
  console.log(line(
642
658
  /**
643
659
  * A LINHA DIZ QUE FICARAM DE FORA - e nao AFIRMA por que, porque ela nao sabe.
@@ -779,7 +795,11 @@ export async function add(slug, opts) {
779
795
  */
780
796
  const stackDeps = await resolveDeps(projectRoot).catch(() => ({}));
781
797
  const onNext = projectConfig.target === "next" && "next" in stackDeps;
782
- const headHome = onNext ? "app/layout.tsx" : "vite" in stackDeps ? "index.html" : "your root HTML";
798
+ const headHome = onNext
799
+ ? "app/layout.tsx"
800
+ : "vite" in stackDeps
801
+ ? "index.html"
802
+ : "your root HTML";
783
803
  const fontPlan = onNext
784
804
  ? nextFontSnippet({
785
805
  families,
@@ -9,6 +9,7 @@ import { unsentEvents } from "../doctor/ledger.js";
9
9
  import { readRequests } from "../doctor/requests.js";
10
10
  import { declaredReference } from "../group-role.js";
11
11
  import { CHECKER_SINCE, installedBehind, MATERIALISER_SINCE, READER_SINCE, } from "../install-marks.js";
12
+ import { readInstalledAt } from "../installed-at.js";
12
13
  import { fmt, say } from "../lang.js";
13
14
  import { measuredScope } from "../measured-scope.js";
14
15
  import { body, bodyWrapped, paint, section, snippet } from "../output.js";
@@ -34,7 +35,8 @@ async function wholeRepoMeasured(root) {
34
35
  return false;
35
36
  try {
36
37
  const c = JSON.parse(raw);
37
- const narrowed = (typeof c.scope === "string" && c.scope.trim() !== "") || (Array.isArray(c.scopes) && c.scopes.length > 0);
38
+ const narrowed = (typeof c.scope === "string" && c.scope.trim() !== "") ||
39
+ (Array.isArray(c.scopes) && c.scopes.length > 0);
38
40
  return c.measured != null && typeof c.measured === "object" && !narrowed;
39
41
  }
40
42
  catch {
@@ -66,7 +68,10 @@ async function locksIn(root) {
66
68
  if (!raw)
67
69
  continue;
68
70
  try {
69
- out.push(JSON.parse(raw));
71
+ const lock = JSON.parse(raw);
72
+ /** A hora mora ao lado desde 29/09 - ver `installed-at.ts`; o `.lock` antigo ainda responde. */
73
+ const at = await readInstalledAt(join(dsDir, entry.name), lock);
74
+ out.push(at ? { ...lock, fetchedAt: at } : lock);
70
75
  }
71
76
  catch {
72
77
  // lock ilegível - o `status` já reporta isso com o nome do arquivo
@@ -228,7 +233,10 @@ opts = {}) {
228
233
  * sistema de 36).
229
234
  */
230
235
  const scope = await measuredScope(root, lock.slug);
231
- if (!scope.system && scope.usage.length === 0 && lock.reading !== "none" && !(await wholeRepoMeasured(root)))
236
+ if (!scope.system &&
237
+ scope.usage.length === 0 &&
238
+ lock.reading !== "none" &&
239
+ !(await wholeRepoMeasured(root)))
232
240
  out.push({
233
241
  says: `"${lock.slug}" does not record where it was measured, so a re-measure would read this whole repo instead of the folder the system came from. Sync asks once and remembers the answer.`,
234
242
  /**
@@ -521,6 +529,7 @@ export async function versionBehind(root, opts = {}) {
521
529
  return repaired;
522
530
  if (body.version > lock.version)
523
531
  return {
532
+ kind: "version",
524
533
  says: `v${body.version} of "${lock.slug}" is published and this repo is on v${lock.version}. The CSS here and the rules your agent reads are both v${lock.version} - they move together, which is why this is worth saying rather than applying.`,
525
534
  run: `npx synthesisui upgrade ${lock.slug}`,
526
535
  };
@@ -707,7 +716,7 @@ opts = {}) {
707
716
  ...(opts.cli ? { cli: opts.cli } : {}),
708
717
  ...(opts.home ? { home: opts.home } : {}),
709
718
  }).catch(() => null);
710
- if (remote)
719
+ if (remote && !(opts.skipVersion && remote.kind === "version"))
711
720
  items.push(remote);
712
721
  return items;
713
722
  }
@@ -98,7 +98,7 @@ export function markedBefore(before, documentJson) {
98
98
  return { ...before, ...marked };
99
99
  }
100
100
  /** As seis cores da versão que está instalada agora - `{}` quando ainda não há nenhuma. */
101
- async function installedRoles(slugDir) {
101
+ export async function installedRoles(slugDir) {
102
102
  const lock = JSON.parse(await readFile(join(slugDir, ".lock"), "utf8").catch(() => "{}"));
103
103
  if (typeof lock.version !== "number")
104
104
  return {};
@@ -11,7 +11,7 @@ import { PAGES_MAX, pageCompositionOf } from "../census-pages.js";
11
11
  import { readCredentials, readProjectConfig, readToken, resolveRegistry, sameRegistry, } from "../config.js";
12
12
  import { declaredElsewhere } from "../declared-elsewhere.js";
13
13
  import { architectureGap, architectureRule, componentHome, describeArchitecture, describeChoice, describeGap, detectArchitectures, homeLine, packagingOf, proposeNewHome, resolvesAs, countsAsOrganization, } from "../doctor/architecture.js";
14
- import { findBrokenRefs } from "../doctor/broken-refs.js";
14
+ import { findBrokenRefs, findOrphanVars, findSynonyms, synonymLine } from "../doctor/broken-refs.js";
15
15
  import { nestingRules, propRules, readDefinitionProps, readNesting, readRuntime, } from "../doctor/call-sites.js";
16
16
  import { asCatalogueTable, describeFallback, fetchCatalogue, } from "../doctor/catalogue-fetch.js";
17
17
  import { describeClassStyle, detectClassStyle, } from "../doctor/class-style.js";
@@ -2620,6 +2620,8 @@ export async function takeCensus(root, opts) {
2620
2620
  }
2621
2621
  }
2622
2622
  const brokenRefs = findBrokenRefs(sources, declaredNames);
2623
+ const orphanVars = findOrphanVars(css, sources);
2624
+ const synonyms = findSynonyms(css, sources);
2623
2625
  const conventions = detectConventions(sources);
2624
2626
  const classStyle = detectClassStyle(sources);
2625
2627
  /**
@@ -2696,6 +2698,8 @@ export async function takeCensus(root, opts) {
2696
2698
  : {}),
2697
2699
  ...(animations.size > 0 ? { animations: [...animations].sort() } : {}),
2698
2700
  ...(brokenRefs.length > 0 ? { brokenRefs } : {}),
2701
+ ...(orphanVars.length > 0 ? { orphanVars } : {}),
2702
+ ...(synonyms.length > 0 ? { synonyms } : {}),
2699
2703
  /**
2700
2704
  * OS SISTEMAS QUE O CSS DELE JÁ CHAMA PELO NOME - ver `wiredSlugs`.
2701
2705
  *
@@ -3051,6 +3055,38 @@ async function summarize(c, root, scope) {
3051
3055
  sayAdoption(c);
3052
3056
  sayCollisions(c);
3053
3057
  await sayBrokenRefs(c, root, scope);
3058
+ sayOrphanVars(c);
3059
+ saySynonyms(c);
3060
+ }
3061
+ /**
3062
+ * A MESMA COR COM VÁRIOS NOMES - uma linha por cor, até três (item 16 da bateria, 29/09). Propõe o nome que o código
3063
+ * mais lê; quem decide se junta é ele.
3064
+ */
3065
+ function saySynonyms(c) {
3066
+ const all = c.synonyms ?? [];
3067
+ if (all.length === 0)
3068
+ return;
3069
+ console.log("");
3070
+ console.log(section("One colour, several names"));
3071
+ for (const g of all.slice(0, 3))
3072
+ console.log(body(synonymLine(g)));
3073
+ if (all.length > 3)
3074
+ console.log(body(paint.faint(`(${all.length - 3} more colours with more than one name)`)));
3075
+ }
3076
+ /**
3077
+ * DECLARADAS E NUNCA USADAS - uma linha, nunca uma acusação (item 15 da bateria, 29/09).
3078
+ *
3079
+ * No b1 eram três sobras de um tema antigo, e o import passava calado. Quem lê decide se apaga: pode ser de propósito.
3080
+ */
3081
+ function sayOrphanVars(c) {
3082
+ const all = c.orphanVars ?? [];
3083
+ if (all.length === 0)
3084
+ return;
3085
+ const names = all.slice(0, 6).map((o) => paint.strong(o.name));
3086
+ const more = all.length > 6 ? ` and ${all.length - 6} more` : "";
3087
+ console.log("");
3088
+ console.log(section("Declared and never used"));
3089
+ console.log(body(`${all.length} variable${all.length === 1 ? "" : "s"} your CSS declares and nothing reads: ${names.join(", ")}${more}. Usually left over from an old theme - yours to delete or keep.`));
3054
3090
  }
3055
3091
  /**
3056
3092
  * OS ESCOPOS QUE VIRARAM UM SISTEMA, e o que cada um perdeu no empate.
@@ -4138,6 +4174,13 @@ const DECIDEM = [
4138
4174
  const NA_TELA_DO_IMPORT = [
4139
4175
  "How this project is organised",
4140
4176
  ...DECIDEM,
4177
+ /**
4178
+ * E AS ÓRFÃS, numa linha (item 15 da bateria, 29/09): no b1 o dev contava três sobras de tema e o import passava
4179
+ * calado. Cabe porque é uma linha só, e ela muda o que ele faz a seguir: apagar ou manter.
4180
+ */
4181
+ "Declared and never used",
4182
+ /** E a mesma cor com vários nomes (item 16 da bateria, 29/09): até três linhas, e cada uma propõe um nome. */
4183
+ "One colour, several names",
4141
4184
  ];
4142
4185
  function sayReach(c) {
4143
4186
  const reach = ladderReach(c.declared);
@@ -4654,6 +4697,20 @@ quiet = false) {
4654
4697
  * token nenhum o sistema inteiro é a semente, e "v1 is your tokens" levava ao contrário. Ver
4655
4698
  * `nao-espelha-o-que-nao-existe.spec.ts`.
4656
4699
  */
4700
+ /**
4701
+ * AS CORES QUE O PROJETO NÃO TEM - item 18 da bateria (28/09), liberado por ele em 29/09. No b4 a borda, o sucesso e o
4702
+ * perigo vinham do nosso ponto de partida, e o dev achava que eram dele. O servidor manda quais (`fromSeed`).
4703
+ */
4704
+ export function fromSeedLine(roles) {
4705
+ if (roles.length === 0)
4706
+ return null;
4707
+ const list = roles.length === 1
4708
+ ? roles[0]
4709
+ : `${roles.slice(0, -1).join(", ")} or ${roles[roles.length - 1]}`;
4710
+ return roles.length === 1
4711
+ ? `Your code names no ${list} colour, so it comes from our starting point - yours to change on the colours page.`
4712
+ : `Your code names no ${list} colour, so those come from our starting point - yours to change on the colours page.`;
4713
+ }
4657
4714
  export function birthLine(name, declared) {
4658
4715
  return declared === 0
4659
4716
  ? `${name} - your code names no token yet, so this system is our starting point, with your colours and sizes measured next to it (listed in the report).`
@@ -5160,6 +5217,15 @@ export async function runImport(opts) {
5160
5217
  */
5161
5218
  if (payload?.primary)
5162
5219
  console.log(body(payload.primary));
5220
+ /**
5221
+ * E AS QUE ELE NÃO TEM (item 18 da bateria, 29/09). Com a porta das cores aberta a frase dela já diz tudo, e com zero
5222
+ * token a frase do nascimento já diz que o sistema inteiro é o ponto de partida - aí esta linha seria repetição.
5223
+ */
5224
+ const seedLine = !payload?.coloursDoor && Object.keys(census.declared ?? {}).length > 0
5225
+ ? fromSeedLine(payload?.fromSeed ?? [])
5226
+ : null;
5227
+ if (seedLine)
5228
+ console.log(body(seedLine));
5163
5229
  /**
5164
5230
  * A PORTA - convite, nunca bloqueio (o ponto 1 que ele aprovou em 28/09). O servidor manda a frase pronta, a mesma
5165
5231
  * que o quadro do sistema mostra, e o link da tela de escolher daquele sistema.
@@ -13,11 +13,16 @@ import { measuredScope, rememberScope } from "../measured-scope.js";
13
13
  import { fromCensus } from "../memory/observation.js";
14
14
  import { reportMeasurement } from "../memory/report.js";
15
15
  import { mergeCensus } from "../merge-census.js";
16
- import { describeGrowth, recordGrowthForTurn, whatGrew } from "../what-grew.js";
16
+ import { readVocabulary } from "../new-project-theme.js";
17
+ import { gitFacts, offerNewVersion } from "../new-version.js";
17
18
  import { body, paint, section, snippet } from "../output.js";
19
+ import { fetchDesignSystem } from "../registry.js";
18
20
  import { repoStateOf } from "../repo-state.js";
21
+ import { describeGrowth, recordGrowthForTurn, whatGrew } from "../what-grew.js";
19
22
  import { reportWhatIsLeft } from "./align.js";
23
+ import { installedRoles, roleColours } from "./colours.js";
20
24
  import { resolveReadParts, siblingProjects, takeCensus, } from "./import.js";
25
+ import { upgrade } from "./upgrade.js";
21
26
  /**
22
27
  * What each decision means ON THIS MACHINE - the card decides, the sync
23
28
  * enacts. The web never touches the local file; this line is printed right
@@ -321,9 +326,50 @@ export async function sync(opts) {
321
326
  * dizendo o que sobrou resolve o que a dúvida realmente era: acabou ou não? Mudo quando não sobrou
322
327
  * nada, que é o caso normal.
323
328
  */
324
- await reportWhatIsLeft(root);
329
+ /**
330
+ * UMA VERSÃO NOVA, DITA POR EXTENSO - etapa 02 de "cada um sabe se o seu código está em dia" (29/09): o que mudou,
331
+ * e o caminho sem conflito. Quando ela foi dita aqui, a lista do fim não a repete.
332
+ */
333
+ const offered = await offerNewVersionHere(root, slug, base).catch(() => "current");
334
+ await reportWhatIsLeft(root, offered === "current" ? {} : { skipVersion: true });
325
335
  sayRefused(refused);
326
336
  }
337
+ async function offerNewVersionHere(root, slug, base) {
338
+ const slugDir = join(root, "_synthesisui", "ds", slug);
339
+ const lock = JSON.parse(await readFile(join(slugDir, ".lock"), "utf8").catch(() => "{}"));
340
+ if (typeof lock.version !== "number")
341
+ return "current";
342
+ const { global } = await readVocabulary(root, slug);
343
+ return offerNewVersion(root, { slug, version: lock.version }, { globalCss: global }, {
344
+ latest: async () => {
345
+ const p = await fetchDesignSystem(base, slug);
346
+ return {
347
+ version: p.version,
348
+ roles: roleColours(p.artifacts["tokens.css"] ?? ""),
349
+ };
350
+ },
351
+ installedRoles: () => installedRoles(slugDir),
352
+ git: () => gitFacts(root, slug, global),
353
+ ask: askYesNo,
354
+ upgrade: (s) => upgrade(s, { registry: base, dir: root }),
355
+ tty: Boolean(process.stdin.isTTY && process.stdout.isTTY),
356
+ });
357
+ }
358
+ async function askYesNo(question) {
359
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
360
+ return false;
361
+ const { createInterface } = await import("node:readline/promises");
362
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
363
+ try {
364
+ return /^y(es)?$/i.test((await rl.question(question)).trim());
365
+ }
366
+ catch {
367
+ return false;
368
+ }
369
+ finally {
370
+ rl.close();
371
+ }
372
+ }
327
373
  /**
328
374
  * O QUE NÃO FOI ENVIADO, DITO POR ÚLTIMO - e o "por último" é o desenho, não um detalhe.
329
375
  *
@@ -359,6 +405,16 @@ export async function remeasure(args) {
359
405
  * a frase diz que é o esperado, e o `.lock` guarda isso, para o `align` não pedir de novo.
360
406
  */
361
407
  if (head?.status === 404) {
408
+ /**
409
+ * MAS O 404 TAMBÉM É A RESPOSTA A UMA SESSÃO RECUSADA (29/09): a rota não diz "não te conheço" para não revelar
410
+ * sistema de ninguém. O token de www.synthesisui.com num sync contra o localhost caía aqui, e o `teste-e09-fechamento`,
411
+ * que TEM leitura, foi marcado como nunca medido. Antes de concluir, pergunta à sessão.
412
+ */
413
+ if (!(await sessionAccepted(base, token))) {
414
+ console.log(section("Sync"));
415
+ console.log(body(`${base} does not accept this machine's login, so nothing was re-measured - and nothing was marked. Sign in there and run this again: npx synthesisui login --registry ${base}`));
416
+ return null;
417
+ }
362
418
  await markNoReading(root, slug);
363
419
  console.log(section("Sync"));
364
420
  console.log(body(`"${slug}" was not read from a repository - it comes from the gallery or was built on the platform - so there is nothing here to re-measure. That is expected, and this repo will stop asking.`));
@@ -376,10 +432,22 @@ export async function remeasure(args) {
376
432
  * SEM CENSO GRAVADO NÃO HÁ O QUE REMEDIR CONTRA. Um sistema que nasceu da galeria não tem escopo
377
433
  * nem leitura, e medir o repo dele produziria um censo de um projeto que ninguém mapeou para ele.
378
434
  */
435
+ /**
436
+ * A LEITURA EXISTE, então uma marca "nunca medido" no `.lock` está errada - a que uma sessão recusada deixou antes de
437
+ * 29/09 (ver o 404 acima). Sai aqui, para o `align` voltar a pedir o que precisa.
438
+ */
439
+ if (stored.hasCensus)
440
+ await unmarkNoReading(root, slug);
441
+ /**
442
+ * O SISTEMA DA GALERIA APRENDE COM O QUE ELE CONSTRÓI - item 8 da bateria (28/09), liberado por ele em 29/09.
443
+ *
444
+ * Esta resposta era "did not come from an import (...) Import it first", e no b11 tudo o que o agente construiu em cima
445
+ * do sistema da galeria ficava fora da plataforma. Sem leitura gravada, a medição vai sobre o repositório inteiro (a
446
+ * mesma queda de escopo que `scopes` abaixo já faz), e a frase diz que é a primeira.
447
+ */
379
448
  if (!stored.hasCensus) {
380
449
  console.log(section("Sync"));
381
- console.log(body(`"${slug}" did not come from an import, so there is no measurement to refresh. Import it first: npx synthesisui import`));
382
- return null;
450
+ console.log(body(`This is the first measurement of what this project built on "${slug}" - it came from the gallery, so the platform had not read this repo yet. What you created goes to your system's draft; the system's own colours and type stay as they are.`));
383
451
  }
384
452
  /**
385
453
  * A DISTÂNCIA ATÉ O CLI PUBLICADO, dita onde ele já está.
@@ -1048,6 +1116,32 @@ async function askForScope(root) {
1048
1116
  export async function installedSlug(root) {
1049
1117
  return (await installedSlugs(root))[0] ?? null;
1050
1118
  }
1119
+ /** A sessão desta máquina vale em `base`? - a mesma pergunta que o `status` faz. Sem resposta, diz que não. */
1120
+ async function sessionAccepted(base, token) {
1121
+ const res = await fetch(`${base}/api/auth/get-session`, {
1122
+ headers: { Authorization: `Bearer ${token}` },
1123
+ }).catch(() => null);
1124
+ if (!res?.ok)
1125
+ return false;
1126
+ const who = (await res.json().catch(() => null));
1127
+ return Boolean(who?.user);
1128
+ }
1129
+ /** Tira do `.lock` a marca de "sem leitura", quando a plataforma diz que a leitura existe. */
1130
+ async function unmarkNoReading(root, slug) {
1131
+ const path = join(root, "_synthesisui", "ds", slug, ".lock");
1132
+ const raw = await readFile(path, "utf8").catch(() => "");
1133
+ if (!raw)
1134
+ return;
1135
+ try {
1136
+ const { reading, ...rest } = JSON.parse(raw);
1137
+ if (reading !== "none")
1138
+ return;
1139
+ await writeFile(path, `${JSON.stringify(rest, null, 2)}\n`, "utf8");
1140
+ }
1141
+ catch {
1142
+ /** Um `.lock` ilegível não é deste passo consertar. */
1143
+ }
1144
+ }
1051
1145
  /** Anota no `.lock` que a plataforma não tem leitura deste sistema - ver o 404 em `remeasure`. */
1052
1146
  async function markNoReading(root, slug) {
1053
1147
  const path = join(root, "_synthesisui", "ds", slug, ".lock");
@@ -1,14 +1,15 @@
1
1
  import { readdir, readFile, writeFile } from "node:fs/promises";
2
- import { speakInHisProject } from "../in-his-form.js";
3
2
  import { join } from "node:path";
4
3
  import { pinnedHookVersion, wireAgent } from "../agent-wiring.js";
5
4
  import { agentsToMaintain } from "../agents-chosen.js";
6
5
  import { isOlderCli } from "../cli-version.js";
6
+ import { refreshColourBlockOnDisk } from "../colour-names.js";
7
7
  import { generateComponentFiles } from "../component-codegen.js";
8
8
  import { readProjectConfig, readToken, resolveRegistry } from "../config.js";
9
9
  import { unsentEvents } from "../doctor/ledger.js";
10
10
  import { diffLocalDocuments, localChangelogMarkdown, } from "../document-diff.js";
11
11
  import { detectAppDirs } from "../global-sheet.js";
12
+ import { speakInHisProject } from "../in-his-form.js";
12
13
  import { installedBehind, MATERIALISER_SINCE } from "../install-marks.js";
13
14
  import { body, section, snippet } from "../output.js";
14
15
  import { reactMajorOf, readInstalledConvention, readInstalledScheme, theirThemeVars, themeVocabOf, } from "../project-facts.js";
@@ -19,6 +20,7 @@ import { readCensus, unreadComment, unreadForComponent, } from "../unread-for-co
19
20
  import { editedSinceWritten, keptLine, readWritten, recordWritten, } from "../written.js";
20
21
  import { add } from "./add.js";
21
22
  import { reportWhatIsLeft } from "./align.js";
23
+ import { installedRoles } from "./colours.js";
22
24
  import { doctor } from "./doctor.js";
23
25
  /**
24
26
  * The highest `v<n>` below `installed` among the folder names given, or the one
@@ -384,6 +386,8 @@ export async function upgrade(asked, opts) {
384
386
  console.log(`✓ ${slug} v${installed} is newer than the registry's v${latest.version} - nothing to do.`);
385
387
  return;
386
388
  }
389
+ /** As seis cores de ANTES - o bloco do CSS global dele guarda estas (etapa 01, 29/09). */
390
+ const coloursBefore = await installedRoles(slugDir);
387
391
  // 1. re-materialize the artifacts (v<latest>/ + root re-exports + .lock);
388
392
  // setup hints suppressed - an upgrade means the app is already wired.
389
393
  await add(slug, {
@@ -392,6 +396,11 @@ export async function upgrade(asked, opts) {
392
396
  setupHints: false,
393
397
  ...(opts.cli ? { cli: opts.cli } : {}),
394
398
  });
399
+ /**
400
+ * AS SEIS CORES NOVAS NO CSS GLOBAL DELE - etapa 01 de "cada um sabe se o seu código está em dia", 29/09. Quem
401
+ * recebia a versão de um colega ficava com os valores velhos no bloco que o `colours` escreveu.
402
+ */
403
+ const colourBlock = await refreshColourBlockOnDisk(root, slug, coloursBefore, await installedRoles(slugDir));
395
404
  // 2. regenerate YOUR materialized components (the ones `component` wrote)
396
405
  const config = await readProjectConfig(root);
397
406
  const regenerated = [];
@@ -541,6 +550,12 @@ export async function upgrade(asked, opts) {
541
550
  else {
542
551
  console.log(body("No breaking changes detected."));
543
552
  }
553
+ if (colourBlock.changes.length > 0) {
554
+ console.log("");
555
+ console.log(body(`✓ ${colourBlock.file}: ${colourBlock.changes.length === 1 ? "1 of your six colours" : `${colourBlock.changes.length} of your six colours`} now ${colourBlock.changes.length === 1 ? "has" : "have"} the new value:`));
556
+ for (const c of colourBlock.changes)
557
+ console.log(body(` ${c.name}: ${c.from} → ${c.to} (${c.role})`));
558
+ }
544
559
  if (regenerated.length > 0) {
545
560
  console.log("");
546
561
  console.log(body(`Regenerated ${regenerated.length} materialized component(s): ${regenerated.join(", ")}`));
@@ -579,5 +594,5 @@ export async function upgrade(asked, opts) {
579
594
  * `button-gen`: "Could not regenerate (...) (removed/renamed in v2?)". Sem cabeçalho, a pasta é a melhor pista.
580
595
  */
581
596
  export function recipeNameOf(head, entry) {
582
- return /Generated by SynthesisUI - "([^"]+)" from the/.exec(head)?.[1] ?? entry;
597
+ return (/Generated by SynthesisUI - "([^"]+)" from the/.exec(head)?.[1] ?? entry);
583
598
  }
@@ -174,3 +174,64 @@ export function findBrokenRefs(sources, declared) {
174
174
  b.count - a.count ||
175
175
  a.name.localeCompare(b.name));
176
176
  }
177
+ const THEME_BLOCK = /@theme\b[^{]*\{[^}]*\}/g;
178
+ const DECLARES_VAR = /(--[a-zA-Z0-9_-]+)\s*:/g;
179
+ const READS_IN_JS = /getPropertyValue\(\s*['"`](--[a-zA-Z0-9_-]+)/g;
180
+ const TAILWIND_SHORT = /\((--[a-zA-Z0-9_-]+)\)/g;
181
+ export function findOrphanVars(css, sources) {
182
+ const declared = new Set();
183
+ for (const m of css.replace(THEME_BLOCK, "").matchAll(DECLARES_VAR))
184
+ declared.add(m[1]);
185
+ if (declared.size === 0)
186
+ return [];
187
+ const used = new Set();
188
+ for (const text of [css, ...sources.map((s) => s.source)]) {
189
+ for (const m of text.matchAll(VAR_REF))
190
+ used.add(m[1]);
191
+ for (const m of text.matchAll(READS_IN_JS))
192
+ used.add(m[1]);
193
+ for (const m of text.matchAll(TAILWIND_SHORT))
194
+ used.add(m[1]);
195
+ }
196
+ return [...declared].filter((n) => !used.has(n)).map((name) => ({ name }));
197
+ }
198
+ const DECLARES_COLOUR = /(--[a-zA-Z0-9_-]+)\s*:\s*(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\))\s*[;}]/g;
199
+ export function findSynonyms(css, sources) {
200
+ const byValue = new Map();
201
+ for (const m of css.replace(THEME_BLOCK, "").matchAll(DECLARES_COLOUR)) {
202
+ const value = m[2].replace(/\s+/g, "").toLowerCase();
203
+ const names = byValue.get(value) ?? [];
204
+ if (!names.includes(m[1]))
205
+ names.push(m[1]);
206
+ byValue.set(value, names);
207
+ }
208
+ const uses = new Map();
209
+ /** Só nas fontes: a folha `css` é a soma das folhas que também estão lá, e contar as duas dobraria cada leitura. */
210
+ for (const text of sources.length > 0 ? sources.map((s) => s.source) : [css])
211
+ for (const m of text.matchAll(VAR_REF))
212
+ uses.set(m[1], (uses.get(m[1]) ?? 0) + 1);
213
+ const groups = [];
214
+ for (const [value, names] of byValue) {
215
+ if (names.length < 2)
216
+ continue;
217
+ groups.push({
218
+ value,
219
+ names: names
220
+ .map((name) => ({ name, uses: uses.get(name) ?? 0 }))
221
+ .sort((a, b) => b.uses - a.uses),
222
+ });
223
+ }
224
+ return groups.sort((a, b) => b.names.length - a.names.length);
225
+ }
226
+ /** "#2563eb has 4 names: ... Your code reads --brand the most - keeping one would make the rest aliases." */
227
+ export function synonymLine(g) {
228
+ const listed = g.names
229
+ .map((n) => `${n.name} (${n.uses > 0 ? `${n.uses}×` : "never read"})`)
230
+ .join(", ");
231
+ const top = g.names[0]?.uses ?? 0;
232
+ const most = g.names.filter((n) => n.uses === top && top > 0).map((n) => n.name);
233
+ const tail = most.length === 0
234
+ ? "Nothing reads any of them yet - keeping one would make the rest aliases."
235
+ : `Your code reads ${most.length === 1 ? most[0] : `${most.slice(0, -1).join(", ")} and ${most[most.length - 1]}`} the most - keeping one would make the rest aliases.`;
236
+ return `${g.value} has ${g.names.length} names: ${listed}. ${tail}`;
237
+ }
@@ -310,7 +310,11 @@
310
310
  * `[color:#ffffff]`, `font-body` em vez de `[font-family:Inter,_sans-serif]`), e a largura em px no degrau vira `w-10`.
311
311
  * Um componente escrito antes reprova na própria checagem.
312
312
  */
313
- export const MATERIALISER_SINCE = "0.16.476";
313
+ /**
314
+ * 0.16.483 (29/09): o `.lock` sai sem a hora (`fetchedAt` vai para `.fetched-at.json`, ignorado pelo git) - dois
315
+ * upgrades da mesma versão, em branches separadas, davam conflito numa linha só dele. Ver `installed-at.ts`.
316
+ */
317
+ export const MATERIALISER_SINCE = "0.16.483";
314
318
  /**
315
319
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
316
320
  *
@@ -0,0 +1,41 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /**
4
+ * A HORA EM QUE A VERSÃO ATERROU NESTE CLONE, FORA DO `.lock` - pedido dele em 29/09 ("tira, neste PR").
5
+ *
6
+ * MEDIDO: dois clones trouxeram a mesma versão em branches separadas e o merge parou numa linha só do `.lock` -
7
+ * `"fetchedAt"`, a hora de cada download. O `globals.css`, os tokens e a pasta da versão juntaram limpo. O `.lock` é
8
+ * commitado e o resto dele é função da versão, do compilador e das regras; a hora era a única coisa que descrevia UMA
9
+ * máquina, e é ela que fazia dois upgrades iguais brigarem.
10
+ *
11
+ * Então ela mora num arquivo ao lado, na lista do `.gitignore` que o CLI gerencia - como o `.last-sync.json`. Quem lê
12
+ * (`repairSaid` no align, `installedAt` no repo-state) lê daqui, e do `.lock` antigo quando este arquivo ainda não
13
+ * existe - um repo instalado antes continua respondendo.
14
+ */
15
+ export const INSTALLED_AT_FILE = ".fetched-at.json";
16
+ /** A hora nova: anda quando algo aterrou, e fica onde estava quando nada mudou - a mesma regra do `.lock` de antes. */
17
+ export function nextInstalledAt(v) {
18
+ const was = v.prevAt ?? v.prevLock?.fetchedAt ?? null;
19
+ if (!v.prevLock || !was)
20
+ return v.now;
21
+ const { fetchedAt: _was, ...before } = v.prevLock;
22
+ const landed = JSON.stringify(before) !== JSON.stringify(v.identity);
23
+ return landed ? v.now : was;
24
+ }
25
+ export async function readInstalledAt(slugDir, lock) {
26
+ const raw = await readFile(join(slugDir, INSTALLED_AT_FILE), "utf8").catch(() => null);
27
+ if (raw) {
28
+ try {
29
+ const at = JSON.parse(raw).fetchedAt;
30
+ if (typeof at === "string")
31
+ return at;
32
+ }
33
+ catch {
34
+ // ilegível: cai no `.lock` antigo
35
+ }
36
+ }
37
+ return typeof lock?.fetchedAt === "string" ? lock.fetchedAt : null;
38
+ }
39
+ export async function writeInstalledAt(slugDir, at) {
40
+ await writeFile(join(slugDir, INSTALLED_AT_FILE), `${JSON.stringify({ fetchedAt: at }, null, 2)}\n`, "utf8");
41
+ }
@@ -261,6 +261,13 @@ export function mergeCensus(list) {
261
261
  * dele continua lendo o censo como antes.
262
262
  */
263
263
  scopes: list.map((c, i) => nameOf(c, i)),
264
+ /**
265
+ * SEM ÓRFÃS NUM CENSO DE VÁRIOS ESCOPOS: a declarada num escopo pode ser lida no outro, e cada escopo só viu o seu.
266
+ * Dizer "nunca usada" sem ter olhado tudo seria acusar errado - fica calado (item 15 da bateria, 29/09).
267
+ */
268
+ orphanVars: undefined,
269
+ /** E pelo mesmo motivo os sinônimos: cada escopo contou as leituras só no que viu. */
270
+ synonyms: undefined,
264
271
  declared,
265
272
  observed,
266
273
  totals,
@@ -0,0 +1,201 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { body, section, snippet } from "./output.js";
5
+ export function pathFor(f) {
6
+ if (f.remoteVersion != null && f.remoteVersion >= f.latest)
7
+ return "pull";
8
+ if (f.dirty.length > 0)
9
+ return "commit-first";
10
+ return f.tty ? "ask" : "say";
11
+ }
12
+ /** As seis cores que mudaram entre a versão instalada e a publicada, na ordem dos papéis. */
13
+ export function colourChanges(before, after) {
14
+ const low = (x) => (x ?? "").trim().toLowerCase();
15
+ return Object.keys(after)
16
+ .filter((r) => before[r] && after[r] && low(before[r]) !== low(after[r]))
17
+ .map((role) => ({
18
+ role,
19
+ from: before[role],
20
+ to: after[role],
21
+ }));
22
+ }
23
+ /**
24
+ * O QUADRADO DA COR (29/09, pedido dele olhando o terminal: *"deveria ter um quadrado com as cores ao lado do hex"*). Um
25
+ * bloco com o fundo na cor exata (24 bits); sem terminal, nada - o agente lê texto, e um código de escape é ruído.
26
+ */
27
+ export function colourSwatch(hex, tty) {
28
+ const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
29
+ if (!tty || !m)
30
+ return "";
31
+ const [r, g, b] = [m[1], m[2], m[3]].map((x) => Number.parseInt(x, 16));
32
+ return `\u001b[48;2;${r};${g};${b}m \u001b[0m`;
33
+ }
34
+ /** O aviso, antes de qualquer escrita: as duas versões e o que mudou nas seis cores. */
35
+ export function newVersionLines(v) {
36
+ const sw = (hex) => {
37
+ const s = v.swatch?.(hex) ?? "";
38
+ return s ? `${s} ` : "";
39
+ };
40
+ const out = [
41
+ `v${v.latest} of "${v.slug}" is published and this repo is on v${v.installed}.`,
42
+ ];
43
+ if (v.changes.length > 0) {
44
+ out.push("What changed in your six colours:");
45
+ for (const c of v.changes)
46
+ out.push(` ${c.role.padEnd(11)}${sw(c.from)}${c.from} → ${sw(c.to)}${c.to}`);
47
+ }
48
+ else {
49
+ out.push("None of your six colours changed - the rest (rules, components) is listed in UPGRADE.md once it is here.");
50
+ }
51
+ return out;
52
+ }
53
+ function run(cmd, args, cwd, timeout = 8000) {
54
+ return new Promise((resolve) => {
55
+ execFile(cmd, args, { cwd, timeout, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }, (err, stdout) => resolve(err ? null : stdout.toString()));
56
+ });
57
+ }
58
+ /**
59
+ * O `.lock` só difere do commit pelo `reading` que o próprio sync anota (o 404 da leitura, em `markNoReading`)?
60
+ * Então a mudança é nossa, e o upgrade a reescreve igual - não é trabalho dele a proteger. Achado no PASS de 29/09.
61
+ */
62
+ async function onlyOurReading(root, lockPath) {
63
+ const committed = await run("git", ["show", `HEAD:./${lockPath}`], root);
64
+ const now = await readFile(join(root, lockPath), "utf8").catch(() => null);
65
+ if (committed == null || now == null)
66
+ return false;
67
+ try {
68
+ const { reading: _a, ...was } = JSON.parse(committed);
69
+ const { reading: _b, ...is } = JSON.parse(now);
70
+ return JSON.stringify(was) === JSON.stringify(is);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ export async function gitFacts(root, slug, globalCss) {
77
+ const none = {
78
+ repo: false,
79
+ origin: false,
80
+ dirty: [],
81
+ remoteVersion: null,
82
+ remoteRef: null,
83
+ gh: false,
84
+ };
85
+ const inside = await run("git", ["rev-parse", "--is-inside-work-tree"], root);
86
+ if (inside?.trim() !== "true")
87
+ return none;
88
+ const paths = [`_synthesisui/ds/${slug}`, ...(globalCss ? [globalCss] : [])];
89
+ const status = (await run("git", ["status", "--porcelain", "--", ...paths], root)) ?? "";
90
+ const lockPath = `_synthesisui/ds/${slug}/.lock`;
91
+ const dirty = [];
92
+ for (const f of status
93
+ .split("\n")
94
+ .map((l) => l.slice(3).trim())
95
+ .filter(Boolean)) {
96
+ if (f === lockPath && (await onlyOurReading(root, lockPath)))
97
+ continue;
98
+ dirty.push(f);
99
+ }
100
+ let remoteVersion = null;
101
+ let remoteRef = null;
102
+ const origin = await run("git", ["remote", "get-url", "origin"], root);
103
+ if (origin) {
104
+ /** O fetch só lê; sem rede ele falha calado e o resto segue sem a informação. */
105
+ await run("git", ["fetch", "--quiet", "origin"], root, 10000);
106
+ const head = (await run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], root))?.trim();
107
+ const candidates = [head, "origin/main", "origin/master"].filter(Boolean);
108
+ const prefix = ((await run("git", ["rev-parse", "--show-prefix"], root)) ?? "").trim();
109
+ for (const ref of candidates) {
110
+ const lock = await run("git", ["show", `${ref}:${prefix}_synthesisui/ds/${slug}/.lock`], root);
111
+ if (lock == null)
112
+ continue;
113
+ remoteRef = ref;
114
+ try {
115
+ const v = JSON.parse(lock).version;
116
+ remoteVersion = typeof v === "number" ? v : null;
117
+ }
118
+ catch {
119
+ remoteVersion = null;
120
+ }
121
+ break;
122
+ }
123
+ }
124
+ const gh = (await run("gh", ["auth", "status"], root, 5000)) != null;
125
+ return {
126
+ repo: true,
127
+ origin: origin != null,
128
+ dirty,
129
+ remoteVersion,
130
+ remoteRef,
131
+ gh,
132
+ };
133
+ }
134
+ /**
135
+ * O aviso inteiro, com o caminho escolhido. Devolve o que aconteceu - o `sync` usa para não repetir, na lista do que
136
+ * falta, a linha de versão que acabou de ser dita aqui.
137
+ */
138
+ export async function offerNewVersion(_root, lock, where, deps, print = (l) => console.log(l)) {
139
+ const latest = await deps.latest().catch(() => null);
140
+ if (!latest || latest.version <= lock.version)
141
+ return "current";
142
+ const changes = colourChanges(await deps.installedRoles(), latest.roles);
143
+ const facts = await deps.git();
144
+ const path = pathFor({
145
+ tty: deps.tty,
146
+ dirty: facts.dirty,
147
+ remoteVersion: facts.remoteVersion,
148
+ latest: latest.version,
149
+ });
150
+ const slug = lock.slug;
151
+ print("");
152
+ print(section(`A new version of ${slug}`));
153
+ for (const l of newVersionLines({
154
+ slug,
155
+ installed: lock.version,
156
+ latest: latest.version,
157
+ changes,
158
+ swatch: (hex) => colourSwatch(hex, deps.tty),
159
+ }))
160
+ print(body(l));
161
+ print("");
162
+ if (path === "pull") {
163
+ const branch = (facts.remoteRef ?? "origin/main").replace(/^origin\//, "");
164
+ print(body(`Someone on your team already brought v${latest.version} into ${facts.remoteRef}. Bring it from git instead of upgrading here - doing the same change twice, in two commits, is what makes a conflict:`));
165
+ print(snippet([`git pull origin ${branch}`]));
166
+ return path;
167
+ }
168
+ if (path === "commit-first") {
169
+ print(body("You have changes not committed where the upgrade writes. Commit or stash them first, so the upgrade lands in a diff of its own:"));
170
+ for (const f of facts.dirty)
171
+ print(body(` ${f}`));
172
+ print("");
173
+ print(body("then:"));
174
+ print(snippet([`npx synthesisui upgrade ${slug}`]));
175
+ return path;
176
+ }
177
+ if (path === "say") {
178
+ print(body("To bring it here:"));
179
+ print(snippet([`npx synthesisui upgrade ${slug}`]));
180
+ return path;
181
+ }
182
+ const yes = await deps.ask(` Bring v${latest.version} into this repo now? It rewrites _synthesisui/ds/${slug}${where.globalCss ? ` and the six colours in ${where.globalCss}` : ""}. [y/N] `);
183
+ if (!yes) {
184
+ print(body("Nothing was changed. When you want it:"));
185
+ print(snippet([`npx synthesisui upgrade ${slug}`]));
186
+ return "declined";
187
+ }
188
+ await deps.upgrade(slug);
189
+ print("");
190
+ print(section("Give it to your team"));
191
+ print(body("It is in your files now, not in git. Commit it on a branch of its own, so whoever pulls gets it without redoing it:"));
192
+ print(snippet([
193
+ `git switch -c synthesisui-${slug}-v${latest.version}`,
194
+ "git status # only what the upgrade wrote should be listed",
195
+ "git add -A",
196
+ `git commit -m "synthesisui: ${slug} v${latest.version}"`,
197
+ ...(facts.origin ? ["git push -u origin HEAD"] : []),
198
+ ...(facts.origin && facts.gh ? ["gh pr create --fill"] : []),
199
+ ]));
200
+ return "upgraded";
201
+ }
@@ -26,6 +26,7 @@
26
26
  import { readFile } from "node:fs/promises";
27
27
  import { join } from "node:path";
28
28
  import { readEvents, } from "./doctor/ledger.js";
29
+ import { readInstalledAt } from "./installed-at.js";
29
30
  /**
30
31
  * O estado de um slug neste projeto, ou nada quando não há `.lock`.
31
32
  *
@@ -34,6 +35,8 @@ import { readEvents, } from "./doctor/ledger.js";
34
35
  */
35
36
  export async function repoStateOf(projectRoot, slug, cli) {
36
37
  const lock = await readJson(join(projectRoot, "_synthesisui", "ds", slug, ".lock"));
38
+ /** A hora mora ao lado do `.lock` desde 29/09 - ver `installed-at.ts`. */
39
+ const installedAt = await readInstalledAt(join(projectRoot, "_synthesisui", "ds", slug), lock);
37
40
  /**
38
41
  * O ÚLTIMO DOCTOR, PELO MESMO LEITOR QUE O `sync` USA (`readEvents`). Abrir o arquivo por conta
39
42
  * própria aqui seria uma segunda implementação do formato do registro - e a primeira coisa que
@@ -93,9 +96,7 @@ export async function repoStateOf(projectRoot, slug, cli) {
93
96
  ? { unrepresentedMatches: doctor.unrepresentedMatches }
94
97
  : {}),
95
98
  ...(runs.length > 0 ? { runs } : {}),
96
- ...(typeof lock?.fetchedAt === "string"
97
- ? { installedAt: lock.fetchedAt }
98
- : {}),
99
+ ...(installedAt ? { installedAt } : {}),
99
100
  };
100
101
  /** Só o `cli` não é estado: sem nada do repo, não há o que reportar. */
101
102
  return Object.keys(payload).length > 1 ? payload : null;
@@ -0,0 +1,85 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ const SKIP = new Set([
4
+ "node_modules",
5
+ "_synthesisui",
6
+ ".next",
7
+ ".git",
8
+ "dist",
9
+ "build",
10
+ "out",
11
+ ".turbo",
12
+ ]);
13
+ /** Um teto, para um monorepo grande não virar uma varredura longa na hora de escrever o bloco. */
14
+ const MAX_FILES = 600;
15
+ const TAILWIND_PREFIX = /\b(?:sm|md|lg|xl|2xl):[a-z-]+/;
16
+ const MEDIA = /@media\b/;
17
+ const MATCH_MEDIA = /\bmatchMedia\s*\(|\buseMediaQuery\s*\(/;
18
+ const INLINE = /\bstyle=\{\{/g;
19
+ const CLASSNAME = /\bclassName=/g;
20
+ async function walk(root) {
21
+ const out = [];
22
+ const queue = [root];
23
+ while (queue.length > 0 && out.length < MAX_FILES) {
24
+ const dir = queue.shift();
25
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
26
+ for (const e of entries) {
27
+ if (SKIP.has(e.name))
28
+ continue;
29
+ const full = join(dir, e.name);
30
+ if (e.isDirectory())
31
+ queue.push(full);
32
+ else if (/\.(tsx|jsx|ts|js|css|scss|sass|less|html|vue|svelte)$/.test(e.name))
33
+ out.push(full);
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+ export async function readResponsiveForm(root) {
39
+ const files = await walk(root);
40
+ if (files.length === 0)
41
+ return null;
42
+ let media = null;
43
+ let js = null;
44
+ let inline = 0;
45
+ let classes = 0;
46
+ let componentSheets = 0;
47
+ for (const file of files) {
48
+ const src = await readFile(file, "utf8").catch(() => "");
49
+ const rel = relative(root, file);
50
+ const markup = /\.(tsx|jsx|html|vue|svelte)$/.test(file);
51
+ const sheet = /\.(css|scss|sass|less)$/.test(file);
52
+ if (markup && TAILWIND_PREFIX.test(src))
53
+ return { kind: "tailwind" };
54
+ if (sheet && !/globals?\.(css|scss)$/.test(file))
55
+ componentSheets += 1;
56
+ if (!media && (sheet || markup) && MEDIA.test(src))
57
+ media = rel;
58
+ if (!js && /\.(tsx|jsx|ts|js)$/.test(file) && MATCH_MEDIA.test(src))
59
+ js = rel;
60
+ if (markup) {
61
+ inline += src.match(INLINE)?.length ?? 0;
62
+ classes += src.match(CLASSNAME)?.length ?? 0;
63
+ }
64
+ }
65
+ if (media)
66
+ return { kind: "media", file: media };
67
+ if (js)
68
+ return { kind: "js", file: js };
69
+ return { kind: "none", inline: inline > classes && componentSheets === 0 };
70
+ }
71
+ /** A frase para o bloco do agente - uma, curta, com o fato medido e o que fazer. */
72
+ export function responsiveLine(form) {
73
+ switch (form.kind) {
74
+ case "tailwind":
75
+ return "**This project makes screens responsive with Tailwind prefixes** (`md:`, `lg:`). Use the same prefixes for a breakpoint - never a new stylesheet or a `matchMedia` hook for it.";
76
+ case "media":
77
+ return `**This project makes screens responsive with \`@media\` in its stylesheets** - see \`${form.file}\`. Put a new breakpoint in the stylesheet of the component that needs it, the same way.`;
78
+ case "js":
79
+ return `**This project makes screens responsive in code**, with \`matchMedia\` - see \`${form.file}\`. Reuse that for a new breakpoint instead of adding a stylesheet.`;
80
+ case "none":
81
+ return form.inline
82
+ ? "**This project has no breakpoint yet, and it styles inline** (`style={{}}`). If a screen needs one, keep the form: a small `matchMedia` hook next to the component, not a new stylesheet - and say that you added it."
83
+ : "**This project has no breakpoint yet.** If a screen needs one, add it in the form this project already styles with, and say that you added it.";
84
+ }
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.482",
3
+ "version": "0.16.484",
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": {