Files
Jyotisha/docs/testing/button-contrast-harness.cjs
T

121 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Button contrast harness (TASK-site-button-contrast-20260928, BUG-1079).
// Renders sample buttons against the real built CSS in headless Chrome, light
// and dark, and prints text / background / opacity / contrast per sample.
// Class strings are read from the source (button.tsx, calendar.tsx,
// birth-date-picker.tsx) so the samples follow the component.
// Not a repo dependency: uses the machine's global playwright-core + Chrome.
//
// Usage (from the repo root):
// rm -rf /tmp/btn && mkdir -p /tmp/btn && cp frontend/.next/static/chunks/*.css /tmp/btn/ # a FRESH build only
// # or staging's live CSS:
// for c in $(curl -s https://staging.jyotisha.chat/login | grep -o '/_next/static/chunks/[^"]*\.css' | sort -u); do curl -s "https://staging.jyotisha.chat$c" -o /tmp/btn/$(basename $c); done
// node docs/testing/button-contrast-harness.cjs /tmp/btn [frontend-dir] [out.json]
//
// A stale .next once hid a live fix during the 2026-09-28 scan: copy CSS only
// from the build you mean to measure.
const { chromium } = require(process.env.PLAYWRIGHT_CORE || "/usr/local/lib/node_modules/playwright-core");
const fs = require("fs"), path = require("path");
const dir = process.argv[2];
const fe = process.argv[3] || path.join(__dirname, "..", "..", "frontend");
const outFile = process.argv[4] || path.join(dir, "contrast.json");
// User pages load globals.css (Tailwind) and never antd's reset; admin routes
// load antd/dist/reset.css + admin.css and no Tailwind at all. A build's chunk
// folder holds both, so the admin-only reset is skipped unless asked for.
const includeAdmin = process.env.HARNESS_INCLUDE_ADMIN === "1";
const cssFiles = fs.readdirSync(dir).filter(f => f.endsWith(".css"))
.filter(f => includeAdmin || !fs.readFileSync(path.join(dir, f), "utf8").includes("input::-ms-clear"));
console.log(`stylesheets (${includeAdmin ? "admin included" : "user pages"}): ${cssFiles.join(", ")}`);
const buttonSrc = fs.readFileSync(path.join(fe, "src/components/ui/button.tsx"), "utf8");
const cvaBase = buttonSrc.match(/cva\(\s*"([^"]+)"/)[1];
const block = key => buttonSrc.slice(buttonSrc.indexOf(`${key}: {`), buttonSrc.indexOf("}", buttonSrc.indexOf(`${key}: {`)));
const pick = (key, name) => block(key).match(new RegExp(`(?:\\b|")${name}"?:\\s*"([^"]*)"`))[1];
const variant = name => pick("variant", name);
const size = name => pick("size", name);
const calendarSrc = fs.readFileSync(path.join(fe, "src/components/ui/calendar.tsx"), "utf8");
const dayClass = calendarSrc.match(/data-selected-single=\{[\s\S]*?className=\{cn\(\s*"([^"]+)"/)[1];
const pickerSrc = fs.readFileSync(path.join(fe, "src/components/birth-date-picker.tsx"), "utf8");
const calendarRootClass = (pickerSrc.match(/<Calendar\s[\s\S]*?className="([^"]*)"/) || [, ""])[1];
const triggerClass = (pickerSrc.match(/variant="outline"[\s\S]*?className="([^"]*)"/) || [, ""])[1];
const B = (v, sz = "default", extra = "", attrs = "") =>
`<button data-slot="button" ${attrs} class="${cvaBase} ${variant(v)} ${size(sz)} ${extra}">导出</button>`;
const samples = [
["Button 默认(实心)", B("default")],
["Button 默认 · 禁用", B("default", "default", "", "disabled")],
["Button 描边", B("outline")],
["Button 描边 · 禁用", B("outline", "default", "", "disabled")],
["Button 幽灵", B("ghost")],
["Button 幽灵 · 禁用", B("ghost", "default", "", "disabled")],
[".button-primary", `<button class="button-primary">保存</button>`],
[".button-primary · 禁用", `<button class="button-primary" disabled>保存</button>`],
[".danger-primary", `<button class="button-primary danger-primary">删除</button>`],
[".danger-primary · 禁用", `<button class="button-primary danger-primary" disabled>删除</button>`],
[".button-secondary", `<button class="button-secondary">返回</button>`],
[".button-secondary · 禁用", `<button class="button-secondary" disabled>返回</button>`],
["输入框发送键", `<div class="composer"><div class="composer-row-end">${B("default", "icon")}</div></div>`],
["输入框发送键 · 禁用", `<div class="composer"><div class="composer-row-end">${B("default", "icon", "", "disabled")}</div></div>`],
["输入框停止键", `<div class="composer"><div class="composer-row-end">${B("default", "icon", "composer-stop")}</div></div>`],
["日历选中日", `<div class="${calendarRootClass}">${B("ghost", "icon", dayClass, 'data-selected-single="true"')}</div>`],
["日期选择器(未选,占位)", B("outline", "default", triggerClass, 'data-empty="true"')],
["报告页 Button 默认", `<div class="personal-report-reader">${B("default")}</div>`],
["报告页 Button 默认 · 禁用", `<div class="personal-report-reader">${B("default", "default", "", "disabled")}</div>`],
["报告页 描边 · 禁用", `<div class="personal-report-reader">${B("outline", "default", "", "disabled")}</div>`],
["报告页页眉「生成报告」· 禁用", B("outline", "sm", "report-center-generate shrink-0 whitespace-nowrap", "disabled")],
// Hover samples: the third element marks the sample to be hovered first.
["Button 默认 · 悬停", B("default"), true],
["Button 描边 · 悬停", B("outline"), true],
[".button-primary · 悬停", `<button class="button-primary">保存</button>`, true],
[".danger-primary · 悬停", `<button class="button-primary danger-primary">删除</button>`, true],
["输入框发送键 · 悬停", `<div class="composer"><div class="composer-row-end">${B("default", "icon")}</div></div>`, true],
["日历选中日 · 悬停", `<div class="${calendarRootClass}">${B("ghost", "icon", dayClass, 'data-selected-single="true"')}</div>`, true],
];
function lum([r, g, b]) { const f = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }
function oklabToRgb(L, a, b) {
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3;
const lin = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s];
return lin.map(v => { v = Math.min(1, Math.max(0, v)); return 255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055); });
}
function parse(c) {
// Tailwind's `/80` opacity computes to `oklab(L a b / alpha)`.
if (c.startsWith("oklab(")) {
const m = c.slice(6).match(/-?[\d.]+/g).map(Number);
return { rgb: oklabToRgb(m[0], m[1], m[2]), a: m.length > 3 ? m[3] : 1 };
}
// color-mix() computes to `color(srgb r g b / a)` with 0–1 channels; rgb()/rgba() use 0–255.
const srgb = c.startsWith("color(srgb");
const m = c.replace(/^color\(srgb/, "").match(/[\d.]+/g).map(Number);
const rgb = m.slice(0, 3).map(v => (srgb ? v * 255 : v));
return { rgb, a: m.length > 3 ? m[3] : 1 };
}
function over(fg, bg, a) { return fg.map((v, i) => v * a + bg[i] * (1 - a)); }
function ratio(a, b) { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); }
const hex = c => "#" + c.map(v => Math.round(v).toString(16).padStart(2, "0")).join("");
(async () => {
const browser = await chromium.launch({ executablePath: process.env.CHROME || "/usr/bin/google-chrome", args: ["--no-sandbox"] });
const page = await browser.newPage();
const rows = [];
for (const theme of ["light", "dark"]) {
const html = `<!doctype html><html data-theme="${theme}"><head>${cssFiles.map(f => `<link rel="stylesheet" href="file://${path.join(dir, f)}">`).join("")}</head><body>${samples.map(([, h], i) => `<div id="s${i}" style="padding:8px">${h}</div>`).join("")}</body></html>`;
fs.writeFileSync(path.join(dir, `h-${theme}.html`), html);
await page.goto("file://" + path.join(dir, `h-${theme}.html`));
for (let i = 0; i < samples.length; i++) {
if (samples[i][2]) { await page.hover(`#s${i} button`); await page.waitForTimeout(250); } else { await page.mouse.move(0, 0); await page.waitForTimeout(160); }
const r = await page.evaluate((i) => { const b = document.querySelector(`#s${i} button`); const s = getComputedStyle(b);
const container = b.parentElement; const pageBg = getComputedStyle(document.body).backgroundColor;
return { color: s.color, bg: s.backgroundColor, op: s.opacity, pageBg, fontSize: s.fontSize, cursor: s.cursor, border: s.borderTopColor, cBg: getComputedStyle(container).backgroundColor }; }, i);
const page0 = parse(r.pageBg).rgb; const bgc = parse(r.bg); const fgc = parse(r.color); const op = Number(r.op);
const bgSolid = over(bgc.rgb, page0, bgc.a); const fgSolid = over(fgc.rgb, bgSolid, fgc.a);
const bgFinal = over(bgSolid, page0, op), fgFinal = over(fgSolid, page0, op);
rows.push({ theme, name: samples[i][0], text: hex(fgFinal), bg: hex(bgFinal), opacity: op, contrast: ratio(fgFinal, bgFinal).toFixed(2), fontSize: r.fontSize });
}
}
await browser.close();
console.table(rows);
fs.writeFileSync(outFile, JSON.stringify(rows, null, 1));
})();