Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/__tests__/unit/appearance-settings.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
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<string, number> = {
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);
});
});
1 change: 1 addition & 0 deletions src/app/api/settings/app/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const ALLOWED_KEYS = [
'dangerously_skip_permissions',
'locale',
'thinking_mode',
'appearance_font_size',
];

export async function GET() {
Expand Down
2 changes: 1 addition & 1 deletion src/app/chat/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
</TooltipTrigger>
<TooltipContent>
<p className="text-xs break-all">{sessionWorkingDir || projectName}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">Click to open in Finder</p>
<p className="text-[0.625rem] text-muted-foreground mt-0.5">Click to open in Finder</p>
</TooltipContent>
</Tooltip>
<span className="text-xs text-muted-foreground shrink-0">/</span>
Expand Down
16 changes: 13 additions & 3 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -32,22 +34,30 @@ 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 (
<html lang="en" suppressHydrationWarning>
<head>
{/* Anti-FOUC: set data-theme-family from localStorage, validate against known IDs */}
<script dangerouslySetInnerHTML={{ __html: `(function(){try{var v=${JSON.stringify(validIds)};var f=localStorage.getItem('codepilot_theme_family')||'default';if(v.indexOf(f)<0)f='default';document.documentElement.setAttribute('data-theme-family',f)}catch(e){}})();` }} />
{/* Anti-FOUC: set font-size from localStorage before hydration.
Key and px map generated from @/lib/appearance — no hardcoded values. */}
<script dangerouslySetInnerHTML={{ __html: `(function(){try{var s=localStorage.getItem(${JSON.stringify(APPEARANCE_STORAGE_KEY)});var m=${fontSizePxMap};var p=m[s]||${FONT_SIZES[DEFAULT_FONT_SIZE].px};document.documentElement.style.fontSize=p+'px'}catch(e){}})();` }} />
<style id="theme-family-vars" dangerouslySetInnerHTML={{ __html: themeFamilyCSS }} />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider>
<ThemeFamilyProvider families={familiesMeta}>
<I18nProvider>
<AppShell>{children}</AppShell>
</I18nProvider>
<AppearanceProvider>
<I18nProvider>
<AppShell>{children}</AppShell>
</I18nProvider>
</AppearanceProvider>
</ThemeFamilyProvider>
</ThemeProvider>
</body>
Expand Down
6 changes: 3 additions & 3 deletions src/components/ai-elements/tool-actions-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ function ToolActionRow({ tool }: { tool: ToolAction }) {
</span>

{filePath && (category === 'read' || category === 'write') && (
<span className="text-muted-foreground/40 text-[11px] font-mono truncate max-w-[200px] hidden sm:inline">
<span className="text-muted-foreground/40 text-[0.6875rem] font-mono truncate max-w-[200px] hidden sm:inline">
{truncatePath(filePath)}
</span>
)}
Expand Down Expand Up @@ -236,7 +236,7 @@ export function ToolActionsGroup({
)}
/>

<span className="inline-flex items-center justify-center rounded bg-muted/80 px-1.5 py-0.5 text-[10px] font-medium leading-none text-muted-foreground/70 tabular-nums">
<span className="inline-flex items-center justify-center rounded bg-muted/80 px-1.5 py-0.5 text-[0.625rem] font-medium leading-none text-muted-foreground/70 tabular-nums">
{tools.length}
</span>

Expand All @@ -246,7 +246,7 @@ export function ToolActionsGroup({

{/* Show running task description on the right */}
{runningDesc && (
<span className="ml-auto text-muted-foreground/40 text-[11px] font-mono truncate max-w-[40%]">
<span className="ml-auto text-muted-foreground/40 text-[0.6875rem] font-mono truncate max-w-[40%]">
{runningDesc}
</span>
)}
Expand Down
1 change: 0 additions & 1 deletion src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const router = useRouter();
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [permissionProfile, setPermissionProfile] = useState<'default' | 'full_access'>(initialPermissionProfile || 'default');

// Workspace mismatch banner state
const [workspaceMismatchPath, setWorkspaceMismatchPath] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(initialHasMore);
Expand Down
2 changes: 1 addition & 1 deletion src/components/chat/CliToolsPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export function CliToolsPopover({
<Terminal size={16} className="shrink-0 text-muted-foreground" />
<span className="font-medium text-xs truncate">{tool.name}</span>
{tool.version && (
<span className="text-[10px] text-muted-foreground shrink-0">v{tool.version}</span>
<span className="text-[0.625rem] text-muted-foreground shrink-0">v{tool.version}</span>
)}
{tool.summary && (
<span className="text-xs text-muted-foreground truncate ml-auto max-w-[200px]">{tool.summary}</span>
Expand Down
2 changes: 1 addition & 1 deletion src/components/chat/ContextUsageIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export function ContextUsageIndicator({ messages, modelName }: ContextUsageIndic
<span>{formatTokens(usage.outputTokens)}</span>
</div>
</div>
<p className="text-[10px] text-muted-foreground pt-1 border-t border-border">
<p className="text-[0.625rem] text-muted-foreground pt-1 border-t border-border">
{t('context.estimate')}
</p>
</div>
Expand Down
8 changes: 4 additions & 4 deletions src/components/chat/ImageGenCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export function ImageGenCard({
{/* Reference images (垫图) */}
{referenceImages && referenceImages.length > 0 && (
<div>
<span className="text-[10px] text-muted-foreground/60 mb-1 block">
<span className="text-[0.625rem] text-muted-foreground/60 mb-1 block">
{t('imageGen.referenceImages' as TranslationKey)}
</span>
<div className="flex gap-1.5 flex-wrap">
Expand All @@ -140,18 +140,18 @@ export function ImageGenCard({
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 flex-wrap">
{model && (
<Badge variant="secondary" className="text-[10px] gap-1">
<Badge variant="secondary" className="text-[0.625rem] gap-1">
<PaintBrush size={12} />
{model}
</Badge>
)}
{aspectRatio && (
<Badge variant="outline" className="text-[10px]">
<Badge variant="outline" className="text-[0.625rem]">
{aspectRatio}
</Badge>
)}
{imageSize && (
<Badge variant="outline" className="text-[10px]">
<Badge variant="outline" className="text-[0.625rem]">
{imageSize}
</Badge>
)}
Expand Down
4 changes: 2 additions & 2 deletions src/components/chat/MessageInputParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function FileAttachmentsCapsules() {
className="h-5 w-5 rounded object-cover"
/>
)}
<span className="max-w-[120px] truncate text-[11px]">
<span className="max-w-[120px] truncate text-[0.6875rem]">
{file.filename || 'file'}
</span>
<Button
Expand Down Expand Up @@ -153,7 +153,7 @@ export function CommandBadge({
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 text-primary pl-2.5 pr-1.5 py-1 text-xs font-medium border border-primary/20">
<span className="font-mono">{command}</span>
{description && (
<span className="text-primary/60 text-[10px]">{description}</span>
<span className="text-primary/60 text-[0.625rem]">{description}</span>
)}
<Button
type="button"
Expand Down
2 changes: 1 addition & 1 deletion src/components/chat/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ function TokenUsageDisplay({ usage }: { usage: TokenUsage }) {
return (
<span className="group/tokens relative cursor-default text-xs text-muted-foreground/50">
<span>{totalTokens.toLocaleString()} tokens{costStr}</span>
<span className="pointer-events-none absolute bottom-full left-0 mb-1.5 whitespace-nowrap rounded-md bg-popover px-2.5 py-1.5 text-[11px] text-popover-foreground shadow-md border border-border/50 opacity-0 group-hover/tokens:opacity-100 transition-opacity duration-150 z-50">
<span className="pointer-events-none absolute bottom-full left-0 mb-1.5 whitespace-nowrap rounded-md bg-popover px-2.5 py-1.5 text-[0.6875rem] text-popover-foreground shadow-md border border-border/50 opacity-0 group-hover/tokens:opacity-100 transition-opacity duration-150 z-50">
In: {usage.input_tokens.toLocaleString()} · Out: {usage.output_tokens.toLocaleString()}
{usage.cache_read_input_tokens ? ` · Cache: ${usage.cache_read_input_tokens.toLocaleString()}` : ''}
{costStr}
Expand Down
10 changes: 5 additions & 5 deletions src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ function RewindButton({ sessionId, userMessageId }: { sessionId: string; userMes

if (state === 'done') {
return (
<span className="text-[10px] text-status-success-foreground ml-2">
<span className="text-[0.625rem] text-status-success-foreground ml-2">
{t('messageList.rewindDone' as TranslationKey)}
</span>
);
Expand All @@ -102,22 +102,22 @@ function RewindButton({ sessionId, userMessageId }: { sessionId: string; userMes
if (state === 'preview' && preview) {
return (
<span className="inline-flex items-center gap-1.5 ml-2">
<span className="text-[10px] text-muted-foreground">
<span className="text-[0.625rem] text-muted-foreground">
{preview.filesChanged?.length || 0} files, +{preview.insertions || 0}/-{preview.deletions || 0}
</span>
<Button
variant="link"
size="xs"
onClick={handleRewind}
className="text-[10px] text-primary h-auto p-0"
className="text-[0.625rem] text-primary h-auto p-0"
>
{t('messageList.rewindConfirm' as TranslationKey)}
</Button>
<Button
variant="link"
size="xs"
onClick={() => setState('idle')}
className="text-[10px] text-muted-foreground h-auto p-0"
className="text-[0.625rem] text-muted-foreground h-auto p-0"
>
{t('messageList.rewindCancel' as TranslationKey)}
</Button>
Expand All @@ -131,7 +131,7 @@ function RewindButton({ sessionId, userMessageId }: { sessionId: string; userMes
size="xs"
onClick={handleDryRun}
disabled={state === 'loading'}
className="text-[10px] text-muted-foreground hover:text-foreground ml-2 opacity-0 group-hover:opacity-100 h-auto p-0"
className="text-[0.625rem] text-muted-foreground hover:text-foreground ml-2 opacity-0 group-hover:opacity-100 h-auto p-0"
>
{state === 'loading' ? '...' : t('messageList.rewindToHere' as TranslationKey)}
</Button>
Expand Down
4 changes: 2 additions & 2 deletions src/components/chat/PermissionPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ function AskUserQuestionUI({
return (
<div key={qIdx} className="space-y-2">
{q.header && (
<span className="inline-block rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
<span className="inline-block rounded-full bg-muted px-2 py-0.5 text-[0.625rem] font-medium text-muted-foreground">
{q.header}
</span>
)}
Expand Down Expand Up @@ -217,7 +217,7 @@ function ExitPlanModeUI({
<ul className="space-y-0.5">
{allowedPrompts.map((p, i) => (
<li key={i} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">{p.tool}</span>
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.625rem]">{p.tool}</span>
<span>{p.prompt}</span>
</li>
))}
Expand Down
6 changes: 3 additions & 3 deletions src/components/chat/StreamingMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,10 @@ function StreamingStatusBar({ statusText, onForceStop }: { statusText?: string;
<Shimmer duration={1.5}>{displayText}</Shimmer>
</span>
{isWarning && !isCritical && (
<span className="text-status-warning-foreground text-[10px]">Running longer than usual</span>
<span className="text-status-warning-foreground text-[0.625rem]">Running longer than usual</span>
)}
{isCritical && (
<span className="text-status-error-foreground text-[10px]">Tool may be stuck</span>
<span className="text-status-error-foreground text-[0.625rem]">Tool may be stuck</span>
)}
</div>
<span className="text-muted-foreground/50">|</span>
Expand All @@ -158,7 +158,7 @@ function StreamingStatusBar({ statusText, onForceStop }: { statusText?: string;
variant="outline"
size="xs"
onClick={onForceStop}
className="ml-auto border-status-error-border bg-status-error-muted text-[10px] font-medium text-status-error-foreground hover:bg-status-error-muted"
className="ml-auto border-status-error-border bg-status-error-muted text-[0.625rem] font-medium text-status-error-foreground hover:bg-status-error-muted"
>
Force stop
</Button>
Expand Down
10 changes: 5 additions & 5 deletions src/components/chat/batch-image-gen/BatchExecutionItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,18 @@ export function BatchExecutionItem({ item }: BatchExecutionItemProps) {
<div className="flex-1 min-w-0">
<p className="text-xs text-foreground truncate">{item.prompt}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-muted-foreground">{item.aspect_ratio}</span>
<span className="text-[10px] text-muted-foreground">{item.image_size}</span>
<span className={`text-[10px] font-medium ${statusColor}`}>{statusLabel}</span>
<span className="text-[0.625rem] text-muted-foreground">{item.aspect_ratio}</span>
<span className="text-[0.625rem] text-muted-foreground">{item.image_size}</span>
<span className={`text-[0.625rem] font-medium ${statusColor}`}>{statusLabel}</span>
{item.error && (
<span className="text-[10px] text-status-error-foreground truncate">{item.error}</span>
<span className="text-[0.625rem] text-status-error-foreground truncate">{item.error}</span>
)}
</div>
</div>

{/* Retry count */}
{item.retry_count > 0 && (
<span className="text-[10px] text-muted-foreground shrink-0">
<span className="text-[0.625rem] text-muted-foreground shrink-0">
retry {item.retry_count}
</span>
)}
Expand Down
Loading