fix(ui): one solid-button colour site-wide; solid disabled is grey, not faded (BUG-1079)

Drop the unlayered button { color: inherit } that beat every layered Tailwind
text utility; scope the raw-button disabled fade away from <Button>. Add the
--color-action-fill token pair (light #fbfaf7 on #a9583e 4.85:1, dark #f2f0ea
on #8f4a33 5.77:1) for <Button> default, .button-primary, composer send/stop
and the calendar's selected day. Remove the BUG-1078 report-only override.

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 7db58230ad
commit 12aac87bde
7 changed files with 133 additions and 126 deletions
+45 -64
View File
@@ -35,6 +35,11 @@
--color-action-soft: var(--color-action-soft);
--color-action-hover: var(--color-action-hover);
--color-action-on-dark: var(--color-action-on-dark);
--color-action-fill: var(--color-action-fill);
--color-action-fill-hover: var(--color-action-fill-hover);
--color-on-action-fill: var(--color-on-action-fill);
--color-action-fill-disabled: var(--color-action-fill-disabled);
--color-on-action-fill-disabled: var(--color-on-action-fill-disabled);
--color-focus: var(--color-focus);
--color-ink: var(--color-ink);
--color-ink-strong: var(--color-ink-strong);
@@ -104,6 +109,15 @@
--color-action-hover: #8f4630;
--color-focus: #cc785c;
--color-action-on-dark: #d78064;
/* Solid-button fill pair (BUG-1079): one primary button colour site-wide.
#fbfaf7 on #a9583e is 4.85:1; the brand coral #cc785c stays for
non-button fills (rails, rings, carets, radios, avatars). Disabled solid
buttons are neutral grey at full opacity, never a faded clay. */
--color-action-fill: #a9583e;
--color-action-fill-hover: #8f4630;
--color-on-action-fill: #fbfaf7;
--color-action-fill-disabled: var(--color-canvas-strong);
--color-on-action-fill-disabled: var(--color-ink-tertiary);
--color-ink: #1d1d1f;
--color-ink-strong: #32322f;
--color-ink-secondary: #5f5f59;
@@ -245,6 +259,12 @@
--color-action-hover: #e59273;
--color-focus: #d78064;
--color-action-on-dark: #8f4a33;
/* #f2f0ea on #8f4a33 is 5.77:1; hover mixes 6% ink in. */
--color-action-fill: #8f4a33;
--color-action-fill-hover: color-mix(in srgb, #8f4a33 94%, #f2f0ea);
--color-on-action-fill: #f2f0ea;
--color-action-fill-disabled: var(--color-canvas-strong);
--color-on-action-fill-disabled: var(--color-ink-tertiary);
--color-ink: #f2f0ea;
--color-ink-strong: #e2dfd6;
@@ -333,6 +353,12 @@
--color-action-hover: #e59273;
--color-focus: #d78064;
--color-action-on-dark: #8f4a33;
/* #f2f0ea on #8f4a33 is 5.77:1; hover mixes 6% ink in. */
--color-action-fill: #8f4a33;
--color-action-fill-hover: color-mix(in srgb, #8f4a33 94%, #f2f0ea);
--color-on-action-fill: #f2f0ea;
--color-action-fill-disabled: var(--color-canvas-strong);
--color-on-action-fill-disabled: var(--color-ink-tertiary);
--color-ink: #f2f0ea;
--color-ink-strong: #e2dfd6;
@@ -407,9 +433,13 @@ html, body { width: 100%; height: 100%; overflow: hidden; }
body { margin: 0; background: var(--color-canvas); color: var(--color-ink); font-family: var(--font-body); text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; }
button, input, textarea, select { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; }
button:disabled { cursor: default; opacity: .45; }
/* BUG-1079: the old unlayered `button { color: inherit; }` beat every layered
Tailwind text utility (Tailwind's preflight already inherits color in
@layer base), so solid <Button>s showed the page ink on clay. The disabled
fade stays for raw <button>s at the same specificity; <Button>
(data-slot="button") takes its disabled look from its own utilities. */
button:disabled:where(:not([data-slot="button"])) { cursor: default; opacity: .45; }
.app-loading-content { width: min(420px, 100%); display: grid; justify-items: center; gap: 8px; color: var(--color-ink-secondary); text-align: center; }
.app-loading-symbol { position: relative; width: 56px; height: 56px; display: grid; place-items: center; }
.app-loading-mark { width: 30px; height: 30px; background: url("/jyotish-logo.png") center / contain no-repeat; }
@@ -1800,9 +1830,10 @@ button:disabled { cursor: default; opacity: .45; }
.composer-row-end { display: flex; flex: 1; align-items: center; justify-content: flex-end; gap: var(--space-3); }
.composer:focus-within { border-color: var(--color-action); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-action) 15%, transparent); }
.composer textarea { width: 100%; min-width: 0; min-height: 28px; max-height: 148px; resize: none; padding: var(--space-1) 0 0; border: 0; outline: 0; background: transparent; color: var(--color-ink); line-height: 1.5; font-size: var(--type-body-md); }
.composer .composer-row-end > button { width: 36px; height: 36px; display: grid; flex: 0 0 auto; place-items: center; border: 0; color: var(--color-on-dark); cursor: pointer; transition: background-color 120ms ease-out, transform 120ms ease-out; border-radius: 999px; background: var(--color-action-strong); }
.composer .composer-stop { background: var(--color-action-strong); }
.composer .composer-stop:not(:disabled):hover { background: var(--color-action-hover); }
.composer .composer-row-end > button { width: 36px; height: 36px; display: grid; flex: 0 0 auto; place-items: center; border: 0; color: var(--color-on-action-fill); cursor: pointer; transition: background-color 120ms ease-out, transform 120ms ease-out; border-radius: 999px; background: var(--color-action-fill); }
.composer .composer-row-end > button:disabled { opacity: 1; background: var(--color-action-fill-disabled); color: var(--color-on-action-fill-disabled); }
.composer .composer-stop { background: var(--color-action-fill); }
.composer .composer-stop:not(:disabled):hover { background: var(--color-action-fill-hover); }
.character-remaining {
flex-shrink: 0;
color: var(--color-ink-tertiary);
@@ -1998,10 +2029,12 @@ button.chart-library-item { cursor: pointer; text-align: left; }
input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input), select:not([class^="ant-"]):not([class*=" ant-"]) { width: 100%; min-height: 44px; padding: 0 var(--space-3); border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: var(--type-body-md); }
input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled, select:not([class^="ant-"]):not([class*=" ant-"]):disabled { color: var(--color-ink-tertiary); background: var(--color-canvas-muted); }
.button-primary, .button-secondary { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0 var(--space-4); border: 1px solid var(--color-action); cursor: pointer; text-decoration: none; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; border-radius: var(--radius-md); font-size: var(--type-body-sm); font-weight: 500; }
.button-primary { border-color: var(--color-action-strong); background: var(--color-action-strong); color: var(--color-on-dark); }
.button-primary { border-color: var(--color-action-fill); background: var(--color-action-fill); color: var(--color-on-action-fill); }
.button-primary:disabled { opacity: 1; border-color: var(--color-action-fill-disabled); background: var(--color-action-fill-disabled); color: var(--color-on-action-fill-disabled); }
.button-secondary { border-color: var(--color-border-strong); background: var(--color-canvas); color: var(--color-ink); }
.form-error, .form-success { margin: var(--space-3) 0 0; padding: var(--space-3); border-left: 3px solid currentColor; line-height: 1.5; border-radius: 0 var(--radius-md) var(--radius-md) 0; font-size: var(--type-caption); }
.danger-primary { border-color: var(--color-danger); background: var(--color-danger); }
/* Keeps its own label colour: 7.13:1 light, 6.04:1 dark (BUG-1079 D5). */
.danger-primary { border-color: var(--color-danger); background: var(--color-danger); color: var(--color-on-dark); }
.auth-page { display: grid; place-items: center; padding: var(--space-8); background: var(--color-canvas-soft); }
.auth-shell { width: min(1040px, 100%); min-height: min(680px, calc(100dvh - 64px)); display: grid; grid-template-columns: 1.08fr .92fr; overflow: hidden; border-radius: var(--radius-xl); background: var(--color-canvas); box-shadow: var(--shadow-elevated); }
@@ -2050,7 +2083,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
@media (hover: hover) {
.new-chat:not(:disabled):hover { background: var(--sidebar-accent); color: var(--color-action-hover); }
.composer button:not(:disabled):hover, .button-primary:not(:disabled):hover { background: var(--color-action-hover); }
.composer button:not(:disabled):hover { background: var(--color-action-hover); }
.button-primary:not(:disabled):hover, .composer .composer-row-end > button:not(:disabled):hover { background: var(--color-action-fill-hover); }
.model-selector-trigger:not(:disabled):hover { background: var(--color-canvas-muted); color: var(--color-ink); }
.model-selector-option:hover { background: var(--color-canvas-soft); color: var(--color-ink); }
.chat-profile-picker-trigger:hover { background: var(--color-canvas-muted); color: var(--color-ink); }
@@ -3998,62 +4032,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
@media (min-width: 860px) { .report-export-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media print { .report-export-drawer { display: none !important; } }
/* ── 我的报告 buttons (BUG-1078) ────────────────────────────────────────────
Scoped to the report surfaces only: the list (`.report-center-*`), the
reader and its export drawer (`.personal-report-reader`), and the
state / error / not-found / loading screens (`.personal-report-state`).
The global tokens and components/ui/button.tsx stay as they are; other
pages keep the shared primary button (product decision 2026-09-28).
Why the shared solid button looked wrong: the unlayered `button { color:
inherit; }` near the top of this sheet beats Tailwind's layered
`text-primary-foreground`, so a solid <button> renders in the inherited ink —
#1d1d1f on #a9583e in the light theme (3.33:1, the reported 「黑字深红底」)
and #f2f0ea on #d78064 in the dark theme (2.57:1). The unlayered
`button:disabled { opacity: .45 }` then fades the whole thing.
- Solid (`variant="default"`): light = #fbfaf7 on #a9583e (4.85:1);
dark = --color-ink #f2f0ea on --color-action-on-dark #8f4a33 (5.77:1),
hover mixes 6% ink in (5.1:1).
- Disabled, any variant: a neutral grey fill with tertiary ink, opacity 1.
「生成报告」 lives in the SecondaryPageShell header, outside these roots,
so it carries its own `.report-center-generate` hook for this rule.
The solid variant is matched as `[class~="bg-primary"]`, not `.bg-primary`:
this sheet does not define Tailwind utilities, and class-name-definition-
contract reads every `.name` here as one of our own class families. */
:is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) {
--report-button-fill: var(--color-action);
/* Same as the shared `hover:bg-primary/80`, so the light theme is unchanged. */
--report-button-fill-hover: color-mix(in srgb, var(--color-action) 80%, transparent);
--report-button-ink: var(--color-on-dark);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) :is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) {
--report-button-fill: var(--color-action-on-dark);
--report-button-fill-hover: color-mix(in srgb, var(--color-action-on-dark) 94%, var(--color-ink));
--report-button-ink: var(--color-ink);
}
}
:root[data-theme="dark"] :is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) {
--report-button-fill: var(--color-action-on-dark);
--report-button-fill-hover: color-mix(in srgb, var(--color-action-on-dark) 94%, var(--color-ink));
--report-button-ink: var(--color-ink);
}
:is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) [data-slot="button"][class~="bg-primary"] {
background: var(--report-button-fill);
color: var(--report-button-ink);
}
@media (hover: hover) {
:is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) [data-slot="button"][class~="bg-primary"]:not(:disabled):hover {
background: var(--report-button-fill-hover);
}
}
:is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) [data-slot="button"]:disabled,
.report-center-generate[data-slot="button"]:disabled {
opacity: 1;
border-color: transparent;
background: var(--color-canvas-strong);
color: var(--color-ink-tertiary);
}
/* ============================================================
personal-report: unique block — editorial reader + A4 print
Only /reports/[reportId] uses these semantic classes. The report owns its
@@ -4778,6 +4756,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
--color-action: #a9583e;
--color-action-strong: #cc785c;
--color-action-hover: #8f4630;
--color-action-fill: #a9583e;
--color-action-fill-hover: #8f4630;
--color-on-action-fill: #fbfaf7;
--color-focus: #cc785c;
--color-danger: #9a2f2f;
--color-success: #28633e;
@@ -50,7 +50,7 @@ export function BirthDatePicker({ value, disabled, onChange }: BirthDatePickerPr
<Calendar
key={value || "empty"}
mode="single"
className="[--cell-size:2.75rem] [&_button[data-selected-single=true]]:text-primary-foreground!"
className="[--cell-size:2.75rem]"
locale={zhCN}
selected={selected}
defaultMonth={selected ?? defaultMonth}
@@ -207,7 +207,7 @@ export function GeneratePersonalReportButton({
type="button"
variant="outline"
size="sm"
className="report-center-generate shrink-0 whitespace-nowrap"
className="shrink-0 whitespace-nowrap"
onClick={() => void handleGenerate()}
disabled={submitting}
title="根据已保存的具体出生分钟生成完整报告;未校正会标明方向性参考"
+2 -2
View File
@@ -4,11 +4,11 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"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-45 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
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",
outline: "border-border bg-background hover:bg-muted hover:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-muted",
ghost: "hover:bg-muted hover:text-foreground",
+1 -1
View File
@@ -214,7 +214,7 @@ function CalendarDayButton({
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto min-h-(--cell-size) w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
"relative isolate z-10 flex aspect-square size-auto min-h-(--cell-size) w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-action-fill data-[range-end=true]:text-on-action-fill data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-action-fill data-[range-start=true]:text-on-action-fill data-[selected-single=true]:bg-action-fill data-[selected-single=true]:text-on-action-fill dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
@@ -2,13 +2,17 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
// BUG-1078 (TASK-chart-types-and-report-buttons-20260928 T7). The report
// surfaces get their own button colours; the shared button and the theme
// tokens must stay exactly as other pages use them.
// 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");
const SCOPE = ":is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader)";
function blockTokens(opener: string): Map<string, string> {
const start = css.indexOf(opener);
@@ -28,66 +32,85 @@ function contrast(a: string, b: string): number {
return (hi + 0.05) / (lo + 0.05);
}
test("the shared button keeps its variants, disabled fade and data-slot hook", () => {
assert.match(buttonSource, /default: "bg-primary text-primary-foreground hover:bg-primary\/80"/);
assert.match(buttonSource, /disabled:pointer-events-none disabled:opacity-50/);
assert.match(buttonSource, /data-slot="button"/);
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("the theme tokens the fix reads are unchanged", () => {
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"), "#a9583e");
assert.equal(light.get("color-on-dark"), "#fbfaf7");
assert.equal(light.get("color-action-on-dark"), "#d78064");
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"), "#d78064");
assert.equal(dark.get("color-on-dark"), "#241f1c");
assert.equal(dark.get("color-action-on-dark"), "#8f4a33");
assert.equal(dark.get("color-ink"), "#f2f0ea");
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("button overrides exist only inside the report scope", () => {
const rules = [...css.matchAll(/([^{}]*\[data-slot="button"\][^{}]*)\{/g)].map((hit) => hit[1].trim());
assert.ok(rules.length >= 3, "solid, hover and disabled rules are present");
assert.ok(rules.some((selector) => selector.endsWith('[data-slot="button"][class~="bg-primary"]')), "solid rule matches the default variant");
// Comments stripped, as class-name-definition-contract does.
assert.doesNotMatch(css.replace(/\/\*[\s\S]*?\*\//g, " "), /\.bg-primary\b/, "the sheet must not define the Tailwind utility itself");
for (const selector of rules) assert.ok(selector.startsWith(SCOPE), `unscoped button rule: ${selector}`);
// 「生成报告」 sits in the SecondaryPageShell header, outside the report roots;
// it joins the disabled rule through its own report-owned hook.
assert.match(css, new RegExp(`${SCOPE.replace(/[().]/g, "\\$&")} \\[data-slot="button"\\]:disabled,\\n\\.report-center-generate\\[data-slot="button"\\]:disabled \\{\\n opacity: 1;`));
// Both dark paths (OS preference and pinned) switch the fill.
assert.equal(css.split(`:root:not([data-theme="light"]) ${SCOPE}`).length - 1, 1);
assert.equal(css.split(`:root[data-theme="dark"] ${SCOPE}`).length - 1, 1);
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("solid report buttons read at least 4.5:1 in both themes, up from inherited ink", () => {
// Before: the global unlayered `button { color: inherit; }` beat
// `text-primary-foreground`, so the label was the page ink.
assert.ok(contrast("#1d1d1f", "#a9583e") < 4.5, "light before: near-black on clay");
assert.ok(contrast("#f2f0ea", "#d78064") < 4.5, "dark before: ink on lifted coral");
assert.ok(contrast("#fbfaf7", "#a9583e") >= 4.5, "light after");
assert.ok(contrast("#f2f0ea", "#8f4a33") >= 4.5, "dark after");
// The override must set `color` itself, or the global inherit wins again.
const solid = css.match(/\[data-slot="button"\]\[class~="bg-primary"\] \{([^}]*)\}/);
assert.ok(solid);
assert.match(solid[1], /color: var\(--report-button-ink\);/);
assert.match(css, /\nbutton \{ color: inherit; \}/, "the global rule this works around is unchanged");
});
test("the selected export preset no longer uses the dark red-brown action-soft fill", () => {
const rule = css.match(/\.report-export-presets \[aria-pressed="true"\] \{([^}]*)\}/);
assert.ok(rule);
assert.doesNotMatch(rule[1], /--color-action-soft|--color-action\b/);
assert.match(rule[1], /--color-ink/);
});
test("the header 「生成报告」 button carries the report hook, so submitting is grey rather than faded", () => {
assert.match(generateSource, /className="report-center-generate shrink-0 whitespace-nowrap"/);
assert.match(generateSource, /disabled=\{submitting\}/);
const shell = readFileSync(new URL("../src/components/secondary-page-shell.tsx", import.meta.url), "utf8");
assert.doesNotMatch(shell, /report-center/);
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"\]/);
const preset = rule('.report-export-presets [aria-pressed="true"]');
assert.doesNotMatch(preset, /--color-action-soft|--color-action\b/);
assert.match(preset, /--color-ink/);
});
@@ -56,6 +56,9 @@ test("lets model controls fit their labels and keeps the stop action on-brand",
assert.doesNotMatch(globalStyles, /\.model-selector-copy b\s*\{[^}]*text-overflow:\s*ellipsis/);
// 原值 `var(--color-action)` / 新值 `var(--color-action-strong)` / 原因:停止键是填充面,
// 走品牌 coral 那一阶;文字阶留给 --color-action。见 BUG-738。
assert.match(globalStyles, /\.composer \.composer-stop\s*\{[^}]*background:\s*var\(--color-action-strong\)/);
assert.match(globalStyles, /\.composer \.composer-stop:not\(:disabled\):hover\s*\{[^}]*background:\s*var\(--color-action-hover\)/);
// 原值 `var(--color-action-strong)` + 悬停 `var(--color-action-hover)` / 新值
// `var(--color-action-fill)` + 悬停 `var(--color-action-fill-hover)` / 原因:BUG-1079
// 全站实心按钮统一一种底色(浅 #a9583e 4.85:1、深 #8f4a33 5.77:1),停止键与发送键同色。
assert.match(globalStyles, /\.composer \.composer-stop\s*\{[^}]*background:\s*var\(--color-action-fill\)/);
assert.match(globalStyles, /\.composer \.composer-stop:not\(:disabled\):hover\s*\{[^}]*background:\s*var\(--color-action-fill-hover\)/);
});