mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-21 16:34:21 +08:00
🐛 fix(theme): 提升连接状态与 SQL 关键字辨识度
- 为亮暗主题和内置预设增加独立连接状态语义色 - 强化 Host、数据库状态点及双行页签字段对比度 - 为 Monaco SQL 关键字补充高对比加粗规则 - 增加基础主题与内置预设的对比度回归测试
This commit is contained in:
66
frontend/src/components/MonacoEditor.theme.test.ts
Normal file
66
frontend/src/components/MonacoEditor.theme.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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;');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user