// Button contrast harness (TASK-site-button-contrast-20260928). // Renders sample buttons against the real built CSS in headless Chrome, light // and dark, and prints text / background / opacity / contrast per sample. // Not a repo dependency: uses the machine's global playwright-core + Chrome. // // Usage: // mkdir -p /tmp/btn && cp frontend/.next/static/chunks/*.css /tmp/btn/ # or download staging's 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 // // Only copy a *fresh* build's CSS: a stale .next once hid a live fix during // the 2026-09-28 scan. Update `base` / `V` below if button.tsx classes change. 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 cssFiles = fs.readdirSync(dir).filter(f => f.endsWith(".css")); const base = "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-[background-color,border-color,color,box-shadow,transform] duration-150 ease-out outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:scale-[0.96] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 h-11 gap-2 px-4"; const V = { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "border-border bg-background hover:bg-muted hover:text-foreground", ghost: "hover:bg-muted hover:text-foreground" }; const samples = [ ["Button 默认(实心)", ``], ["Button 默认 · 禁用", ``], ["Button 描边", ``], ["Button 描边 · 禁用", ``], [".button-primary", ``], [".button-primary · 禁用", ``], [".danger-primary", ``], [".danger-primary · 禁用", ``], [".button-secondary", ``], ["输入框发送键", `
`], ["输入框发送键 · 禁用", `
`], ["报告页 Button 默认(BUG-1078 后)", `
`], ["报告页 Button 默认 · 禁用", `
`], ]; 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 parse(c) { const m = c.match(/[\d.]+/g).map(Number); return { rgb: m.slice(0,3), 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(([n,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 { const b = document.querySelector(`#s${i} button`); const s = getComputedStyle(b); const bodyBg = getComputedStyle(document.body).backgroundColor; return { color: s.color, bg: s.backgroundColor, op: s.opacity, bodyBg }; }, i); const page0 = parse(r.bodyBg).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) }); } } await browser.close(); console.table(rows); fs.writeFileSync(path.join(dir,"contrast.json"), JSON.stringify(rows,null,1)); })();