synthesisui 0.16.506 → 0.16.507

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.
@@ -0,0 +1,114 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { readToken, resolveRegistry } from "../config.js";
4
+ import { parseDesignMd, readingOf, scoreOf, valuePoints, } from "../reading/fidelity-values.js";
5
+ import { PAGE_CENSUS } from "../reading/page-census.js";
6
+ import { HIDE_OVERLAYS, PAGE_LANGUAGE } from "../reading/page-language.js";
7
+ import { readSite } from "../reading/read-page.js";
8
+ import { findServer, launch, loadReader } from "./read.js";
9
+ /**
10
+ * `synthesisui fidelity` - O PLACAR DE FIDELIDADE (jornada "o placar de fidelidade", E04, 23/10).
11
+ *
12
+ * A página que o agente está construindo, lida no dev server do projeto e comparada com a linguagem do site de origem -
13
+ * a leitura que viaja dentro do DESIGN.md. Os VALORES (tipo, cor, botão, escala, respiro, largura) se comparam aqui, na
14
+ * máquina da pessoa; a COMPOSIÇÃO (os padrões do site, vistos nas fotos da página) é julgada no site, que tem o modelo,
15
+ * e por isso pede login. Cada ponto que não bate vem com a instrução de conserto, para o agente seguir.
16
+ *
17
+ * Lê o projeto e o navegador; não escreve nada.
18
+ */
19
+ export async function fidelity(opts) {
20
+ const root = resolve(opts.dir ?? ".");
21
+ const say = (s) => console.log(s);
22
+ const designPath = resolve(root, opts.design ?? "DESIGN.md");
23
+ if (!existsSync(designPath)) {
24
+ say(`No DESIGN.md here (${designPath}). Read a website on synthesisui.com, download its DESIGN.md into the project, and run this again.`);
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ const design = parseDesignMd(readFileSync(designPath, "utf8"));
29
+ if (!design.reading) {
30
+ say("This DESIGN.md has no reading inside, so there is nothing to compare against. Download it again from synthesisui.com (Start from a website) - the new ones carry the reading.");
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+ const briefPath = resolve(root, opts.brief ?? "PRODUCT.md");
35
+ const brief = existsSync(briefPath) ? readFileSync(briefPath, "utf8") : "";
36
+ const base = await findServer(opts.url, root);
37
+ if (!base) {
38
+ say("No dev server answered (tried :3000, :3001, :5173, :4321). Start it - `npm run dev` - and run `synthesisui fidelity` again, or pass `--url`.");
39
+ process.exitCode = 1;
40
+ return;
41
+ }
42
+ const pw = loadReader(say);
43
+ const browser = pw ? await launch(pw, say) : null;
44
+ if (!browser) {
45
+ say("No browser to read the page with.");
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+ const at = new URL(base);
50
+ const site = await readSite(browser, at.origin, [at.pathname || "/"], { skipPixels: true, settleMs: 15_000 });
51
+ const page = site.pages.get(`${at.pathname || "/"}|desktop`);
52
+ await page.evaluate(HIDE_OVERLAYS).catch(() => null);
53
+ const height = Number(await page.evaluate("document.documentElement.scrollHeight")) || 900;
54
+ const step = Math.max(900, Math.floor((height - 900) / 7));
55
+ const tiles = [];
56
+ for (let i = 0; i < 8; i++) {
57
+ await page.evaluate(`window.scrollTo(0, ${Math.min(i * step, Math.max(0, height - 900))})`);
58
+ await page.waitForTimeout(700);
59
+ tiles.push(`data:image/jpeg;base64,${(await page.screenshot({ type: "jpeg", quality: 60 })).toString("base64")}`);
60
+ }
61
+ await page.evaluate("window.scrollTo(0, 0)");
62
+ const language = (await page.evaluate(PAGE_LANGUAGE));
63
+ const census = (await page.evaluate(PAGE_CENSUS));
64
+ const mine = readingOf(language, census, { steps: site.reading.passos, sectionTitle: site.reading.tituloSecao });
65
+ await site.close();
66
+ await browser.close();
67
+ const values = valuePoints(design.reading, mine);
68
+ let composition = [];
69
+ let compositionNote = "";
70
+ const token = await readToken();
71
+ if (!design.patterns.length)
72
+ compositionNote = "This DESIGN.md lists no patterns, so composition was not judged.";
73
+ else if (!token)
74
+ compositionNote = "Composition was not judged: it runs on synthesisui.com and needs `synthesisui login`.";
75
+ else {
76
+ const res = await fetch(`${resolveRegistry(opts.registry)}/api/fidelity`, {
77
+ method: "POST",
78
+ headers: { "content-type": "application/json", Authorization: `Bearer ${token}` },
79
+ body: JSON.stringify({ patterns: design.patterns, tiles, brief }),
80
+ }).catch(() => null);
81
+ const got = res?.ok ? (await res.json()) : null;
82
+ if (got?.points)
83
+ composition = got.points;
84
+ else
85
+ compositionNote = `Composition was not judged: synthesisui.com answered ${res ? res.status : "nothing"}.`;
86
+ }
87
+ const score = scoreOf(values, composition);
88
+ if (opts.json) {
89
+ console.log(JSON.stringify({ source: design.source, page: base, score, values, composition, note: compositionNote || undefined }, null, 2));
90
+ return;
91
+ }
92
+ say(`Fidelity of ${base} to ${design.source ?? "the DESIGN.md"}`);
93
+ say(` values ${score.values} / ${score.valuesOf}`);
94
+ say(` composition ${composition.length ? `${score.composition} / ${score.compositionOf}` : "not judged"}`);
95
+ const off = values.filter((v) => v.state !== "igual");
96
+ if (off.length) {
97
+ say("\nValues that differ - fix these:");
98
+ for (const v of off)
99
+ say(` - ${v.label}: ${v.state === "parcial" ? "close" : "different"} (source ${v.site}, page ${v.page}). ${v.fix}`);
100
+ }
101
+ const missing = composition.filter((c) => c.state === "parcial" || c.state === "ausente");
102
+ if (missing.length) {
103
+ say("\nPatterns of the source the page does not follow yet:");
104
+ for (const c of missing)
105
+ say(` - ${c.name} (${c.state === "parcial" ? "partly" : "missing"}): ${c.fix || c.rule}`);
106
+ }
107
+ const out = composition.filter((c) => c.state === "fora");
108
+ if (out.length)
109
+ say(`\nNot counted - your brief rules them out: ${out.map((c) => `${c.name} (${c.evidence})`).join("; ")}.`);
110
+ if (compositionNote)
111
+ say(`\n${compositionNote}`);
112
+ if (!off.length && !missing.length && composition.length)
113
+ say("\nThe page follows every measured value and pattern of the source.");
114
+ }
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { absorb } from "./commands/absorb.js";
7
7
  import { add } from "./commands/add.js";
8
8
  import { adopt } from "./commands/adopt.js";
9
9
  import { confirm, read } from "./commands/read.js";
10
+ import { fidelity } from "./commands/fidelity.js";
10
11
  import { advise } from "./commands/advise.js";
11
12
  import { align } from "./commands/align.js";
12
13
  import { autopilot } from "./commands/autopilot.js";
@@ -73,6 +74,9 @@ Usage - governance (deterministic, FREE):
73
74
  where one is built from another - then the values you repeat,
74
75
  with file and line. No account, no network, nothing written
75
76
  synthesisui read [--url <dev server>] [--routes /,/x]
77
+ synthesisui fidelity [--url <dev server>] [--design DESIGN.md] [--json]
78
+ score the page you are building against the website its DESIGN.md
79
+ was read from: every value and pattern that does not match yet, with the fix
76
80
  synthesisui read --confirm [answers.json] the person's answers become the roles and breaks the check keeps
77
81
  read the page you already built and say the system it decided:
78
82
  roles, breaks, drift and contrast, with file and line
@@ -330,6 +334,18 @@ async function main() {
330
334
  routes: typeof flags.routes === "string" ? flags.routes.split(",").map((r) => r.trim()).filter(Boolean) : undefined,
331
335
  });
332
336
  break;
337
+ case "fidelity":
338
+ // The page being built, read on the dev server and scored against the source site's language (the reading inside
339
+ // DESIGN.md). Values compare here; composition is judged on the site (needs login). Writes nothing.
340
+ await fidelity({
341
+ dir,
342
+ url: typeof flags.url === "string" ? flags.url : undefined,
343
+ design: typeof flags.design === "string" ? flags.design : undefined,
344
+ brief: typeof flags.brief === "string" ? flags.brief : undefined,
345
+ registry: typeof flags.registry === "string" ? flags.registry : undefined,
346
+ json: flags.json === true,
347
+ });
348
+ break;
333
349
  case "adopt":
334
350
  // Dry by default: `--write` is the only way anything lands on disk.
335
351
  await adopt({
@@ -0,0 +1,192 @@
1
+ /** Os papéis de texto nos nomes do impeccable, do maior para o menor - um por faixa, o que carrega mais texto nela. */
2
+ export function namedType(type) {
3
+ const bands = [
4
+ ["hero", (r) => r.size >= 56],
5
+ ["display", (r) => r.size >= 40 && r.size < 56],
6
+ ["headline", (r) => r.size >= 26 && r.size < 40],
7
+ ["title", (r) => r.size >= 19 && r.size < 26],
8
+ ["body", (r) => r.size >= 15 && r.size < 19],
9
+ ["caption", (r) => r.size >= 13 && r.size < 15],
10
+ ["micro", (r) => r.size < 13],
11
+ ];
12
+ const out = [];
13
+ for (const [name, inBand] of bands) {
14
+ /** Um título de verdade (h1-h3) vence o texto grande que não é título - na Apple, o 48 px dos títulos de seção
15
+ * perdia para os números de 40 px, que carregam mais texto (medido em 22/10). */
16
+ const heading = (r) => (/^h[1-3]$/.test(r.tag ?? "") ? 1 : 0);
17
+ const pick = type.filter(inBand).sort((a, b) => heading(b) - heading(a) || b.chars - a.chars)[0];
18
+ if (pick)
19
+ out.push([name, pick]);
20
+ }
21
+ return out;
22
+ }
23
+ export const sat = (hex) => {
24
+ if (!hex || !/^#[0-9a-f]{6}/i.test(hex))
25
+ return 0;
26
+ const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
27
+ const mx = Math.max(...c);
28
+ return mx === 0 ? 0 : (mx - Math.min(...c)) / mx;
29
+ };
30
+ /** Cada forma de componente com um nome que diz o que ela é: a cheia com cor é a ação; a contornada, a secundária. */
31
+ export function namedComponents(list) {
32
+ const seen = new Map();
33
+ const name = (c) => {
34
+ const filled = c.bg && c.bg !== "transparent";
35
+ const pill = c.radius >= c.height / 2 - 1;
36
+ const base = filled && sat(c.bg) > 0.35
37
+ ? "button-primary"
38
+ : filled
39
+ ? pill
40
+ ? "pill-quiet"
41
+ : "button-quiet"
42
+ : c.border
43
+ ? "button-outline"
44
+ : pill
45
+ ? "tab-pill"
46
+ : "link-shape";
47
+ const n = (seen.get(base) ?? 0) + 1;
48
+ seen.set(base, n);
49
+ return n === 1 ? base : `${base}-${n}`;
50
+ };
51
+ /** Só o que tem texto e tamanho de botão: os ícones sem rótulo e os pontinhos de carrossel (8 px) não são componentes
52
+ * que outro produto copia pela forma. */
53
+ return list
54
+ .filter((c) => c.samples.length > 0 && c.height >= 20)
55
+ .map((c) => ({ ...c, border: c.border === "transparent" ? null : c.border }))
56
+ .slice(0, 8)
57
+ .map((c) => [name(c), c]);
58
+ }
59
+ /** A cor que carrega mais texto nos h1 e h2 medidos - a cor dos títulos (ver o comentário em `designMdOf`). */
60
+ /**
61
+ * A cor dos títulos que se leem NO FUNDO DA PÁGINA: os títulos brancos das faixas escuras do Stripe têm mais texto que os
62
+ * marinho, e venciam por volume (23/10). Com o fundo dado, só conta a cor que tem 3 : 1 ou mais contra ele.
63
+ */
64
+ export function headingInkOf(language, ground) {
65
+ const lum = (h) => { const c = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((x) => (x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4)); return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; };
66
+ const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
67
+ const readable = (c) => !ground || !/^#[0-9a-f]{6}/i.test(ground) || contrast(c.slice(0, 7), ground.slice(0, 7)) >= 3;
68
+ const byColor = new Map();
69
+ for (const r of language.type)
70
+ if (/^h[12]$/.test(r.tag ?? "") && r.color && /^#[0-9a-f]{6}$/i.test(r.color) && readable(r.color))
71
+ byColor.set(r.color, (byColor.get(r.color) ?? 0) + r.chars);
72
+ return [...byColor.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
73
+ }
74
+ /**
75
+ * A LEITURA A PARTIR DA LINGUAGEM, igual dos dois lados: a escala são os tamanhos dos papéis de texto, e o título de
76
+ * seção é o h2 que carrega mais texto (ou, sem h2, o maior papel que se repete). Derivar dos dois lados do mesmo jeito é o
77
+ * que faz a comparação valer - o site e a página passam pela mesma régua.
78
+ */
79
+ export function readingOf(language, census,
80
+ /** A escala e o título de seção da CLASSIFICAÇÃO (`classify`), quando há: no Stripe o título de seção é um <p> de 32 px,
81
+ * e nenhuma regra por tag o acha - a classificação acha pelo tamanho que se repete. */
82
+ measured) {
83
+ if (measured && measured.sectionTitle > 0)
84
+ return { language, census, steps: measured.steps, sectionTitle: measured.sectionTitle };
85
+ /* texto de verdade (60+ caracteres no papel) ou título - o miúdo de interface que se repete (11, 12, 13 px) não é escala */
86
+ const steps = [...new Set(language.type.filter((r) => (r.count >= 2 && r.chars >= 60) || /^h[1-3]$/.test(r.tag ?? "")).map((r) => Math.round(r.size)))].sort((a, b) => a - b);
87
+ /* o título de seção é o h2 que mais se REPETE (um por seção), e não o que tem mais texto */
88
+ const h2 = language.type.filter((r) => r.tag === "h2").sort((a, b) => b.count - a.count || b.chars - a.chars)[0];
89
+ const repeated = language.type.filter((r) => r.count >= 2).sort((a, b) => b.size - a.size)[0];
90
+ return { language, census, steps, sectionTitle: Math.round(h2?.size ?? repeated?.size ?? 0) };
91
+ }
92
+ const near = (a, b, ok, part) => {
93
+ if (a == null || b == null)
94
+ return null;
95
+ const d = Math.abs(a - b);
96
+ return d <= ok ? "igual" : d <= part ? "parcial" : "diferente";
97
+ };
98
+ const rel = (a, b, ok, part) => {
99
+ if (a == null || b == null || a === 0)
100
+ return null;
101
+ const d = Math.abs(a - b) / a;
102
+ return d <= ok ? "igual" : d <= part ? "parcial" : "diferente";
103
+ };
104
+ const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
105
+ const hexy = (h) => !!h && /^#[0-9a-f]{6}/i.test(h);
106
+ const colorState = (a, b) => {
107
+ if (!hexy(a) || !hexy(b))
108
+ return null;
109
+ const [x, y] = [rgb(a.slice(0, 7)), rgb(b.slice(0, 7))];
110
+ const d = Math.sqrt(x.reduce((n, v, i) => n + (v - y[i]) ** 2, 0));
111
+ return d <= 6 ? "igual" : d <= 24 ? "parcial" : "diferente";
112
+ };
113
+ const lh = (r) => r?.lineHeight ? Math.round((r.lineHeight / r.size) * 100) / 100 : null;
114
+ /** O papel de tipo do título de seção: o do tamanho medido, de preferência num h2. */
115
+ function sectionRole(l, px) {
116
+ const same = l.type.filter((r) => Math.abs(r.size - px) <= 1);
117
+ return same.find((r) => r.tag === "h2") ?? same[0];
118
+ }
119
+ export function valuePoints(site, page) {
120
+ const out = [];
121
+ const push = (key, label, s, p, state, fix) => {
122
+ if (state)
123
+ out.push({ key, label, site: s, page: p, state, fix: state === "igual" ? "" : fix });
124
+ };
125
+ const sType = new Map(namedType(site.language.type));
126
+ const pType = new Map(namedType(page.language.type));
127
+ const sh = sType.get("hero"), ph = pType.get("hero");
128
+ if (sh && ph) {
129
+ const st = [near(sh.size, ph.size, 2, 8), sh.weight === ph.weight ? "igual" : "diferente", near(sh.letterSpacingEm, ph.letterSpacingEm, 0.006, 0.015)];
130
+ const worst = st.includes("diferente") ? "diferente" : st.includes("parcial") ? "parcial" : "igual";
131
+ push("hero", "Cover title", `${sh.size} px · ${sh.weight} · ${sh.letterSpacingEm}em`, `${ph.size} px · ${ph.weight} · ${ph.letterSpacingEm}em`, worst, `Set the cover title at ${sh.size}px, weight ${sh.weight}, letter-spacing ${sh.letterSpacingEm}em.`);
132
+ }
133
+ push("section-title", "Section title", `${site.sectionTitle} px`, `${page.sectionTitle} px`, near(site.sectionTitle, page.sectionTitle, 1, 4), `Section titles are ${site.sectionTitle}px.`);
134
+ const ss = sectionRole(site.language, site.sectionTitle), ps = sectionRole(page.language, page.sectionTitle);
135
+ push("title-leading", "Title line-height", `${lh(ss) ?? "-"}`, `${lh(ps) ?? "-"}`, near(lh(ss), lh(ps), 0.05, 0.12), `Section titles use a line-height of ${lh(ss)} (tight) - the page has ${lh(ps)}.`);
136
+ const sb = sType.get("body"), pb = pType.get("body");
137
+ if (sb && pb)
138
+ push("body", "Body text", `${sb.size} px · ${sb.weight} · line ${lh(sb)}`, `${pb.size} px · ${pb.weight} · line ${lh(pb)}`, near(sb.size, pb.size, 1, 2) === "igual" && sb.weight === pb.weight ? near(lh(sb), lh(pb), 0.08, 0.2) : "diferente", `Body text is ${sb.size}px, weight ${sb.weight}, line-height ${lh(sb)}.`);
139
+ const shi = headingInkOf(site.language, site.census.roles?.canvas ?? site.census.ground);
140
+ const phi = headingInkOf(page.language, page.census.roles?.canvas ?? page.census.ground);
141
+ push("heading-ink", "Title colour", shi ?? "-", phi ?? "-", colorState(shi, phi), `Titles are ${shi}, not ${phi}.`);
142
+ const sr = site.census.roles ?? {}, pr = page.census.roles ?? {};
143
+ push("ink", "Text colour", sr.foreground ?? "-", pr.foreground ?? "-", colorState(sr.foreground, pr.foreground), `Running text is ${sr.foreground}.`);
144
+ push("canvas", "Page ground", sr.canvas ?? site.census.ground, pr.canvas ?? page.census.ground, colorState(sr.canvas ?? site.census.ground, pr.canvas ?? page.census.ground), `The page ground is ${sr.canvas ?? site.census.ground}.`);
145
+ push("hairline", "Hairline", sr.border ?? "-", pr.border ?? "-", colorState(sr.border, pr.border), `Rules and borders are ${sr.border}.`);
146
+ const sbtn = namedComponents(site.language.components).find(([k]) => k === "button-primary")?.[1];
147
+ const pbtn = namedComponents(page.language.components).find(([k]) => k === "button-primary")?.[1];
148
+ if (sbtn && pbtn) {
149
+ const st = [colorState(sbtn.bg, pbtn.bg), near(sbtn.radius, pbtn.radius, 1, 4), near(sbtn.height, pbtn.height, 4, 10)];
150
+ const worst = st.includes("diferente") ? "diferente" : st.includes("parcial") || st.includes(null) ? "parcial" : "igual";
151
+ push("button", "Primary button", `${sbtn.bg} · corners ${sbtn.radius} · ${sbtn.height} px`, `${pbtn.bg} · corners ${pbtn.radius} · ${pbtn.height} px`, worst, `The primary button is ${sbtn.bg}, ${sbtn.radius}px corners, ${sbtn.height}px tall.`);
152
+ }
153
+ const missing = site.steps.filter((x) => !page.steps.some((y) => Math.abs(x - y) <= 1));
154
+ const share = site.steps.length ? 1 - missing.length / site.steps.length : 1;
155
+ push("scale", "Type scale", site.steps.join(" · "), page.steps.join(" · "), share >= 0.9 ? "igual" : share >= 0.6 ? "parcial" : "diferente", `The type scale also uses ${missing.join(", ")} px.`);
156
+ const srh = site.language.rhythm, prh = page.language.rhythm;
157
+ push("section-gap", "Space between sections", `${srh?.sectionGap ?? "-"} px`, `${prh?.sectionGap ?? "-"} px`, rel(srh?.sectionGap, prh?.sectionGap, 0.2, 0.4), `Sections are about ${srh?.sectionGap}px apart (the page has ${prh?.sectionGap}px).`);
158
+ push("inner-gap", "Space inside sections", `${srh?.innerGap ?? "-"} px`, `${prh?.innerGap ?? "-"} px`, rel(srh?.innerGap, prh?.innerGap, 0.25, 0.5), `Blocks inside a section sit about ${srh?.innerGap}px apart (the page has ${prh?.innerGap}px).`);
159
+ push("content-width", "Content width", `${srh?.contentWidth ?? "-"} px`, `${prh?.contentWidth ?? "-"} px`, rel(srh?.contentWidth, prh?.contentWidth, 0.05, 0.12), `Content sits in a ${srh?.contentWidth}px column.`);
160
+ return out;
161
+ }
162
+ export function scoreOf(values, composition) {
163
+ const v = values.reduce((n, p) => n + (p.state === "igual" ? 1 : p.state === "parcial" ? 0.5 : 0), 0);
164
+ const allowed = composition.filter((p) => p.state !== "fora");
165
+ const c = allowed.reduce((n, p) => n + (p.state === "presente" ? 1 : p.state === "parcial" ? 0.5 : 0), 0);
166
+ return { values: v, valuesOf: values.length, composition: c, compositionOf: allowed.length };
167
+ }
168
+ /**
169
+ * A LEITURA DO SITE VIAJA DENTRO DO DESIGN.md (E04, 23/10), num comentário que o impeccable ignora. O placar compara a
170
+ * página do projeto com ELA - sem reler o site a cada chamada (uns 90 s) e com a lista de padrões FIXA: relidos a cada
171
+ * vez, os padrões mudavam, e a nota da mesma página foi de 3/7 a 0,5/10 (medido em 23/10).
172
+ */
173
+ /** A marca que o site escreve no DESIGN.md (`embedReading`, em apps/web/src/lib/engine/design-md.ts). */
174
+ const MARK = "synthesisui:reading v1";
175
+ export function parseDesignMd(md) {
176
+ let reading = null;
177
+ let source = null;
178
+ const m = md.match(new RegExp(`<!-- ${MARK} ([A-Za-z0-9+/=]+) -->`));
179
+ if (m) {
180
+ try {
181
+ const got = JSON.parse(Buffer.from(m[1], "base64").toString("utf8"));
182
+ source = got.source;
183
+ reading = readingOf(got.language, got.census, got.measured);
184
+ }
185
+ catch {
186
+ reading = null;
187
+ }
188
+ }
189
+ const section = md.split(/^## Patterns\s*$/m)[1]?.split(/^## /m)[0] ?? "";
190
+ const patterns = [...section.matchAll(/^- \*\*(.+?)\*\* - (.+)$/gm)].map((x) => ({ name: x[1].trim(), rule: x[2].trim() }));
191
+ return { source, reading, patterns };
192
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * O CENSO DE UMA PÁGINA RENDERIZADA - o que ela pinta, além do que ela escreve (jornada "a porta por URL", E05, 21/10).
3
+ *
4
+ * A leitura (`page-script.ts`) mede o TEXTO: tamanho, papel, contraste. Um design system precisa também das cores, das
5
+ * famílias de fonte, dos cantos e dos espaços - e uma URL pública não tem código para o `import` contar. Então se conta
6
+ * o que o navegador calculou: cada valor, quantas vezes ele aparece nos elementos visíveis. É a mesma forma do censo
7
+ * do `import` (`kind`, `value`, `count`), para o site levar ao mesmo caminho que faz nascer um sistema de um projeto.
8
+ *
9
+ * `declared` são as variáveis CSS que a raiz da página declara (`--brand: #5e6ad2`): o nome é o sinal mais forte que o
10
+ * `import` sabe ler. `scheme` é a face, pelo fundo da página.
11
+ *
12
+ * Roda no navegador, como texto: não importa nada. Morava só no site (21/10: o CLI não o chamava, e `reachable.spec.ts`
13
+ * reprova export que o pacote não usa); desde 23/10 o CLI o chama no placar de fidelidade (`fidelity`), que lê a página
14
+ * do projeto no localhost com a mesma régua com que o site leu o site de origem.
15
+ */
16
+ export const PAGE_CENSUS = `(() => {
17
+ const counts = new Map();
18
+ const add = (kind, value) => {
19
+ if (!value) return;
20
+ const k = kind + "|" + value;
21
+ counts.set(k, (counts.get(k) || 0) + 1);
22
+ };
23
+ const hex = (c) => {
24
+ const m = c && c.match(/rgba?\\(([\\d.]+),\\s*([\\d.]+),\\s*([\\d.]+)(?:,\\s*([\\d.]+))?\\)/);
25
+ if (!m) return null;
26
+ const a = m[4] === undefined ? 1 : Number(m[4]);
27
+ if (a === 0) return null;
28
+ const h = (n) => Math.round(Number(n)).toString(16).padStart(2, "0");
29
+ return "#" + h(m[1]) + h(m[2]) + h(m[3]) + (a < 1 ? h(a * 255) : "");
30
+ };
31
+ const px = (v) => (v && v !== "0px" && /^[\\d.]+px$/.test(v) ? Math.round(parseFloat(v)) + "px" : null);
32
+ let scanned = 0;
33
+ for (const el of document.querySelectorAll("body *")) {
34
+ const r = el.getBoundingClientRect();
35
+ if (r.width < 1 || r.height < 1) continue;
36
+ const cs = getComputedStyle(el);
37
+ if (cs.visibility === "hidden" || cs.display === "none" || Number(cs.opacity) === 0) continue;
38
+ scanned++;
39
+ const ownText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
40
+ if (ownText) {
41
+ add("color", hex(cs.color));
42
+ add("font", cs.fontFamily.split(",")[0].replace(/["']/g, "").trim());
43
+ }
44
+ add("color", hex(cs.backgroundColor));
45
+ if (parseFloat(cs.borderTopWidth) > 0) add("color", hex(cs.borderTopColor));
46
+ add("radius", px(cs.borderTopLeftRadius));
47
+ for (const p of ["paddingTop", "paddingLeft", "rowGap", "columnGap"]) add("spacing", px(cs[p]));
48
+ }
49
+ const declared = {};
50
+ const root = getComputedStyle(document.documentElement);
51
+ for (let i = 0; i < root.length && Object.keys(declared).length < 400; i++) {
52
+ const name = root[i];
53
+ if (name.startsWith("--")) {
54
+ const v = root.getPropertyValue(name).trim();
55
+ if (v && v.length < 120) declared[name] = v;
56
+ }
57
+ }
58
+ const ground = [document.body, document.documentElement].map((e) => hex(getComputedStyle(e).backgroundColor)).find(Boolean) || "#ffffff";
59
+ const [R, G, B] = [1, 3, 5].map((i) => parseInt(ground.slice(i, i + 2), 16) / 255);
60
+ const scheme = 0.2126 * R + 0.7152 * G + 0.0722 * B < 0.5 ? "dark" : "light";
61
+ /**
62
+ * OS PAPÉIS QUE A PÁGINA MOSTRA, contados por uso e não por nome (21/10, depois do juízo dele no sistema da Apple: o
63
+ * papel "muted" tinha saído a cor de um iPhone, "finish-iphoneduo-night-sky", e o "accent" o fundo do menu, pelo
64
+ * nome do token). Cada papel é o valor que mais aparece no lugar que o define; o que a página não mostra fica de fora.
65
+ */
66
+ const tally = () => new Map();
67
+ const bump = (m, k, n = 1) => { if (k) m.set(k, (m.get(k) || 0) + n); };
68
+ const top = (m, skip = []) => [...m.entries()].filter(([k]) => !skip.includes(k)).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
69
+ const bodyText = tally(), links = tally(), borders = tally(), surfaces = tally(), headFam = tally(), bodyFam = tally(), monoFam = tally();
70
+ const vw = innerWidth;
71
+ for (const el of document.querySelectorAll("body *")) {
72
+ const r = el.getBoundingClientRect();
73
+ if (r.width < 1 || r.height < 1) continue;
74
+ const cs = getComputedStyle(el);
75
+ if (cs.visibility === "hidden" || cs.display === "none") continue;
76
+ const own = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(" ");
77
+ const fam = cs.fontFamily.split(",")[0].replace(/["']/g, "").trim();
78
+ const tag = el.tagName.toLowerCase();
79
+ if (own.length > 2) {
80
+ const len = own.length;
81
+ if (/^h[1-3]$/.test(tag)) bump(headFam, fam, len);
82
+ else if (tag === "code" || tag === "pre" || tag === "kbd") bump(monoFam, fam, len);
83
+ else bump(bodyFam, fam, len);
84
+ if (tag === "a" || el.closest("a")) bump(links, hex(cs.color), len);
85
+ else if (!/^h[1-6]$/.test(tag) && parseFloat(cs.fontSize) >= 13) bump(bodyText, hex(cs.color), len);
86
+ }
87
+ if (parseFloat(cs.borderTopWidth) > 0 && parseFloat(cs.borderTopWidth) <= 2) bump(borders, hex(cs.borderTopColor), Math.round(r.width));
88
+ const bg = hex(cs.backgroundColor);
89
+ if (bg && r.width > vw * 0.25 && r.height > 60) bump(surfaces, bg, Math.round((r.width * r.height) / 1000));
90
+ }
91
+ /** Saturação (0 a 1) e contraste contra o chão - o que separa um cinza de texto de uma cor de marca. */
92
+ const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
93
+ const sat = (h) => { const c = rgb(h); const mx = Math.max(...c), mn = Math.min(...c); return mx === 0 ? 0 : (mx - mn) / mx; };
94
+ const lum = (h) => { const c = rgb(h).map((x) => (x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4)); return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; };
95
+ const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
96
+ const opaque = (h) => h && h.length === 7;
97
+ const pick = (m, ok) => [...m.entries()].filter(([k]) => opaque(k) && ok(k)).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
98
+ /** O texto que se lê sobre o chão: um branco sobre uma faixa escura não é o texto da página. */
99
+ const foreground = pick(bodyText, (k) => contrast(k, ground) >= 4.5);
100
+ /** O secundário é o texto MAIS CLARO que o principal, e não só outro: em stripe.com (22/10) o principal é #64748d e o
101
+ * secundário saiu #000000, o preto dos títulos - mais escuro que o texto que ele devia acompanhar. */
102
+ const muted = pick(bodyText, (k) => k !== foreground && contrast(k, ground) >= 2.5 && sat(k) < 0.25 && (!foreground || contrast(k, ground) < contrast(foreground, ground)));
103
+ /** A cor da marca: o link com cor, e não o preto do menu. */
104
+ const primary = pick(links, (k) => sat(k) >= 0.35 && contrast(k, ground) >= 2) || pick(surfaces, (k) => sat(k) >= 0.35);
105
+ /** A borda é o traço neutro que separa, não o contorno azul de um botão - e que SE VÊ no chão (pelo menos 1,1 : 1).
106
+ * Medido em 22/10 no parque aquático: a borda mais usada era o branco que contorna os blocos azuis, e no chão da
107
+ * página (#eff7ff, 1,08 : 1) todo traço do sistema ficou invisível. */
108
+ const border = pick(borders, (k) => sat(k) < 0.15 && k !== ground && contrast(k, ground) >= 1.1 && contrast(k, ground) < 3);
109
+ const surface = pick(surfaces, (k) => k !== ground && contrast(k, ground) < 1.25);
110
+ const roles = {
111
+ canvas: ground,
112
+ ...(foreground ? { foreground } : {}),
113
+ ...(muted ? { muted } : {}),
114
+ ...(primary ? { primary } : {}),
115
+ ...(border ? { border } : {}),
116
+ ...(surface ? { surface } : {}),
117
+ };
118
+ for (const v of Object.values(roles)) if (v && !counts.has("color|" + v)) counts.set("color|" + v, 1);
119
+ const fonts = {
120
+ ...(top(headFam) ? { display: top(headFam) } : {}),
121
+ ...(top(bodyFam) ? { body: top(bodyFam) } : {}),
122
+ ...(top(monoFam) ? { mono: top(monoFam) } : {}),
123
+ };
124
+ const observed = [...counts.entries()]
125
+ .map(([k, count]) => {
126
+ const i = k.indexOf("|");
127
+ return { kind: k.slice(0, i), value: k.slice(i + 1), count };
128
+ })
129
+ .sort((a, b) => b.count - a.count);
130
+ return { declared, observed, scanned, scheme, ground, roles, fonts };
131
+ })()`;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * A LINGUAGEM MEDIDA DE UMA PÁGINA - o refinamento que o censo não pega (jornada "a página vira um DESIGN.md", E03, 22/10).
3
+ *
4
+ * O censo conta valores (cores, cantos, fontes). Isto mede como a página os USA, que é onde mora o refinamento - o que
5
+ * ele apontou na Apple: "a textura das fontes, o refinamento (...) o respiro, o posicionamento (...) os botões":
6
+ *
7
+ * type cada papel de texto: tamanho, peso, altura de linha, espaço entre letras, família, cor e quantas vezes
8
+ * components cada forma de botão, pílula ou aba, agrupada pela assinatura (fundo, texto, canto, padding, altura)
9
+ * bars as barras fixas: altura, fundo, desfoque, canto
10
+ * sections o respiro vertical das seções e o fundo de cada uma
11
+ * cards os blocos de canto grande: canto, fundo, sombra, padding
12
+ *
13
+ * Tudo é estilo calculado pelo navegador - nada aqui é palpite. O que só a composição mostra (o carrossel que corta, as
14
+ * abas) é a outra metade (E04). Roda no navegador, como texto.
15
+ */
16
+ export const PAGE_LANGUAGE = `(() => {
17
+ /* Sem olhar a opacidade: páginas como a da Apple revelam o texto ao rolar (opacity 0 até lá) - medido em 22/10, os
18
+ títulos de 48 px sumiam da medida. O que está no layout conta. */
19
+ const vis = (el) => { const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return r.width > 0 && r.height > 0 && cs.visibility !== "hidden" && cs.display !== "none"; };
20
+ const hex = (c) => {
21
+ const m = c && c.match(/rgba?\\(([\\d.]+),\\s*([\\d.]+),\\s*([\\d.]+)(?:,\\s*([\\d.]+))?\\)/);
22
+ if (!m) return null;
23
+ const a = m[4] === undefined ? 1 : Number(m[4]);
24
+ if (a === 0) return "transparent";
25
+ const h = (n) => Math.round(Number(n)).toString(16).padStart(2, "0");
26
+ return "#" + h(m[1]) + h(m[2]) + h(m[3]) + (a < 1 ? h(a * 255) : "");
27
+ };
28
+ const px = (v) => Math.round(parseFloat(v) * 100) / 100;
29
+ const ls = (cs) => (cs.letterSpacing === "normal" ? 0 : px(cs.letterSpacing));
30
+ const lh = (cs) => (cs.lineHeight === "normal" ? null : px(cs.lineHeight));
31
+ const fam = (cs) => cs.fontFamily.split(",")[0].replace(/["']/g, "").trim();
32
+ const own = (el) => [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(" ").trim();
33
+
34
+ /* A COR QUE O ESTILO NÃO CONTA (22/10, stripe.com): o título é desenhado duas vezes, a cópia de baixo verde opaca e a
35
+ de cima misturada (mix-blend-mode) - a tela mostra a mistura. O texto debaixo de uma camada que mistura (ou dentro
36
+ dela) conta no tamanho do papel, mas não na cor dele: a mesma regra da leitura (page-script.ts, "mistura"). Uma camada
37
+ POR CIMA só conta se pinta algo (texto, fundo, imagem): a "stats-section__border" do Stripe é multiply e cobre a
38
+ seção inteira, mas só desenha as linhas - o título debaixo dela segue com a cor dele. */
39
+ const misturadores = [...document.querySelectorAll("body *")].filter((e) => { const c = getComputedStyle(e); return c.mixBlendMode !== "normal" || c.webkitBackgroundClip === "text" || c.backgroundClip === "text"; }).slice(0, 200).map((e) => { const q = e.getBoundingClientRect(); const c = getComputedStyle(e); return { e, pinta: !!(e.innerText || "").trim() || !/rgba\\(0, 0, 0, 0\\)|transparent/.test(c.backgroundColor) || c.backgroundImage !== "none", x: q.left, y: q.top, w: q.width, h: q.height }; });
40
+ const misturado = (el) => { const q = el.getBoundingClientRect(); const cx = q.left + q.width / 2, cy = q.top + q.height / 2; return misturadores.some((m) => (m.e === el || !el.contains(m.e)) && (m.pinta || m.e.contains(el)) && cx >= m.x && cx <= m.x + m.w && cy >= m.y && cy <= m.y + m.h); };
41
+
42
+ /* TIPOGRAFIA: um papel é a combinação tamanho + peso + família; o que ele faz vem da tag mais comum nele. */
43
+ const roles = new Map();
44
+ for (const el of document.querySelectorAll("body *")) {
45
+ const t = own(el);
46
+ if (t.length < 2 || !vis(el)) continue;
47
+ const cs = getComputedStyle(el);
48
+ const key = [px(cs.fontSize), cs.fontWeight, fam(cs), ls(cs)].join("|");
49
+ const r = roles.get(key) || { size: px(cs.fontSize), weight: Number(cs.fontWeight), family: fam(cs), letterSpacing: ls(cs), lineHeights: {}, colors: {}, tags: {}, count: 0, chars: 0, sample: t.slice(0, 60) };
50
+ r.count++; r.chars += t.length;
51
+ const l = lh(cs); if (l !== null) r.lineHeights[l] = (r.lineHeights[l] || 0) + t.length;
52
+ if (!misturado(el)) { const c = hex(cs.color); r.colors[c] = (r.colors[c] || 0) + t.length; }
53
+ /* a tag do TÍTULO que contém o texto, quando há: no Stripe o texto dos títulos de seção mora num span dentro do h2, e
54
+ o papel de 32 px saía como "span" (23/10) */
55
+ const tag = (el.closest("h1, h2, h3, h4") || el).tagName.toLowerCase(); r.tags[tag] = (r.tags[tag] || 0) + 1;
56
+ roles.set(key, r);
57
+ }
58
+ const top = (o) => Object.entries(o).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
59
+ const type = [...roles.values()]
60
+ .filter((r) => r.count >= 2 || /^h[1-3]$/.test(top(r.tags)))
61
+ /* a altura de linha e a cor são as que carregam mais texto naquele papel, e não as do primeiro elemento */
62
+ .map((r) => ({ ...r, color: top(r.colors), tag: top(r.tags), lineHeight: top(r.lineHeights) === null ? null : Number(top(r.lineHeights)), letterSpacingEm: r.size ? Math.round((r.letterSpacing / r.size) * 1000) / 1000 : 0, colors: undefined, tags: undefined, lineHeights: undefined }))
63
+ .sort((a, b) => b.size - a.size || b.chars - a.chars)
64
+ .slice(0, 20);
65
+
66
+ /* COMPONENTES: o que se clica e tem forma (fundo, borda ou canto) - agrupado pela assinatura. */
67
+ const comps = new Map();
68
+ for (const el of document.querySelectorAll("a, button, [role=tab], [role=button], input[type=submit]")) {
69
+ if (!vis(el)) continue;
70
+ const cs = getComputedStyle(el);
71
+ const r = el.getBoundingClientRect();
72
+ const bg = hex(cs.backgroundColor);
73
+ const border = parseFloat(cs.borderTopWidth) > 0 ? hex(cs.borderTopColor) : null;
74
+ const radius = px(cs.borderTopLeftRadius);
75
+ if ((bg === "transparent" || !bg) && !border && radius < 8) continue;
76
+ if (r.height > 80 || r.width > 600) continue;
77
+ const text = (el.innerText || el.value || "").trim().replace(/\\s+/g, " ");
78
+ const sig = { bg, color: hex(cs.color), border, radius: Math.min(radius, 999), padX: px(cs.paddingLeft), padY: px(cs.paddingTop), height: Math.round(r.height), size: px(cs.fontSize), weight: Number(cs.fontWeight), letterSpacing: ls(cs) };
79
+ const key = JSON.stringify(sig);
80
+ const c = comps.get(key) || { ...sig, count: 0, samples: [], box: { x: Math.round(r.left), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height) } };
81
+ c.count++; if (text && c.samples.length < 3 && !c.samples.includes(text)) c.samples.push(text.slice(0, 32));
82
+ comps.set(key, c);
83
+ }
84
+ const components = [...comps.values()].sort((a, b) => b.count - a.count).slice(0, 14);
85
+
86
+ /* BARRAS FIXAS */
87
+ const bars = [...document.querySelectorAll("body *")]
88
+ .filter((el) => { const cs = getComputedStyle(el); return (cs.position === "fixed" || cs.position === "sticky") && vis(el) && el.getBoundingClientRect().width > innerWidth * 0.4 && el.getBoundingClientRect().height < 140; })
89
+ .slice(0, 4)
90
+ .map((el) => { const cs = getComputedStyle(el); const r = el.getBoundingClientRect(); return { position: cs.position, height: Math.round(r.height), width: Math.round(r.width), bg: hex(cs.backgroundColor), blur: cs.backdropFilter && cs.backdropFilter !== "none" ? cs.backdropFilter : null, radius: px(cs.borderTopLeftRadius), text: (el.innerText || "").trim().replace(/\\s+/g, " ").slice(0, 60), box: { x: Math.round(r.left), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height) } }; });
91
+
92
+ /* RESPIRO: as seções - os filhos diretos do conteúdo principal, ou as <section> */
93
+ const root = document.querySelector("main") || document.body;
94
+ const blocks = [...root.querySelectorAll(":scope > *, section")].filter((el) => vis(el) && el.getBoundingClientRect().height > 200);
95
+ const sections = blocks.slice(0, 24).map((el) => { const cs = getComputedStyle(el); return { top: px(cs.paddingTop), bottom: px(cs.paddingBottom), bg: hex(cs.backgroundColor) }; });
96
+
97
+ /* CARTÕES */
98
+ const cardMap = new Map();
99
+ for (const el of document.querySelectorAll("body *")) {
100
+ const cs = getComputedStyle(el); const r = el.getBoundingClientRect();
101
+ const radius = px(cs.borderTopLeftRadius);
102
+ if (radius < 12 || r.width < 200 || r.height < 160 || !vis(el)) continue;
103
+ const sig = { radius, bg: hex(cs.backgroundColor), shadow: cs.boxShadow !== "none", border: parseFloat(cs.borderTopWidth) > 0 ? hex(cs.borderTopColor) : null, pad: px(cs.paddingTop) };
104
+ const key = JSON.stringify(sig);
105
+ const c = cardMap.get(key) || { ...sig, count: 0, box: { x: Math.round(r.left), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height) } };
106
+ c.count++; cardMap.set(key, c);
107
+ }
108
+ const cards = [...cardMap.values()].sort((a, b) => b.count - a.count).slice(0, 8);
109
+
110
+ /* O RESPIRO, como o site o faz (jornada "o placar de fidelidade", E03, 23/10): a página vira faixas de conteúdo (texto,
111
+ mídia, quadros com fundo ou borda) e os vãos verticais entre faixas seguidas mostram o ritmo - o recuo da seção não
112
+ basta: o Stripe espaça com margem e vão, e o "respiro das seções" saía vazio. Os vãos de 64 px ou mais são o espaço
113
+ entre seções; os menores, o respiro de dentro. E a largura do conteúdo: o primeiro bloco com max-width acima de cada
114
+ título. */
115
+ const solid = (el) => { const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return r.width > 0 && r.height >= 4 && cs.visibility !== "hidden" && cs.display !== "none" && cs.position !== "fixed" && cs.position !== "sticky"; };
116
+ const spans = [];
117
+ for (const el of document.querySelectorAll("body *")) {
118
+ if (!solid(el)) continue;
119
+ const cs = getComputedStyle(el); const r = el.getBoundingClientRect();
120
+ const text = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim().length > 1);
121
+ const media = /^(IMG|SVG|CANVAS|VIDEO|PICTURE)$/i.test(el.tagName);
122
+ const boxy = (cs.backgroundColor !== "rgba(0, 0, 0, 0)" || parseFloat(cs.borderTopWidth) > 0) && r.height < 1200 && r.width < innerWidth * 0.98;
123
+ if (text || media || boxy) spans.push([r.top + scrollY, r.bottom + scrollY]);
124
+ }
125
+ spans.sort((a, b) => a[0] - b[0]);
126
+ const bands = []; for (const [t, b] of spans) { const last = bands[bands.length - 1]; if (last && t <= last[1] + 1) last[1] = Math.max(last[1], b); else bands.push([t, b]); }
127
+ const gaps = []; for (let i = 1; i < bands.length; i++) gaps.push(Math.round(bands[i][0] - bands[i - 1][1]));
128
+ const median = (xs) => { const q = [...xs].sort((a, b) => a - b); return q.length ? q[Math.floor(q.length / 2)] : null; };
129
+ const widths = {};
130
+ for (const h of document.querySelectorAll("h1, h2, h3")) {
131
+ if (!vis(h)) continue;
132
+ /* o MAIS LARGO dos blocos com max-width acima do título: o mais próximo é a coluna de texto (672 px nas landings de
133
+ exemplo), e o que o site decide é o contêiner da página (1266 px no Stripe) */
134
+ let widest = 0;
135
+ for (let a = h.parentElement; a && a !== document.body; a = a.parentElement) { const c = getComputedStyle(a); const w = a.getBoundingClientRect().width; if (c.maxWidth !== "none" && w >= 600 && w < innerWidth - 16 && w > widest) widest = w; }
136
+ if (widest) { const k = Math.round(widest); widths[k] = (widths[k] || 0) + 1; }
137
+ }
138
+ const content = Object.entries(widths).sort((a, b) => b[1] - a[1])[0];
139
+ /* sem um contêiner de 900 px ou mais com max-width, o recuo lateral: a margem esquerda mais comum dos títulos (as
140
+ landings de exemplo limitam a página com padding, e o max-width que se acha é o da coluna de texto) */
141
+ const lefts = {};
142
+ for (const h of document.querySelectorAll("h1, h2, h3")) { if (!vis(h)) continue; const L = Math.round(h.getBoundingClientRect().left / 4) * 4; if (L > 0 && L < innerWidth / 3) lefts[L] = (lefts[L] || 0) + 1; }
143
+ const inset = Object.entries(lefts).sort((a, b) => b[1] - a[1])[0];
144
+ const contentWidth = content && Number(content[0]) >= 900 ? Number(content[0]) : inset ? innerWidth - 2 * Number(inset[0]) : content ? Number(content[0]) : null;
145
+ const rhythm = { bands: bands.length, sectionGap: median(gaps.filter((g) => g >= 64)), innerGap: median(gaps.filter((g) => g > 0 && g < 64)), contentWidth };
146
+
147
+ return { type, components, bars, sections, cards, rhythm, width: innerWidth };
148
+ })()`;
149
+ /**
150
+ * O AVISO DE COOKIES E O MODAL, ESCONDIDOS - só nas fotos: a leitura já foi medida. Medido em 21/10 nos 21 sites: 7 dobras
151
+ * saíam com o banner por cima. E em 23/10, na leitura da linguagem, o modelo descreveu o aviso de cookies do Stripe como
152
+ * um padrão de composição do site ("Persistent utility overlays").
153
+ */
154
+ export const HIDE_OVERLAYS = `(() => { for (const el of document.querySelectorAll("body *")) { const cs = getComputedStyle(el); if (cs.position !== "fixed" && cs.position !== "sticky") continue; const t = (el.innerText || "").slice(0, 600); const dialog = el.getAttribute("role") === "dialog" || el.getAttribute("aria-modal") === "true"; if (/cookie|consent|privacidade|lgpd|gdpr|sales reps|chat now|est[aá] visualizando|you.re viewing/i.test(t) || (dialog && el.getBoundingClientRect().width < innerWidth * 0.9)) el.style.setProperty("display", "none", "important"); } })()`;
@@ -27,8 +27,10 @@ export const PAGE_SCRIPT = String.raw `(() => {
27
27
  verde opaca e, por cima, a mesma frase com mix-blend-mode: hard-light - a tela mostra a mistura, e a cópia de baixo
28
28
  sozinha nunca aparece. Um texto debaixo de qualquer um destes (ancestral ou camada por cima) tem pintura que o
29
29
  estilo não conta. O desfoque de fundo (backdrop-filter) só conta POR CIMA do texto: no cartão de vidro que o
30
- contém, ele desfoca o que está atrás do cartão, e o texto segue com a cor dele (medido em linear.app, 22/10). */
31
- const misturadores = [...document.querySelectorAll("body *")].filter((e) => { const c = getComputedStyle(e); return c.mixBlendMode !== "normal" || (c.backdropFilter && c.backdropFilter !== "none") || c.webkitBackgroundClip === "text" || c.backgroundClip === "text"; }).slice(0, 200).map((e) => { const q = e.getBoundingClientRect(); const c = getComputedStyle(e); return { e, so: !(c.mixBlendMode !== "normal" || c.webkitBackgroundClip === "text" || c.backgroundClip === "text"), x: q.left + scrollX, y: q.top + scrollY, w: q.width, h: q.height }; });
30
+ contém, ele desfoca o que está atrás do cartão, e o texto segue com a cor dele (medido em linear.app, 22/10). E uma
31
+ camada de mistura POR CIMA só conta se pinta algo (texto, fundo, imagem): a "stats-section__border" do Stripe é
32
+ multiply e cobre a seção inteira, mas só desenha as linhas. */
33
+ const misturadores = [...document.querySelectorAll("body *")].filter((e) => { const c = getComputedStyle(e); return c.mixBlendMode !== "normal" || (c.backdropFilter && c.backdropFilter !== "none") || c.webkitBackgroundClip === "text" || c.backgroundClip === "text"; }).slice(0, 200).map((e) => { const q = e.getBoundingClientRect(); const c = getComputedStyle(e); return { e, so: !(c.mixBlendMode !== "normal" || c.webkitBackgroundClip === "text" || c.backgroundClip === "text"), pinta: !!(e.innerText || "").trim() || !/rgba\(0, 0, 0, 0\)|transparent/.test(c.backgroundColor) || c.backgroundImage !== "none", x: q.left + scrollX, y: q.top + scrollY, w: q.width, h: q.height }; });
32
34
  const textos = [];
33
35
  const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
34
36
  const vistos = new Set();
@@ -64,7 +66,7 @@ export const PAGE_SCRIPT = String.raw `(() => {
64
66
  if (c.filter && c.filter !== "none") mistura = true;
65
67
  }
66
68
  const cx = r.left + scrollX + r.width / 2, cy = r.top + scrollY + r.height / 2;
67
- if (!mistura) mistura = misturadores.some((m) => (m.e === el || !el.contains(m.e)) && !(m.so && m.e.contains(el)) && cx >= m.x && cx <= m.x + m.w && cy >= m.y && cy <= m.y + m.h);
69
+ if (!mistura) mistura = misturadores.some((m) => (m.e === el || !el.contains(m.e)) && !(m.so && m.e.contains(el)) && (m.e.contains(el) || m.so || m.pinta) && cx >= m.x && cx <= m.x + m.w && cy >= m.y && cy <= m.y + m.h);
68
70
  if (/^rgba\([^)]*,\s*0\)$|^transparent$/.test(cs.webkitTextFillColor || "")) mistura = true;
69
71
  const f = fundo(el);
70
72
  textos.push({
@@ -72,6 +72,13 @@ export async function readSite(browser, base, routes, opts = {}) {
72
72
  const code = res?.status?.();
73
73
  statuses.set(`${rota}|${disp}`, typeof code === "number" ? code : null);
74
74
  await page.waitForTimeout(800);
75
+ /**
76
+ * A cor no estado final: sem transição, cada cor salta para o fim. Medido em 23/10 no Stripe (`transition: color
77
+ * 1.2s`): numa máquina lenta o título era lido no meio do caminho, branco sobre branco. As animações ficam.
78
+ */
79
+ await page
80
+ .evaluate(`(() => { const s = document.createElement("style"); s.textContent = "*, *::before, *::after { transition: none !important; }"; document.head.appendChild(s); document.body.offsetHeight; })()`)
81
+ .catch(() => null);
75
82
  const textos = (await page.evaluate(PAGE_SCRIPT));
76
83
  pages.set(`${rota}|${disp}`, page);
77
84
  return textos.map((t) => ({ ...t, rota, disp }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.506",
3
+ "version": "0.16.507",
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": {