🐛 fix(theme): 提升连接状态与 SQL 关键字辨识度

- 为亮暗主题和内置预设增加独立连接状态语义色
- 强化 Host、数据库状态点及双行页签字段对比度
- 为 Monaco SQL 关键字补充高对比加粗规则
- 增加基础主题与内置预设的对比度回归测试
This commit is contained in:
Syngnat
2026-07-13 09:54:23 +08:00
parent 7bdce19d01
commit 00473e7ac0
9 changed files with 229 additions and 30 deletions

View File

@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest';
import { BUILTIN_CUSTOM_THEME_PRESETS } from '../utils/customThemePresets';
import { registerGonaviMonacoThemes } from './MonacoEditor';
const readHexProperty = (css: string, property: string): string => {
const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = css.match(new RegExp(`${escaped}\\s*:\\s*(#[0-9a-f]{6})`, 'i'));
if (!match?.[1]) throw new Error(`Missing hexadecimal custom property: ${property}`);
return match[1];
};
const relativeLuminance = (hex: string): number => {
const channels = [1, 3, 5].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255);
const [red, green, blue] = channels.map((channel) => (
channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
));
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
};
const contrastRatio = (foreground: string, background: string): number => {
const light = Math.max(relativeLuminance(foreground), relativeLuminance(background));
const dark = Math.min(relativeLuminance(foreground), relativeLuminance(background));
return (light + 0.05) / (dark + 0.05);
};
describe('GoNavi Monaco themes', () => {
it('defines bold SQL keyword colors that remain AA-readable across every built-in preset', () => {
const defineTheme = vi.fn();
registerGonaviMonacoThemes({ editor: { defineTheme } } as never);
const definitions = new Map(
defineTheme.mock.calls.map(([name, definition]) => [name, definition]),
);
const keywordTokens = [
'keyword.sql',
'keyword.try.sql',
'keyword.catch.sql',
'keyword.block.sql',
'keyword.choice.sql',
];
const lightRules = definitions.get('transparent-light')?.rules ?? [];
const darkRules = definitions.get('transparent-dark')?.rules ?? [];
for (const token of keywordTokens) {
expect(lightRules.find((rule: any) => rule.token === token)).toMatchObject({
foreground: '6D28D9',
fontStyle: 'bold',
});
expect(darkRules.find((rule: any) => rule.token === token)).toMatchObject({
foreground: 'C792EA',
fontStyle: 'bold',
});
}
for (const preset of BUILTIN_CUSTOM_THEME_PRESETS) {
const keyword = (preset.baseMode === 'dark' ? darkRules : lightRules)
.find((rule: any) => rule.token === 'keyword.sql');
const background = readHexProperty(preset.css, '--gn-bg-input');
expect(
contrastRatio(`#${keyword.foreground}`, background),
`${preset.id} SQL keyword must contrast with its editor background`,
).toBeGreaterThanOrEqual(4.5);
}
});
});

View File

@@ -495,7 +495,13 @@ export const registerGonaviMonacoThemes: BeforeMount = (monaco) => {
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
rules: [
{ token: 'keyword.sql', foreground: 'C792EA', fontStyle: 'bold' },
{ token: 'keyword.try.sql', foreground: 'C792EA', fontStyle: 'bold' },
{ token: 'keyword.catch.sql', foreground: 'C792EA', fontStyle: 'bold' },
{ token: 'keyword.block.sql', foreground: 'C792EA', fontStyle: 'bold' },
{ token: 'keyword.choice.sql', foreground: 'C792EA', fontStyle: 'bold' },
],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#ffffff10',
@@ -507,7 +513,13 @@ export const registerGonaviMonacoThemes: BeforeMount = (monaco) => {
monaco.editor.defineTheme('transparent-light', {
base: 'vs',
inherit: true,
rules: [],
rules: [
{ token: 'keyword.sql', foreground: '6D28D9', fontStyle: 'bold' },
{ token: 'keyword.try.sql', foreground: '6D28D9', fontStyle: 'bold' },
{ token: 'keyword.catch.sql', foreground: '6D28D9', fontStyle: 'bold' },
{ token: 'keyword.block.sql', foreground: '6D28D9', fontStyle: 'bold' },
{ token: 'keyword.choice.sql', foreground: '6D28D9', fontStyle: 'bold' },
],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#00000010',

View File

@@ -1260,8 +1260,8 @@ describe('Sidebar locate toolbar', () => {
expect(v2TreeTitleCss).toContain('min-width: 100%;');
expect(v2TreeTitleCss).toContain('overflow: visible;');
expect(css).toMatch(/\.gn-v2-tree-status \{[^}]*width: 14px;[^}]*height: 14px;[^}]*flex: 0 0 14px;[^}]*overflow: visible;/s);
expect(css).toMatch(/\.gn-v2-tree-status::before \{[^}]*width: 7px;[^}]*height: 7px;[^}]*border-radius: 50%;/s);
expect(css).toMatch(/\.gn-v2-tree-status\.is-success::before \{[^}]*background: #22c55e;[^}]*box-shadow: 0 0 0 4px rgba\(34, 197, 94, 0\.18\);/s);
expect(css).toMatch(/\.gn-v2-tree-status::before \{[^}]*width: 9px;[^}]*height: 9px;[^}]*border: 1\.5px solid var\(--gn-fg-4\);[^}]*border-radius: 50%;/s);
expect(css).toMatch(/\.gn-v2-tree-status\.is-success::before \{[^}]*border: 0;[^}]*background: var\(--gn-status-connected\);[^}]*box-shadow: 0 0 0 3px color-mix\(in srgb, var\(--gn-status-connected\) 22%, transparent\);/s);
const treeLabelCss = readCssRuleBlock(css, 'body[data-ui-version="v2"] .gn-v2-tree-label');
expect(treeLabelCss).toContain('flex: 0 0 auto;');
expect(treeLabelCss).toContain('overflow: visible;');

View File

@@ -289,6 +289,9 @@ describe('TabManager hover info', () => {
expect(source).toContain('buildTabDisplayModel(tab, connection, appearance.tabDisplay, t)');
expect(source).toContain('displayModel={displayModel}');
expect(source).toContain('displayModel.primaryParts.map(renderV2TabDisplayPart)');
expect(source).toContain('renderV2TabSecondaryParts(displayModel.secondaryParts)');
expect(source).toContain('aria-label={displayModel.secondaryText}');
expect(source).toContain('className="gn-v2-tab-label-separator" aria-hidden="true">·</span>');
expect(source).toContain("if (part.key === 'kind')");
expect(source).toContain('className="gn-v2-tab-kind"');
expect(source).toContain('hasDoubleLineTabLabel');

View File

@@ -260,6 +260,13 @@ const renderV2TabDisplayPart = (part: TabDisplayPart) => {
);
};
const renderV2TabSecondaryParts = (parts: TabDisplayPart[]) => parts.map((part, index) => (
<React.Fragment key={part.key}>
{index > 0 ? <span className="gn-v2-tab-label-separator" aria-hidden="true">·</span> : null}
{renderV2TabDisplayPart(part)}
</React.Fragment>
));
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
tab,
displayModel,
@@ -304,8 +311,12 @@ const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
: displayModel.primaryText}
</span>
{showSecondaryLine ? (
<span className="gn-v2-tab-label-secondary" title={displayModel.secondaryText}>
{displayModel.secondaryText}
<span
className="gn-v2-tab-label-secondary"
title={displayModel.secondaryText}
aria-label={displayModel.secondaryText}
>
{renderV2TabSecondaryParts(displayModel.secondaryParts)}
</span>
) : null}
</span>