选中项改认页签自己的标记。边框和文字用主题色,背景保持中性。整篇和自选重新描上主题色边框。 检查:language-switch-selected 1 通过;report-buttons-scope 与 class-name-definition-contract 共 9 通过。两轮都是 0 失败,退出码 0。 Co-Authored-By: Grok (agent loop)
154 lines
9.6 KiB
TypeScript
154 lines
9.6 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
|
||
// BUG-1079 (TASK-site-button-contrast-20260928). This file began as the BUG-1078
|
||
// report-only contract; the report scope is gone and one site-wide definition
|
||
// replaced it, so the file now pins that definition. Every assertion that
|
||
// changed is listed with 原值 / 新值 / 原因 in
|
||
// docs/tasks/PROGRESS-site-button-contrast-20260928.md.
|
||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||
const cssNoComments = css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||
const buttonSource = readFileSync(new URL("../src/components/ui/button.tsx", import.meta.url), "utf8");
|
||
const calendarSource = readFileSync(new URL("../src/components/ui/calendar.tsx", import.meta.url), "utf8");
|
||
const pickerSource = readFileSync(new URL("../src/components/birth-date-picker.tsx", import.meta.url), "utf8");
|
||
const generateSource = readFileSync(new URL("../src/components/personal-report/generate-personal-report-button.tsx", import.meta.url), "utf8");
|
||
|
||
function blockTokens(opener: string): Map<string, string> {
|
||
const start = css.indexOf(opener);
|
||
assert.notEqual(start, -1, `missing block: ${opener}`);
|
||
const body = css.slice(start, css.indexOf("\n}", start));
|
||
return new Map([...body.matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)].map((hit) => [hit[1], hit[2].trim()]));
|
||
}
|
||
|
||
function luminance(hex: string): number {
|
||
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
|
||
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||
}
|
||
|
||
function contrast(a: string, b: string): number {
|
||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||
return (hi + 0.05) / (lo + 0.05);
|
||
}
|
||
|
||
function rule(selector: string): string {
|
||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
const hit = cssNoComments.match(new RegExp(`(?:^|[}\\n])\\s*${escaped}\\s*\\{([^}]*)\\}`));
|
||
assert.ok(hit, `missing rule: ${selector}`);
|
||
return hit[1];
|
||
}
|
||
|
||
test("the unlayered element rules no longer override button utilities", () => {
|
||
// Root cause of BUG-1078 / BUG-1079: unlayered `button { color: inherit; }` beat
|
||
// every layered Tailwind text utility.
|
||
assert.doesNotMatch(cssNoComments, /(^|[}\n])\s*button\s*\{\s*color\s*:/);
|
||
assert.doesNotMatch(cssNoComments, /(^|[}\n])\s*button:disabled\s*\{/);
|
||
// Raw <button>s keep the old fade at the old specificity; <Button> is left to its utilities.
|
||
assert.match(cssNoComments, /button:disabled:where\(:not\(\[data-slot="button"\]\)\) \{ cursor: default; opacity: \.45; \}/);
|
||
});
|
||
|
||
test("one solid-button token pair per theme, readable at 4.5:1 or better", () => {
|
||
const light = blockTokens(":root {\n color-scheme: light;");
|
||
const preferred = blockTokens(':root:not([data-theme="light"]) {');
|
||
const pinned = blockTokens(':root[data-theme="dark"] {');
|
||
assert.equal(light.get("color-action-fill"), "#a9583e");
|
||
assert.equal(light.get("color-on-action-fill"), "#fbfaf7");
|
||
assert.equal(light.get("color-action-fill-hover"), "#8f4630");
|
||
assert.equal(light.get("color-action-fill-disabled"), "var(--color-canvas-strong)");
|
||
assert.equal(light.get("color-on-action-fill-disabled"), "var(--color-ink-tertiary)");
|
||
assert.ok(contrast("#fbfaf7", "#a9583e") >= 4.5);
|
||
assert.ok(contrast("#fbfaf7", "#8f4630") >= 4.5);
|
||
for (const dark of [preferred, pinned]) {
|
||
assert.equal(dark.get("color-action-fill"), "#8f4a33");
|
||
assert.equal(dark.get("color-on-action-fill"), "#f2f0ea");
|
||
assert.equal(dark.get("color-action-fill-hover"), "color-mix(in srgb, #8f4a33 94%, #f2f0ea)");
|
||
}
|
||
assert.ok(contrast("#f2f0ea", "#8f4a33") >= 4.5);
|
||
// D6: --color-primary keeps its mapping; text-primary icons still use it.
|
||
assert.match(css, /--color-primary: var\(--color-action\);\n --color-primary-foreground: var\(--color-on-dark\);/);
|
||
// D2: the brand coral stays for non-button fills.
|
||
assert.equal(light.get("color-action-strong"), "#cc785c");
|
||
});
|
||
|
||
test("the shared Button, .button-primary, the composer controls and the calendar read the same fill", () => {
|
||
assert.match(buttonSource, /default: "bg-action-fill text-on-action-fill hover:bg-action-fill-hover disabled:bg-action-fill-disabled disabled:text-on-action-fill-disabled disabled:opacity-100"/);
|
||
// Non-solid variants keep the old 45% disabled fade (red line 3).
|
||
assert.match(buttonSource, /disabled:pointer-events-none disabled:opacity-45/);
|
||
assert.match(buttonSource, /outline: "border-border bg-background hover:bg-muted hover:text-foreground"/);
|
||
assert.match(buttonSource, /data-slot="button"/);
|
||
assert.match(rule(".button-primary"), /background: var\(--color-action-fill\); color: var\(--color-on-action-fill\);/);
|
||
assert.match(rule(".composer .composer-row-end > button"), /color: var\(--color-on-action-fill\);[^}]*background: var\(--color-action-fill\);/);
|
||
assert.match(rule(".composer .composer-stop"), /background: var\(--color-action-fill\);/);
|
||
for (const state of ["selected-single", "range-start", "range-end"]) {
|
||
assert.match(calendarSource, new RegExp(`data-\\[${state}=true\\]:bg-action-fill data-\\[${state}=true\\]:text-on-action-fill`));
|
||
}
|
||
assert.doesNotMatch(calendarSource, /bg-primary|text-primary-foreground/);
|
||
// The `!` rescue for the selected day compensated for the root cause and is gone.
|
||
assert.doesNotMatch(pickerSource, /text-primary-foreground!/);
|
||
});
|
||
|
||
test("solid disabled buttons are grey at full opacity, never a faded clay", () => {
|
||
for (const selector of [".button-primary:disabled", ".composer .composer-row-end > button:disabled"]) {
|
||
const body = rule(selector);
|
||
assert.match(body, /opacity: 1;/);
|
||
assert.match(body, /background: var\(--color-action-fill-disabled\);/);
|
||
assert.match(body, /color: var\(--color-on-action-fill-disabled\);/);
|
||
}
|
||
});
|
||
|
||
test(".danger-primary keeps its own label colour instead of taking the new fill ink", () => {
|
||
const body = rule(".danger-primary");
|
||
assert.match(body, /background: var\(--color-danger\);/);
|
||
assert.match(body, /color: var\(--color-on-dark\);/);
|
||
assert.ok(contrast("#fbfaf7", "#9a2f2f") >= 4.5, "light 7.13");
|
||
assert.ok(contrast("#241f1c", "#e08573") >= 4.5, "dark 6.04");
|
||
});
|
||
|
||
test("the BUG-1078 report-only override is gone; the selected export preset stays neutral", () => {
|
||
assert.doesNotMatch(css, /--report-button-fill|--report-button-ink/);
|
||
assert.doesNotMatch(cssNoComments, /report-center-generate/);
|
||
assert.doesNotMatch(generateSource, /report-center-generate/);
|
||
assert.doesNotMatch(cssNoComments, /\[class~="bg-primary"\]/);
|
||
// 原值:范围单选卡与语言分段 doesNotMatch(/--color-action-soft|--color-action\b/) 且 match(/--color-ink|--color-canvas/)
|
||
// 新值:四条选中规则不得用 --color-action-soft / --color-action-fill;描边、圆点、勾选框和语言分段用 --color-action-strong(语言文字用 --color-action);底色只许 transparent / --color-canvas*(勾选框 18px 小方块的 background 可以是 --color-action-strong)
|
||
// 原因:issue #90 推翻 DESIGN「Selected states use canvas-muted + ink」。BUG-1078 的主语(不得用 --color-action-soft 深色块)保留。旧正则 --color-action\b 会误伤 --color-action-strong
|
||
// 原值:整篇卡片与语言分段的选中规则只认 aria-checked="true"
|
||
// 新值:认 data-active 或 aria-selected="true"。勾选框仍认 aria-checked
|
||
// 原因:导出范围和语言已经是页签,页签不设 aria-checked,旧规则对不上,选中和未选中看起来一样
|
||
const selected = {
|
||
mode: rule('.report-export-mode:is([data-active], [aria-selected="true"])'),
|
||
radio: rule('.report-export-mode:is([data-active], [aria-selected="true"]) .report-export-radio'),
|
||
box: rule('.report-export-row:is([aria-checked="true"], [aria-checked="mixed"]) .report-export-box'),
|
||
segment: rule('.report-export-segment button:is([data-active], [aria-selected="true"])'),
|
||
};
|
||
for (const body of Object.values(selected)) {
|
||
assert.doesNotMatch(body, /--color-action-soft|--color-action-fill\b/);
|
||
}
|
||
assert.match(selected.mode, /border-color: var\(--color-action-strong\);/);
|
||
assert.match(selected.mode, /background: transparent;/);
|
||
assert.match(selected.radio, /border: 5px solid var\(--color-action-strong\);/);
|
||
assert.match(selected.box, /border-color: var\(--color-action-strong\);/);
|
||
assert.match(selected.box, /background: var\(--color-action-strong\);/);
|
||
assert.doesNotMatch(selected.box, /(?<![\w-])color\s*:/);
|
||
assert.match(rule(".report-export-box"), /color: var\(--color-canvas\);/);
|
||
assert.match(selected.segment, /background: var\(--color-canvas\);/);
|
||
assert.match(selected.segment, /color: var\(--color-action\);/);
|
||
assert.match(selected.segment, /font-weight: 600;/);
|
||
assert.match(selected.segment, /inset 0 0 0 1px var\(--color-action-strong\)/);
|
||
const neutralBackground = /^(?:transparent|var\(--color-canvas(?:-[a-z]+)?\))$/;
|
||
for (const [name, body] of [["mode", selected.mode], ["radio", selected.radio], ["segment", selected.segment]] as const) {
|
||
for (const value of backgrounds(body)) assert.match(value, neutralBackground, name);
|
||
}
|
||
for (const value of backgrounds(selected.box)) {
|
||
assert.match(value, /^(?:transparent|var\(--color-canvas(?:-[a-z]+)?\)|var\(--color-action-strong\))$/);
|
||
}
|
||
assert.equal(blockTokens(":root {\n color-scheme: light;").get("color-action-strong"), "#cc785c");
|
||
assert.equal(blockTokens(':root:not([data-theme="light"]) {').get("color-action-strong"), "#d78064");
|
||
assert.equal(blockTokens(':root[data-theme="dark"] {').get("color-action-strong"), "#d78064");
|
||
});
|
||
|
||
function backgrounds(body: string): string[] {
|
||
return [...body.matchAll(/background(?:-color)?\s*:\s*([^;]+);/g)].map((hit) => hit[1].trim());
|
||
}
|