From 75660ef3db29a74c07d117651f490d995c5bdfa8 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 26 Jul 2026 21:39:26 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20style(theme):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=B8=BB=E8=A6=81=E6=93=8D=E4=BD=9C=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E6=80=81=E5=9C=A8=E5=90=84=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E4=B8=8B=E5=87=A0=E4=B9=8E=E4=B8=8D=E5=8F=AF=E8=BE=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因在 token 层:--gn-ant-primary-hover 与 --gn-ant-primary-active 都直接取 palette.accent2, 两者取值完全相同,hover 与按下态在视觉上无任何区别;而 accent2 又是 accent 的近邻色 (内置主题的强调色刻意低饱和),实测 base→hover 对比度仅 1.05–1.29, 其中 Deep Ocean 的 accent2 甚至比 accent 更亮(调色板方向反了)。 - 新增 mixHex 与 --gn-accent-hover / --gn-accent-active,按 82% / 60% 派生逐级加深的三态, 并让 --gn-ant-primary-hover/active 指向它。该链路经 customTheme.ts:read → App.tsx colorPrimaryHover/Active 传给 antd,全站主要操作按钮一并受益 - 混合锚点按模式区分:深色主题向面板底色混合;浅色主题的面板比强调色更亮, 向面板混合会把按钮冲淡成禁用观感,改为向纯黑混合(用 fg1 只能到 1.19–1.22 不达阈值) - 提交事务按钮(DataGrid 与 SQL 编辑器两处)拆分 :hover 与 :active, 此前两者共用同一条规则、点击无反馈;回退值保留 accent-2 兼容旧自定义主题 - 补 4 项不变式测试:三态互不相同、两段对比度均达 1.25、亮度单调递减不反向。 该用例已实测抓住浅色主题的方向反转与阈值不足 实测:深色主题 base→hover 1.31–1.39、hover→active 1.43–1.55;浅色 1.35 / 1.45。 未处理:默认 v2 深色主题未定义 --gn-on-accent 而回退 #fff,白字压在 #22c55e 上仅 2.28、 禁用态文字 2.05,均不满足 WCAG AA;按本次确认范围未纳入。 --- frontend/src/utils/customThemePresets.ts | 77 ++++++++++++- .../customThemePresetsAccentStates.test.ts | 104 ++++++++++++++++++ frontend/src/v2-theme.css | 20 +++- 3 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 frontend/src/utils/customThemePresetsAccentStates.test.ts diff --git a/frontend/src/utils/customThemePresets.ts b/frontend/src/utils/customThemePresets.ts index 67065def..9ac4335a 100644 --- a/frontend/src/utils/customThemePresets.ts +++ b/frontend/src/utils/customThemePresets.ts @@ -62,7 +62,63 @@ export type BuiltinCustomThemePreset = CustomThemeDefinition & { }; }; -const BUILTIN_THEME_REVISION = 2026071301; +const BUILTIN_THEME_REVISION = 2026072601; + +/** + * 主要操作按钮交互态的派生比例(强调色占比,其余混入面板底色)。 + * + * 原先 --gn-ant-primary-hover 与 --gn-ant-primary-active 都直接取 palette.accent2, + * 二者取值完全相同,因此 hover 与按下态在视觉上无任何区别;而 accent2 本身又是 accent 的 + * 近邻色(内置主题的强调色刻意低饱和),实测 base→hover 对比度仅 1.05–1.29, + * 其中 Deep Ocean 的 accent2 甚至比 accent 更亮(调色板方向反了),几乎完全看不出状态变化。 + * + * 改为按固定比例混合派生「逐级加深」的三态:与调色板自身的取值无关, + * 因此内置主题与用户自定义主题都能得到一致可辨的三态。 + * 实测深色主题下 base→hover 1.31–1.39、hover→active 1.43–1.55, + * 且强调色占比不低于 60%,色相仍可辨认。 + * + * 混合锚点必须按模式区分,否则方向会反: + * - 深色主题的面板底色比强调色更暗,向面板混合即加深,且能留在主题自身的色系里; + * - 浅色主题的面板底色比强调色更亮(如 warm-paper 的 #fffcf5 vs accent #2d7864), + * 向面板混合会把按钮**冲淡**、与页面对比更弱,看起来像被禁用。 + * 浅色主题改为向纯黑混合:其 fg1 与 accent 都偏深(#292722 vs #2d7864), + * 用 fg1 作锚点亮度变化太小(base→hover 仅 1.19–1.22,仍不达阈值), + * 纯黑可得 1.35 / 1.45,与深色主题的 1.31 / 1.43 观感一致。 + */ +const ACCENT_HOVER_RATIO = 0.82; +const ACCENT_ACTIVE_RATIO = 0.60; + +/** accentStateAnchor 返回让强调色「加深」的混合锚点。 */ +const accentStateAnchor = (palette: BuiltinThemePalette): string => ( + palette.mode === 'light' ? '#000000' : palette.panel +); + +const parseHexColor = (value: string): [number, number, number] | null => { + const text = value.trim().replace(/^#/, ''); + if (!/^[0-9a-fA-F]{6}$/.test(text)) { + return null; + } + return [ + Number.parseInt(text.slice(0, 2), 16), + Number.parseInt(text.slice(2, 4), 16), + Number.parseInt(text.slice(4, 6), 16), + ]; +}; + +/** mixHex 按 ratio 保留 accent、其余混入 towards,返回十六进制。 */ +const mixHex = (accent: string, towards: string, ratio: number): string => { + const a = parseHexColor(accent); + const b = parseHexColor(towards); + if (!a || !b) { + // 任一侧不是 6 位十六进制(例如被改成 rgba)时保持原值,避免产出非法颜色。 + return accent; + } + const channel = (index: number): string => { + const mixed = Math.round(a[index] * ratio + b[index] * (1 - ratio)); + return Math.max(0, Math.min(255, mixed)).toString(16).padStart(2, '0'); + }; + return `#${channel(0)}${channel(1)}${channel(2)}`; +}; const createBuiltinThemeCss = (id: string, palette: BuiltinThemePalette): string => `/* GoNavi built-in theme: ${id} */ body[data-custom-theme], @@ -118,9 +174,12 @@ body[data-custom-theme][data-ui-version="v2"] { --gn-kbd-bg: ${palette.kbdBg}; --gn-kbd-fg: ${palette.kbdFg}; + --gn-accent-hover: ${mixHex(palette.accent, accentStateAnchor(palette), ACCENT_HOVER_RATIO)}; + --gn-accent-active: ${mixHex(palette.accent, accentStateAnchor(palette), ACCENT_ACTIVE_RATIO)}; + --gn-ant-primary: ${palette.accent}; - --gn-ant-primary-hover: ${palette.accent2}; - --gn-ant-primary-active: ${palette.accent2}; + --gn-ant-primary-hover: ${mixHex(palette.accent, accentStateAnchor(palette), ACCENT_HOVER_RATIO)}; + --gn-ant-primary-active: ${mixHex(palette.accent, accentStateAnchor(palette), ACCENT_ACTIVE_RATIO)}; --gn-ant-primary-bg: ${palette.accentSoft}; --gn-ant-primary-bg-hover: ${palette.accentSoftHover}; --gn-ant-primary-border: ${palette.accent}; @@ -176,13 +235,21 @@ body[data-custom-theme][data-ui-version="v2"] .ant-btn-primary.ant-btn-dangerous body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-transaction-commit-button:hover, body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-transaction-commit-button:focus, -body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-transaction-commit-button:focus-visible, -body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-transaction-commit-button:active { +body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-transaction-commit-button:focus-visible { border-color: var(--gn-ant-primary-border) !important; background: var(--gn-ant-primary-bg-hover) !important; box-shadow: 0 0 0 1px var(--gn-ant-control-outline), var(--gn-shadow-sm) !important; } +/* 按下态与 hover 必须可区分:原先 :active 与 :hover 共用同一条规则, + 点击时没有任何视觉反馈。 */ +body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-transaction-commit-button:active { + border-color: var(--gn-accent-active) !important; + background: var(--gn-accent-active) !important; + color: var(--gn-on-accent, #fff) !important; + box-shadow: none !important; +} + body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-transaction-commit-button .gn-v2-toolbar-kbd { background: var(--gn-ant-primary-bg) !important; color: var(--gn-accent-text, var(--gn-accent)) !important; diff --git a/frontend/src/utils/customThemePresetsAccentStates.test.ts b/frontend/src/utils/customThemePresetsAccentStates.test.ts new file mode 100644 index 00000000..3cf81316 --- /dev/null +++ b/frontend/src/utils/customThemePresetsAccentStates.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import { BUILTIN_CUSTOM_THEME_PRESETS } from './customThemePresets'; + +/** + * 内置主题的「主要操作按钮」三态可辨性不变式。 + * + * 回归背景:--gn-ant-primary-hover 与 --gn-ant-primary-active 原先都直接取 palette.accent2, + * 二者取值完全相同,hover 与按下态在视觉上无任何区别;而 accent2 又是 accent 的近邻色 + * (内置主题的强调色刻意低饱和),实测 base→hover 对比度仅 1.05–1.29, + * 其中 Deep Ocean 的 accent2 比 accent 更亮(调色板方向反了), + * 导致「有待提交变更」这类关键状态的交互反馈几乎不可见。 + * + * 本用例断言生成 CSS(函数返回值)里的派生结果,而不是读取源码文本: + * 一旦有人把 hover/active 改回 accent2 或让两者相同,这里会失败。 + */ + +const readToken = (css: string, token: string): string => { + const match = new RegExp(`${token}:\\s*([^;]+);`).exec(css); + if (!match) { + throw new Error(`生成的主题 CSS 缺少 ${token}`); + } + return match[1].trim(); +}; + +const parseHex = (value: string): [number, number, number] => { + const text = value.trim().replace(/^#/, ''); + expect(text, `${value} 应为 6 位十六进制颜色`).toMatch(/^[0-9a-fA-F]{6}$/); + return [ + Number.parseInt(text.slice(0, 2), 16), + Number.parseInt(text.slice(2, 4), 16), + Number.parseInt(text.slice(4, 6), 16), + ]; +}; + +const channelLuminance = (channel: number): number => { + const c = channel / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +}; + +const relativeLuminance = ([r, g, b]: [number, number, number]): number => ( + 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b) +); + +const contrastRatio = (a: string, b: string): number => { + const la = relativeLuminance(parseHex(a)); + const lb = relativeLuminance(parseHex(b)); + const [hi, lo] = la >= lb ? [la, lb] : [lb, la]; + return (hi + 0.05) / (lo + 0.05); +}; + +/** 填充色变化的可感知下限。低于此值时状态切换在实机上基本看不出来。 */ +const MIN_STATE_DELTA = 1.25; + +describe('内置主题的主要操作按钮三态', () => { + it('每个内置主题都提供 accent / hover / active 三个不同的派生色', () => { + expect(BUILTIN_CUSTOM_THEME_PRESETS.length).toBeGreaterThan(0); + + for (const preset of BUILTIN_CUSTOM_THEME_PRESETS) { + const accent = readToken(preset.css, '--gn-ant-primary'); + const hover = readToken(preset.css, '--gn-ant-primary-hover'); + const active = readToken(preset.css, '--gn-ant-primary-active'); + + expect(hover, `${preset.id} 的 hover 不应等于基色`).not.toBe(accent); + expect(active, `${preset.id} 的 active 不应等于基色`).not.toBe(accent); + expect(active, `${preset.id} 的 active 不应等于 hover(否则按下无反馈)`).not.toBe(hover); + } + }); + + it('accent → hover → active 的对比度变化均达到可感知阈值', () => { + for (const preset of BUILTIN_CUSTOM_THEME_PRESETS) { + const accent = readToken(preset.css, '--gn-ant-primary'); + const hover = readToken(preset.css, '--gn-ant-primary-hover'); + const active = readToken(preset.css, '--gn-ant-primary-active'); + + expect( + contrastRatio(accent, hover), + `${preset.id} 的 base→hover 对比度不足`, + ).toBeGreaterThanOrEqual(MIN_STATE_DELTA); + expect( + contrastRatio(hover, active), + `${preset.id} 的 hover→active 对比度不足`, + ).toBeGreaterThanOrEqual(MIN_STATE_DELTA); + } + }); + + it('三态亮度单调递减,不出现深浅方向颠倒', () => { + for (const preset of BUILTIN_CUSTOM_THEME_PRESETS) { + const accent = relativeLuminance(parseHex(readToken(preset.css, '--gn-ant-primary'))); + const hover = relativeLuminance(parseHex(readToken(preset.css, '--gn-ant-primary-hover'))); + const active = relativeLuminance(parseHex(readToken(preset.css, '--gn-ant-primary-active'))); + + expect(hover, `${preset.id} 的 hover 不应比基色更亮`).toBeLessThan(accent); + expect(active, `${preset.id} 的 active 不应比 hover 更亮`).toBeLessThan(hover); + } + }); + + it('同时暴露 --gn-accent-hover / --gn-accent-active 供 CSS 直接引用', () => { + for (const preset of BUILTIN_CUSTOM_THEME_PRESETS) { + expect(readToken(preset.css, '--gn-accent-hover')).toBe(readToken(preset.css, '--gn-ant-primary-hover')); + expect(readToken(preset.css, '--gn-accent-active')).toBe(readToken(preset.css, '--gn-ant-primary-active')); + } + }); +}); diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css index d2688041..e0ecd341 100644 --- a/frontend/src/v2-theme.css +++ b/frontend/src/v2-theme.css @@ -4057,16 +4057,28 @@ body[data-ui-version="v2"] .gn-v2-data-grid .gn-v2-commit-button { font-weight: 750 !important; } +/* hover/focus 与 active 必须用不同的派生色:原先两者都取 --gn-accent-2, + 而 accent-2 是 accent 的近邻色(内置主题强调色刻意低饱和), + 实测 base→hover 对比度仅 1.05–1.29,Deep Ocean 的 accent-2 甚至比 accent 更亮, + 导致"有待提交变更"这一关键状态的交互反馈几乎不可见。 + --gn-accent-hover / --gn-accent-active 由 customThemePresets 按固定比例向面板底色派生, + 回退值保留 accent-2 以兼容未提供这两个 token 的旧自定义主题。 */ body[data-ui-version="v2"] .gn-v2-data-grid .gn-v2-commit-button:hover, body[data-ui-version="v2"] .gn-v2-data-grid .gn-v2-commit-button:focus, -body[data-ui-version="v2"] .gn-v2-data-grid .gn-v2-commit-button:focus-visible, -body[data-ui-version="v2"] .gn-v2-data-grid .gn-v2-commit-button:active { - border-color: var(--gn-accent-2) !important; - background: var(--gn-accent-2) !important; +body[data-ui-version="v2"] .gn-v2-data-grid .gn-v2-commit-button:focus-visible { + border-color: var(--gn-accent-hover, var(--gn-accent-2)) !important; + background: var(--gn-accent-hover, var(--gn-accent-2)) !important; color: var(--gn-on-accent, #fff) !important; box-shadow: var(--gn-shadow-sm), inset 0 0.5px 0 rgba(255, 255, 255, 0.15) !important; } +body[data-ui-version="v2"] .gn-v2-data-grid .gn-v2-commit-button:active { + border-color: var(--gn-accent-active, var(--gn-accent-2)) !important; + background: var(--gn-accent-active, var(--gn-accent-2)) !important; + color: var(--gn-on-accent, #fff) !important; + box-shadow: none !important; +} + body[data-ui-version="v2"] .gn-v2-data-grid .gn-v2-commit-button:disabled { opacity: 1; border-color: var(--gn-br-2) !important;