Make desktop sidebar collapse recoverable without stealing focus

The desktop shell now owns sidebar collapse state, the collapsed rail keeps a
visible recovery affordance, and keyboard search expands the sidebar before
focusing. The UI work also smooths the width transition so the main chat area
can take over without the previous abrupt cutover.

Constraint: The desktop app must keep a recovery path visible in narrow layouts on macOS and Windows
Constraint: Existing sidebar behavior needed regression coverage before further UI work
Rejected: Fully hide the sidebar at 0 width | recovery affordance became too fragile for this iteration
Rejected: Keep boxed restore controls in the collapsed rail | visually heavy and easy to misplace
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep the collapsed rail width and the inner sidebar panel width in sync or expand controls will be clipped again
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run test Sidebar.test.tsx
Not-tested: Full desktop manual pass across every app screen after the final icon/layout tweaks
This commit is contained in:
程序员阿江(Relakkes) 2026-04-20 18:13:49 +08:00
parent e20ccf8093
commit 03112f7152
7 changed files with 416 additions and 141 deletions

View File

@ -14,6 +14,7 @@ import { useTranslation } from '../../i18n'
export function AppShell() {
const fetchSettings = useSettingsStore((s) => s.fetchAll)
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
const [ready, setReady] = useState(false)
const [startupError, setStartupError] = useState<string | null>(null)
const t = useTranslation()
@ -94,9 +95,19 @@ export function AppShell() {
}
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main id="content-area" className="flex-1 flex flex-col overflow-hidden">
<div className="h-screen flex overflow-hidden bg-[var(--color-surface)]">
<div
data-testid="sidebar-shell"
data-state={sidebarOpen ? 'open' : 'closed'}
className="sidebar-shell"
>
<Sidebar />
</div>
<main
id="content-area"
data-sidebar-state={sidebarOpen ? 'open' : 'closed'}
className="min-w-0 flex-1 flex flex-col overflow-hidden"
>
<TabBar />
<ContentRouter />
</main>

View File

@ -25,6 +25,8 @@ vi.mock('../../i18n', () => ({
'sidebar.timeGroup.last30days': 'Last 30 Days',
'sidebar.timeGroup.older': 'Older',
'sidebar.missingDir': 'Missing',
'sidebar.collapse': 'Collapse sidebar',
'sidebar.expand': 'Expand sidebar',
}
return translations[key] ?? key
@ -70,6 +72,7 @@ describe('Sidebar', () => {
disconnectSession,
} as Partial<ReturnType<typeof useChatStore.getState>>)
useUIStore.setState({
sidebarOpen: true,
addToast,
} as Partial<ReturnType<typeof useUIStore.getState>>)
})
@ -155,4 +158,25 @@ describe('Sidebar', () => {
expect(useTabStore.getState().tabs).toEqual([])
expect(useTabStore.getState().activeTabId).toBeNull()
})
it('collapses into an icon rail and expands back', async () => {
render(<Sidebar />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
})
expect(useUIStore.getState().sidebarOpen).toBe(false)
expect(screen.queryByPlaceholderText('Search sessions')).not.toBeInTheDocument()
expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'closed')
expect(screen.getByTestId('sidebar-expand-button')).toHaveClass('sidebar-toggle-button--collapsed')
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Expand sidebar' }))
})
expect(useUIStore.getState().sidebarOpen).toBe(true)
expect(screen.getByPlaceholderText('Search sessions')).toBeInTheDocument()
expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'open')
})
})

View File

@ -22,6 +22,8 @@ export function Sidebar() {
const deleteSession = useSessionStore((s) => s.deleteSession)
const renameSession = useSessionStore((s) => s.renameSession)
const addToast = useUIStore((s) => s.addToast)
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
const toggleSidebar = useUIStore((s) => s.toggleSidebar)
const activeTabId = useTabStore((s) => s.activeTabId)
const closeTab = useTabStore((s) => s.closeTab)
const disconnectSession = useChatStore((s) => s.disconnectSession)
@ -34,7 +36,11 @@ export function Sidebar() {
fetchSessions()
}, [fetchSessions])
// Close context menu on click outside
useEffect(() => {
if (!contextMenu || sidebarOpen) return
setContextMenu(null)
}, [contextMenu, sidebarOpen])
useEffect(() => {
if (!contextMenu) return
const close = () => setContextMenu(null)
@ -42,7 +48,6 @@ export function Sidebar() {
return () => document.removeEventListener('click', close)
}, [contextMenu])
// Filter by selected projects, then by search query
const filteredSessions = useMemo(() => {
let result = sessions
if (selectedProjects.length > 0) {
@ -55,7 +60,6 @@ export function Sidebar() {
return result
}, [sessions, selectedProjects, searchQuery])
// Group by time
const timeGroups = useMemo(() => groupByTime(filteredSessions), [filteredSessions])
const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => {
@ -103,7 +107,7 @@ export function Sidebar() {
const t = useTranslation()
const TIME_GROUP_LABELS: Record<TimeGroup, string> = {
const timeGroupLabels: Record<TimeGroup, string> = {
today: t('sidebar.timeGroup.today'),
yesterday: t('sidebar.timeGroup.yesterday'),
last7days: t('sidebar.timeGroup.last7days'),
@ -112,32 +116,56 @@ export function Sidebar() {
}
return (
<aside onMouseDown={handleSidebarDrag} className="w-[var(--sidebar-width)] h-full flex flex-col bg-[var(--color-surface-sidebar)] border-r border-[var(--color-border)] select-none">
{/* Brand logo — extra top padding in desktop to clear macOS traffic lights (not needed on Windows) */}
<div className={`px-3 pb-1.5 flex items-center justify-between ${isTauri && !isWindows ? 'pt-[44px]' : 'pt-3'}`}>
<div className="flex items-center gap-2.5">
<img src="/app-icon.jpg" alt="" className="h-8 w-8 rounded-lg flex-shrink-0" />
<span className="text-[13px] font-semibold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
Claude Code <span className="text-[var(--color-primary-container)]">Haha</span>
</span>
<aside
onMouseDown={handleSidebarDrag}
className="sidebar-panel relative h-full flex flex-col bg-[var(--color-surface-sidebar)] border-r border-[var(--color-border)] select-none"
data-state={sidebarOpen ? 'open' : 'closed'}
aria-label="Sidebar"
>
<div className={`px-3 pb-2 ${isTauri && !isWindows ? 'pt-[44px]' : 'pt-3'}`}>
<div className={`flex ${sidebarOpen ? 'items-center justify-between gap-3' : 'flex-col items-center gap-2'}`}>
<div className={`flex min-w-0 items-center ${sidebarOpen ? 'gap-2.5' : 'justify-center'}`}>
<img src="/app-icon.jpg" alt="" className="h-8 w-8 rounded-lg flex-shrink-0" />
<span
className={`sidebar-copy ${sidebarOpen ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} text-[13px] font-semibold tracking-tight text-[var(--color-text-primary)]`}
style={{ fontFamily: 'var(--font-headline)' }}
>
Claude Code <span className="text-[var(--color-primary-container)]">Haha</span>
</span>
</div>
<div className={`flex items-center ${sidebarOpen ? 'gap-1.5' : 'flex-col gap-2'}`}>
<a
href="https://github.com/NanmiCoder/cc-haha"
target="_blank"
rel="noopener noreferrer"
className={`sidebar-copy ${sidebarOpen ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} inline-flex items-center justify-center rounded-md p-1 text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]`}
title="GitHub"
tabIndex={sidebarOpen ? undefined : -1}
aria-hidden={!sidebarOpen}
>
<GitHubIcon />
</a>
<button
type="button"
onClick={toggleSidebar}
data-testid={sidebarOpen ? 'sidebar-collapse-button' : 'sidebar-expand-button'}
className={`sidebar-toggle-button ${sidebarOpen ? 'sidebar-toggle-button--open h-8 w-8' : 'sidebar-toggle-button--collapsed h-8 w-8'} flex items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)]`}
aria-label={sidebarOpen ? t('sidebar.collapse') : t('sidebar.expand')}
title={sidebarOpen ? t('sidebar.collapse') : t('sidebar.expand')}
>
<SidebarToggleIcon collapsed={!sidebarOpen} />
</button>
</div>
</div>
<a
href="https://github.com/NanmiCoder/cc-haha"
target="_blank"
rel="noopener noreferrer"
className="rounded-md p-1 text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
title="GitHub"
>
<GitHubIcon />
</a>
</div>
{/* Navigation */}
<div className="px-3 pb-3 flex flex-col gap-0.5">
<div className={`px-3 pb-3 flex flex-col ${sidebarOpen ? 'gap-0.5' : 'items-center gap-2'}`}>
<NavItem
active={false}
collapsed={!sidebarOpen}
label={t('sidebar.newSession')}
onClick={async () => {
try {
// Use current active session's workDir as default for new session
const currentTabId = useTabStore.getState().activeTabId
const currentSession = currentTabId
? useSessionStore.getState().sessions.find((s) => s.id === currentTabId)
@ -149,8 +177,7 @@ export function Sidebar() {
} catch (error) {
addToast({
type: 'error',
message:
error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
message: error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
})
}
}}
@ -160,6 +187,8 @@ export function Sidebar() {
</NavItem>
<NavItem
active={activeTabId === SCHEDULED_TAB_ID}
collapsed={!sidebarOpen}
label={t('sidebar.scheduled')}
onClick={() => useTabStore.getState().openTab(SCHEDULED_TAB_ID, t('sidebar.scheduled'), 'scheduled')}
icon={<ClockIcon />}
>
@ -167,108 +196,124 @@ export function Sidebar() {
</NavItem>
</div>
{/* Project filter */}
<div className="px-3 pb-1 flex items-center justify-between">
<ProjectFilter />
</div>
{/* Search */}
<div className="px-3 pb-2">
<input
id="sidebar-search"
type="text"
placeholder={t('sidebar.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-7 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
/>
</div>
{/* Session list — grouped by time */}
<div className="flex-1 overflow-y-auto px-3">
{error && (
<div className="mx-1 mt-2 rounded-[var(--radius-md)] border border-[var(--color-error)]/20 bg-[var(--color-error)]/5 px-3 py-2">
<div className="text-xs font-medium text-[var(--color-error)]">{t('sidebar.sessionListFailed')}</div>
<div className="mt-1 text-[11px] text-[var(--color-text-secondary)] break-words">{error}</div>
<button
onClick={() => fetchSessions()}
className="mt-2 text-[11px] font-medium text-[var(--color-brand)] hover:underline"
>
{t('common.retry')}
</button>
</div>
)}
{filteredSessions.length === 0 && (
<div className="px-3 py-4 text-xs text-[var(--color-text-tertiary)] text-center">
{searchQuery ? t('sidebar.noMatching') : t('sidebar.noSessions')}
</div>
)}
{TIME_GROUP_ORDER.map((group) => {
const items = timeGroups.get(group)
if (!items || items.length === 0) return null
return (
<div key={group} className="mb-1">
<div className="px-2 pt-3 pb-1 text-[11px] font-semibold text-[var(--color-text-tertiary)] tracking-wide">
{TIME_GROUP_LABELS[group]}
</div>
{items.map((session) => (
<div key={session.id} className="relative">
{renamingId === session.id ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleFinishRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishRename()
if (e.key === 'Escape') { setRenamingId(null); setRenameValue('') }
}}
className="w-full px-3 py-2 text-sm rounded-[var(--radius-md)] border border-[var(--color-border-focus)] bg-[var(--color-surface)] text-[var(--color-text-primary)] outline-none ml-1"
/>
) : (
<button
onClick={() => {
useTabStore.getState().openTab(session.id, session.title)
useChatStore.getState().connectToSession(session.id)
}}
onContextMenu={(e) => handleContextMenu(e, session.id)}
className={`
w-full flex items-center gap-2 pl-4 pr-3 py-1.5 text-sm text-left rounded-[var(--radius-md)] transition-colors duration-200 group
${session.id === activeTabId
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
<span className="w-1 h-1 rounded-full flex-shrink-0" style={{
backgroundColor: session.id === activeTabId ? 'var(--color-brand)' : 'var(--color-text-tertiary)',
opacity: session.id === activeTabId ? 1 : 0.5,
}} />
<span className="truncate flex-1">{session.title || 'Untitled'}</span>
{!session.workDirExists && (
<span
className="text-[10px] text-[var(--color-warning)] flex-shrink-0"
title={session.workDir ?? ''}
>
{t('sidebar.missingDir')}
</span>
)}
<span className="text-[10px] text-[var(--color-text-tertiary)] flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
{formatRelativeTime(session.modifiedAt)}
</span>
</button>
)}
</div>
))}
{sidebarOpen ? (
<>
<div className="sidebar-section sidebar-section--visible flex-none px-3 pb-1">
<div className="flex items-center justify-between">
<ProjectFilter />
</div>
)
})}
</div>
</div>
{/* Settings button at bottom */}
<div className="p-3 border-t border-[var(--color-border)]">
<div className="sidebar-section sidebar-section--visible flex-none px-3 pb-2">
<input
id="sidebar-search"
type="text"
placeholder={t('sidebar.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-8 px-2.5 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none transition-colors focus:border-[var(--color-border-focus)]"
/>
</div>
<div className="sidebar-section sidebar-section--visible flex-1 min-h-0">
<div className="flex-1 overflow-y-auto px-3">
{error && (
<div className="mx-1 mt-2 rounded-[var(--radius-md)] border border-[var(--color-error)]/20 bg-[var(--color-error)]/5 px-3 py-2">
<div className="text-xs font-medium text-[var(--color-error)]">{t('sidebar.sessionListFailed')}</div>
<div className="mt-1 text-[11px] text-[var(--color-text-secondary)] break-words">{error}</div>
<button
onClick={() => fetchSessions()}
className="mt-2 text-[11px] font-medium text-[var(--color-brand)] hover:underline"
>
{t('common.retry')}
</button>
</div>
)}
{filteredSessions.length === 0 && (
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">
{searchQuery ? t('sidebar.noMatching') : t('sidebar.noSessions')}
</div>
)}
{TIME_GROUP_ORDER.map((group) => {
const items = timeGroups.get(group)
if (!items || items.length === 0) return null
return (
<div key={group} className="mb-1">
<div className="px-2 pb-1 pt-3 text-[11px] font-semibold tracking-wide text-[var(--color-text-tertiary)]">
{timeGroupLabels[group]}
</div>
{items.map((session) => (
<div key={session.id} className="relative">
{renamingId === session.id ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleFinishRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishRename()
if (e.key === 'Escape') {
setRenamingId(null)
setRenameValue('')
}
}}
className="ml-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border-focus)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] outline-none"
/>
) : (
<button
onClick={() => {
useTabStore.getState().openTab(session.id, session.title)
useChatStore.getState().connectToSession(session.id)
}}
onContextMenu={(e) => handleContextMenu(e, session.id)}
className={`
group w-full rounded-[var(--radius-md)] py-1.5 pl-4 pr-3 text-left text-sm transition-colors duration-200
${session.id === activeTabId
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
<span className="flex items-center gap-2">
<span
className="h-1 w-1 flex-shrink-0 rounded-full"
style={{
backgroundColor: session.id === activeTabId ? 'var(--color-brand)' : 'var(--color-text-tertiary)',
opacity: session.id === activeTabId ? 1 : 0.5,
}}
/>
<span className="flex-1 truncate">{session.title || 'Untitled'}</span>
{!session.workDirExists && (
<span
className="flex-shrink-0 text-[10px] text-[var(--color-warning)]"
title={session.workDir ?? ''}
>
{t('sidebar.missingDir')}
</span>
)}
<span className="flex-shrink-0 text-[10px] text-[var(--color-text-tertiary)] opacity-0 transition-opacity group-hover:opacity-100">
{formatRelativeTime(session.modifiedAt)}
</span>
</span>
</button>
)}
</div>
))}
</div>
)
})}
</div>
</div>
</>
) : (
<div className="flex-1" aria-hidden="true" />
)}
<div className={`border-t border-[var(--color-border)] p-3 ${sidebarOpen ? '' : 'flex justify-center'}`}>
<NavItem
active={activeTabId === SETTINGS_TAB_ID}
collapsed={!sidebarOpen}
label={t('sidebar.settings')}
onClick={() => useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')}
icon={<span className="material-symbols-outlined text-[18px]">settings</span>}
>
@ -276,24 +321,23 @@ export function Sidebar() {
</NavItem>
</div>
{/* Context menu */}
{contextMenu && (
{contextMenu && sidebarOpen && (
<div
className="fixed z-50 bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-md)] py-1 min-w-[140px]"
className="fixed z-50 min-w-[140px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] py-1"
style={{ left: contextMenu.x, top: contextMenu.y, boxShadow: 'var(--shadow-dropdown)' }}
>
<button
onClick={() => {
const session = sessions.find(s => s.id === contextMenu.id)
const session = sessions.find((s) => s.id === contextMenu.id)
handleStartRename(contextMenu.id, session?.title || '')
}}
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-colors"
className="w-full px-3 py-1.5 text-left text-xs text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
{t('common.rename')}
</button>
<button
onClick={() => handleDelete(contextMenu.id)}
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-error)] hover:bg-[var(--color-surface-hover)] transition-colors"
className="w-full px-3 py-1.5 text-left text-xs text-[var(--color-error)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
{t('common.delete')}
</button>
@ -327,20 +371,41 @@ function groupByTime(sessions: SessionListItem[]): Map<TimeGroup, SessionListIte
return groups
}
function NavItem({ active, onClick, icon, children }: { active: boolean; onClick: () => void; icon: React.ReactNode; children: React.ReactNode }) {
function NavItem({
active,
collapsed,
label,
onClick,
icon,
children,
}: {
active: boolean
collapsed: boolean
label: string
onClick: () => void
icon: React.ReactNode
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
aria-label={label}
title={collapsed ? label : undefined}
className={`
w-full flex items-center gap-2.5 px-3 py-2 text-sm rounded-[var(--radius-md)] transition-colors duration-200
flex items-center rounded-[var(--radius-md)] transition-all duration-200
${collapsed ? 'h-10 w-10 justify-center px-0 py-0' : 'w-full gap-2.5 px-3 py-2 text-sm'}
${active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] font-medium'
? 'bg-[var(--color-surface-selected)] font-medium text-[var(--color-text-primary)] shadow-[0_8px_24px_rgba(15,23,42,0.08)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}
`}
>
{icon}
{children}
<span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
{icon}
</span>
<span className={`sidebar-copy ${collapsed ? 'sidebar-copy--hidden' : 'sidebar-copy--visible'}`}>
{children}
</span>
</button>
)
}
@ -382,3 +447,21 @@ function ClockIcon() {
</svg>
)
}
function SidebarToggleIcon({ collapsed }: { collapsed: boolean }) {
return (
<svg
width={collapsed ? 16 : 14}
height={collapsed ? 16 : 14}
viewBox="0 0 14 14"
fill="none"
className={`sidebar-toggle-icon ${collapsed ? 'sidebar-toggle-icon--collapsed' : 'sidebar-toggle-icon--open'}`}
aria-hidden="true"
>
<path
d={collapsed ? 'M5 3 9 7l-4 4' : 'M9 3 5 7l4 4'}
className="sidebar-toggle-chevron"
/>
</svg>
)
}

View File

@ -7,6 +7,7 @@ import { useUIStore } from '../stores/uiStore'
export function useKeyboardShortcuts() {
const setActiveSession = useSessionStore((s) => s.setActiveSession)
const setActiveView = useUIStore((s) => s.setActiveView)
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen)
const closeModal = useUIStore((s) => s.closeModal)
const activeModal = useUIStore((s) => s.activeModal)
const stopGeneration = useChatStore((s) => s.stopGeneration)
@ -34,8 +35,12 @@ export function useKeyboardShortcuts() {
// Cmd+K — Focus search (sidebar search input)
if (meta && e.key === 'k') {
e.preventDefault()
const searchInput = document.querySelector('aside input[type="text"]') as HTMLInputElement
searchInput?.focus()
setSidebarOpen(true)
requestAnimationFrame(() => {
const searchInput = document.querySelector('#sidebar-search') as HTMLInputElement | null
searchInput?.focus()
searchInput?.select()
})
}
// Escape — Close modal or clear state
@ -56,5 +61,5 @@ export function useKeyboardShortcuts() {
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [closeModal, setActiveSession, setActiveView, stopGeneration])
}, [closeModal, setActiveSession, setActiveView, setSidebarOpen, stopGeneration])
}

View File

@ -31,6 +31,8 @@ export const en = {
'sidebar.timeGroup.last7days': 'Last 7 days',
'sidebar.timeGroup.last30days': 'Last 30 days',
'sidebar.timeGroup.older': 'Older',
'sidebar.collapse': 'Collapse sidebar',
'sidebar.expand': 'Expand sidebar',
// ─── Title Bar ──────────────────────────────────────
'titlebar.code': 'Code',

View File

@ -33,6 +33,8 @@ export const zh: Record<TranslationKey, string> = {
'sidebar.timeGroup.last7days': '最近 7 天',
'sidebar.timeGroup.last30days': '最近 30 天',
'sidebar.timeGroup.older': '更早',
'sidebar.collapse': '折叠侧边栏',
'sidebar.expand': '展开侧边栏',
// ─── Title Bar ──────────────────────────────────────
'titlebar.code': '代码',

View File

@ -146,8 +146,11 @@
/* Layout dimensions */
--sidebar-width: 280px;
--sidebar-rail-width: 72px;
--titlebar-height: 40px;
--statusbar-height: 36px;
--motion-sidebar-duration: 280ms;
--motion-sidebar-easing: cubic-bezier(0.22, 1, 0.36, 1);
/* Surface aliases */
--color-surface-sidebar: var(--color-surface-container-low);
@ -424,6 +427,151 @@ button, input, textarea, select, a, [role="button"] {
box-shadow: var(--shadow-focus-ring), var(--shadow-dropdown);
}
.sidebar-shell {
width: var(--sidebar-width);
flex-shrink: 0;
overflow: hidden;
transition: width var(--motion-sidebar-duration) var(--motion-sidebar-easing);
will-change: width;
}
.sidebar-shell[data-state="closed"] {
width: var(--sidebar-rail-width);
}
.sidebar-panel {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
transition:
background-color var(--motion-sidebar-duration) var(--motion-sidebar-easing),
border-color var(--motion-sidebar-duration) var(--motion-sidebar-easing);
}
.sidebar-panel[data-state="closed"] {
width: var(--sidebar-rail-width);
min-width: var(--sidebar-rail-width);
}
.sidebar-toggle-button {
color: color-mix(in srgb, var(--color-text-secondary) 72%, transparent);
background: transparent;
transition:
color 180ms ease,
background-color 180ms ease,
transform 180ms ease;
border: 0;
box-shadow: none;
}
.sidebar-toggle-button:hover {
color: color-mix(in srgb, var(--color-text-primary) 88%, transparent);
background: color-mix(in srgb, var(--color-surface-hover) 34%, transparent);
}
.sidebar-toggle-button:active {
transform: translateY(0.5px);
background: color-mix(in srgb, var(--color-surface-hover) 54%, transparent);
}
.sidebar-toggle-button--open {
color: color-mix(in srgb, var(--color-text-secondary) 68%, transparent);
}
.sidebar-toggle-button--open:hover {
color: color-mix(in srgb, var(--color-text-primary) 82%, transparent);
}
.sidebar-toggle-button--collapsed {
color: color-mix(in srgb, var(--color-text-primary) 78%, transparent);
}
.sidebar-toggle-button--collapsed:hover {
color: var(--color-text-primary);
}
.sidebar-toggle-button--collapsed:active {
}
.sidebar-toggle-icon {
overflow: visible;
}
.sidebar-toggle-chevron {
stroke: currentColor;
transition:
opacity 180ms ease,
transform 180ms ease;
vector-effect: non-scaling-stroke;
}
.sidebar-toggle-chevron {
opacity: 0.95;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.85;
transform-origin: 50% 50%;
}
.sidebar-toggle-icon--open .sidebar-toggle-chevron {
transform: translateX(-0.2px);
}
.sidebar-toggle-button--collapsed .sidebar-toggle-chevron {
stroke-width: 2;
transform: translateX(0.4px);
}
.sidebar-copy {
overflow: hidden;
white-space: nowrap;
transition:
max-width var(--motion-sidebar-duration) var(--motion-sidebar-easing),
opacity calc(var(--motion-sidebar-duration) - 40ms) ease,
transform var(--motion-sidebar-duration) var(--motion-sidebar-easing);
}
.sidebar-copy--visible {
max-width: 240px;
opacity: 1;
transform: translateX(0);
}
.sidebar-copy--hidden {
max-width: 0;
opacity: 0;
transform: translateX(-10px);
}
.sidebar-section {
overflow: hidden;
transition:
max-height var(--motion-sidebar-duration) var(--motion-sidebar-easing),
opacity calc(var(--motion-sidebar-duration) - 40ms) ease,
transform var(--motion-sidebar-duration) var(--motion-sidebar-easing);
}
.sidebar-section--visible {
max-height: 1200px;
opacity: 1;
transform: translateX(0);
}
.sidebar-section--hidden {
max-height: 0;
opacity: 0;
transform: translateX(-12px);
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.sidebar-shell,
.sidebar-panel,
.sidebar-copy,
.sidebar-section {
transition-duration: 0.01ms !important;
}
}
.markdown-prose .md-table-wrap table {
border-collapse: separate;
border-spacing: 0;