🎨 style(theme): 修复主要操作按钮交互态在各主题下几乎不可辨

根因在 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;按本次确认范围未纳入。
This commit is contained in:
Syngnat
2026-07-26 21:39:26 +08:00
parent b8af7bede8
commit 75660ef3db
3 changed files with 192 additions and 9 deletions

View File

@@ -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.051.29
* 其中 Deep Ocean 的 accent2 甚至比 accent 更亮(调色板方向反了),几乎完全看不出状态变化。
*
* 改为按固定比例混合派生「逐级加深」的三态:与调色板自身的取值无关,
* 因此内置主题与用户自定义主题都能得到一致可辨的三态。
* 实测深色主题下 base→hover 1.311.39、hover→active 1.431.55
* 且强调色占比不低于 60%,色相仍可辨认。
*
* 混合锚点必须按模式区分,否则方向会反:
* - 深色主题的面板底色比强调色更暗,向面板混合即加深,且能留在主题自身的色系里;
* - 浅色主题的面板底色比强调色更亮(如 warm-paper 的 #fffcf5 vs accent #2d7864
* 向面板混合会把按钮**冲淡**、与页面对比更弱,看起来像被禁用。
* 浅色主题改为向纯黑混合:其 fg1 与 accent 都偏深(#292722 vs #2d7864
* 用 fg1 作锚点亮度变化太小base→hover 仅 1.191.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;

View File

@@ -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.051.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'));
}
});
});

View File

@@ -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.051.29Deep 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;