diff --git a/frontend/src/utils/customThemePresets.ts b/frontend/src/utils/customThemePresets.ts index e53cd461..db0458cc 100644 --- a/frontend/src/utils/customThemePresets.ts +++ b/frontend/src/utils/customThemePresets.ts @@ -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.75)Midnight 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.05–1.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 { diff --git a/frontend/src/utils/customThemePresetsAccentStates.test.ts b/frontend/src/utils/customThemePresetsAccentStates.test.ts index e06c2c58..0098f655 100644 --- a/frontend/src/utils/customThemePresetsAccentStates.test.ts +++ b/frontend/src/utils/customThemePresetsAccentStates.test.ts @@ -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 文字,与普通主操作同色系且文字对比极低; diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css index dd759709..68c2d738 100644 --- a/frontend/src/v2-theme.css +++ b/frontend/src/v2-theme.css @@ -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.05–1.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 {