🎨 style(theme): 提升主操作按钮醒目度、修复默认主题标签对比度并降级保存按钮

- 新增 --gn-accent-strong 系列(提饱和 1.55×)供主按钮填充:内置主题的强调色刻意低饱和
  (Comfort Dark 仅 20%、Deep Ocean 28%),淡到接近灰蓝,填在按钮上读起来"没有颜色"。
  只改按钮填充,侧边栏高亮与文字仍用原 accent,保持主题整体淡雅。
  实测深色主题饱和度提升到 31%–72%,对工具栏对比度仍 ≥6.0、标签 ≥6.1
- 提饱和只对深色主题生效:浅色主题的强调色本身已约 46% 饱和,提了反而把白色标签从 4.5+
  压到 4.14 跌破 AA(该情况由新增测试抓出)
- 默认 v2 主题补上此前从未定义的 --gn-on-accent:所有 var(--gn-on-accent, #fff) 都在回退
  白字,深色下压 #22c55e 仅 2.28、浅色下压 #16a34a 仅 3.30,均不满足 WCAG AA。
  改为 #0a2417 / #052e16,得 7.21 / 4.52
- 「保存」按钮降为描边次级样式:它与「执行」原本都是实心按钮,而两者填充色(accent 与 info)
  在内置主题下区分度仅 1.05–1.25,相邻两个同色方块分不出谁是谁。
  由于 Midnight Navy / Nord Slate 的 accent 本身即蓝青系,靠拉开色相无法解决,
  改用层级区分:工具栏里只有「执行」是实心主按钮,主操作因此真正突出
- 补 2 项不变式测试:accent-strong 饱和度提升且标签 ≥4.5、其三态同样逐级加深

说明:原计划的「拉开执行与保存色相」经测量不可行(accent 自身即蓝青系,warn 已被手动事务
占用),故改为层级区分,与之前沟通的方案不同。
This commit is contained in:
Syngnat
2026-07-26 21:59:17 +08:00
parent 57d6e50884
commit 08fff6aa36
3 changed files with 189 additions and 23 deletions

View File

@@ -150,6 +150,78 @@ const readableOnColor = (background: string): string => {
* 白字只剩 2.65 的对比度;反之深色主题的浅琥珀底配深字,则必须往白色叠加。
* 因此按前景色是深还是浅,决定徽标底往相反方向偏移。
*/
/**
* 主操作按钮填充色的饱和度提升倍数。
*
* 内置主题的强调色刻意低饱和Comfort Dark 仅 20%、Deep Ocean 28%),淡到接近灰蓝,
* 填充在按钮上读起来"没有颜色"、不像可点的主操作。这里只提升**按钮填充**用的派生色,
* 侧边栏高亮、文字、边框等仍用原 accent保持主题整体的淡雅气质。
*
* 取 1.55:实测 4 个深色内置主题的饱和度提升到 31%72%,同时对工具栏底的对比度仍 ≥6.0、
* 标签可读性 ≥6.1再往上1.75Midnight Navy 会到 82%,偏霓虹。
*
* 只对深色主题生效:浅色主题的强调色本身已有约 46% 饱和(如 warm-paper 的 #2d7864
* 不存在"淡到没颜色"的问题;对其提饱和反而会把白色标签的对比度从 4.5+ 压到 4.14,跌破 AA。
*/
const ACCENT_STRONG_SATURATION = 1.55;
const accentStrongSaturation = (palette: BuiltinThemePalette): number => (
palette.mode === 'light' ? 1 : ACCENT_STRONG_SATURATION
);
const rgbToHsl = ([r, g, b]: [number, number, number]): [number, number, number] => {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const lightness = (max + min) / 2;
if (max === min) {
return [0, 0, lightness];
}
const delta = max - min;
const saturation = delta / (1 - Math.abs(2 * lightness - 1));
let hue: number;
if (max === rn) {
hue = ((gn - bn) / delta + (gn < bn ? 6 : 0)) * 60;
} else if (max === gn) {
hue = ((bn - rn) / delta + 2) * 60;
} else {
hue = ((rn - gn) / delta + 4) * 60;
}
return [hue, saturation, lightness];
};
const hslToHex = ([hue, saturation, lightness]: [number, number, number]): string => {
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
const secondary = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
const offset = lightness - chroma / 2;
const sector = Math.floor(hue / 60) % 6;
const table: [number, number, number][] = [
[chroma, secondary, 0],
[secondary, chroma, 0],
[0, chroma, secondary],
[0, secondary, chroma],
[secondary, 0, chroma],
[chroma, 0, secondary],
];
const [r, g, b] = table[sector < 0 ? 0 : sector];
const channel = (value: number): string => Math.max(0, Math.min(255, Math.round((value + offset) * 255)))
.toString(16)
.padStart(2, '0');
return `#${channel(r)}${channel(g)}${channel(b)}`;
};
/** saturate 保持色相与明度,仅提升饱和度。 */
const saturate = (color: string, factor: number): string => {
const rgb = parseHexColor(color);
if (!rgb) {
return color;
}
const [hue, saturation, lightness] = rgbToHsl(rgb);
return hslToHex([hue, Math.min(1, saturation * factor), lightness]);
};
const badgeBackground = (base: string, onColor: string): string => {
const rgb = parseHexColor(onColor);
// 前景偏深时把徽标底往白色偏移,前景偏浅(白字)时往黑色偏移。
@@ -231,6 +303,11 @@ body[data-custom-theme][data-ui-version="v2"] {
--gn-accent-hover: ${mixHex(palette.accent, accentStateAnchor(palette), ACCENT_HOVER_RATIO)};
--gn-accent-active: ${mixHex(palette.accent, accentStateAnchor(palette), ACCENT_ACTIVE_RATIO)};
/* 只给主操作按钮填充用的提饱和派生色;侧边栏高亮、文字、边框仍用原 accent。 */
--gn-accent-strong: ${saturate(palette.accent, accentStrongSaturation(palette))};
--gn-accent-strong-hover: ${mixHex(saturate(palette.accent, accentStrongSaturation(palette)), accentStateAnchor(palette), ACCENT_HOVER_RATIO)};
--gn-accent-strong-active: ${mixHex(saturate(palette.accent, accentStrongSaturation(palette)), accentStateAnchor(palette), ACCENT_ACTIVE_RATIO)};
/* 手动事务的「提交」按钮刻意不跟随主题强调色:待提交事务是需要用户显式决断的状态,
若与普通主操作同色则无法区分(回滚已用 danger提交用 warn 形成语义配对)。
on-warn 按亮度择优,因为 warn 在各预设间跨度很大(#9f5f1d ~ #ebcb8b。 */
@@ -318,21 +395,28 @@ body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-transaction-commit-bu
color: var(--gn-accent-text, var(--gn-accent)) !important;
}
/* 「保存」降为次级样式:与 v2-theme.css 保持一致。
原先填 --gn-info而 info 与 accent 在内置主题下的区分度仅 1.051.25
两个相邻实心按钮一眼分不清;且 Midnight Navy / Nord Slate 的 accent 本身即蓝青系,
靠色相无法拉开,故改用层级区分,只让「执行」保持实心主按钮。 */
body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-toolbar-save-action.ant-btn-primary:not(:disabled) {
background: var(--gn-info) !important;
border-color: var(--gn-info) !important;
color: var(--gn-on-info, #fff) !important;
background: transparent !important;
border-color: var(--gn-br-3) !important;
color: var(--gn-fg-2) !important;
box-shadow: none !important;
}
body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-toolbar-save-action.ant-btn-primary:not(:disabled):hover,
body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-toolbar-save-action.ant-btn-primary:not(:disabled):focus-visible {
background: color-mix(in srgb, var(--gn-info) 86%, var(--gn-bg-panel)) !important;
border-color: color-mix(in srgb, var(--gn-info) 86%, var(--gn-bg-panel)) !important;
background: var(--gn-bg-hover) !important;
border-color: var(--gn-accent) !important;
color: var(--gn-fg-1) !important;
}
body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-toolbar-save-action.ant-btn-primary:not(:disabled):active {
background: color-mix(in srgb, var(--gn-info) 74%, var(--gn-bg-panel)) !important;
border-color: color-mix(in srgb, var(--gn-info) 74%, var(--gn-bg-panel)) !important;
background: var(--gn-bg-active) !important;
border-color: var(--gn-accent) !important;
color: var(--gn-fg-1) !important;
}
body[data-custom-theme][data-ui-version="v2"] .gn-v2-ai-panel .ai-logo {

View File

@@ -103,6 +103,60 @@ describe('内置主题的主要操作按钮三态', () => {
});
});
const saturationOf = (value: string): number => {
const [r, g, b] = parseHex(value).map((c) => c / 255);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const lightness = (max + min) / 2;
return max === min ? 0 : (max - min) / (1 - Math.abs(2 * lightness - 1));
};
/**
* 主操作按钮填充用的提饱和派生色。
* 回归背景内置主题的强调色刻意低饱和Comfort Dark 仅 20%、Deep Ocean 28%
* 直接填在按钮上读起来"没有颜色"、不像可点的主操作。
*/
describe('主操作按钮的提饱和填充色', () => {
it('accent-strong 的饱和度高于原 accent且标签仍可读', () => {
for (const preset of BUILTIN_CUSTOM_THEME_PRESETS) {
const accent = readToken(preset.css, '--gn-accent');
const strong = readToken(preset.css, '--gn-accent-strong');
const onAccent = readToken(preset.css, '--gn-on-accent');
// 浅色主题的强调色本身已足够饱和,不提升(提了会让白色标签跌破 AA
if (preset.baseMode === 'dark') {
expect(
saturationOf(strong),
`${preset.id} 的 accent-strong 饱和度未提升`,
).toBeGreaterThan(saturationOf(accent));
} else {
expect(saturationOf(strong)).toBeCloseTo(saturationOf(accent), 5);
}
expect(
contrastRatio(onAccent, strong),
`${preset.id} 的主按钮标签对比度不足`,
).toBeGreaterThanOrEqual(4.5);
}
});
it('accent-strong 的三态同样逐级加深且达到可感知阈值', () => {
for (const preset of BUILTIN_CUSTOM_THEME_PRESETS) {
const strong = readToken(preset.css, '--gn-accent-strong');
const hover = readToken(preset.css, '--gn-accent-strong-hover');
const active = readToken(preset.css, '--gn-accent-strong-active');
expect(
contrastRatio(strong, hover),
`${preset.id} 的 strong base→hover 对比度不足`,
).toBeGreaterThanOrEqual(MIN_STATE_DELTA);
expect(
contrastRatio(hover, active),
`${preset.id} 的 strong hover→active 对比度不足`,
).toBeGreaterThanOrEqual(MIN_STATE_DELTA);
}
});
});
/**
* 手动事务「提交」按钮用 warn 实心填充,刻意不跟随主题强调色。
* 回归背景:原先用 accent-soft 底 + accent-2 文字,与普通主操作同色系且文字对比极低;

View File

@@ -46,6 +46,12 @@ body[data-ui-version="v2"][data-theme="light"] {
--gn-accent: #15803d;
--gn-accent-2: #166534;
--gn-accent-soft: #dcfce7;
/* --gn-on-accent 此前未定义,所有 var(--gn-on-accent, #fff) 都回退成白字:
白字压在 #16a34a 上仅 3.30,不满足 WCAG AA 的 4.5。深绿可得 4.52。 */
--gn-on-accent: #052e16;
--gn-accent-strong: #15803d;
--gn-accent-strong-hover: #126634;
--gn-accent-strong-active: #0d4d27;
--gn-info: #0369a1;
--gn-info-soft: #e0f2fe;
--gn-warn: #d97706;
@@ -91,6 +97,11 @@ body[data-ui-version="v2"][data-theme="dark"] {
--gn-accent: #22c55e;
--gn-accent-2: #16a34a;
--gn-accent-soft: rgba(34, 197, 94, 0.16);
/* 同上:白字压在 #22c55e 上仅 2.28,深绿可得 7.21。 */
--gn-on-accent: #0a2417;
--gn-accent-strong: #22c55e;
--gn-accent-strong-hover: #1ca14d;
--gn-accent-strong-active: #157a3b;
--gn-info: #38bdf8;
--gn-info-soft: rgba(56, 189, 248, 0.14);
--gn-warn: #f59e0b;
@@ -276,37 +287,54 @@ body[data-ui-version="v2"] .ant-btn-default:hover {
color: var(--gn-fg-1) !important;
}
/* 主操作按钮用提饱和的 --gn-accent-strong 填充:内置主题的 accent 刻意低饱和
Comfort Dark 仅 20%、Deep Ocean 28%),直接填在按钮上读起来"没有颜色"、不像主操作。
只改按钮填充,侧边栏高亮与文字仍用原 accent保持主题整体淡雅。
前景色改用 --gn-on-accent硬编码 #fff 在默认深色主题上压 #22c55e 仅 2.28,不满足 AA。 */
body[data-ui-version="v2"] .ant-btn-primary {
background: var(--gn-accent) !important;
border-color: var(--gn-accent-2) !important;
color: #fff !important;
background: var(--gn-accent-strong, var(--gn-accent)) !important;
border-color: var(--gn-accent-strong, var(--gn-accent-2)) !important;
color: var(--gn-on-accent, #fff) !important;
font-weight: 600 !important;
box-shadow: var(--gn-shadow-sm), inset 0 0.5px 0 rgba(255,255,255,0.15) !important;
}
body[data-ui-version="v2"] .ant-btn-primary:hover {
background: var(--gn-accent-2) !important;
border-color: var(--gn-accent-2) !important;
background: var(--gn-accent-strong-hover, var(--gn-accent-2)) !important;
border-color: var(--gn-accent-strong-hover, var(--gn-accent-2)) !important;
color: var(--gn-on-accent, #fff) !important;
}
/* 查询工具栏「保存」:与「运行」同款实心饱满,色值用蓝色 */
body[data-ui-version="v2"] .ant-btn-primary:active {
background: var(--gn-accent-strong-active, var(--gn-accent-2)) !important;
border-color: var(--gn-accent-strong-active, var(--gn-accent-2)) !important;
color: var(--gn-on-accent, #fff) !important;
}
/* 查询工具栏「保存」降为次级样式:描边 + 中性底。
原先它与「执行」一样是实心饱满按钮而两者在内置主题下的填充色accent 与 info
区分度仅 1.051.25 —— 相邻两个同色实心方块,一眼分不出哪个是执行、哪个是保存。
由于 Midnight Navy / Nord Slate 的 accent 本身就是蓝青系,靠拉开色相无法解决,
改用层级区分:工具栏里只有「执行」是实心主按钮,其余降级,主操作因此真正突出。 */
body[data-ui-version="v2"] .gn-v2-query-toolbar-save-action.ant-btn-primary {
background: #2563eb !important;
border-color: #1d4ed8 !important;
color: #fff !important;
background: transparent !important;
border-color: var(--gn-br-3) !important;
color: var(--gn-fg-2) !important;
font-weight: 600 !important;
box-shadow: var(--gn-shadow-sm), inset 0 0.5px 0 rgba(255, 255, 255, 0.18) !important;
box-shadow: none !important;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-save-action.ant-btn-primary:hover {
background: #1d4ed8 !important;
border-color: #1e40af !important;
color: #fff !important;
body[data-ui-version="v2"] .gn-v2-query-toolbar-save-action.ant-btn-primary:hover,
body[data-ui-version="v2"] .gn-v2-query-toolbar-save-action.ant-btn-primary:focus-visible {
background: var(--gn-bg-hover) !important;
border-color: var(--gn-accent) !important;
color: var(--gn-fg-1) !important;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-save-action.ant-btn-primary:active {
background: #1e40af !important;
border-color: #1e3a8a !important;
background: var(--gn-bg-active) !important;
border-color: var(--gn-accent) !important;
color: var(--gn-fg-1) !important;
}
body[data-ui-version="v2"] .ant-btn-primary:disabled {