docs(ui): BUG-1079 progress, design, bug history, changelog, device checklist, contrast harness
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
12aac87bde
commit
fa58df16eb
@@ -1,59 +1,120 @@
|
||||
// Button contrast harness (TASK-site-button-contrast-20260928).
|
||||
// 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:
|
||||
// mkdir -p /tmp/btn && cp frontend/.next/static/chunks/*.css /tmp/btn/ # or download staging's CSS:
|
||||
// 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
|
||||
// node docs/testing/button-contrast-harness.cjs /tmp/btn [frontend-dir] [out.json]
|
||||
//
|
||||
// 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.
|
||||
// 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 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 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 默认(实心)", `<button data-slot="button" class="${base} ${V.default}">导出</button>`],
|
||||
["Button 默认 · 禁用", `<button data-slot="button" disabled class="${base} ${V.default}">导出</button>`],
|
||||
["Button 描边", `<button data-slot="button" class="${base} ${V.outline}">取消</button>`],
|
||||
["Button 描边 · 禁用", `<button data-slot="button" disabled class="${base} ${V.outline}">取消</button>`],
|
||||
["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>`],
|
||||
["输入框发送键", `<div class="composer"><div class="composer-row-end"><button>↑</button></div></div>`],
|
||||
["输入框发送键 · 禁用", `<div class="composer"><div class="composer-row-end"><button disabled>↑</button></div></div>`],
|
||||
["报告页 Button 默认(BUG-1078 后)", `<div class="personal-report-reader"><button data-slot="button" class="${base} ${V.default}">导出选中</button></div>`],
|
||||
["报告页 Button 默认 · 禁用", `<div class="personal-report-reader"><button data-slot="button" disabled class="${base} ${V.default}">导出选中</button></div>`],
|
||||
[".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 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("");
|
||||
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(([n,h],i)=>`<div id="s${i}" style="padding:8px">${h}</div>`).join("")}</body></html>`;
|
||||
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++) {
|
||||
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 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 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) });
|
||||
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(path.join(dir,"contrast.json"), JSON.stringify(rows,null,1));
|
||||
fs.writeFileSync(outFile, JSON.stringify(rows, null, 1));
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user