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:
Jesse_Chen
2026-09-28 17:42:23 +08:00
co-authored by Claude Opus 5.5
parent 12aac87bde
commit fa58df16eb
8 changed files with 284 additions and 35 deletions
+89 -28
View File
@@ -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));
})();
@@ -0,0 +1,21 @@
# 真机清单 · 全站实心按钮配色与禁用态(BUG-1079,2026-09-28)
每一项在**浅色**和**深色**各走一遍(手机「设置 → 显示与亮度」切换,或浏览器外观设置)。看两件事:①实心按钮是白字配陶土色、字清楚;②按钮不可点时是灰底灰字,不是半透明的淡红。
| # | 页面 / 位置 | 操作 | 期望 |
| --- | --- | --- | --- |
| 1 | 登录页 | 输入邮箱前后看「发送验证码」 | 不可点时灰;可点时白字陶土底 |
| 2 | 首次引导 | 资料没填完 / 填完的「下一步」「保存」 | 同上 |
| 3 | 点数与订阅面板 | 打开购买点数、订阅 | 购买类按钮白字陶土底 |
| 4 | 兑换码付费墙 | 输入兑换码前后 | 空时灰,有码后白字陶土底 |
| 5 | 人物档案 | 「重试」;删除人物的确认按钮 | 重试是陶土底;删除确认仍是深红底白字(浅色)/ 浅红底深字(深色),不变 |
| 6 | 设置 → 星盘资料 | 修改后「保存」 | 白字陶土底;保存中灰 |
| 7 | 星历 | 未登录时「去登录」、出错时「重试」 | 白字陶土底 |
| 8 | 生时校正 | 各卡片的主按钮(提交、继续、再次校正) | 白字陶土底;提交中灰 |
| 9 | 对话输入框 | 空输入时的发送键、输入后发送键、生成中的停止键、排队区 | 空时灰;可发时陶土底白箭头;停止键同色 |
| 10 | 出生日期选择器 | 打开日历选一天;未选时的占位文字 | 选中日白字陶土底;占位字是浅灰(以前是正文黑) |
| 11 | 我的报告 → 分块导出 | 不勾选 / 勾选后「导出选中」 | 与上次一样:未选灰、选后白字陶土底 |
| 12 | 我的报告列表页眉「生成报告」 | 点一次,看「正在创建…」 | 描边按钮变淡(与其它描边按钮一致;上次报告单里是灰底,现已统一) |
| 13 | 悬停(电脑) | 鼠标移到上述实心按钮 | 浅色变更深、深色略提亮,字始终清楚 |
对比度数值与测量方法:`node docs/testing/button-contrast-harness.cjs <css 目录>`,见脚本头部说明。