synthesisui 0.16.329 → 0.16.330

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.
@@ -18,6 +18,7 @@ import { classifyAside, crosswalk, floorSize, isLibrary, observedRules, useLiveC
18
18
  import { keyframeOffsets, moduleImports, partialCandidates, readGlobalClasses, readModuleCss, readModuleUsage, sheetImports, transcribeModule, } from "../doctor/css-modules.js";
19
19
  import { dataContract } from "../doctor/data-contract.js";
20
20
  import { inherit, readDeclaredForms, } from "../doctor/declared-forms.js";
21
+ import { defaultSchemeOf, } from "../doctor/default-scheme.js";
21
22
  import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
22
23
  import { accountExportsInto, emptyExportAccount, exportInvariantHolds, } from "../doctor/export-census.js";
23
24
  import { fragmentsOfSource, fragmentsOfStylesheet, judgeFragments, } from "../doctor/fragments.js";
@@ -2101,6 +2102,24 @@ export async function takeCensus(root, opts) {
2101
2102
  * `workspaces` declaram, que é onde recharts/chakra/mui realmente moram.
2102
2103
  */
2103
2104
  finishSignals(signals, importTally, versions);
2105
+ /**
2106
+ * QUAL FACE O PROJETO ABRE - observação estrutural, aqui porque os sinais de tema já
2107
+ * fecharam. Ver `default-scheme.ts` para a escada de evidência e para por que ela nunca
2108
+ * usa luminância. `census.scheme`, decidido bem depois em `runImport`, é outra coisa: a
2109
+ * decisão RESOLVIDA daquela execução, e ela nunca reescreve o que foi observado.
2110
+ */
2111
+ const polarity = defaultSchemeOf({
2112
+ sheets: globalSheets.map((g) => ({ file: g.file, css: g.body })),
2113
+ signals: signals.theme,
2114
+ });
2115
+ const declaredSchemes = {
2116
+ base: Object.fromEntries(schemes.base),
2117
+ light: Object.fromEntries(schemes.light),
2118
+ dark: Object.fromEntries(schemes.dark),
2119
+ defaultScheme: polarity.scheme,
2120
+ defaultSchemeSource: polarity.source,
2121
+ ...(polarity.evidence ? { defaultSchemeEvidence: polarity.evidence } : {}),
2122
+ };
2104
2123
  // Every name their CSS defines, from the same harvest the token table came
2105
2124
  // from - so a reference is judged against what actually exists, not against
2106
2125
  // the subset we managed to read as a ramp.
@@ -2343,9 +2362,10 @@ export async function takeCensus(root, opts) {
2343
2362
  classStyle,
2344
2363
  ...(Object.keys(looks).length > 0 ? { looks } : {}),
2345
2364
  ...(naming.components > 0 ? { naming } : {}),
2346
- ...(schemes.alt.size > 0
2347
- ? { declaredAlt: Object.fromEntries(schemes.alt) }
2365
+ ...(schemes.dark.size > 0
2366
+ ? { declaredAlt: Object.fromEntries(schemes.dark) }
2348
2367
  : {}),
2368
+ declaredSchemes,
2349
2369
  observed: distinctValues(d),
2350
2370
  ...(components.length > 0 ? { components } : {}),
2351
2371
  ...(defined.length > 0 ? { defined } : {}),
@@ -3351,6 +3371,41 @@ async function askScheme(suggested) {
3351
3371
  * light greys, and its dashboard is near-black - so the count agrees with the
3352
3372
  * screen. It is only a default; the person confirms it either way.
3353
3373
  */
3374
+ /**
3375
+ * A FACE QUE O PROJETO ABRE, quando a estrutura dele respondeu - senão `null`.
3376
+ *
3377
+ * Só vale para medição NOVA: um censo sem `declaredSchemes` foi medido por uma versão que
3378
+ * não fazia esta pergunta, e responder por ele seria inventar. Ver a nota de compatibilidade
3379
+ * em `Census.declaredSchemes`.
3380
+ */
3381
+ export function observedPolarity(census) {
3382
+ const fact = census.declaredSchemes;
3383
+ if (!fact || fact.defaultScheme === "unknown")
3384
+ return null;
3385
+ return fact.defaultScheme;
3386
+ }
3387
+ /**
3388
+ * QUANDO A ESTEIRA TEM DE FICAR CALADA sobre qual face abre.
3389
+ *
3390
+ * Medição nova, polaridade `unknown`, e nenhum lado declarado explicitamente: aqui a base
3391
+ * é de verdade indeterminada, e a escada de neutros só sabe responder por LUMINÂNCIA - a
3392
+ * evidência que este contrato recusa. Sem TTY, `askScheme` devolve esse palpite calado, e
3393
+ * era assim que "a base é clara" voltava pela porta dos fundos.
3394
+ *
3395
+ * Com pelo menos um override explícito, as duas faces são conhecíveis mesmo sem saber qual
3396
+ * abre, então este portão não fecha - o que fica pendente é só o padrão.
3397
+ *
3398
+ * Censo LEGADO nunca cai aqui: ele não tem o fato, e o comportamento dele é preservado
3399
+ * inteiro. Decisão do dono (28/08, saída A).
3400
+ */
3401
+ export function mustNotGuessPolarity(census) {
3402
+ const fact = census.declaredSchemes;
3403
+ if (!fact)
3404
+ return false;
3405
+ if (fact.defaultScheme !== "unknown")
3406
+ return false;
3407
+ return (Object.keys(fact.light).length === 0 && Object.keys(fact.dark).length === 0);
3408
+ }
3354
3409
  export function defaultScheme(declared) {
3355
3410
  let dark = 0;
3356
3411
  let light = 0;
@@ -4088,15 +4143,23 @@ export async function runImport(opts) {
4088
4143
  // contradict it.
4089
4144
  (read?.has?.length === 1
4090
4145
  ? read.has[0]
4091
- : reach.light && reach.dark
4092
- ? await askScheme(defaultScheme(census.declared))
4093
- : reach.dark
4094
- ? "dark"
4095
- : reach.light
4096
- ? "light"
4097
- : undefined);
4146
+ : (observedPolarity(census) ??
4147
+ (mustNotGuessPolarity(census)
4148
+ ? undefined
4149
+ : reach.light && reach.dark
4150
+ ? await askScheme(defaultScheme(census.declared))
4151
+ : reach.dark
4152
+ ? "dark"
4153
+ : reach.light
4154
+ ? "light"
4155
+ : undefined)));
4098
4156
  if (scheme)
4099
4157
  census.scheme = scheme;
4158
+ if (!scheme && mustNotGuessPolarity(census)) {
4159
+ console.log("");
4160
+ console.log(body("Your stylesheet does not say which face this project opens in, and nothing here declares a light or a dark scheme on its own. Nothing was guessed: the measurement is complete and the default stays yours to pick."));
4161
+ console.log(body(`Name it with ${paint.strong("--scheme dark")} or ${paint.strong("--scheme light")}, or answer the question this command asks at a terminal.`));
4162
+ }
4100
4163
  // The file on disk has to say what we sent, or the next person to read it is
4101
4164
  // reading a different import than the one that happened.
4102
4165
  await writeFile(out, `${JSON.stringify(census, null, 2)}\n`, "utf8");
@@ -0,0 +1,143 @@
1
+ /**
2
+ * QUAL FACE O PROJETO ABRE - lida da estrutura dele, ou não afirmada.
3
+ *
4
+ * O QUE ISTO RESOLVE PARA O CLIENTE: um sistema que abre no escuro parava de ser escuro
5
+ * ao chegar aqui, porque a esteira tratava "a base da cascata" como se fosse "a face
6
+ * clara". Ninguém nunca declarou isso; era uma suposição embutida em dois leitores.
7
+ *
8
+ * ESTE FATO É SÓ OBSERVAÇÃO. `census.scheme` é a decisão RESOLVIDA daquela execução -
9
+ * `--scheme`, a resposta de quem estava no terminal, a leitura do agente. Uma decisão
10
+ * nunca reescreve o que foi observado: se ele digitou `--scheme=dark` num projeto sem
11
+ * sinal nenhum, o fato continua `unknown` e a execução continua `dark`. Misturar os dois
12
+ * apagaria a única informação capaz de dizer "este projeto não nos conta qual face abre".
13
+ *
14
+ * A EVIDÊNCIA É ESTRUTURAL, e por decisão do dono (28/08) nunca:
15
+ * luminância das cores, nome de token, nome de repositório, presença isolada de `.dark`
16
+ * ou `.light`, convenção de um framework específico.
17
+ *
18
+ * Sem sinal, `unknown`. Um palpite aqui vale menos que a pergunta que a esteira já sabe
19
+ * fazer.
20
+ */
21
+ import { scopedBlocks } from "./scheme-scope.js";
22
+ /** `color-scheme: dark` - e as formas que não respondem nada. */
23
+ const COLOR_SCHEME = /^color-scheme$/i;
24
+ function schemeWord(value) {
25
+ const words = value.toLowerCase().trim().split(/\s+/).filter(Boolean);
26
+ // `light dark`, `normal`, `only light` declaram capacidade ou neutralidade, não
27
+ // polaridade. Só uma palavra sozinha é uma afirmação.
28
+ if (words.length !== 1)
29
+ return null;
30
+ return words[0] === "dark" || words[0] === "light" ? words[0] : null;
31
+ }
32
+ /**
33
+ * O sinal mais forte, e o único que vive dentro da própria folha: um `color-scheme`
34
+ * numa raiz SEM escopo de esquema.
35
+ *
36
+ * A restrição do escopo é o que faz a regra valer em qualquer projeto. Medido em 28/08:
37
+ * o `frontend-hub` declara `[data-theme='dark'] { color-scheme: light }` dentro de um
38
+ * `.module.scss` de componente - contraditório e local. Ele é excluído duas vezes, por
39
+ * ter escopo de esquema e por não ser de documento, então um componente nunca decide a
40
+ * polaridade do projeto.
41
+ *
42
+ * E `html[data-theme="light"] { color-scheme: light }` é CORROBORAÇÃO do override, não
43
+ * voto para o padrão: o bloco já diz de qual face ele é.
44
+ */
45
+ function fromColorScheme(sheets) {
46
+ const votes = [];
47
+ for (const sheet of sheets) {
48
+ for (const block of scopedBlocks(sheet.css)) {
49
+ if (block.scope !== "base")
50
+ continue;
51
+ for (const d of block.declarations) {
52
+ if (!COLOR_SCHEME.test(d.name))
53
+ continue;
54
+ const word = schemeWord(d.value);
55
+ if (!word)
56
+ continue;
57
+ votes.push({
58
+ scheme: word,
59
+ file: sheet.file,
60
+ selector: block.stack[block.stack.length - 1] ?? "",
61
+ });
62
+ }
63
+ }
64
+ }
65
+ if (votes.length === 0)
66
+ return null;
67
+ const distinct = new Set(votes.map((v) => v.scheme));
68
+ if (distinct.size > 1)
69
+ return { scheme: "unknown", source: "conflicting" };
70
+ return {
71
+ scheme: votes[0].scheme,
72
+ source: "root-color-scheme",
73
+ evidence: { file: votes[0].file, selector: votes[0].selector },
74
+ };
75
+ }
76
+ /**
77
+ * A polaridade padrão do projeto, ou `unknown`.
78
+ *
79
+ * A ESCADA, e a primeira que responde vence:
80
+ *
81
+ * 1 `color-scheme` numa raiz sem escopo de esquema - está na folha dele
82
+ * 2 `defaultTheme="dark"` no provider - o próprio provider diz
83
+ * 3 `<html data-theme="dark">` num layout raiz - o portador, verbatim
84
+ * 4 nada disso unknown
85
+ *
86
+ * `color-scheme` vem primeiro porque é a declaração do PADRÃO WEB para exatamente esta
87
+ * pergunta, e porque ela mora na folha que o escopo medido já carrega - os sinais 2 e 3
88
+ * vêm de um app que um `--scope packages/ui` pode não estar lendo. Medido no
89
+ * FlowSanctuary: os três campos de tema vêm `null`, e só o sinal 1 responde.
90
+ *
91
+ * `nota`: `scopedBlocks` é chamado de novo aqui em vez de receber os blocos prontos. É
92
+ * uma varredura de string sobre folhas que já estão em memória, e o custo de passá-la
93
+ * adiante seria acoplar esta pergunta ao laço do import.
94
+ */
95
+ export function defaultSchemeOf(input) {
96
+ /**
97
+ * A PORTA FECHADA EM VEZ DE VIGIADA: nenhuma saída daqui pode dizer ao mesmo tempo "não
98
+ * sei" e "achei na folha dele". Uma edição futura que produzisse esse par cairia no
99
+ * estado honesto em vez de viajar até a plataforma como um fato que não se sustenta.
100
+ */
101
+ const fact = pickDefaultScheme(input);
102
+ return schemeFactIsCoherent({
103
+ defaultScheme: fact.scheme,
104
+ defaultSchemeSource: fact.source,
105
+ })
106
+ ? fact
107
+ : { scheme: "unknown", source: "absent" };
108
+ }
109
+ function pickDefaultScheme(input) {
110
+ const declared = fromColorScheme(input.sheets);
111
+ if (declared)
112
+ return declared;
113
+ const provider = input.signals?.defaultTheme?.trim().toLowerCase();
114
+ if (provider === "dark" || provider === "light") {
115
+ return { scheme: provider, source: "theme-default" };
116
+ }
117
+ /**
118
+ * O `<html>` carrega o tema escrito à mão. Casado contra o atributo que o PRÓPRIO
119
+ * projeto usa: um `class="dark"` e um `data-theme="dark"` são o mesmo fato em
120
+ * projetos diferentes, e cravar um dos dois seria escolher por convenção.
121
+ */
122
+ const attribute = input.signals?.attribute?.trim();
123
+ for (const carried of input.signals?.htmlCarries ?? []) {
124
+ const key = attribute && attribute !== "class" ? attribute : "class";
125
+ const m = new RegExp(`${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=\\s*["'\`{]*([^"'\`}]*)`, "i").exec(carried);
126
+ const word = m?.[1] && schemeWord(m[1].split(/\s+/).pop() ?? "");
127
+ if (word)
128
+ return { scheme: word, source: "html-carrier" };
129
+ }
130
+ return { scheme: "unknown", source: "absent" };
131
+ }
132
+ /**
133
+ * A INVARIANTE DO CONTRATO, escrita uma vez e usada pelos dois lados.
134
+ *
135
+ * `unknown` se e somente se a fonte é `conflicting` ou `absent`. Sem isto, um `unknown`
136
+ * com fonte `root-color-scheme` seria representável - e ele diria ao mesmo tempo "não sei"
137
+ * e "achei na folha dele".
138
+ */
139
+ export function schemeFactIsCoherent(fact) {
140
+ const undetermined = fact.defaultSchemeSource === "conflicting" ||
141
+ fact.defaultSchemeSource === "absent";
142
+ return (fact.defaultScheme === "unknown") === undetermined;
143
+ }
@@ -1,77 +1,54 @@
1
1
  /**
2
- * TOKENS BY SCHEME - which declarations belong to the base scheme and which to
3
- * the dark one.
2
+ * TOKENS POR ESQUEMA - de qual face é cada declaração que o projeto escreveu.
4
3
  *
5
- * The harvest used to flatten every stylesheet into one `name → value` map with
6
- * the first declaration winning, which silently discards the second half of
7
- * every themed project. A shadcn codebase declares its whole vocabulary twice -
8
- * `:root` and `.dark` - so half of it was being thrown away before anything
9
- * could read it.
4
+ * A colheita achatava toda folha num único mapa `nome → valor` com a primeira declaração
5
+ * vencendo, o que descarta em silêncio a segunda metade de todo projeto com tema: um
6
+ * codebase shadcn declara o vocabulário inteiro duas vezes - `:root` e `.dark` - e metade
7
+ * era jogada fora antes de qualquer um ler.
10
8
  *
11
- * Honest scope note (measured 30/07 on the project this was written for): its
12
- * own dark block holds exactly two tokens, because its dark mode lives in a
13
- * Tailwind `dark:` variant applied in components rather than in token values.
14
- * This reader finds what a project declares; it cannot find what a project
15
- * expresses in class names.
9
+ * O QUE MUDOU EM 28/08, e é a razão de existirem três baldes em vez de dois: `base` e
10
+ * `alt` misturavam duas perguntas. `:root` responde ONDE na cascata; `.dark` responde DE
11
+ * QUAL ESQUEMA. Tratar a primeira como se fosse a segunda embute "a base é a face clara",
12
+ * que nenhum projeto declara - e um projeto que abre no escuro recebia a face clara
13
+ * pintada com os valores do escuro. Medido no FlowSanctuary: 17 declarações claras, 0
14
+ * chegando, `canvas` e `foreground` no mesmo hex.
16
15
  *
17
- * Deliberately a scanner, not a CSS parser. It tracks brace depth and the
18
- * selector that opened each level, which is all that is needed to answer "which
19
- * scope is this declaration in" - and it survives the nesting Tailwind v4
20
- * produces (`@layer base { :root { … } }`) without a dependency.
21
- */
22
- /** A scope that means "this is the dark theme". Covers the spellings that
23
- * actually appear: shadcn's `.dark`, the data-attribute forms, and the
24
- * `:where(...)` wrapper Tailwind v4 emits for a custom variant.
16
+ * Agora `base` é posição, `light` e `dark` são esquema, e qual face abre por padrão é
17
+ * outro fato inteiro - ver `default-scheme.ts`.
25
18
  *
26
- * The closing `]` is load-bearing and a spec caught it missing:
27
- * `[data-theme="darkroom"]` was being read as the dark theme. */
28
- const DARK_SCOPE = /(^|[\s,(])(\.dark\b|\.theme-dark\b|\[data-(?:theme|scheme|mode)\s*[~^|*$]?=\s*["']?dark["']?\s*\])/i;
29
- /** A scope that carries a document's own tokens, as opposed to a component's. */
30
- const ROOT_SCOPE = /(^|[\s,(])(:root\b|html\b|body\b)/i;
31
- const DECL = /(--[a-z0-9-]+)\s*:\s*([^;]+)/gi;
19
+ * Nota de escopo honesta (medida em 30/07 no projeto para o qual isto foi escrito): o
20
+ * bloco escuro dele tem exatamente dois tokens, porque o modo escuro dele vive numa
21
+ * variante `dark:` do Tailwind aplicada nos componentes, não em valor de token. Este
22
+ * leitor acha o que um projeto DECLARA; não acha o que ele expressa em nome de classe.
23
+ */
24
+ import { scopedBlocks } from "./scheme-scope.js";
25
+ /**
26
+ * A máquina do Tailwind v4, que não é vocabulário de ninguém - ela é emitida em `@theme`
27
+ * junto com o que a pessoa escreveu.
28
+ */
29
+ const MACHINERY = /^--tw-/;
32
30
  export function parseSchemeBlocks(css) {
33
31
  const base = new Map();
34
- const alt = new Map();
35
- const clean = css.replace(/\/\*[\s\S]*?\*\//g, "");
36
- // The selector (or at-rule) that opened each open brace, innermost last.
37
- const stack = [];
38
- let prelude = "";
39
- for (let i = 0; i < clean.length; i++) {
40
- const ch = clean[i];
41
- if (ch === "{") {
42
- stack.push(prelude.trim());
43
- prelude = "";
44
- continue;
45
- }
46
- if (ch === "}") {
47
- stack.pop();
48
- prelude = "";
32
+ const light = new Map();
33
+ const dark = new Map();
34
+ const into = { base, light, dark };
35
+ for (const block of scopedBlocks(css)) {
36
+ if (block.scope === "other")
49
37
  continue;
50
- }
51
- if (ch === ";") {
52
- // A declaration ends here: everything since the last delimiter is it.
53
- const text = prelude;
54
- prelude = "";
55
- if (stack.length === 0)
56
- continue;
57
- const dark = stack.some((s) => DARK_SCOPE.test(s));
58
- const rooted = stack.some((s) => ROOT_SCOPE.test(s) || /^@theme\b/i.test(s));
59
- if (!dark && !rooted)
38
+ const bucket = into[block.scope];
39
+ for (const d of block.declarations) {
40
+ // Só vocabulário: `scopedBlocks` também colhe propriedade padrão, porque a
41
+ // detecção de polaridade precisa do `color-scheme` com a MESMA classificação
42
+ // de escopo. Aqui só interessa o que o projeto NOMEIA.
43
+ if (!d.name.startsWith("--") || MACHINERY.test(d.name))
60
44
  continue;
61
- DECL.lastIndex = 0;
62
- const m = DECL.exec(text);
63
- if (!m)
64
- continue;
65
- const name = m[1].toLowerCase();
66
- const value = m[2].trim();
67
- const into = dark ? alt : base;
68
- // First declaration wins, the same rule the flat harvest used - a later
69
- // sheet overriding an earlier one is a cascade question this cannot see.
70
- if (!into.has(name))
71
- into.set(name, value);
72
- continue;
45
+ // A primeira declaração vence, a mesma regra que a colheita achatada usava - uma
46
+ // folha posterior sobrescrevendo uma anterior é uma questão de cascata que este
47
+ // scanner não vê. Vale DENTRO de um balde; entre baldes quem resolve é a
48
+ // plataforma, e ali o override vence a base.
49
+ if (!bucket.has(d.name))
50
+ bucket.set(d.name, d.value);
73
51
  }
74
- prelude += ch;
75
52
  }
76
- return { base, alt };
53
+ return { base, light, dark };
77
54
  }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * ONDE UMA DECLARAÇÃO VIVE - e o cliente para de receber a face errada do próprio sistema.
3
+ *
4
+ * O QUE ELE VIVIA: um projeto que abre no escuro e declara o claro à mão em
5
+ * `[data-theme="light"]` recebia a face clara pintada com os valores do escuro - fundo e
6
+ * texto no mesmo hex, contraste 1,00:1. Medido no FlowSanctuary em 28/08: 17 declarações
7
+ * claras, 0 chegando.
8
+ *
9
+ * A CAUSA ERAM DUAS COISAS MISTURADAS num só juízo. `:root` responde "onde na cascata",
10
+ * `.dark` responde "de qual esquema" - e os dois leitores de token tratavam a primeira
11
+ * pergunta como se respondesse a segunda. Daí "base significa claro", que nenhum projeto
12
+ * jamais declarou.
13
+ *
14
+ * Aqui elas se separam. Este módulo responde SÓ a pergunta do escopo, e responde a mesma
15
+ * coisa para os dois leitores - `parseRootTokens` e `parseSchemeBlocks` tinham regex
16
+ * próprias e DISCORDAVAM: `html.dark` era escuro para um e raiz para o outro.
17
+ *
18
+ * A polaridade padrão do projeto - qual face abre - é outro fato, e mora em
19
+ * `defaultSchemeOf`. Um seletor não sabe qual face é a padrão; só sabe de qual face ele é.
20
+ */
21
+ /**
22
+ * O MARCADOR DE ESQUEMA, nas grafias que aparecem de verdade.
23
+ *
24
+ * O `]` de fechamento carrega peso e um spec já pegou ele faltando: sem ele,
25
+ * `[data-theme="darkroom"]` era lido como o tema escuro.
26
+ *
27
+ * Sem fronteira à esquerda de propósito - `html.dark` é escuro, e exigir espaço antes do
28
+ * ponto fazia essa forma (a que o Tailwind com `darkMode: "class"` produz) cair no
29
+ * repouso. A fronteira que importa é a da DIREITA, e ela está no `\b` / no `]`.
30
+ */
31
+ const SCHEME_MARK = /(\.dark\b|\.light\b|\.theme-dark\b|\.theme-light\b|\[data-(?:theme|scheme|mode)\s*[~^|*$]?=\s*["']?(?:dark|light)["']?\s*\])/gi;
32
+ const DARK_MARK = /(\.dark\b|\.theme-dark\b|\[data-(?:theme|scheme|mode)\s*[~^|*$]?=\s*["']?dark["']?\s*\])/i;
33
+ const LIGHT_MARK = /(\.light\b|\.theme-light\b|\[data-(?:theme|scheme|mode)\s*[~^|*$]?=\s*["']?light["']?\s*\])/i;
34
+ /** As raízes do documento - os lugares onde alguém escreve o vocabulário do projeto. */
35
+ const ROOT_MARK = /(^|[\s>+~,])(:root|html|:host|body)\b/gi;
36
+ /** `@theme` do Tailwind v4 é raiz por definição: é onde a paleta inteira é declarada. */
37
+ const AT_THEME = /^@theme\b/i;
38
+ /**
39
+ * A OUTRA GRAFIA DE "ESTA DECLARAÇÃO É DAQUELE ESQUEMA" - a que o CSS oferece nativamente.
40
+ *
41
+ * `@media (prefers-color-scheme: dark) { :root { … } }` diz exatamente o mesmo que
42
+ * `.dark { … }`, e sem esta linha ela cairia em `base` - o mesmo defeito de sempre com
43
+ * outra sintaxe. Zero ocorrências nas quatro populações medidas em 28/08; entra porque
44
+ * ignorá-la APAGARIA declarações que hoje chegam, não para cobrir um caso hipotético.
45
+ */
46
+ const PREFERS = /prefers-color-scheme\s*:\s*(dark|light)/i;
47
+ /**
48
+ * AT-RULES QUE SÓ EMBRULHAM - elas não são seletor de ninguém.
49
+ *
50
+ * `@layer base { :root { … } }` é a forma que o Tailwind v4 emite, e tratá-la como um
51
+ * nível da pilha fazia o `:root` de dentro virar `other`. `@media (min-width: …)` idem.
52
+ */
53
+ const TRANSPARENT_AT = /^@(layer|supports|container|scope|media)\b/i;
54
+ /**
55
+ * O seletor fala do DOCUMENTO INTEIRO, ou de um pedaço dele?
56
+ *
57
+ * A régua: tire os marcadores de esquema e as raízes. Se sobrou seletor, sobrou
58
+ * componente - e uma declaração de componente não é foundation.
59
+ *
60
+ * `html.dark .holo-card` vira `html .holo-card` vira ` .holo-card` - sobrou, então é
61
+ * local. `:root[data-theme="light"]` vira `:root` vira vazio - documento.
62
+ *
63
+ * Medido nas quatro populações em 28/08: a régua tira 2 declarações de 1128, e as 2 são
64
+ * o defeito (`--holo-inner-bg`, uma variável da `@utility holo-card` do codelevel).
65
+ */
66
+ function isDocumentScoped(selector) {
67
+ if (AT_THEME.test(selector.trim()))
68
+ return true;
69
+ // `:where(…)` e `:is(…)` são pura AGRUPAÇÃO - o que importa é o que está dentro.
70
+ // Sem desembrulhar, `:where([data-theme=dark], [data-theme=dark] *)` do repositório
71
+ // real virava local, e com ele os dois tokens que o comentário de `tokens.ts` cita
72
+ // por nome (`--loader-color`, `--color-track`).
73
+ const flat = selector.replace(/:(?:where|is)\s*\(/gi, "").replace(/\)/g, "");
74
+ return flat.split(",").every((one) => {
75
+ SCHEME_MARK.lastIndex = 0;
76
+ const scoped = SCHEME_MARK.test(one);
77
+ ROOT_MARK.lastIndex = 0;
78
+ const rooted = ROOT_MARK.test(one);
79
+ let bare = one.replace(SCHEME_MARK, "").replace(ROOT_MARK, "$1");
80
+ // `[data-theme=dark] *` é a mesma declaração alcançando tudo que herda dela - a
81
+ // forma que o Tailwind v4 emite. O `*` só some quando o pedaço JÁ é de esquema ou
82
+ // de raiz; `*, ::before, ::after` sozinho continua sendo o `--tw-*` do v3, que
83
+ // nunca foi vocabulário de ninguém.
84
+ if (scoped || rooted)
85
+ bare = bare.replace(/\*/g, "");
86
+ return bare.replace(/[\s>+~]/g, "").trim() === "";
87
+ });
88
+ }
89
+ /**
90
+ * De qual face é esta pilha de seletores - do mais externo ao mais interno.
91
+ *
92
+ * PILHA e não seletor solto porque o aninhamento decide: `@layer base { html { … } }` e
93
+ * `[data-theme="light"] { .x { … } }` só se respondem olhando os dois níveis.
94
+ *
95
+ * A ORDEM DA DECISÃO É LEI: esquema explícito vence posição. Um bloco que diz `light`
96
+ * é claro mesmo escrito em cima de `:root`, e é justamente essa inversão que fazia 14
97
+ * declarações claras da nossa própria folha entrarem como base.
98
+ *
99
+ * Claro E escuro na mesma pilha é contradição aninhada, e contradição não se adivinha:
100
+ * sai `other`.
101
+ */
102
+ export function schemeScopeOf(stack) {
103
+ const all = stack.map((s) => s.trim()).filter((s) => s !== "");
104
+ if (all.length === 0)
105
+ return "other";
106
+ /** O que o `@media (prefers-color-scheme: …)` da pilha diz, quando ela tem um. */
107
+ const prefers = all
108
+ .filter((s) => TRANSPARENT_AT.test(s))
109
+ .map((s) => PREFERS.exec(s)?.[1]?.toLowerCase())
110
+ .find((s) => s != null);
111
+ /** Os embrulhos saem: eles não selecionam nada, então não classificam nada. */
112
+ const real = all.filter((s) => !TRANSPARENT_AT.test(s));
113
+ if (real.length === 0)
114
+ return "other";
115
+ const dark = prefers === "dark" || real.some((s) => DARK_MARK.test(s));
116
+ const light = prefers === "light" || real.some((s) => LIGHT_MARK.test(s));
117
+ if (dark && light)
118
+ return "other";
119
+ // Toda parte da pilha precisa ser de documento. Um `html { }` que contém `.card { }`
120
+ // fala do card, e o nível interno é quem denuncia isso.
121
+ if (!real.every((s) => isDocumentScoped(s)))
122
+ return "other";
123
+ if (dark)
124
+ return "dark";
125
+ if (light)
126
+ return "light";
127
+ // Sem marcador de esquema, só é base quem é raiz de verdade. `isDocumentScoped` já
128
+ // aprovou a pilha inteira, então basta ter uma raiz nomeada em algum nível - o
129
+ // `@layer base { :root { … } }` que o Tailwind v4 emite passa por aqui.
130
+ const rooted = real.some((s) => {
131
+ if (AT_THEME.test(s.trim()))
132
+ return true;
133
+ ROOT_MARK.lastIndex = 0;
134
+ return ROOT_MARK.test(s);
135
+ });
136
+ return rooted ? "base" : "other";
137
+ }
138
+ /**
139
+ * A VARREDURA ÚNICA - os dois leitores de token passam por aqui.
140
+ *
141
+ * Deliberadamente um scanner, não um parser de CSS. O que ele precisa responder é uma
142
+ * pergunta só - "de qual escopo é esta declaração" -, e para isso bastam a profundidade
143
+ * de chave e o seletor que abriu cada nível. É o que o faz sobreviver ao aninhamento que
144
+ * o Tailwind v4 produz sem uma dependência (o CLI é publicado standalone e tem zero deps).
145
+ *
146
+ * Existe porque `parseRootTokens` e `parseSchemeBlocks` tinham cada um a sua varredura e a
147
+ * sua regex de escopo, e DISCORDAVAM: `html.dark` era raiz para um e nada para o outro.
148
+ * Duas respostas para a mesma pergunta é como a face errada chega na tela do cliente.
149
+ *
150
+ * MAS UM SCANNER DE CHAVE INGÊNUO LÊ CONTEÚDO COMO SE FOSSE ESTRUTURA, e isso não é
151
+ * hipótese: medido em 29/08 contra a versão de 28/08, 7 de 12 formas de CSS VÁLIDO
152
+ * quebravam. `content: "}"` fechava o bloco e a declaração seguinte sumia; `--x: "a;b"`
153
+ * chegava truncado em `"a`; um `url("data:image/svg+xml;base64,…")` perdia o payload
154
+ * inteiro; e a remoção de comentário feita por regex sobre o arquivo inteiro comia o
155
+ * miolo de qualquer string que contivesse `/*`.
156
+ *
157
+ * A PROPRIEDADE, e ela vale para qualquer folha: delimitador que pertence ao CONTEÚDO
158
+ * léxico de um valor não pode mexer na pilha estrutural. Por isso o laço abaixo tem
159
+ * estado - código, aspas simples, aspas duplas, escape, comentário e profundidade de
160
+ * parênteses -, e `{`, `}`, `;` e `:` só são estruturais no primeiro deles.
161
+ */
162
+ export function scopedBlocks(css) {
163
+ const out = [];
164
+ const stack = [];
165
+ /** Índice em `out` do bloco de cada nível aberto, para as declarações irem no lugar. */
166
+ const at = [];
167
+ /** O texto desde o último delimitador - vira seletor num `{` e declaração num `;`/`}`. */
168
+ let prelude = "";
169
+ /** Onde, dentro de `prelude`, está o `:` que separa nome de valor. -1 = nenhum ainda. */
170
+ let colon = -1;
171
+ /** `null` fora de string; a aspa que abriu, dentro dela. */
172
+ let quote = null;
173
+ let inComment = false;
174
+ /**
175
+ * `url(data:image/svg+xml;base64,…)` sem aspas é CSS válido, e ali o `;` é payload.
176
+ * Contar parênteses cobre isso e todo `calc()`/`clamp()` de uma vez, sem citar função.
177
+ */
178
+ let parens = 0;
179
+ const flush = (closing) => {
180
+ const text = prelude;
181
+ const nameEnd = colon;
182
+ prelude = "";
183
+ colon = -1;
184
+ const here = at[at.length - 1];
185
+ if (here != null && nameEnd >= 0) {
186
+ const name = text.slice(0, nameEnd).trim().toLowerCase();
187
+ const value = text.slice(nameEnd + 1).trim();
188
+ if (name && value && NAME.test(name)) {
189
+ out[here].declarations.push({ name, value });
190
+ }
191
+ }
192
+ if (closing) {
193
+ stack.pop();
194
+ at.pop();
195
+ }
196
+ };
197
+ for (let i = 0; i < css.length; i++) {
198
+ const ch = css[i];
199
+ if (inComment) {
200
+ if (ch === "*" && css[i + 1] === "/") {
201
+ inComment = false;
202
+ i += 1;
203
+ }
204
+ continue;
205
+ }
206
+ if (quote) {
207
+ // Uma barra invertida consome o próximo caractere, seja ele qual for - inclusive
208
+ // outra barra e a própria aspa de fechamento.
209
+ if (ch === "\\") {
210
+ prelude += ch + (css[i + 1] ?? "");
211
+ i += 1;
212
+ continue;
213
+ }
214
+ if (ch === quote)
215
+ quote = null;
216
+ prelude += ch;
217
+ continue;
218
+ }
219
+ // `/*` só ABRE comentário fora de string - senão `"/* isto não é comentário */"`
220
+ // perderia o próprio conteúdo.
221
+ if (ch === "/" && css[i + 1] === "*") {
222
+ inComment = true;
223
+ i += 1;
224
+ continue;
225
+ }
226
+ if (ch === '"' || ch === "'") {
227
+ quote = ch;
228
+ prelude += ch;
229
+ continue;
230
+ }
231
+ if (ch === "(")
232
+ parens += 1;
233
+ else if (ch === ")")
234
+ parens = Math.max(0, parens - 1);
235
+ if (parens === 0) {
236
+ if (ch === "{") {
237
+ stack.push(prelude.trim());
238
+ prelude = "";
239
+ colon = -1;
240
+ at.push(out.length);
241
+ out.push({
242
+ scope: schemeScopeOf(stack),
243
+ stack: [...stack],
244
+ declarations: [],
245
+ });
246
+ continue;
247
+ }
248
+ if (ch === "}" || ch === ";") {
249
+ // A ÚLTIMA DECLARAÇÃO NÃO PRECISA DE `;`, e `:root { --x: red }` é CSS válido -
250
+ // por isso o `}` também fecha uma declaração antes de fechar o bloco.
251
+ flush(ch === "}");
252
+ continue;
253
+ }
254
+ // O PRIMEIRO `:` do prelúdio separa nome de valor. Só o primeiro: um
255
+ // `background: url(a:b)` já está protegido pelos parênteses, e `a:hover { }`
256
+ // nunca chega ao `flush` porque termina em `{`.
257
+ if (ch === ":" && colon < 0)
258
+ colon = prelude.length;
259
+ }
260
+ prelude += ch;
261
+ }
262
+ return out;
263
+ }
264
+ /**
265
+ * Um nome de propriedade, custom ou padrão. `--my_var` é legal, então `_` entra.
266
+ *
267
+ * Propriedade padrão também passa: `color-scheme: dark` numa raiz é a evidência
268
+ * estrutural de qual face o projeto abre, e ela precisa da MESMA classificação de escopo
269
+ * que os tokens - ver `default-scheme.ts`. Quem só quer vocabulário filtra por `--` no
270
+ * consumo.
271
+ */
272
+ const NAME = /^(--[a-z0-9_-]+|-{0,2}[a-z][a-z0-9-]*)$/i;
@@ -11,17 +11,8 @@
11
11
  * Pure and dependency-free on purpose: every function here takes text and
12
12
  * returns data, so the whole diagnosis is testable without a filesystem.
13
13
  */
14
- /**
15
- * Where the vocabulary being measured against came from.
16
- *
17
- * `"installed"` is a system we wrote. `"yours"` is the project's OWN custom
18
- * properties, harvested from its stylesheets - the case that matters most,
19
- * because the people who feel this problem hardest already have a design
20
- * system and had no reason to adopt ours before seeing a number.
21
- */
22
- /** `"adopted"` is theirs too, but described by `adopt` and therefore NAMED -
23
- * it must not be offered a system to install, having just adopted one. */
24
14
  import { DEFAULT_ROOT_PX } from "./root-size.js";
15
+ import { parseSchemeBlocks } from "./scheme-blocks.js";
25
16
  export const EMPTY_TABLE = {
26
17
  source: null,
27
18
  name: null,
@@ -307,94 +298,19 @@ export function parseDeclaredNames(css) {
307
298
  * (declared on `*, ::before, ::after`) and Radix's `--radix-*` (set on the
308
299
  * component) without naming a single vendor.
309
300
  *
310
- * The flat brace regex is deliberate and handles one level of nesting for
311
- * free: against `@media x { :root { ... } }` the outer selector fails to match
312
- * because its body contains a brace, so the scan moves on and finds the inner
313
- * block on its own.
301
+ * A CLASSIFICAÇÃO É COMPARTILHADA desde 28/08 - ver `scheme-scope.ts`. Este leitor tinha
302
+ * regex própria de raiz e discordava do leitor de esquemas: `html.dark .holo-card` era
303
+ * raiz aqui e nada lá, então uma variável LOCAL de um componente do codelevel
304
+ * (`--holo-inner-bg`, declarada dentro de `@utility holo-card`) subia para cá como token
305
+ * global do sistema - e subia com o valor do tema ESCURO na face que a plataforma lê como
306
+ * padrão. Duas respostas para a mesma pergunta é como a face errada chega na tela.
307
+ *
308
+ * Aqui ficam só as declarações de escopo `base`: raiz de documento, sem escopo de esquema.
309
+ * O que um projeto declara explicitamente como claro ou escuro viaja em
310
+ * `Census.declaredSchemes`, com a face nomeada em vez de achatada.
314
311
  */
315
312
  export function parseRootTokens(css) {
316
- const out = new Map();
317
- const isRoot = (sel) => /^@theme\b/i.test(sel) || /(^|[\s,>+~])(:root|html|:host)\b/i.test(sel);
318
- // Every `<selector> {` in the file. Nested ones show up too and are filtered
319
- // by isRoot, so an @keyframes inside @theme is skipped rather than mined.
320
- const opens = /([^{}]*)\{/g;
321
- let m = opens.exec(css);
322
- while (m !== null) {
323
- // The capture runs back to the previous brace, so it carries imports and
324
- // comments with it. The SELECTOR is only what follows the last `;` or
325
- // comment - and `@theme` is anchored to the start, so without this it
326
- // matched only when the block happened to be the first thing in the file.
327
- // Every fixture had it first. This repo's own globals.css does not, and
328
- // reported 0 of its 48 tokens (27/07).
329
- const sel = (m[1]
330
- .replace(/\/\*[\s\S]*?\*\//g, "")
331
- .split(";")
332
- .pop() ?? "").trim();
333
- if (!isRoot(sel)) {
334
- m = opens.exec(css);
335
- continue;
336
- }
337
- // Walk to the MATCHING close, counting depth. The flat regex this replaces
338
- // required a body with no braces at all, so it silently skipped any
339
- // `@theme` containing `@keyframes` - which is the documented Tailwind v4
340
- // layout. Measured against this repo's own globals.css: 48 tokens present,
341
- // 2 found, and the 2 came from unrelated test fixtures (27/07).
342
- const start = m.index + m[0].length;
343
- let depth = 1;
344
- let i = start;
345
- while (i < css.length && depth > 0) {
346
- const c = css[i];
347
- if (c === "{")
348
- depth++;
349
- else if (c === "}")
350
- depth--;
351
- i++;
352
- }
353
- // Only declarations that are DIRECT children count. A custom property
354
- // inside a keyframe step is animation state, not a design token.
355
- let flat = "";
356
- let d = 0;
357
- for (let j = start; j < i - 1; j++) {
358
- const c = css[j];
359
- if (c === "{")
360
- d++;
361
- else if (c === "}")
362
- d--;
363
- else if (d === 0)
364
- flat += c;
365
- }
366
- for (const t of flat.matchAll(/(--[a-z0-9_-]+)\s*:\s*([^;}]+)/gi)) {
367
- const name = t[1].toLowerCase();
368
- // Belt and braces: v4 emits some `--tw-*` bookkeeping into @theme, and
369
- // it is machinery, not somebody's design vocabulary.
370
- if (name.startsWith("--tw-"))
371
- continue;
372
- const value = t[2].trim();
373
- if (!value)
374
- continue;
375
- /**
376
- * O ALIAS FICA, E É RESOLVIDO DEPOIS - antes ele era descartado aqui, e era justamente o
377
- * token mais cheio de significado que sumia.
378
- *
379
- * `--loader-color: var(--color-ocean-500)` não é ruído: é um PAPEL, a frase que diz "esta
380
- * função é esta cor". Descartá-lo deixava só as primitivas - a paleta sem o vocabulário.
381
- *
382
- * A assimetria que isso produzia, medida no repo do dono em 11/08: `declaredAlt` sai de
383
- * `parseSchemeBlocks`, que não tem este filtro, então o valor ESCURO de `--loader-color` e
384
- * `--color-track` viajava e o CLARO não. Dois tokens presentes só no dark, e o
385
- * `CircularProgress.tsx:100` lendo `var(--color-track, …)` sem que ninguém soubesse o que
386
- * `--color-track` é.
387
- *
388
- * Resolver é o mesmo que o caminho `installed` já faz com `resolveAliases`: a máquina existia
389
- * e só não era usada aqui.
390
- */
391
- if (!out.has(name))
392
- out.set(name, value);
393
- }
394
- opens.lastIndex = i;
395
- m = opens.exec(css);
396
- }
397
- return out;
313
+ return parseSchemeBlocks(css).base;
398
314
  }
399
315
  export function buildTable(input) {
400
316
  const source = input.source ?? "installed";
@@ -367,7 +367,7 @@ export const CHECKER_SINCE = "0.16.308";
367
367
  * nunca era alcançado. Medido: 1134 componentes, 77 mudaram, e a receita que o cliente recebe muda
368
368
  * com eles. Uma medição anterior não produz o conserto, então a marca sobe.
369
369
  */
370
- export const READER_SINCE = "0.16.329";
370
+ export const READER_SINCE = "0.16.330";
371
371
  /**
372
372
  * O QUE ESTÁ INSTALADO AQUI FICOU PARA TRÁS - e as DUAS condições que fazem isso ser verdade.
373
373
  *
@@ -89,6 +89,23 @@ export function mergeCensus(list) {
89
89
  ];
90
90
  const declared = { ...asRecord(first.declared) };
91
91
  const declaredAlt = { ...asRecord(first.declaredAlt) };
92
+ /**
93
+ * OS TRÊS BALDES DE ESQUEMA, escopo a escopo - ver `Census.declaredSchemes`.
94
+ *
95
+ * Mesmo desempate de `declared`: o primeiro escopo vence e a perda entra em `conflicts`,
96
+ * porque dois pacotes que declaram o mesmo nome é um achado, não um detalhe.
97
+ *
98
+ * A POLARIDADE NÃO SE SOMA. Dois escopos que observam faces padrão diferentes não têm
99
+ * média: viram `conflicting`, que é a verdade. Um escopo que não observou nada não vota -
100
+ * senão o silêncio de um pacote apagaria a evidência do outro.
101
+ */
102
+ const firstSchemes = first.declaredSchemes;
103
+ const schemeBuckets = {
104
+ base: { ...asRecord(firstSchemes?.base) },
105
+ light: { ...asRecord(firstSchemes?.light) },
106
+ dark: { ...asRecord(firstSchemes?.dark) },
107
+ };
108
+ const polarities = firstSchemes ? [firstSchemes] : [];
92
109
  const keyframes = { ...(first.keyframes ?? {}) };
93
110
  const looks = { ...(first.looks ?? {}) };
94
111
  let observed = first.observed.map((v) => ({ ...v }));
@@ -111,6 +128,13 @@ export function mergeCensus(list) {
111
128
  const firstName = nameOf(first, 0);
112
129
  mergeDeclared(declared, asRecord(c.declared), firstName, scopeName, conflicts);
113
130
  mergeDeclared(declaredAlt, asRecord(c.declaredAlt), firstName, scopeName, conflicts);
131
+ const theirSchemes = c.declaredSchemes;
132
+ if (theirSchemes) {
133
+ polarities.push(theirSchemes);
134
+ for (const face of ["base", "light", "dark"]) {
135
+ mergeDeclared(schemeBuckets[face], asRecord(theirSchemes[face]), firstName, scopeName, conflicts);
136
+ }
137
+ }
114
138
  /**
115
139
  * O COMPONENTE QUE OS DOIS DEFINEM fica com a receita do primeiro escopo, e
116
140
  * o descarte entra em `collisions` - a mesma lista que `name-claim.ts`
@@ -197,6 +221,23 @@ export function mergeCensus(list) {
197
221
  };
198
222
  if (Object.keys(declaredAlt).length > 0)
199
223
  out.declaredAlt = declaredAlt;
224
+ if (polarities.length > 0) {
225
+ const observed = polarities.filter((p) => p.defaultScheme !== "unknown");
226
+ const distinct = new Set(observed.map((p) => p.defaultScheme));
227
+ const winner = distinct.size === 1 ? observed[0] : null;
228
+ out.declaredSchemes = {
229
+ ...schemeBuckets,
230
+ defaultScheme: winner ? winner.defaultScheme : "unknown",
231
+ defaultSchemeSource: winner
232
+ ? winner.defaultSchemeSource
233
+ : distinct.size > 1
234
+ ? "conflicting"
235
+ : "absent",
236
+ ...(winner?.defaultSchemeEvidence
237
+ ? { defaultSchemeEvidence: winner.defaultSchemeEvidence }
238
+ : {}),
239
+ };
240
+ }
200
241
  if (Object.keys(keyframes).length > 0)
201
242
  out.keyframes = keyframes;
202
243
  if (Object.keys(looks).length > 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.329",
3
+ "version": "0.16.330",
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": {