// 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(/ ``; 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-primary · 禁用", ``], [".danger-primary", ``], [".danger-primary · 禁用", ``], [".button-secondary", ``], [".button-secondary · 禁用", ``], ["输入框发送键", `
${B("default", "icon")}
`], ["输入框发送键 · 禁用", `
${B("default", "icon", "", "disabled")}
`], ["输入框停止键", `
${B("default", "icon", "composer-stop")}
`], ["日历选中日", `
${B("ghost", "icon", dayClass, 'data-selected-single="true"')}
`], ["日期选择器(未选,占位)", B("outline", "default", triggerClass, 'data-empty="true"')], ["报告页 Button 默认", `
${B("default")}
`], ["报告页 Button 默认 · 禁用", `
${B("default", "default", "", "disabled")}
`], ["报告页 描边 · 禁用", `
${B("outline", "default", "", "disabled")}
`], ["报告页页眉「生成报告」· 禁用", 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 · 悬停", ``, true], [".danger-primary · 悬停", ``, true], ["输入框发送键 · 悬停", `
${B("default", "icon")}
`, true], ["日历选中日 · 悬停", `
${B("ghost", "icon", dayClass, 'data-selected-single="true"')}
`, 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 = `${cssFiles.map(f => ``).join("")}${samples.map(([, h], i) => `
${h}
`).join("")}`; 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)); })();