synthesisui 0.16.463 → 0.16.466

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.
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { readdir, stat } from "node:fs/promises";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  const run = promisify(execFile);
@@ -43,8 +43,31 @@ async function isGitRepo(root) {
43
43
  }));
44
44
  return stdout.trim() === "true";
45
45
  }
46
+ /**
47
+ * O `.gitignore` DA RAIZ, NA FORMA SIMPLES - rodada 4 do refinamento, 27/09. No leigo-4 (sem git) o fim
48
+ * do turno checou o `next-env.d.ts`, que o Next gera e que o `.gitignore` do `create-next-app` já exclui:
49
+ * sem git, ninguém lia aquele arquivo. Cobre nome, `/` na frente (só na raiz), `/` no fim (só pasta) e
50
+ * `*`; a negação (`!`) é ignorada - no pior caso um arquivo que ele quer checado fica de fora, como era
51
+ * antes do conserto de 27/09 para a pasta inteira.
52
+ */
53
+ async function ignoredBy(root) {
54
+ const text = await readFile(join(root, ".gitignore"), "utf8").catch(() => "");
55
+ const rules = text
56
+ .split("\n")
57
+ .map((l) => l.trim())
58
+ .filter((l) => l && !l.startsWith("#") && !l.startsWith("!"))
59
+ .map((l) => {
60
+ const onlyDir = l.endsWith("/");
61
+ const body = l.replace(/^\//, "").replace(/\/$/, "");
62
+ const anchored = l.startsWith("/") || body.includes("/");
63
+ const glob = body.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]");
64
+ return { onlyDir, re: new RegExp(anchored ? `^${glob}$` : `(^|/)${glob}$`) };
65
+ });
66
+ return (rel, dir) => rules.some((r) => (!r.onlyDir || dir) && r.re.test(rel));
67
+ }
46
68
  async function walked(root) {
47
69
  const out = [];
70
+ const ignored = await ignoredBy(root);
48
71
  const go = async (rel) => {
49
72
  if (out.length >= WALK_MAX)
50
73
  return;
@@ -52,6 +75,8 @@ async function walked(root) {
52
75
  if (entry.name.startsWith(".") || WALK_SKIP.has(entry.name))
53
76
  continue;
54
77
  const next = rel ? `${rel}/${entry.name}` : entry.name;
78
+ if (ignored(next, entry.isDirectory()))
79
+ continue;
55
80
  if (entry.isDirectory())
56
81
  await go(next);
57
82
  else if (entry.isFile())
@@ -2,7 +2,43 @@ import { DEFAULT_CONFIG, writeProjectConfig } from "../config.js";
2
2
  import { body, section, snippet } from "../output.js";
3
3
  import { resolveDeps, stylesFor, tailwindMajor } from "../stack.js";
4
4
  import { formMix, measuredStyle } from "../styles-flavour.js";
5
+ import { readFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { readProjectTheme, writeBesideTheirs, writeNewProjectTheme } from "../new-project-theme.js";
5
8
  import { add } from "./add.js";
9
+ const whereOf = (answer) => answer === "file" || answer === "1"
10
+ ? "file"
11
+ : answer === "separate" || answer === "2"
12
+ ? "separate"
13
+ : answer === "none" || answer === "3" || answer === ""
14
+ ? "none"
15
+ : null;
16
+ /**
17
+ * A PERGUNTA REAL DO TERCEIRO CASO - dono, 27/09: *"quando o projeto já tem os tokens, a gente tem que fazer
18
+ * uma pergunta real"*. Só num terminal de verdade; sem ele, `null`, e aí é "em lugar nenhum". O Enter também é
19
+ * "em lugar nenhum": escrever os nomes do sistema no projeto dele só com o sim dele.
20
+ */
21
+ async function askWhere(tokensFile, separate, entry, intro) {
22
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
23
+ return null;
24
+ const { createInterface } = await import("node:readline/promises");
25
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
26
+ try {
27
+ console.log("");
28
+ console.log(` ${intro}`);
29
+ console.log(` 1 in your tokens file (${tokensFile}), in a block of its own`);
30
+ console.log(` 2 in a file of its own, ${separate}, imported at the top of ${entry}`);
31
+ console.log(" 3 nowhere - keep only yours; components come with the values written");
32
+ const answer = (await rl.question(" [1/2/3] (3): ")).trim();
33
+ return whereOf(answer) ?? "none";
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ finally {
39
+ rl.close();
40
+ }
41
+ }
6
42
  /**
7
43
  * Bootstraps a project for SynthesisUI: writes `_synthesisui/config.json`
8
44
  * (where `template` materializes pages, where components live, which framework to
@@ -134,6 +170,51 @@ export async function init(opts) {
134
170
  setupHints: false,
135
171
  ...(opts.cli ? { cli: opts.cli } : {}),
136
172
  });
173
+ /**
174
+ * O TEMA DO SISTEMA, PELO ESTADO MEDIDO DO PROJETO - ver `readProjectTheme` (rodada 4, 27/09).
175
+ *
176
+ * Só com Tailwind v4, que é quem lê `@theme`. Projeto novo recebe os nomes do sistema; projeto cujos
177
+ * valores já são os do sistema não recebe nada, e os componentes falam os nomes dele; projeto com outro
178
+ * sistema recebe a pergunta de onde o bloco mora - e sem resposta, nada é escrito.
179
+ */
180
+ if (major !== null && major >= 4) {
181
+ const slugDir = join(root, "_synthesisui", "ds", opts.ds);
182
+ const lock = JSON.parse(await readFile(join(slugDir, ".lock"), "utf8").catch(() => "{}"));
183
+ const version = typeof lock.version === "number" ? lock.version : null;
184
+ const tokens = version !== null
185
+ ? await readFile(join(slugDir, `v${version}`, "tokens.css"), "utf8").catch(() => "")
186
+ : "";
187
+ const plan = tokens ? await readProjectTheme(root, tokens, opts.ds) : null;
188
+ if (plan?.kind === "new" && version !== null) {
189
+ const theme = await writeNewProjectTheme(root, tokens, opts.ds, version);
190
+ if (theme.written) {
191
+ console.log("");
192
+ console.log(`✓ ${theme.written}: ${theme.count} names of ${opts.ds} declared in your own @theme, with the values written - your project had none of its own. They are yours: bg-accent, p-md and rounded-lg now paint the system. Fonts come from the GUIDE's next/font recipe; the alternate scheme is not wired.`);
193
+ }
194
+ }
195
+ else if (plan?.kind === "matches") {
196
+ console.log("");
197
+ console.log(` ${plan.matched} of ${opts.ds}'s ${plan.colours} colours are already values in your CSS, so its components speak your names - nothing was written to it.`);
198
+ }
199
+ else if (plan?.kind === "differs" && version !== null && plan.entry) {
200
+ const separate = `${plan.entry.replace(/[^/]*$/, "")}${opts.ds}.css`;
201
+ const intro = `your CSS names its own tokens, and ${plan.matched} of ${opts.ds}'s ${plan.colours} colours are among them - it is another system. Where should ${opts.ds}'s names go?`;
202
+ const where = whereOf(opts.themeIn) ??
203
+ (await askWhere(plan.tokensFile ?? plan.entry, separate, plan.entry, intro)) ??
204
+ "none";
205
+ const theme = await writeBesideTheirs(root, tokens, opts.ds, version, where);
206
+ console.log("");
207
+ if (!theme.written)
208
+ console.log(` ${opts.ds}'s names were not written - your CSS keeps only yours, and components come with the values written. Run init again with --theme-in file or --theme-in separate to change that.`);
209
+ else {
210
+ console.log(`✓ ${theme.written}: ${opts.ds} beside your tokens - ${theme.added} names your project did not have, and every value inside [data-theme="${opts.ds}"]. Put that attribute on the part of the page that should wear it.`);
211
+ if (theme.collisions.length > 0)
212
+ console.log(` ${theme.collisions.length} name${theme.collisions.length === 1 ? "" : "s"} you already use keep your value everywhere else: ${theme.collisions.slice(0, 5).join(", ")}${theme.collisions.length > 5 ? ", …" : ""}.`);
213
+ if (theme.throughInline.length > 0)
214
+ console.log(` declared in your @theme inline, so the selector changes the variable underneath: ${theme.throughInline.slice(0, 3).join(", ")}${theme.throughInline.length > 3 ? ", …" : ""}.`);
215
+ }
216
+ }
217
+ }
137
218
  /**
138
219
  * ERAM DOIS PASSOS E É UM - `INV-GERAL-13`, 11/09.
139
220
  *
@@ -611,8 +611,11 @@ export class ThemeVocab extends Set {
611
611
  * O nome que casa vale se o valor também casa - ou se um dos dois lados não tem valor conhecido, que
612
612
  * é o comportamento de antes. Sem nome que case, o VALOR acha o nome, mas só quando exatamente uma
613
613
  * variável dele o segura: duas seria adivinhar qual das duas ele quis dizer.
614
+ *
615
+ * `prefer` desempata, e só ele: quando o empate inclui a palavra da própria propriedade (`border`
616
+ * numa declaração de borda), é esse o nome. Sem ela no empate, continua sem adivinhar (rodada 4).
614
617
  */
615
- nameFor(ref, ns, byName) {
618
+ nameFor(ref, ns, byName, prefer = null) {
616
619
  const path = /^\{([^}]+)\}$/.exec(ref.trim())?.[1];
617
620
  const want = path ? this.dsValues.get(`--ds-${path.replace(/\./g, "-")}`) : undefined;
618
621
  if (byName && this.has(`--${ns}-${byName}`)) {
@@ -625,7 +628,9 @@ export class ThemeVocab extends Set {
625
628
  const hits = [...this.theirValues]
626
629
  .filter(([n, v]) => n.startsWith(`--${ns}-`) && sameValue(v, want))
627
630
  .map(([n]) => n.slice(ns.length + 3));
628
- return hits.length === 1 ? hits[0] : null;
631
+ if (hits.length === 1)
632
+ return hits[0];
633
+ return prefer && hits.includes(prefer) ? prefer : null;
629
634
  }
630
635
  }
631
636
  const sameValue = (a, b) => a.trim().toLowerCase().replace(/\s+/g, " ") === b.trim().toLowerCase().replace(/\s+/g, " ");
@@ -644,12 +649,34 @@ const paletteKey = (v) => {
644
649
  return null;
645
650
  return `${m[1]}-${m[2]}`;
646
651
  };
647
- const theirColorKey = (value, themeVars) => {
652
+ const theirColorKey = (value, themeVars, prefer = null) => {
648
653
  const key = colorKey(value) ?? (themeVars instanceof ThemeVocab ? paletteKey(value) : null);
649
654
  if (themeVars instanceof ThemeVocab)
650
- return themeVars.nameFor(value, "color", key);
655
+ return themeVars.nameFor(value, "color", key, prefer);
651
656
  return key && minted(themeVars, `--color-${key}`) ? key : null;
652
657
  };
658
+ /** A palavra da propriedade que desempata dois nomes dele com o mesmo valor - ver `nameFor`. */
659
+ const preferFor = (prop) => (prop.startsWith("border") ? "border" : null);
660
+ /**
661
+ * AS CORES DE DENTRO DE UM VALOR COMPOSTO, NO NOME DELE - rodada 4 do refinamento, 27/09.
662
+ *
663
+ * O gradiente do botão do Gossamer (`linear-gradient(135deg, {color.semantic.accent}, …)`) não tem
664
+ * utilitário e ia inteiro para o arbitrário, onde a cor só achava o nome dele depois de um build: num
665
+ * Next novo saía `#eaa83c` com `--color-accent` declarado. Cada referência de cor é resolvida aqui pela
666
+ * mesma regra do utilitário (`theirColorKey`, que não precisa de build) e vira `var(--color-<nome>)`; a
667
+ * que não tem nome segue para a porta de tradução, que escreve o valor.
668
+ *
669
+ * `null` é "ninguém mediu", e aí nada é trocado: escrever `var(--color-x)` sem saber se ele declara
670
+ * seria uma cor que some.
671
+ */
672
+ const namedInside = (prop, value, themeVars) => {
673
+ if (themeVars === null)
674
+ return value;
675
+ return value.replace(/\{color\.[a-zA-Z0-9.-]+\}/g, (ref) => {
676
+ const key = theirColorKey(ref, themeVars, preferFor(prop));
677
+ return key ? `var(--color-${key})` : ref;
678
+ });
679
+ };
653
680
  /** One declaration → Tailwind classes (pretty when mappable, arbitrary-property
654
681
  * otherwise - never dropped). */
655
682
  function declToTailwind(prop, value, themeVars) {
@@ -672,7 +699,7 @@ function declToTailwind(prop, value, themeVars) {
672
699
  break;
673
700
  }
674
701
  case "borderColor": {
675
- const key = theirColorKey(value, themeVars);
702
+ const key = theirColorKey(value, themeVars, "border");
676
703
  if (key)
677
704
  return [`border-${key}`];
678
705
  break;
@@ -681,7 +708,7 @@ function declToTailwind(prop, value, themeVars) {
681
708
  // "1px solid {color.semantic.x}" → border + border-<x>
682
709
  const m = value.match(/^1px\s+solid\s+(\{[^}]+\})$/);
683
710
  if (m) {
684
- const key = theirColorKey(m[1], themeVars);
711
+ const key = theirColorKey(m[1], themeVars, "border");
685
712
  if (key)
686
713
  return ["border", `border-${key}`];
687
714
  }
@@ -767,7 +794,7 @@ function declToTailwind(prop, value, themeVars) {
767
794
  break;
768
795
  }
769
796
  }
770
- return [arbitrary(prop, value)];
797
+ return [arbitrary(prop, namedInside(prop, value, themeVars))];
771
798
  }
772
799
  /**
773
800
  * TAILWIND'S OWN WORD FOR A STATE, WHEN IT HAS ONE - and a mechanical rule when it
package/dist/index.js CHANGED
@@ -540,6 +540,7 @@ async function main() {
540
540
  componentsDir,
541
541
  styles,
542
542
  ds,
543
+ ...(typeof flags["theme-in"] === "string" ? { themeIn: flags["theme-in"] } : {}),
543
544
  cli: CLI_VERSION,
544
545
  });
545
546
  break;
@@ -269,7 +269,13 @@
269
269
  * nome os degraus da paleta que o projeto declara (`{color.coral.700}` -> `bg-coral-700`) - o badge do
270
270
  * Zephyr saía com quatro cores cruas (rodada 3 do refinamento).
271
271
  */
272
- export const MATERIALISER_SINCE = "0.16.463";
272
+ /**
273
+ * 0.16.463 -> 0.16.464 em 27/09, e o passo 1 dá **SIM**: o gerador que o `upgrade` usa passa a achar o nome
274
+ * dele dentro de um valor composto - o gradiente do botão do Gossamer saía `#eaa83c, #dd4161` com
275
+ * `--color-accent` e `--color-primary` declarados - e a desempatar pela palavra da propriedade numa borda
276
+ * (rodada 4 do refinamento).
277
+ */
278
+ export const MATERIALISER_SINCE = "0.16.464";
273
279
  /**
274
280
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
275
281
  *
@@ -0,0 +1,306 @@
1
+ import { readdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join, relative } from "node:path";
3
+ import { tongueOf } from "./their-tongue.js";
4
+ /** O que o `create-next-app` escreve - não conta como vocabulário DELE. */
5
+ const BOILERPLATE = new Set([
6
+ "--background",
7
+ "--foreground",
8
+ "--color-background",
9
+ "--color-foreground",
10
+ "--font-sans",
11
+ "--font-mono",
12
+ "--font-geist-sans",
13
+ "--font-geist-mono",
14
+ ]);
15
+ const SKIP = new Set(["node_modules", ".next", "dist", "build", "out", "_synthesisui", "coverage"]);
16
+ const TAILWIND_IMPORT = /@import\s+[\x22\x27]tailwindcss[\x22\x27]/;
17
+ /**
18
+ * O COMENTÁRIO DO CSS SE ESCREVE COM `\u002a` NO FONTE, e não com o asterisco - a guarda de
19
+ * `nothing-of-ours-is-asked-for.spec.ts` apaga comentários do fonte antes de ler os literais, e um `/*`
20
+ * dentro de uma string a fazia comer o arquivo até o próximo fecho, deixando aspas soltas (a varredura
21
+ * saía de 1,9s para mais de 25s). O texto que chega ao CSS dele é o mesmo.
22
+ */
23
+ const open = (text) => `/\u002a ${text} \u002a/`;
24
+ const begin = (slug) => open(`synthesisui: the ${slug} theme, in your names - begin`);
25
+ const end = (slug) => open(`synthesisui: the ${slug} theme - end`);
26
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
27
+ const END_RE = (slug) => new RegExp(`\\n*${escape(begin(slug))}[\\s\\S]*?${escape(end(slug))}\\n?`);
28
+ async function sheetsOf(root) {
29
+ const out = new Map();
30
+ const walk = async (dir) => {
31
+ for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
32
+ if (entry.name.startsWith(".") || SKIP.has(entry.name))
33
+ continue;
34
+ const path = join(dir, entry.name);
35
+ if (entry.isDirectory())
36
+ await walk(path);
37
+ else if (/\.css$/i.test(entry.name))
38
+ out.set(path, await readFile(path, "utf8").catch(() => ""));
39
+ }
40
+ };
41
+ await walk(root);
42
+ return out;
43
+ }
44
+ /** O nome DELE para uma variável nossa, no namespace que o Tailwind v4 lê - ou `null`. */
45
+ function theirNameFor(ours) {
46
+ const bare = ours.slice("--ds-".length);
47
+ let m;
48
+ if ((m = /^color-semantic-(.+)$/.exec(bare)))
49
+ return `--color-${m[1]}`;
50
+ if ((m = /^color-series-(.+)$/.exec(bare)))
51
+ return `--color-series-${m[1]}`;
52
+ if ((m = /^color-(.+)$/.exec(bare)))
53
+ return `--color-${m[1]}`;
54
+ if ((m = /^(spacing|radius|shadow)-(.+)$/.exec(bare)))
55
+ return `--${m[1]}-${m[2]}`;
56
+ if ((m = /^typography-weights-(.+)$/.exec(bare)))
57
+ return `--font-weight-${m[1]}`;
58
+ if ((m = /^typography-scale-(.+)-font-size$/.exec(bare)))
59
+ return `--text-${m[1]}`;
60
+ if ((m = /^typography-scale-(.+)-(line-height|letter-spacing)$/.exec(bare)))
61
+ return `--text-${m[1]}--${m[2]}`;
62
+ if ((m = /^motion-easings-(.+)$/.exec(bare)))
63
+ return `--ease-${m[1]}`;
64
+ return null;
65
+ }
66
+ export async function writeNewProjectTheme(root, tokensCss, slug, version) {
67
+ const sheets = await sheetsOf(root);
68
+ const own = new Set();
69
+ for (const css of sheets.values()) {
70
+ const withoutOurs = css.replace(END_RE(slug), "\n");
71
+ for (const m of withoutOurs.matchAll(/(?:^|[{;\s])(--[a-zA-Z0-9_-]+)\s*:/g)) {
72
+ const name = m[1];
73
+ if (!name.startsWith("--ds-") && !name.startsWith("--tw-") && !BOILERPLATE.has(name))
74
+ own.add(name);
75
+ }
76
+ }
77
+ if (own.size > 0)
78
+ return { written: null, count: 0, reason: "own-vocabulary" };
79
+ const entries = [...sheets].filter(([, css]) => TAILWIND_IMPORT.test(css));
80
+ if (entries.length === 0)
81
+ return { written: null, count: 0, reason: "no-tailwind-entry" };
82
+ if (entries.length > 1)
83
+ return { written: null, count: 0, reason: "many-tailwind-entries" };
84
+ const [path, css] = entries[0];
85
+ const system = new Map(systemNames(tokensCss, (await readTheirs(root, slug)).every));
86
+ const lines = [...system].map(([n, v]) => ` ${n}: ${v};`);
87
+ if (lines.length === 0)
88
+ return { written: null, count: 0, reason: "empty-sheet" };
89
+ const next = [];
90
+ const canvas = system.get("--color-canvas") ?? system.get("--color-surface");
91
+ const text = system.get("--color-foreground");
92
+ if (canvas && /--background\s*:/.test(css))
93
+ next.push(` --background: ${canvas};`);
94
+ if (text && /--foreground\s*:/.test(css))
95
+ next.push(` --foreground: ${text};`);
96
+ const block = [
97
+ begin(slug),
98
+ open(`${slug} v${version}: the system's values under the names Tailwind turns into utilities. They are yours now - change a value here and every component follows. Written by \`init\`, because this project had no tokens of its own.`),
99
+ "@theme {",
100
+ ...lines,
101
+ "}",
102
+ ...(next.length > 0 ? ["", ":root {", ...next, "}"] : []),
103
+ end(slug),
104
+ ].join("\n");
105
+ const base = css.replace(END_RE(slug), "\n").replace(/\n*$/, "\n");
106
+ await writeFile(path, `${base}\n${block}\n`, "utf8");
107
+ return { written: relative(root, path).split("\\").join("/"), count: lines.length };
108
+ }
109
+ const importLine = (slug) => `@import "./${slug}.css";`;
110
+ /** O CSS dele sem nada nosso: sem o bloco e sem a linha que importa o arquivo à parte. */
111
+ const withoutOurs = (css, slug) => css.replace(END_RE(slug), "\n").replace(new RegExp(`^${escape(importLine(slug))}\\n`, "m"), "");
112
+ async function readTheirs(root, slug) {
113
+ const sheets = await sheetsOf(root);
114
+ const raw = new Map();
115
+ const own = new Set();
116
+ const inline = new Map();
117
+ const counts = new Map();
118
+ for (const [path, full] of sheets) {
119
+ if (full.startsWith(begin(slug)))
120
+ continue;
121
+ const css = withoutOurs(full, slug);
122
+ let n = 0;
123
+ for (const m of css.matchAll(/(?:^|[{;\s])(--[a-zA-Z0-9_-]+)\s*:\s*([^;}]+)/g)) {
124
+ const name = m[1];
125
+ if (name.startsWith("--ds-") || name.startsWith("--tw-"))
126
+ continue;
127
+ if (!raw.has(name))
128
+ raw.set(name, m[2].trim());
129
+ if (!BOILERPLATE.has(name)) {
130
+ own.add(name);
131
+ n += 1;
132
+ }
133
+ }
134
+ counts.set(path, n);
135
+ for (const block of css.matchAll(/@theme\s+inline\s*\{([^}]*)\}/g))
136
+ for (const m of block[1].matchAll(/(--[a-zA-Z0-9_-]+)\s*:\s*var\(\s*(--[a-zA-Z0-9_-]+)\s*\)/g))
137
+ inline.set(m[1], m[2]);
138
+ }
139
+ const resolved = (table) => {
140
+ const out = new Map();
141
+ for (const name of table.keys()) {
142
+ const seen = new Set();
143
+ let value = table.get(name);
144
+ for (let ref = /^var\(\s*(--[a-zA-Z0-9_-]+)\s*\)$/.exec(value); ref; ref = /^var\(\s*(--[a-zA-Z0-9_-]+)\s*\)$/.exec(value)) {
145
+ const next = table.get(ref[1]);
146
+ if (next === undefined || seen.has(ref[1]))
147
+ break;
148
+ seen.add(ref[1]);
149
+ value = next;
150
+ }
151
+ out.set(name, value);
152
+ }
153
+ return out;
154
+ };
155
+ const values = resolved(raw);
156
+ const everyRaw = new Map(raw);
157
+ for (const full of sheets.values())
158
+ for (const m of full.matchAll(/(?:^|[{;\s])(--[a-zA-Z0-9_-]+)\s*:\s*([^;}]+)/g))
159
+ if (!everyRaw.has(m[1]) && !m[1].startsWith("--ds-"))
160
+ everyRaw.set(m[1], m[2].trim());
161
+ const every = resolved(everyRaw);
162
+ const entries = [...sheets].filter(([, css]) => TAILWIND_IMPORT.test(css)).map(([p]) => p);
163
+ const rel = (p) => relative(root, p).split("\\").join("/");
164
+ const top = [...counts].sort((a, b) => b[1] - a[1])[0];
165
+ const entry = entries.length === 1 ? entries[0] : null;
166
+ return {
167
+ sheets,
168
+ values,
169
+ own,
170
+ inline,
171
+ every,
172
+ entry: entry ? rel(entry) : null,
173
+ entries: entries.length,
174
+ tokensFile: top && top[1] > 0 ? rel(top[0]) : entry ? rel(entry) : null,
175
+ };
176
+ }
177
+ /**
178
+ * Os nomes do sistema no namespace do Tailwind, com o valor - a mesma tabela do projeto novo.
179
+ *
180
+ * `lookup` responde o valor de um nome DO PROJETO, com o nosso bloco anterior incluído. Depois da primeira
181
+ * rodada existe build, e o `add` seguinte reescreve a folha para apontar para os nomes que o NOSSO bloco
182
+ * criou (`--ds-color-amber-400: var(--color-amber-400)`). Pular essas linhas encolhia o bloco na segunda
183
+ * rodada: medido na prova de 27/09, 82 nomes viraram 38.
184
+ */
185
+ function systemNames(tokensCss, lookup = new Map()) {
186
+ const { names, values } = tongueOf([], tokensCss);
187
+ const out = [];
188
+ for (const ours of new Set([...values.keys(), ...names.keys()])) {
189
+ const theirs = theirNameFor(ours);
190
+ if (!theirs)
191
+ continue;
192
+ const pointed = names.get(ours);
193
+ const value = pointed ? lookup.get(pointed) : values.get(ours);
194
+ if (value && !value.includes("var(--ds-"))
195
+ out.push([theirs, value]);
196
+ }
197
+ return out;
198
+ }
199
+ const same = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
200
+ export async function readProjectTheme(root, tokensCss, slug) {
201
+ const r = await readTheirs(root, slug);
202
+ const base = { entry: r.entry, tokensFile: r.tokensFile };
203
+ const colourValues = [
204
+ ...new Set(systemNames(tokensCss, r.every).filter(([n]) => n.startsWith("--color-")).map(([, v]) => v.toLowerCase())),
205
+ ];
206
+ const held = new Set([...r.values.values()].map((v) => v.trim().toLowerCase()));
207
+ const matched = colourValues.filter((v) => held.has(v)).length;
208
+ const counts = { matched, colours: colourValues.length };
209
+ if (r.entries === 0)
210
+ return { kind: "no-tailwind-entry", ...counts, ...base };
211
+ if (r.entries > 1)
212
+ return { kind: "many-tailwind-entries", ...counts, ...base };
213
+ if (r.own.size === 0)
214
+ return { kind: "new", ...counts, ...base };
215
+ return { kind: matched * 2 >= colourValues.length && matched > 0 ? "matches" : "differs", ...counts, ...base };
216
+ }
217
+ /**
218
+ * O BLOCO DO SISTEMA AO LADO DO DELE - o terceiro caso.
219
+ *
220
+ * O MESMO BLOCO, onde quer que ele more: um `@theme` com os nomes do sistema que ele NÃO declara (as
221
+ * classes passam a existir; as dele continuam significando o mesmo) e `[data-theme="<slug>"]` com todos
222
+ * os valores, inclusive os dos nomes que colidem - o `--color-primary` dele vale no site inteiro, e só
223
+ * dentro do seletor vira o do sistema. No Tailwind v4 a classe de um `@theme` comum lê a variável em
224
+ * tempo de execução (`.bg-canvas{background-color:var(--color-canvas)}`, medido no build de 27/09), e é
225
+ * isso que deixa o seletor trocar o valor.
226
+ *
227
+ * O LIMITE, que o comando diz: um nome que ele declara em `@theme inline` tem o valor colado na classe, e
228
+ * o seletor não o alcança. Ali o bloco troca a variável de baixo (`--color-foreground` -> `--foreground`).
229
+ *
230
+ * `where`: `file` no arquivo de tokens dele, `separate` num `<slug>.css` ao lado do CSS de entrada com o
231
+ * `@import` no topo, `none` em lugar nenhum. Rodar de novo MOVE o bloco: o que estava no outro lugar sai.
232
+ */
233
+ export async function writeBesideTheirs(root, tokensCss, slug, version, where) {
234
+ const r = await readTheirs(root, slug);
235
+ const entryPath = r.entry ? join(root, r.entry) : null;
236
+ const separatePath = entryPath ? join(dirname(entryPath), `${slug}.css`) : null;
237
+ /** Tira de todo lugar o que é nosso, antes de escrever no lugar escolhido. */
238
+ const cleaned = new Map();
239
+ for (const [path, css] of r.sheets) {
240
+ if (path === separatePath && css.startsWith(begin(slug)))
241
+ continue;
242
+ cleaned.set(path, withoutOurs(css, slug));
243
+ }
244
+ const flush = async () => {
245
+ for (const [path, css] of cleaned)
246
+ if (css !== r.sheets.get(path))
247
+ await writeFile(path, css, "utf8");
248
+ if (separatePath && r.sheets.get(separatePath)?.startsWith(begin(slug)) && where !== "separate")
249
+ await rm(separatePath, { force: true });
250
+ };
251
+ const empty = { written: null, collisions: [], throughInline: [], added: 0 };
252
+ if (where === "none" || !entryPath || !separatePath) {
253
+ await flush();
254
+ return empty;
255
+ }
256
+ const system = systemNames(tokensCss, r.every);
257
+ if (system.length === 0) {
258
+ await flush();
259
+ return empty;
260
+ }
261
+ const missing = system.filter(([n]) => !r.own.has(n) && !r.values.has(n));
262
+ const collisions = system
263
+ .filter(([n, v]) => r.values.has(n) && !r.inline.has(n) && !same(r.values.get(n), v))
264
+ .map(([n]) => n);
265
+ const throughInline = [];
266
+ const scoped = system.map(([n, v]) => {
267
+ const under = r.inline.get(n);
268
+ if (under) {
269
+ throughInline.push(`${n} -> ${under}`);
270
+ return ` ${under}: ${v};`;
271
+ }
272
+ return ` ${n}: ${v};`;
273
+ });
274
+ const block = [
275
+ begin(slug),
276
+ open(`${slug} v${version}, beside your own tokens. The @theme adds only the names your project did not have; inside the data-theme selector below every value is the system's, including the names you already use, which keep their value everywhere else. Written by \`init\`, where you chose.`),
277
+ ...(missing.length > 0 ? ["@theme {", ...missing.map(([n, v]) => ` ${n}: ${v};`), "}", ""] : []),
278
+ `[data-theme="${slug}"] {`,
279
+ ...scoped,
280
+ "}",
281
+ end(slug),
282
+ ].join("\n");
283
+ let written;
284
+ if (where === "separate") {
285
+ const entry = cleaned.get(entryPath) ?? "";
286
+ const m = TAILWIND_IMPORT.exec(entry);
287
+ const at = m ? entry.indexOf("\n", m.index) + 1 : 0;
288
+ cleaned.set(entryPath, `${entry.slice(0, at)}${importLine(slug)}\n${entry.slice(at)}`);
289
+ await flush();
290
+ await writeFile(separatePath, `${block}\n`, "utf8");
291
+ written = relative(root, separatePath);
292
+ }
293
+ else {
294
+ const target = join(root, r.tokensFile ?? r.entry);
295
+ const css = (cleaned.get(target) ?? "").replace(/\n*$/, "\n");
296
+ cleaned.set(target, `${css}\n${block}\n`);
297
+ await flush();
298
+ written = relative(root, target);
299
+ }
300
+ return {
301
+ written: written.split("\\").join("/"),
302
+ collisions,
303
+ throughInline,
304
+ added: missing.length,
305
+ };
306
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.463",
3
+ "version": "0.16.466",
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": {