synthesisui 0.16.504 → 0.16.506

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.
@@ -37,7 +37,14 @@ const clamp255 = (n) => Math.max(0, Math.min(255, Math.round(n)));
37
37
  const hex2 = (n) => clamp255(n).toString(16).padStart(2, "0");
38
38
  /** `#f0a` -> `#ff00aa`, e `#ff00aa80` devolve o alfa junto. */
39
39
  function fromHex(raw) {
40
- const h = raw.trim().replace(/^#/, "");
40
+ /**
41
+ * O `#` É OBRIGATÓRIO: sem ele, um peso de fonte (`300`) é hex de três dígitos e vira `#330000`. Medido em 22/10 no
42
+ * sistema lido de stripe.com: 30 variáveis `--hds-font-*-weight` entraram como cores escuras. Em CSS, hex sem `#`
43
+ * não é cor.
44
+ */
45
+ if (!raw.trim().startsWith("#"))
46
+ return null;
47
+ const h = raw.trim().slice(1);
41
48
  if (!/^[0-9a-f]{3,8}$/i.test(h))
42
49
  return null;
43
50
  const dup = (s) => s
@@ -5,10 +5,8 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, wri
5
5
  import { createRequire } from "node:module";
6
6
  import { homedir } from "node:os";
7
7
  import { join, relative } from "node:path";
8
- import { classify, pixelRatio } from "../reading/classify.js";
9
- import { PAGE_SCRIPT } from "../reading/page-script.js";
8
+ import { readSite } from "../reading/read-page.js";
10
9
  import { headingsOf } from "../reading/reread.js";
11
- import { decodePng } from "../reading/png.js";
12
10
  import { PENDING, globalClassOf, loadRoleRules, globalStyles, moduleClassName, pxOf, readPending } from "../role-rules.js";
13
11
  import { agentsToMaintain } from "../agents-chosen.js";
14
12
  import { syncClaudeMd } from "../claude-md.js";
@@ -29,10 +27,7 @@ import { projectRootFrom } from "./hook.js";
29
27
  */
30
28
  const READER_VERSION = "1.58.2";
31
29
  const READER_DIR = join(homedir(), ".synthesisui", "reader");
32
- export const VIEWPORTS = [
33
- ["desktop", 1440, 900],
34
- ["celular", 390, 844],
35
- ];
30
+ export { VIEWPORTS, confirmByPixels } from "../reading/read-page.js";
36
31
  /** `download: false` é o fim do turno: ele nunca baixa nada sozinho - sem o leitor na máquina, não relê (E01, fase 3). */
37
32
  export function loadReader(say, download = true) {
38
33
  const override = process.env.SYNTHESISUI_PLAYWRIGHT;
@@ -93,26 +88,6 @@ export async function findServer(url, root, waitMs = 4000) {
93
88
  }
94
89
  return null;
95
90
  }
96
- /**
97
- * CADA CONTRASTE BAIXO CONFERIDO PELOS PIXELS: sem tinta é texto não pintado; acima do alvo, o DOM errou o fundo. A mesma
98
- * conferência na leitura e no fim do turno (E01 da fase 3) - duas cópias divergiriam no primeiro conserto.
99
- */
100
- export async function confirmByPixels(found, pageOf) {
101
- const confirmed = [];
102
- for (const f of found) {
103
- const page = pageOf(f.sample);
104
- let px = null;
105
- if (page && f.sample.w >= 2 && f.sample.h >= 2) {
106
- const png = await page.screenshot({ fullPage: true, clip: { x: Math.max(0, f.sample.x), y: Math.max(0, f.sample.y), width: f.sample.w, height: f.sample.h } }).catch(() => null);
107
- const rgba = png ? decodePng(png) : null;
108
- px = rgba ? pixelRatio(rgba) : null;
109
- }
110
- if (px !== null && (px < 1.2 || px >= f.alvo))
111
- continue;
112
- confirmed.push({ ...f, pixels: px });
113
- }
114
- return confirmed;
115
- }
116
91
  /** O maior tamanho entre as classes de um texto (o do desktop), sem o prefixo de tela - a mesma escolha da checagem. */
117
92
  function sizeClassOf(cls) {
118
93
  const toks = cls.match(/(?:[a-z0-9]+:)?text-(?:\[(?:clamp\([^\]\s]+\)|[\d.]+(?:rem|px|em))\]|xs|sm|base|lg|[2-9]?xl)(?=\s|$|\|)/g) ?? [];
@@ -372,23 +347,12 @@ export async function read(opts) {
372
347
  const routes = opts.routes?.length ? opts.routes : ["/"];
373
348
  const pw = loadReader(say);
374
349
  const browser = (await launch(pw, say));
375
- const seen = [];
376
- const pages = new Map();
350
+ const site = await readSite(browser, base, routes).catch(async (e) => {
351
+ await browser.close().catch(() => { });
352
+ throw e;
353
+ });
354
+ const { reading, seen } = site;
377
355
  try {
378
- for (const rota of routes) {
379
- for (const [disp, w, h] of VIEWPORTS) {
380
- const ctx = await browser.newContext({ viewport: { width: w, height: h }, reducedMotion: "reduce" });
381
- const page = await ctx.newPage();
382
- await page.goto(new URL(rota, base).href, { waitUntil: "networkidle", timeout: 90_000 });
383
- await page.waitForTimeout(800);
384
- const textos = (await page.evaluate(PAGE_SCRIPT));
385
- for (const t of textos)
386
- seen.push({ ...t, rota, disp });
387
- pages.set(`${rota}|${disp}`, { page, ctx });
388
- }
389
- }
390
- const reading = classify(seen);
391
- reading.contraste = await confirmByPixels(reading.contraste, (t) => pages.get(`${t.rota}|${t.disp}`)?.page);
392
356
  const files = sources(root);
393
357
  const home = (t) => homeOf(root, files, t);
394
358
  const out = {
@@ -436,8 +400,7 @@ export async function read(opts) {
436
400
  say(`\n Written to _synthesisui/reading.json. Nothing in your code changed.`);
437
401
  }
438
402
  finally {
439
- for (const { ctx } of pages.values())
440
- await ctx.close().catch(() => { });
403
+ await site.close();
441
404
  await browser.close().catch(() => { });
442
405
  }
443
406
  }
@@ -592,7 +592,7 @@ export const COUNTED_DIFFERENTLY = "this run counts a value as named only when Y
592
592
  * E (E02, no mesmo PR) guarda o tamanho de título sem papel para a próxima leitura perguntar, e cobra o papel que a pessoa
593
593
  * escolheu no "não" (`decided` no rules.json): um hook pinado antes diz "papel novo" uma vez e nunca cobra.
594
594
  */
595
- export const CHECKER_SINCE = "0.16.504";
595
+ export const CHECKER_SINCE = "0.16.506";
596
596
  /**
597
597
  * A ÚLTIMA VERSÃO EM QUE OS LEITORES PASSARAM A PRODUZIR UM CENSO DIFERENTE.
598
598
  *
@@ -16,13 +16,19 @@ export const PAGE_SCRIPT = String.raw `(() => {
16
16
  const cs = getComputedStyle(e);
17
17
  if (cs.backgroundImage && cs.backgroundImage !== "none") img = img || /url\(|gradient/.test(cs.backgroundImage);
18
18
  const bg = parse(cs.backgroundColor); if (bg && bg.a > 0) { camadas.push(bg); if (bg.a >= 1) break; }
19
- if (e.parentElement && [...e.parentElement.children].some((s) => s !== e && /^(VIDEO|IMG)$/.test(s.tagName) && getComputedStyle(s).position === "absolute")) img = true;
19
+ if (e.parentElement && [...e.parentElement.children].some((s) => s !== e && /^(VIDEO|IMG|CANVAS|svg)$/.test(s.tagName) && getComputedStyle(s).position === "absolute")) img = true;
20
20
  }
21
21
  let cor = { r: 255, g: 255, b: 255, a: 1 };
22
22
  for (let i = camadas.length - 1; i >= 0; i--) cor = mix(camadas[i], cor);
23
23
  return { cor, img };
24
24
  };
25
25
  const secao = (el) => { for (let e = el; e; e = e.parentElement) { if (/^(SECTION|HEADER|FOOTER|NAV|MAIN|ASIDE|ARTICLE|DIALOG)$/.test(e.tagName) || e.id) return e.tagName.toLowerCase() + (e.id ? "#" + e.id : ""); } return "body"; };
26
+ /* O QUE MISTURA O QUE ESTÁ EMBAIXO, medido uma vez: em stripe.com (22/10) o título é desenhado duas vezes, uma cópia
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
+ sozinha nunca aparece. Um texto debaixo de qualquer um destes (ancestral ou camada por cima) tem pintura que o
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 }; });
26
32
  const textos = [];
27
33
  const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
28
34
  const vistos = new Set();
@@ -49,12 +55,23 @@ export const PAGE_SCRIPT = String.raw `(() => {
49
55
  }
50
56
  let op = 1; for (let e = el; e; e = e.parentElement) op *= Number(getComputedStyle(e).opacity);
51
57
  const cor = parse(cs.color); if (cor) cor.a *= op;
58
+ /* A PINTURA QUE O ESTILO NÃO CONTA (22/10, stripe.com): o título é rgba(0, 14, 255, .5) com mix-blend-mode:
59
+ hard-light sobre um degradê animado - a cor na tela é a mistura, e nenhum "color" a revela. Texto misturado,
60
+ recortado no fundo (background-clip: text), sem tinta própria ou sob filtro: só a foto diz o contraste. */
61
+ let mistura = false;
62
+ for (let e = el; e && e !== document.documentElement && !mistura; e = e.parentElement) {
63
+ const c = getComputedStyle(e);
64
+ if (c.filter && c.filter !== "none") mistura = true;
65
+ }
66
+ 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);
68
+ if (/^rgba\([^)]*,\s*0\)$|^transparent$/.test(cs.webkitTextFillColor || "")) mistura = true;
52
69
  const f = fundo(el);
53
70
  textos.push({
54
71
  texto: txt.slice(0, 60), tag: el.tagName.toLowerCase(),
55
72
  papel: (el.closest("h1,h2,h3,h4,h5,h6,button,a,label,th,figcaption,blockquote,input") || { tagName: "" }).tagName.toLowerCase() || null,
56
73
  tam: parseFloat(cs.fontSize), familia: cs.fontFamily.split(",")[0].replace(/["']/g, "").trim(), peso: Number(cs.fontWeight),
57
- cor, fundo: f.cor, sobreImagem: f.img, decorativo: !!el.closest("[aria-hidden=true]"),
74
+ cor, fundo: f.cor, sobreImagem: f.img, mistura, decorativo: !!el.closest("[aria-hidden=true]"),
58
75
  cartao, corrido, giro, enfase, comp,
59
76
  cls: [el, ...(tit && tit !== el ? [tit] : []), el.parentElement].filter(Boolean).map((x) => x.getAttribute("class") || "").join(" | ").slice(0, 600),
60
77
  secao: secao(el), x: r.left + scrollX, y: r.top + scrollY, w: r.width, h: r.height,
@@ -0,0 +1,115 @@
1
+ import { classify, pixelRatio, } from "./classify.js";
2
+ import { PAGE_SCRIPT } from "./page-script.js";
3
+ import { decodePng } from "./png.js";
4
+ export const VIEWPORTS = [
5
+ ["desktop", 1440, 900],
6
+ ["celular", 390, 844],
7
+ ];
8
+ /**
9
+ * CADA CONTRASTE BAIXO CONFERIDO PELOS PIXELS: sem tinta é texto não pintado; acima do alvo, o DOM errou o fundo. A mesma
10
+ * conferência na leitura e no fim do turno (E01 da fase 3) - duas cópias divergiriam no primeiro conserto.
11
+ */
12
+ export async function confirmByPixels(found, pageOf) {
13
+ const confirmed = [];
14
+ for (const f of found) {
15
+ const page = pageOf(f.sample);
16
+ let px = null;
17
+ if (page && f.sample.w >= 2 && f.sample.h >= 2) {
18
+ const png = await page
19
+ .screenshot({
20
+ fullPage: true,
21
+ clip: {
22
+ x: Math.max(0, f.sample.x),
23
+ y: Math.max(0, f.sample.y),
24
+ width: f.sample.w,
25
+ height: f.sample.h,
26
+ },
27
+ })
28
+ .catch(() => null);
29
+ const rgba = png ? decodePng(png) : null;
30
+ px = rgba ? pixelRatio(rgba) : null;
31
+ }
32
+ if (px !== null && (px < 1.2 || px >= f.alvo))
33
+ continue;
34
+ confirmed.push({ ...f, pixels: px });
35
+ }
36
+ return confirmed;
37
+ }
38
+ /**
39
+ * Mede `routes` de `base` nas duas larguras, classifica e confere o contraste pelos pixels. Quem chama fecha (`close`).
40
+ * As duas larguras abrem AO MESMO TEMPO (medido em 21/10: resend.com levava 46 s em sequência), e os textos entram na
41
+ * ordem de `VIEWPORTS`, a mesma de antes - a classificação não muda.
42
+ */
43
+ export async function readSite(browser, base, routes, opts = {}) {
44
+ const seen = [];
45
+ const pages = new Map();
46
+ const statuses = new Map();
47
+ const ctxs = [];
48
+ const close = async () => {
49
+ for (const ctx of ctxs)
50
+ await ctx.close().catch(() => { });
51
+ };
52
+ const open = async (rota, disp, w, h) => {
53
+ const ctx = await browser.newContext({
54
+ ...opts.context,
55
+ viewport: { width: w, height: h },
56
+ reducedMotion: "reduce",
57
+ });
58
+ ctxs.push(ctx);
59
+ if (opts.prepare)
60
+ await opts.prepare(ctx);
61
+ const page = await ctx.newPage();
62
+ const href = new URL(rota, base).href;
63
+ const res = opts.settleMs === undefined
64
+ ? await page.goto(href, { waitUntil: "networkidle", timeout: 90_000 })
65
+ : await page
66
+ .goto(href, { waitUntil: "networkidle", timeout: opts.settleMs })
67
+ .catch((e) => {
68
+ if (!/timeout/i.test(e.message))
69
+ throw e;
70
+ return null;
71
+ });
72
+ const code = res?.status?.();
73
+ statuses.set(`${rota}|${disp}`, typeof code === "number" ? code : null);
74
+ await page.waitForTimeout(800);
75
+ const textos = (await page.evaluate(PAGE_SCRIPT));
76
+ pages.set(`${rota}|${disp}`, page);
77
+ return textos.map((t) => ({ ...t, rota, disp }));
78
+ };
79
+ try {
80
+ for (const rota of routes) {
81
+ const timed = async (disp, w, h) => {
82
+ const t = performance.now();
83
+ const textos = await open(rota, disp, w, h);
84
+ opts.onWidth?.(disp, Math.round(performance.now() - t));
85
+ return textos;
86
+ };
87
+ const byWidth = [];
88
+ if (opts.oneAtATime)
89
+ for (const [disp, w, h] of VIEWPORTS)
90
+ byWidth.push(await timed(disp, w, h));
91
+ else
92
+ byWidth.push(...(await Promise.all(VIEWPORTS.map(([disp, w, h]) => timed(disp, w, h)))));
93
+ for (const textos of byWidth)
94
+ seen.push(...textos);
95
+ }
96
+ const reading = classify(seen);
97
+ /**
98
+ * Sem a foto, o par cuja pintura o estilo não conta (`mistura`) sai: o número dele seria da cor declarada, e não da
99
+ * que se vê. Medido em 22/10 em stripe.com - o título acusado a 2,39 : 1 era um azul translúcido misturado sobre um
100
+ * degradê, e o "depois" o pintou de verde. Com a foto (o CLI), quem decide é o pixel, como sempre.
101
+ */
102
+ if (opts.skipPixels)
103
+ reading.contraste = reading.contraste.filter((c) => c.razao >= 1.2 && !c.sample.mistura);
104
+ else {
105
+ const t = performance.now();
106
+ reading.contraste = await confirmByPixels(reading.contraste, (t) => pages.get(`${t.rota}|${t.disp}`));
107
+ opts.onWidth?.("pixels", Math.round(performance.now() - t));
108
+ }
109
+ return { reading, seen, pages, statuses, close };
110
+ }
111
+ catch (e) {
112
+ await close();
113
+ throw e;
114
+ }
115
+ }
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { basename, extname, join, relative, sep } from "node:path";
4
- import { confirmByPixels, declaredPort, findServer, homeOf, launch, loadReader, sources } from "../commands/read.js";
4
+ import { declaredPort, findServer, homeOf, launch, loadReader, sources } from "../commands/read.js";
5
+ import { confirmByPixels } from "./read-page.js";
5
6
  import { loadRoleRules } from "../role-rules.js";
6
7
  import { classify } from "./classify.js";
7
8
  import { PAGE_SCRIPT } from "./page-script.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.504",
3
+ "version": "0.16.506",
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": {