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:
co-authored by
Claude Opus 5.5
parent
7db58230ad
commit
12aac87bde
@@ -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="根据已保存的具体出生分钟生成完整报告;未校正会标明方向性参考"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user