synthesisui 0.16.404 → 0.16.406
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/absorb-plan.js +68 -13
- package/dist/commands/absorb.js +13 -3
- package/dist/doctor/color-distance.js +22 -17
- package/dist/doctor/scan.js +2 -2
- package/dist/proposed-name.js +109 -12
- package/dist/sheet-needed.js +85 -2
- package/dist/use-by-property.js +134 -0
- package/package.json +1 -1
package/dist/absorb-plan.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { deltaE, JND } from "./doctor/color-distance.js";
|
|
2
2
|
import { familySays, nearestToken, normalizeValue, } from "./doctor/tokens.js";
|
|
3
|
-
import { proposeFromTheirScale, tooCloseToPropose } from "./proposed-name.js";
|
|
3
|
+
import { proposeFromTheirScale, proposeFromTheirUse, tooCloseToPropose, whyNotProposedFromUse, } from "./proposed-name.js";
|
|
4
4
|
/** Onde cada tipo de valor mora na fundação. `color` é o único com dois segmentos. */
|
|
5
5
|
const HOME = {
|
|
6
6
|
color: "color",
|
|
@@ -109,7 +109,14 @@ function nearestOwn(theirs, literal, kind) {
|
|
|
109
109
|
* `have` são os caminhos que a fundação já tem, para a proposta nunca sugerir sobrescrever um token
|
|
110
110
|
* existente com outro valor - isso não é absorver, é repintar.
|
|
111
111
|
*/
|
|
112
|
-
export function absorbPlan(d, theirs, have, cap
|
|
112
|
+
export function absorbPlan(d, theirs, have, cap,
|
|
113
|
+
/**
|
|
114
|
+
* A FUNÇÃO DE CADA COR NO CÓDIGO DELE, contada por `countUseByProperty` sobre os mesmos arquivos
|
|
115
|
+
* da varredura. OBRIGATÓRIO de propósito: um parâmetro opcional que dá para esquecer é a próxima
|
|
116
|
+
* ocorrência do defeito em que a tela promete o que a chamada não passou. Quem não tem a contagem
|
|
117
|
+
* passa um mapa vazio e a fonte 2 cala - explicitamente.
|
|
118
|
+
*/
|
|
119
|
+
uses) {
|
|
113
120
|
/**
|
|
114
121
|
* A REPETIÇÃO É UM FILTRO DE RUÍDO, E RUÍDO É UMA PROPRIEDADE DA ESCALA - não do valor.
|
|
115
122
|
*
|
|
@@ -136,6 +143,14 @@ export function absorbPlan(d, theirs, have, cap = 40) {
|
|
|
136
143
|
? [...repeated, ...single]
|
|
137
144
|
: repeated;
|
|
138
145
|
const entries = [];
|
|
146
|
+
/**
|
|
147
|
+
* OS CAMINHOS JÁ PROPOSTOS NESTA RODADA, por qualquer fonte. Dois literais diferentes podem cair
|
|
148
|
+
* na mesma posição (`#111111` e `#121212` são ambos luminosidade 7): o primeiro da lista - o mais
|
|
149
|
+
* repetido, porque é a ordem em que ele vê a lista - leva o caminho, e o segundo fica com o
|
|
150
|
+
* motivo. Sem isto os dois saíam com `color.background.7` e o `--send` postava dois tokens para o
|
|
151
|
+
* mesmo nome.
|
|
152
|
+
*/
|
|
153
|
+
const taken = new Map();
|
|
139
154
|
for (const r of unnamed) {
|
|
140
155
|
if (entries.length >= cap)
|
|
141
156
|
break;
|
|
@@ -194,19 +209,53 @@ export function absorbPlan(d, theirs, have, cap = 40) {
|
|
|
194
209
|
* entra - e ela devolve `null` na dúvida (ver `proposed-name.ts`).
|
|
195
210
|
*/
|
|
196
211
|
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
212
|
+
* DUAS FONTES, NESTA ORDEM (D4, dono, 08/09): a escala dele, e só onde ela cala, a FUNÇÃO que o
|
|
213
|
+
* valor exerce no código dele. Perto demais de um token dele veta as duas - ali a resposta é
|
|
214
|
+
* normalizar, não batizar. A segunda fonte só existe para cor: é a única natureza em que a
|
|
215
|
+
* propriedade escrita é uma função (`background`, `border`), e a única que a contagem lê.
|
|
216
|
+
*/
|
|
217
|
+
/**
|
|
218
|
+
* E SÓ PARA COR - por dois motivos distintos, e vale dizer os dois.
|
|
219
|
+
*
|
|
220
|
+
* A fonte 1 é escala de cor: medido no `frontend-hub` real em 08/09, ela propôs
|
|
221
|
+
* `dashboard.blue.650` para `30px`, `40px` e `50px`, porque `lightness("30px")` lia um prefixo
|
|
222
|
+
* hexadecimal de "3300pp" - consertado na raiz em `color-distance.ts`, e a porta fechada aqui
|
|
223
|
+
* também. A fonte 2 para cor é ESCOPO desta parte, não natureza: `12px` 20× em `gap` também é
|
|
224
|
+
* função medida, e fica para a próxima parte. O `≈` de vizinhança para comprimento continua
|
|
225
|
+
* sendo o de `nearestOwn`.
|
|
199
226
|
*/
|
|
200
|
-
const proposal = theirName || tooCloseToPropose(r.literal, theirs)
|
|
227
|
+
const proposal = r.kind !== "color" || theirName || tooCloseToPropose(r.literal, theirs)
|
|
201
228
|
? null
|
|
202
|
-
: proposeFromTheirScale(r.literal, theirs)
|
|
203
|
-
|
|
229
|
+
: (proposeFromTheirScale(r.literal, theirs) ??
|
|
230
|
+
proposeFromTheirUse(r.literal, uses, theirs.rootPx, r.count));
|
|
231
|
+
const own = pathFor(r.kind, theirName);
|
|
232
|
+
/** Já existe na fundação com OUTRO valor: absorver aqui seria repintar o token deles. */
|
|
233
|
+
if (own && have.has(own))
|
|
234
|
+
continue;
|
|
235
|
+
/**
|
|
236
|
+
* COLISÃO DECLARADA, NUNCA DESCARTADA. Quando o caminho PROPOSTO já existe na fundação para
|
|
237
|
+
* outro valor, a primeira versão fazia `continue` - e a entrada sumia da lista enquanto a
|
|
238
|
+
* última linha do comando dizia "re-run and the next batch appears". Reproduzido pela revisão de
|
|
239
|
+
* DX em 08/09 em duas rodadas seguidas do `absorb`. A entrada FICA, sem caminho, e o motivo
|
|
240
|
+
* diz o que aconteceria: ele decide se é o mesmo token ou outro nome.
|
|
241
|
+
*/
|
|
242
|
+
const collided = proposal !== null && have.has(proposal.path);
|
|
243
|
+
/** EMPATE NA MESMA RODADA, declarado: só um leva o caminho, e o outro diz quem levou. */
|
|
244
|
+
const rival = proposal !== null && !collided ? taken.get(proposal.path) : undefined;
|
|
245
|
+
const path = own || (collided || rival ? "" : (proposal?.path ?? ""));
|
|
246
|
+
if (proposal !== null && !own && !collided && !rival)
|
|
247
|
+
taken.set(proposal.path, { literal: r.literal, count: r.count });
|
|
204
248
|
/** "≈ perto de um token seu" só interessa a quem não tem um EXATO - com o nome na mão, a dica
|
|
205
249
|
* vira ruído, e no arquivo de proposta ela vira uma segunda opção que não é opção. */
|
|
206
250
|
const near = theirName ? undefined : nearestOwn(theirs, r.literal, r.kind);
|
|
207
|
-
/**
|
|
208
|
-
|
|
209
|
-
|
|
251
|
+
/** E quando as duas fontes calam e não há vizinho, o campo em branco diz por quê. */
|
|
252
|
+
const unproposed = collided
|
|
253
|
+
? `would be ${proposal?.path}, but you already named a different colour that way`
|
|
254
|
+
: rival
|
|
255
|
+
? `would be ${proposal?.path}, but ${rival.literal} (${rival.count}×) takes that position this round - name one of them and the other follows`
|
|
256
|
+
: r.kind === "color" && !theirName && !proposal && !near
|
|
257
|
+
? whyNotProposedFromUse(r.literal, uses, theirs.rootPx, r.count)
|
|
258
|
+
: null;
|
|
210
259
|
entries.push({
|
|
211
260
|
value: r.literal,
|
|
212
261
|
kind: r.kind,
|
|
@@ -215,8 +264,11 @@ export function absorbPlan(d, theirs, have, cap = 40) {
|
|
|
215
264
|
path,
|
|
216
265
|
...(theirName ? { theirName: clean(theirName) } : {}),
|
|
217
266
|
/** A PROCEDÊNCIA VIAJA COM A PROPOSTA - ele julga pela origem, nunca pela palavra. */
|
|
218
|
-
...(proposal
|
|
267
|
+
...(proposal && !collided && !rival
|
|
268
|
+
? { proposed: proposal.because }
|
|
269
|
+
: {}),
|
|
219
270
|
...(near ? { near } : {}),
|
|
271
|
+
...(unproposed ? { unproposed } : {}),
|
|
220
272
|
});
|
|
221
273
|
}
|
|
222
274
|
return {
|
|
@@ -280,7 +332,7 @@ export function describeAbsorb(plan) {
|
|
|
280
332
|
if (proposed.length > 0) {
|
|
281
333
|
if (ready.length > 0)
|
|
282
334
|
lines.push("");
|
|
283
|
-
lines.push(`${proposed.length} ${proposed.length === 1 ? "value has" : "values have"} a name PROPOSED from your own scale - approve, edit, or leave ${proposed.length === 1 ? "it" : "them"} hard-coded:`);
|
|
335
|
+
lines.push(`${proposed.length} ${proposed.length === 1 ? "value has" : "values have"} a name PROPOSED from your own vocabulary - your scale, or how you use the value - approve, edit, or leave ${proposed.length === 1 ? "it" : "them"} hard-coded:`);
|
|
284
336
|
for (const e of proposed.slice(0, 8))
|
|
285
337
|
lines.push(` ${e.value.padEnd(24)} ${e.path.padEnd(28)} ${e.proposed}`);
|
|
286
338
|
if (proposed.length > 8)
|
|
@@ -308,7 +360,10 @@ export function describeAbsorb(plan) {
|
|
|
308
360
|
: /** A proposta chega COM a procedência: ele julga pela origem, não pela palavra. */
|
|
309
361
|
e.proposed
|
|
310
362
|
? `${e.path} · ${e.proposed}`
|
|
311
|
-
:
|
|
363
|
+
: /** E o campo em branco chega com o motivo, quando a função medida o tem. */
|
|
364
|
+
e.unproposed
|
|
365
|
+
? `path: "" · ${e.unproposed}`
|
|
366
|
+
: 'path: ""'}`);
|
|
312
367
|
if (pending.length > 8)
|
|
313
368
|
lines.push(` (${pending.length - 8} more)`);
|
|
314
369
|
/**
|
package/dist/commands/absorb.js
CHANGED
|
@@ -2,11 +2,12 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { absorbPlan, describeAbsorb, needingName, } from "../absorb-plan.js";
|
|
4
4
|
import { readProjectConfig, readToken, resolveRegistry } from "../config.js";
|
|
5
|
-
import { diagnose, scanSource } from "../doctor/scan.js";
|
|
5
|
+
import { diagnose, IS_FIXTURE, scanSource, } from "../doctor/scan.js";
|
|
6
6
|
import { withTheirNames } from "../doctor/their-names.js";
|
|
7
7
|
import { measuredScope, scopePaths } from "../measured-scope.js";
|
|
8
8
|
import { body, paint, section, snippet } from "../output.js";
|
|
9
9
|
import { resolveDeps, tailwindMajor } from "../stack.js";
|
|
10
|
+
import { countUseByProperty } from "../use-by-property.js";
|
|
10
11
|
import { loadSystem, walkAll } from "./doctor.js";
|
|
11
12
|
/**
|
|
12
13
|
* `synthesisui absorb` - O SISTEMA APRENDE O DESIGN QUE O CÓDIGO JÁ TEM.
|
|
@@ -85,13 +86,22 @@ export async function absorb(opts) {
|
|
|
85
86
|
? installed.table
|
|
86
87
|
: withTheirNames(theirs, theirs);
|
|
87
88
|
const reports = [];
|
|
89
|
+
/**
|
|
90
|
+
* A FUNÇÃO DE CADA COR, contada num passe próprio sobre os MESMOS arquivos - é a segunda fonte do
|
|
91
|
+
* nome proposto (`proposed-name.ts`). Fixture e teste ficam de fora pela mesma regra do scan: as
|
|
92
|
+
* cores de um `.stories` são do exemplo, não do produto dele.
|
|
93
|
+
*/
|
|
94
|
+
const uses = new Map();
|
|
88
95
|
for await (const file of walkAll(roots)) {
|
|
89
96
|
const source = await readFile(file, "utf8").catch(() => null);
|
|
90
97
|
if (source === null)
|
|
91
98
|
continue;
|
|
92
|
-
|
|
99
|
+
const rel = file.slice(root.length + 1);
|
|
100
|
+
reports.push(scanSource(rel, source, table));
|
|
101
|
+
if (!IS_FIXTURE.test(rel))
|
|
102
|
+
countUseByProperty(source, uses, table.rootPx);
|
|
93
103
|
}
|
|
94
|
-
const plan = absorbPlan(diagnose(reports), theirs, pathsInSystem(installed.documents), opts.cap ?? 40);
|
|
104
|
+
const plan = absorbPlan(diagnose(reports), theirs, pathsInSystem(installed.documents), opts.cap ?? 40, uses);
|
|
95
105
|
console.log(section(hasSystem
|
|
96
106
|
? "What the system could absorb"
|
|
97
107
|
: "Name what you wrote by hand"));
|
|
@@ -97,20 +97,34 @@ export function isNearDuplicate(a, b, threshold = JND) {
|
|
|
97
97
|
* the two halves deciding differently what counts as a dark canvas is worse
|
|
98
98
|
* than either of them being wrong.
|
|
99
99
|
*/
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
/**
|
|
101
|
+
* OS SEIS DÍGITOS DE UMA COR, OU NADA - e o "nada" é o que faltava.
|
|
102
|
+
*
|
|
103
|
+
* `lightness("30px")` devolvia 0.143: "30px" tem 4 caracteres, era expandido para "3300pp", e
|
|
104
|
+
* `parseInt("3300pp", 16)` lê o prefixo hexadecimal em vez de devolver NaN. Efeito medido no
|
|
105
|
+
* `frontend-hub` real em 08/09, pelo `absorb`: a fonte 1 propôs `dashboard.blue.650` para `30px`
|
|
106
|
+
* (espaçamento), `40px` e `50px` (raio) - "between your dashboard-blue-500 and dashboard-blue-800" -,
|
|
107
|
+
* um nome de cor sobre um comprimento. `rgb()`/`hsl()`/`oklch()` não chegam aqui: `normalizeValue`
|
|
108
|
+
* os converte para `#rrggbbaa` antes; o que chega e NÃO é cor é comprimento, tempo ou texto, e a
|
|
109
|
+
* resposta certa para eles é `null`.
|
|
110
|
+
*/
|
|
111
|
+
function sixDigits(hex) {
|
|
112
|
+
const h = hex.trim().replace(/^#/, "");
|
|
113
|
+
if (!/^[0-9a-f]{3,4}$|^[0-9a-f]{6}$|^[0-9a-f]{8}$/i.test(h))
|
|
114
|
+
return null;
|
|
115
|
+
return h.length === 3 || h.length === 4
|
|
103
116
|
? h
|
|
104
117
|
.slice(0, 3)
|
|
105
118
|
.split("")
|
|
106
119
|
.map((c) => c + c)
|
|
107
120
|
.join("")
|
|
108
121
|
: h.slice(0, 6);
|
|
109
|
-
|
|
122
|
+
}
|
|
123
|
+
export function lightness(hex) {
|
|
124
|
+
const full = sixDigits(hex);
|
|
125
|
+
if (full === null)
|
|
110
126
|
return null;
|
|
111
127
|
const n = Number.parseInt(full, 16);
|
|
112
|
-
if (Number.isNaN(n))
|
|
113
|
-
return null;
|
|
114
128
|
return ((0.2126 * ((n >> 16) & 255) +
|
|
115
129
|
0.7152 * ((n >> 8) & 255) +
|
|
116
130
|
0.0722 * (n & 255)) /
|
|
@@ -120,19 +134,10 @@ export function lightness(hex) {
|
|
|
120
134
|
* chromatic, and treating it as coloured throws every off-white surface out of
|
|
121
135
|
* the neutral ladder. */
|
|
122
136
|
export function chroma(hex) {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
? h
|
|
126
|
-
.slice(0, 3)
|
|
127
|
-
.split("")
|
|
128
|
-
.map((c) => c + c)
|
|
129
|
-
.join("")
|
|
130
|
-
: h.slice(0, 6);
|
|
131
|
-
if (full.length !== 6)
|
|
137
|
+
const full = sixDigits(hex);
|
|
138
|
+
if (full === null)
|
|
132
139
|
return null;
|
|
133
140
|
const n = Number.parseInt(full, 16);
|
|
134
|
-
if (Number.isNaN(n))
|
|
135
|
-
return null;
|
|
136
141
|
const r = (n >> 16) & 255;
|
|
137
142
|
const g = (n >> 8) & 255;
|
|
138
143
|
const b = n & 255;
|
package/dist/doctor/scan.js
CHANGED
|
@@ -54,7 +54,7 @@ const IGNORE_LINE = /^\s*(import|@import|\/\/|\*|\/\*)/;
|
|
|
54
54
|
* VISTO pelo scanner e INTERPRETADO por `colorForm` para chegar a uma comparação. As duas metades
|
|
55
55
|
* entraram na mesma mudança porque meia jornada não é meio valor.
|
|
56
56
|
*/
|
|
57
|
-
const COLOR = /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|oklch\([^)]*\)/g;
|
|
57
|
+
export const COLOR = /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|oklch\([^)]*\)/g;
|
|
58
58
|
/**
|
|
59
59
|
* THE QUOTE THAT MADE HALF THE DRIFT INVISIBLE.
|
|
60
60
|
*
|
|
@@ -386,7 +386,7 @@ const IDIOM = new Set(["0", "0px", "1px", "9999px", "100%", "50%"]);
|
|
|
386
386
|
* Counting them accuses the author of drift they did not write, and crediting
|
|
387
387
|
* them inflates a coverage number nobody can act on.
|
|
388
388
|
*/
|
|
389
|
-
const IS_FIXTURE = /(\.(spec|test|stories)\.[a-z]+$|__tests__\/|\/fixtures?\/|(^|\/)\.storybook\/)/;
|
|
389
|
+
export const IS_FIXTURE = /(\.(spec|test|stories)\.[a-z]+$|__tests__\/|\/fixtures?\/|(^|\/)\.storybook\/)/;
|
|
390
390
|
export function scanSource(file, source, table) {
|
|
391
391
|
if (IS_FIXTURE.test(file)) {
|
|
392
392
|
const real = scanCore(file, source, table);
|
package/dist/proposed-name.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { chroma, deltaE, lightness } from "./doctor/color-distance.js";
|
|
2
2
|
import { normalizeValue } from "./doctor/tokens.js";
|
|
3
|
+
import { SVG } from "./use-by-property.js";
|
|
3
4
|
function stepsOf(theirs) {
|
|
4
5
|
const out = [];
|
|
5
6
|
for (const [name, value] of theirs.byName) {
|
|
@@ -102,20 +103,116 @@ export function tooCloseToPropose(literal, theirs, jnd = 2) {
|
|
|
102
103
|
}
|
|
103
104
|
return false;
|
|
104
105
|
}
|
|
106
|
+
/** O que o mapa diz deste literal: as funções, mais usada primeiro, e quantas vezes ao todo. */
|
|
107
|
+
function usesOf(literal, uses, rootPx) {
|
|
108
|
+
const key = normalizeValue(literal, rootPx);
|
|
109
|
+
const per = uses.get(key);
|
|
110
|
+
if (!per)
|
|
111
|
+
return null;
|
|
112
|
+
const entries = [...per.entries()].filter(([, n]) => n > 0);
|
|
113
|
+
if (entries.length === 0)
|
|
114
|
+
return null;
|
|
115
|
+
return {
|
|
116
|
+
key,
|
|
117
|
+
/** Mais usada primeiro; no empate, a ordem alfabética - a frase não pode depender de qual
|
|
118
|
+
* arquivo foi lido antes. */
|
|
119
|
+
per: entries.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)),
|
|
120
|
+
total: entries.reduce((n, [, c]) => n + c, 0),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* A POSIÇÃO É A LUMINOSIDADE DO PRÓPRIO VALOR, em 0..100 - estável entre rodadas, e é isso que a
|
|
125
|
+
* torna um NOME e não um rank.
|
|
126
|
+
*
|
|
127
|
+
* A primeira versão ordenava os literais da mesma função e dava `.1` ao mais escuro. Reproduzido
|
|
128
|
+
* pela revisão de DX em 08/09, rodando o `absorb` duas vezes (o fluxo que o próprio teto de 40
|
|
129
|
+
* recomenda): rodada 1, `#111111` é o mais escuro de dois, sai `color.background.1` e é enviado;
|
|
130
|
+
* rodada 2, aparece `#000000`, mais escuro - ele vira `.1`, colide com o nome já dado, e era
|
|
131
|
+
* descartado em silêncio enquanto a mensagem final dizia "re-run and the next batch appears". Um
|
|
132
|
+
* rank colide sistematicamente com qualquer valor novo mais escuro que os já nomeados.
|
|
133
|
+
*
|
|
134
|
+
* `Math.round(lightness × 100)` do próprio valor não depende de vizinho nenhum: `#111111` é `.7`
|
|
135
|
+
* hoje e em qualquer rodada, `#000000` é `.0`, `#fafafa` é `.98`. Refinamento da D3 (o espírito é
|
|
136
|
+
* "posição por luminosidade; só a posição é calculada"), decisão de implementação de 08/09,
|
|
137
|
+
* reabrível pelo dono.
|
|
138
|
+
*/
|
|
139
|
+
function positionOf(key) {
|
|
140
|
+
const l = lightness(key);
|
|
141
|
+
return l === null ? null : Math.round(l * 100);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* O NOME PELA FUNÇÃO MEDIDA: `color.<propriedade-dele>.<luminosidade 0..100>`.
|
|
145
|
+
*
|
|
146
|
+
* `counted` é quantas ocorrências o SCAN viu para este literal (`r.count` no plano). Se o passe
|
|
147
|
+
* alcançou menos do que isso, parte dos usos está fora de qualquer propriedade de estilo e a
|
|
148
|
+
* contagem é parcial: `unknown` é estado, e uma contagem parcial não vira afirmação - nem "uma
|
|
149
|
+
* função só", nem "dois trabalhos".
|
|
150
|
+
*
|
|
151
|
+
* Só fala onde `theirName` e `proposeFromTheirScale` calam - a ordem é de `absorb-plan.ts` (D4).
|
|
152
|
+
* O `because` carrega a contagem E a posição, para ele julgar pela origem e não pela palavra:
|
|
153
|
+
* *"12× in background, never anywhere else · lightness 7 of 100"*.
|
|
154
|
+
*/
|
|
155
|
+
export function proposeFromTheirUse(literal, uses, rootPx, counted) {
|
|
156
|
+
const u = usesOf(literal, uses, rootPx);
|
|
157
|
+
if (!u || u.total < counted)
|
|
158
|
+
return null;
|
|
159
|
+
if (u.per.length !== 1 || u.total < 2)
|
|
160
|
+
return null;
|
|
161
|
+
const root = u.per[0]?.[0] ?? "";
|
|
162
|
+
if (root === SVG)
|
|
163
|
+
return null;
|
|
164
|
+
const position = positionOf(u.key);
|
|
165
|
+
if (position === null)
|
|
166
|
+
return null;
|
|
167
|
+
return {
|
|
168
|
+
path: `color.${root}.${position}`,
|
|
169
|
+
because: `${u.total}× in ${root}, never anywhere else · lightness ${position} of 100`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const WORDS = [
|
|
173
|
+
"",
|
|
174
|
+
"one",
|
|
175
|
+
"two",
|
|
176
|
+
"three",
|
|
177
|
+
"four",
|
|
178
|
+
"five",
|
|
179
|
+
"six",
|
|
180
|
+
"seven",
|
|
181
|
+
"eight",
|
|
182
|
+
"nine",
|
|
183
|
+
];
|
|
105
184
|
/**
|
|
106
|
-
*
|
|
107
|
-
* comentário.
|
|
185
|
+
* POR QUE O CAMPO FICOU EM BRANCO, em uma linha - a lacuna declarada em vez de calada.
|
|
108
186
|
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
187
|
+
* *"4× background · 1× border - two jobs, no single name"* é a leitura que o cliente consegue agir
|
|
188
|
+
* sobre: ou o valor é dois tokens, ou é um e ele diz qual. A linha de motivo é verdadeira nas MESMAS
|
|
189
|
+
* condições da proposta - e quando a contagem não alcança, ela diz que não alcança.
|
|
111
190
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* e a contagem por propriedade opera sobre uma amostra que não é a contagem real. A proposta então
|
|
115
|
-
* afirmaria *"todos os seus 2 usos são texto"* sobre um valor que também é borda.
|
|
191
|
+
* NUNCA MENTE SOZINHA: devolve `null` quando `proposeFromTheirUse` propõe para o mesmo literal, para
|
|
192
|
+
* que o guard não more só em quem chama.
|
|
116
193
|
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
194
|
+
* E NENHUMA COR FICA EM BRANCO CALADA. Medido no `web-subscribe` real em 08/09: de 644 cores, 55
|
|
195
|
+
* saíam sem contagem nenhuma - `let strokes = ['#26bcfd', …]`, `export const boulder = '#797979'`,
|
|
196
|
+
* `@mixin nav-divider($color: #e5e5e5)` - e o campo em branco não dizia nada. Um valor que nunca
|
|
197
|
+
* está dentro de uma propriedade de estilo não exerce função: é declarado ou calculado, não pintado,
|
|
198
|
+
* e isso é um motivo, não um silêncio. Ou a cor tem proposta, ou tem `≈`, ou tem motivo.
|
|
121
199
|
*/
|
|
200
|
+
export function whyNotProposedFromUse(literal, uses, rootPx, counted) {
|
|
201
|
+
if (proposeFromTheirUse(literal, uses, rootPx, counted))
|
|
202
|
+
return null;
|
|
203
|
+
const u = usesOf(literal, uses, rootPx);
|
|
204
|
+
if (!u)
|
|
205
|
+
return "never inside a style property - declared or computed, not painted";
|
|
206
|
+
if (u.total < counted)
|
|
207
|
+
return `read ${u.total} of ${counted} uses - the rest sit outside a style property`;
|
|
208
|
+
if (u.per.length > 1) {
|
|
209
|
+
const jobs = WORDS[u.per.length] ?? String(u.per.length);
|
|
210
|
+
return `${u.per.map(([root, n]) => `${n}× ${root}`).join(" · ")} - ${jobs} jobs, no single name`;
|
|
211
|
+
}
|
|
212
|
+
if (u.total < 2)
|
|
213
|
+
return "seen once";
|
|
214
|
+
const root = u.per[0]?.[0] ?? "";
|
|
215
|
+
if (root === SVG)
|
|
216
|
+
return "only in svg";
|
|
217
|
+
return `${u.total}× in ${root}, but the value does not read as a colour`;
|
|
218
|
+
}
|
package/dist/sheet-needed.js
CHANGED
|
@@ -72,6 +72,82 @@ function attributeExpressions(source) {
|
|
|
72
72
|
}
|
|
73
73
|
return out;
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* A DECLARAÇÃO DE UMA CONST QUE O ATRIBUTO USA - o salto que faltava, e a mesma regra do atributo.
|
|
77
|
+
*
|
|
78
|
+
* O QUE FICAVA DE FORA, e estava declarado no contrato com o número: a tabela de variantes mora
|
|
79
|
+
* numa const separada e chega ao atributo por VARIÁVEL. O caso real, medido no `frontend-hub`:
|
|
80
|
+
*
|
|
81
|
+
* ```
|
|
82
|
+
* const buttonVariants = cva("… rounded-[var(--radius-md)] …", { variants: { … } });
|
|
83
|
+
* …
|
|
84
|
+
* className={twUtils.cn(buttonVariants({ variant, size, className }))}
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* As classes estão na declaração, não no atributo - então o leitor de 08/09, que já colhia toda
|
|
88
|
+
* string DENTRO da expressão, via `buttonVariants(...)` e não via nenhuma classe. Medido nas duas
|
|
89
|
+
* árvores: **4 + 41** declarações `cva(` e **0 + 85** `tv(`.
|
|
90
|
+
*
|
|
91
|
+
* A REGRA CONTINUA SENDO A MESMA, um salto adiante: o atributo diz quais identificadores importam,
|
|
92
|
+
* e a declaração daqueles identificadores é lida no mesmo arquivo. Nada aqui conhece `cva`, `tv` ou
|
|
93
|
+
* `cn` - o próximo cliente escreve a tabela dele num objeto pelado, e cai na mesma regra.
|
|
94
|
+
*
|
|
95
|
+
* E O ERRO CONTINUA CAINDO PARA O LADO SEGURO: uma const que não é classe - um rótulo, uma
|
|
96
|
+
* mensagem - entrega palavras que o `@theme` não declara, então elas somem na peneira final. O
|
|
97
|
+
* cabeçalho deste módulo diz que entre errar cobrando e errar calando, cobrar é o erro que a pessoa
|
|
98
|
+
* percebe.
|
|
99
|
+
*/
|
|
100
|
+
function declarationsBehind(source, exprs) {
|
|
101
|
+
/**
|
|
102
|
+
* TODO IDENTIFICADOR DA EXPRESSÃO, e não só os que ela CHAMA.
|
|
103
|
+
*
|
|
104
|
+
* A primeira versão pegava `nome(`, e o objeto pelado - `pick(byTone, tone)` - passa a tabela
|
|
105
|
+
* como ARGUMENTO. O que decide é o atributo mencionar o identificador; se ele é chamado ou
|
|
106
|
+
* passado é sintaxe, e sintaxe é o que muda de projeto para projeto.
|
|
107
|
+
*/
|
|
108
|
+
const names = new Set();
|
|
109
|
+
for (const expr of exprs)
|
|
110
|
+
for (const m of expr.matchAll(/\b([A-Za-z_$][\w$]*)\b/g))
|
|
111
|
+
names.add(m[1]);
|
|
112
|
+
if (names.size === 0)
|
|
113
|
+
return [];
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const name of names) {
|
|
116
|
+
/** `const <nome> = ` - e o valor vai até a linha que fecha no MESMO nível de indentação. */
|
|
117
|
+
const at = source.search(new RegExp(`\\b(?:const|let|var)\\s+${name}\\s*=`));
|
|
118
|
+
if (at < 0)
|
|
119
|
+
continue;
|
|
120
|
+
let depth = 0;
|
|
121
|
+
let i = source.indexOf("=", at);
|
|
122
|
+
let quote = null;
|
|
123
|
+
let started = false;
|
|
124
|
+
for (; i < source.length; i += 1) {
|
|
125
|
+
const c = source[i];
|
|
126
|
+
if (quote) {
|
|
127
|
+
if (c === "\\")
|
|
128
|
+
i += 1;
|
|
129
|
+
else if (c === quote)
|
|
130
|
+
quote = null;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (c === '"' || c === "'" || c === "`")
|
|
134
|
+
quote = c;
|
|
135
|
+
else if (c === "(" || c === "{" || c === "[") {
|
|
136
|
+
depth += 1;
|
|
137
|
+
started = true;
|
|
138
|
+
}
|
|
139
|
+
else if (c === ")" || c === "}" || c === "]") {
|
|
140
|
+
depth -= 1;
|
|
141
|
+
if (started && depth <= 0)
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
else if (c === ";" && !started)
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
out.push(source.slice(at, i + 1));
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
75
151
|
/** As classes escritas no código - `hover:animate-shimmer` conta como `animate-shimmer`. */
|
|
76
152
|
function classesIn(source) {
|
|
77
153
|
const out = new Set();
|
|
@@ -89,9 +165,16 @@ function classesIn(source) {
|
|
|
89
165
|
* E TODA STRING DENTRO DA EXPRESSÃO - ver `attributeExpressions`. As três formas de escrever uma
|
|
90
166
|
* string em JS/TS, porque quem escolhe a aspa é o formatador dele, não a gente.
|
|
91
167
|
*/
|
|
92
|
-
|
|
93
|
-
|
|
168
|
+
const exprs = attributeExpressions(source);
|
|
169
|
+
const strings = (text) => {
|
|
170
|
+
for (const lit of text.matchAll(/"([^"]*)"|'([^']*)'|`([^`]*)`/g))
|
|
94
171
|
collect(lit[1] ?? lit[2] ?? lit[3] ?? "");
|
|
172
|
+
};
|
|
173
|
+
for (const expr of exprs)
|
|
174
|
+
strings(expr);
|
|
175
|
+
/** E a declaração de quem o atributo chama - ver `declarationsBehind`. */
|
|
176
|
+
for (const decl of declarationsBehind(source, exprs))
|
|
177
|
+
strings(decl);
|
|
95
178
|
return out;
|
|
96
179
|
}
|
|
97
180
|
/**
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { COLOR } from "./doctor/scan.js";
|
|
2
|
+
import { normalizeValue } from "./doctor/tokens.js";
|
|
3
|
+
/** A raiz que conta como função e NUNCA vira palavra: cor de ícone, não do sistema dele. */
|
|
4
|
+
export const SVG = "svg";
|
|
5
|
+
/**
|
|
6
|
+
* UTILIDADE TAILWIND → PROPRIEDADE CSS QUE ELA ESCREVE. A palavra é a da direita, sempre: `ring` não
|
|
7
|
+
* é propriedade CSS nenhuma - o Tailwind a compila em `box-shadow`, e é isso que o código dele pinta.
|
|
8
|
+
*/
|
|
9
|
+
export const TAILWIND_WRITES = {
|
|
10
|
+
bg: "background",
|
|
11
|
+
from: "background",
|
|
12
|
+
via: "background",
|
|
13
|
+
to: "background",
|
|
14
|
+
text: "color",
|
|
15
|
+
placeholder: "color",
|
|
16
|
+
border: "border",
|
|
17
|
+
divide: "border",
|
|
18
|
+
shadow: "box-shadow",
|
|
19
|
+
ring: "box-shadow",
|
|
20
|
+
"ring-offset": "box-shadow",
|
|
21
|
+
outline: "outline",
|
|
22
|
+
decoration: "text-decoration-color",
|
|
23
|
+
accent: "accent-color",
|
|
24
|
+
caret: "caret-color",
|
|
25
|
+
fill: SVG,
|
|
26
|
+
stroke: SVG,
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* `background:` · `backgroundColor:` · `"background-color":` · `fill="`
|
|
30
|
+
*
|
|
31
|
+
* O identificador não pode vir colado a `.`, `$`, `@`, `-`, letra ou dígito (`colors.dark :` num
|
|
32
|
+
* ternário não é uma propriedade), e a aspa de abertura, quando existe, tem de fechar antes do `:` -
|
|
33
|
+
* senão `"#fff" :` de um ternário viraria a âncora `fff`. O `=` só vale na forma de ATRIBUTO - colado
|
|
34
|
+
* ao nome e seguido de aspa ou `{` -, porque `const BRAND = "#0f5132"` é uma constante, não uma
|
|
35
|
+
* função. O que casa aqui ainda passa por `propertyRoot`: só o que carrega cor vira âncora.
|
|
36
|
+
*/
|
|
37
|
+
const PROPERTY = /(?<![\w.$@#-])(["']?)[a-zA-Z][\w-]*\1(?:\s*:|=(?=["'{]))/g;
|
|
38
|
+
/**
|
|
39
|
+
* `bg-[#…]` · `hover:text-[#…]` · `border-t-[rgba(…)]` · `divide-x-[…]` - as utilidades da tabela,
|
|
40
|
+
* com o sufixo de lado que algumas aceitam.
|
|
41
|
+
*/
|
|
42
|
+
const UTILITY = new RegExp(`(?<![\\w-])(${Object.keys(TAILWIND_WRITES)
|
|
43
|
+
.sort((a, b) => b.length - a.length)
|
|
44
|
+
.join("|")})(?:-[trblxyse])?-\\[`, "g");
|
|
45
|
+
/**
|
|
46
|
+
* A RAIZ DE UMA PROPRIEDADE CSS QUE CARREGA COR (D1) - ou `null` para o que não é uma. A palavra
|
|
47
|
+
* que ele escreveu, sem o sufixo que só diz o lado ou o dialeto: `background-color`,
|
|
48
|
+
* `backgroundColor` e `background-image` são `background`; `border-top-color` e `borderColor` são
|
|
49
|
+
* `border`. Uma chave de objeto, uma variável, uma propriedade que não pinta: `null`.
|
|
50
|
+
*/
|
|
51
|
+
export function propertyRoot(raw) {
|
|
52
|
+
const kebab = raw
|
|
53
|
+
.trim()
|
|
54
|
+
.replace(/^["']|["']$/g, "")
|
|
55
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
56
|
+
.toLowerCase();
|
|
57
|
+
if (/^background(-|$)/.test(kebab))
|
|
58
|
+
return "background";
|
|
59
|
+
if (/^border(-|$)/.test(kebab))
|
|
60
|
+
return "border";
|
|
61
|
+
if (kebab === "color")
|
|
62
|
+
return "color";
|
|
63
|
+
if (/^outline(-|$)/.test(kebab))
|
|
64
|
+
return "outline";
|
|
65
|
+
if (kebab === "box-shadow")
|
|
66
|
+
return "box-shadow";
|
|
67
|
+
if (kebab === "text-shadow")
|
|
68
|
+
return "text-shadow";
|
|
69
|
+
if (/^(fill|stroke|stop-color|flood-color|lighting-color)$/.test(kebab))
|
|
70
|
+
return SVG;
|
|
71
|
+
if (/^(caret-color|accent-color|text-decoration-color|text-emphasis-color|column-rule-color)$/.test(kebab))
|
|
72
|
+
return kebab;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/** Toda âncora da linha que PINTA, com onde ela termina - a mais próxima antes do literal é a dele. */
|
|
76
|
+
function anchorsOf(line) {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const m of line.matchAll(PROPERTY)) {
|
|
79
|
+
const root = propertyRoot(m[0].replace(/\s*[:=]$/, ""));
|
|
80
|
+
if (root !== null)
|
|
81
|
+
out.push({ end: m.index + m[0].length, root });
|
|
82
|
+
}
|
|
83
|
+
for (const m of line.matchAll(UTILITY)) {
|
|
84
|
+
const root = TAILWIND_WRITES[m[1] ?? ""];
|
|
85
|
+
if (root)
|
|
86
|
+
out.push({ end: m.index + m[0].length, root });
|
|
87
|
+
}
|
|
88
|
+
return out.sort((a, b) => a.end - b.end);
|
|
89
|
+
}
|
|
90
|
+
/** A linha FECHOU a declaração: o que vier depois não é continuação dela. */
|
|
91
|
+
const ENDS_DECLARATION = /[;{}]\s*$/;
|
|
92
|
+
/**
|
|
93
|
+
* Conta cada ocorrência de cor de UM arquivo, linha a linha, na função da âncora mais próxima
|
|
94
|
+
* antes dela. `box-shadow: 0 1px #000, 0 2px #fff` conta as duas em `box-shadow`; uma linha com
|
|
95
|
+
* `background` e `borderColor` do mesmo hex conta as duas funções. Acumula em `into` para que o
|
|
96
|
+
* repositório inteiro caiba num único mapa.
|
|
97
|
+
*
|
|
98
|
+
* A DECLARAÇÃO PODE CONTINUAR NA LINHA SEGUINTE - regra do CSS, não de um repositório:
|
|
99
|
+
*
|
|
100
|
+
* box-shadow:
|
|
101
|
+
* 1px 1px 0 0 #2b2927,
|
|
102
|
+
* 2px 2px 0 0 #2b2927;
|
|
103
|
+
*
|
|
104
|
+
* Medido no `web-subscribe` real em 08/09 (`framework/style/_term.scss`): 9 ocorrências assim, sem
|
|
105
|
+
* âncora na própria linha, saíam sem função nenhuma - e o campo em branco saía calado. Quando a linha
|
|
106
|
+
* não tem âncora e a anterior não FECHOU a declaração (não termina em `;`, `}` ou `{`), a âncora da
|
|
107
|
+
* anterior vale para esta. `background: #111;` seguido de `#222` NÃO herda: o `;` fechou.
|
|
108
|
+
*/
|
|
109
|
+
export function countUseByProperty(source, into, rootPx) {
|
|
110
|
+
/** A âncora da declaração ainda aberta, vinda das linhas anteriores. */
|
|
111
|
+
let carried = null;
|
|
112
|
+
for (const line of source.split("\n")) {
|
|
113
|
+
const anchors = anchorsOf(line);
|
|
114
|
+
for (const m of line.matchAll(COLOR)) {
|
|
115
|
+
/** A âncora mais próxima que termina ANTES do literal - ou a herdada, se a linha não tem. */
|
|
116
|
+
let root = anchors.length === 0 ? carried : null;
|
|
117
|
+
for (const a of anchors) {
|
|
118
|
+
if (a.end > m.index)
|
|
119
|
+
break;
|
|
120
|
+
root = a.root;
|
|
121
|
+
}
|
|
122
|
+
if (root === null)
|
|
123
|
+
continue;
|
|
124
|
+
const key = normalizeValue(m[0], rootPx);
|
|
125
|
+
const per = into.get(key) ?? new Map();
|
|
126
|
+
per.set(root, (per.get(root) ?? 0) + 1);
|
|
127
|
+
into.set(key, per);
|
|
128
|
+
}
|
|
129
|
+
if (ENDS_DECLARATION.test(line))
|
|
130
|
+
carried = null;
|
|
131
|
+
else if (anchors.length > 0)
|
|
132
|
+
carried = anchors[anchors.length - 1]?.root ?? null;
|
|
133
|
+
}
|
|
134
|
+
}
|
package/package.json
CHANGED