From 0beccc381e7dc12f89aa3ddc55c58153545b04f8 Mon Sep 17 00:00:00 2001 From: Ilia Grinzovskii Date: Thu, 12 Mar 2026 12:04:51 +0400 Subject: [PATCH] feat: Appearance settings - Add AppearanceSection with theme mode, color theme, font size, action bar toggle - Add AppearanceProvider for font-size management on - Add useAppSettings and useAppearance hooks - Integrate with existing ThemeFamilyProvider and unified icon system - Add i18n keys for appearance settings (en + zh) --- .../unit/appearance-settings.test.ts | 115 ++++++++++++++++++ src/app/api/settings/app/route.ts | 1 + src/app/chat/[id]/page.tsx | 2 +- src/app/layout.tsx | 16 ++- .../ai-elements/tool-actions-group.tsx | 6 +- src/components/chat/ChatView.tsx | 1 - src/components/chat/CliToolsPopover.tsx | 2 +- src/components/chat/ContextUsageIndicator.tsx | 2 +- src/components/chat/ImageGenCard.tsx | 8 +- src/components/chat/MessageInputParts.tsx | 4 +- src/components/chat/MessageItem.tsx | 2 +- src/components/chat/MessageList.tsx | 10 +- src/components/chat/PermissionPrompt.tsx | 4 +- src/components/chat/StreamingMessage.tsx | 6 +- .../batch-image-gen/BatchExecutionItem.tsx | 10 +- .../chat/batch-image-gen/BatchPlanRow.tsx | 4 +- src/components/cli-tools/CliToolCard.tsx | 2 +- src/components/cli-tools/CliToolsManager.tsx | 2 +- src/components/gallery/GalleryDetail.tsx | 6 +- src/components/gallery/GalleryGrid.tsx | 2 +- src/components/gallery/TagManager.tsx | 2 +- src/components/layout/AppShell.tsx | 34 +++--- src/components/layout/AppearanceProvider.tsx | 78 ++++++++++++ src/components/layout/ChatListPanel.tsx | 6 +- src/components/layout/ConnectionStatus.tsx | 2 +- src/components/layout/DocPreview.tsx | 8 +- src/components/layout/ImportSessionDialog.tsx | 4 +- src/components/layout/ProjectGroupHeader.tsx | 4 +- src/components/layout/RightPanel.tsx | 4 +- src/components/layout/SessionListItem.tsx | 6 +- src/components/layout/SplitColumn.tsx | 6 +- src/components/patterns/CommandList.tsx | 2 +- src/components/plugins/McpManager.tsx | 2 +- src/components/plugins/McpServerList.tsx | 2 +- src/components/project/FilePreview.tsx | 6 +- src/components/project/TaskCard.tsx | 4 +- src/components/settings/AppearanceSection.tsx | 52 +++++++- src/components/settings/GeneralSection.tsx | 4 - .../settings/PresetConnectDialog.tsx | 4 +- src/components/settings/ProviderManager.tsx | 12 +- src/components/settings/SettingsLayout.tsx | 8 +- src/components/settings/UsageStatsSection.tsx | 2 +- .../settings/WorkspaceTabPanels.tsx | 2 +- src/components/skills/MarketplaceBrowser.tsx | 2 +- .../skills/MarketplaceSkillCard.tsx | 2 +- .../skills/MarketplaceSkillDetail.tsx | 2 +- src/components/skills/SkillEditor.tsx | 2 +- src/components/skills/SkillsManager.tsx | 6 +- src/hooks/useAppearance.ts | 6 + src/i18n/en.ts | 6 + src/i18n/zh.ts | 6 + src/lib/appearance.ts | 54 ++++++++ 52 files changed, 431 insertions(+), 114 deletions(-) create mode 100644 src/__tests__/unit/appearance-settings.test.ts create mode 100644 src/components/layout/AppearanceProvider.tsx create mode 100644 src/hooks/useAppearance.ts create mode 100644 src/lib/appearance.ts diff --git a/src/__tests__/unit/appearance-settings.test.ts b/src/__tests__/unit/appearance-settings.test.ts new file mode 100644 index 00000000..bec04246 --- /dev/null +++ b/src/__tests__/unit/appearance-settings.test.ts @@ -0,0 +1,115 @@ +/** + * Unit tests for appearance settings constants, validation, and lookup helpers. + * + * Run with: npx tsx --test src/__tests__/unit/appearance-settings.test.ts + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + FONT_SIZES, + DEFAULT_FONT_SIZE, + isValidFontSize, + getFontSizePx, + APPEARANCE_STORAGE_KEY, + readFontSizeFromStorage, + writeFontSizeToStorage, +} from '../../lib/appearance'; +import type { FontSizeKey } from '../../lib/appearance'; + +describe('Appearance Settings Constants', () => { + it('should have 4 font size presets', () => { + assert.equal(Object.keys(FONT_SIZES).length, 4); + }); + + it('should have all sizes in ascending order', () => { + const pxValues = Object.values(FONT_SIZES).map(s => s.px); + for (let i = 1; i < pxValues.length; i++) { + assert.ok(pxValues[i] > pxValues[i - 1], `${pxValues[i]} should be > ${pxValues[i - 1]}`); + } + }); + + it('should have default font size as a valid key', () => { + assert.ok(DEFAULT_FONT_SIZE in FONT_SIZES); + }); + + it('default font size should map to 16px', () => { + assert.equal(FONT_SIZES[DEFAULT_FONT_SIZE].px, 16); + }); +}); + +describe('Appearance Settings Validation', () => { + it('should validate known font size keys', () => { + assert.equal(isValidFontSize('small'), true); + assert.equal(isValidFontSize('default'), true); + assert.equal(isValidFontSize('large'), true); + assert.equal(isValidFontSize('extra-large'), true); + }); + + it('should reject invalid font size keys', () => { + assert.equal(isValidFontSize('huge'), false); + assert.equal(isValidFontSize(''), false); + assert.equal(isValidFontSize(undefined as unknown as string), false); + }); +}); + +describe('Appearance Settings Lookup Helpers', () => { + it('should return px for valid font size', () => { + assert.equal(getFontSizePx('large'), 18); + }); + + it('should fall back to default for invalid font size', () => { + assert.equal(getFontSizePx('huge' as FontSizeKey), FONT_SIZES[DEFAULT_FONT_SIZE].px); + }); +}); + +describe('Appearance Settings localStorage helpers', () => { + let store: Record = {}; + const mockStorage = { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value; }, + removeItem: (key: string) => { delete store[key]; }, + } as unknown as Storage; + + it('APPEARANCE_STORAGE_KEY should be a namespaced string', () => { + store = {}; + assert.ok(APPEARANCE_STORAGE_KEY.startsWith('codepilot_')); + }); + + it('writeFontSizeToStorage should write valid key', () => { + store = {}; + writeFontSizeToStorage('large', mockStorage); + assert.equal(store[APPEARANCE_STORAGE_KEY], 'large'); + }); + + it('readFontSizeFromStorage should return stored value', () => { + store = { [APPEARANCE_STORAGE_KEY]: 'small' }; + assert.equal(readFontSizeFromStorage(mockStorage), 'small'); + }); + + it('readFontSizeFromStorage should return default for missing key', () => { + store = {}; + assert.equal(readFontSizeFromStorage(mockStorage), DEFAULT_FONT_SIZE); + }); + + it('readFontSizeFromStorage should return default for invalid value', () => { + store = { [APPEARANCE_STORAGE_KEY]: 'huge' }; + assert.equal(readFontSizeFromStorage(mockStorage), DEFAULT_FONT_SIZE); + }); +}); + +describe('Anti-FOUC font-size map consistency', () => { + it('FONT_SIZES px values should match the anti-FOUC inline map', () => { + const expected: Record = { + small: 14, + default: 16, + large: 18, + 'extra-large': 20, + }; + for (const [key, opt] of Object.entries(FONT_SIZES)) { + assert.equal(opt.px, expected[key], `FONT_SIZES[${key}].px should be ${expected[key]}`); + } + assert.equal(Object.keys(FONT_SIZES).length, Object.keys(expected).length); + }); +}); diff --git a/src/app/api/settings/app/route.ts b/src/app/api/settings/app/route.ts index 363954cd..5b9d780b 100644 --- a/src/app/api/settings/app/route.ts +++ b/src/app/api/settings/app/route.ts @@ -12,6 +12,7 @@ const ALLOWED_KEYS = [ 'dangerously_skip_permissions', 'locale', 'thinking_mode', + 'appearance_font_size', ]; export async function GET() { diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index 7881593d..3e0cc35a 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -217,7 +217,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {

{sessionWorkingDir || projectName}

-

Click to open in Finder

+

Click to open in Finder

/ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b9f30609..74ebf9e1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,6 +7,8 @@ import { I18nProvider } from "@/components/layout/I18nProvider"; import { AppShell } from "@/components/layout/AppShell"; import { getAllThemeFamilies, getThemeFamilyMetas } from "@/lib/theme/loader"; import { renderThemeFamilyCSS } from "@/lib/theme/render-css"; +import { AppearanceProvider } from "@/components/layout/AppearanceProvider"; +import { FONT_SIZES, DEFAULT_FONT_SIZE, APPEARANCE_STORAGE_KEY } from "@/lib/appearance"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -32,12 +34,18 @@ export default function RootLayout({ const familiesMeta = getThemeFamilyMetas(); const themeFamilyCSS = renderThemeFamilyCSS(families); const validIds = families.map((f) => f.id); + const fontSizePxMap = JSON.stringify( + Object.fromEntries(Object.entries(FONT_SIZES).map(([k, v]) => [k, v.px])) + ); return ( {/* Anti-FOUC: set data-theme-family from localStorage, validate against known IDs */}