diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 485073200d..8d4512e65f 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -151,6 +151,12 @@ Notes: - SVGs inherit `size-3.5` (`size-3` at `xs`). Don't re-set icon size. - Polymorph with `asChild` when the button must render as a link/Slot. +## Badges — one component + +`src/components/ui/badge.tsx`. Variants: `default` (tinted primary), `muted`, +`warn`, `destructive`, `outline`, `solid` (primary fill — icon-corner counts). +Sizes: `default`, `xs`, `overlay` (titlebar glyph counts). + ## Form controls - **`controlVariants`** (`src/components/ui/control.ts`) is the shared shape for diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index 72e297d9ac..33d8d4e235 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -5,9 +5,11 @@ import { useLocation, useNavigate } from 'react-router' import { hudTargetSessionId } from '@/app/hud/handoff' import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode' import { resetLayoutTree } from '@/components/pane-shell/tree/store' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' +import { compactNumber } from '@/lib/format' import { triggerHaptic } from '@/lib/haptics' import { formatModifierToken } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' @@ -15,11 +17,13 @@ import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics' import { toggleHud } from '@/store/hud' import { $fileBrowserOpen, + $panesFlipped, $sidebarOpen, toggleFileBrowserOpen, togglePanesFlipped, toggleSidebarOpen } from '@/store/layout' +import { $unreadSessionCount } from '@/store/session-dot-state' import { appViewForPath, isOverlayView } from '../routes' @@ -43,6 +47,8 @@ export interface TitlebarTool { onSelect?: (event?: MouseEvent) => void /** Keybind action id — when set, the tooltip shows the label + keybind hint. */ actionId?: string + /** Overlay count on the glyph (unread sessions). Hidden when 0/undefined. */ + badge?: number title?: string to?: string } @@ -79,6 +85,24 @@ function LayoutGlyph({ modHeld }: { modHeld: boolean }) { ) } +/** Overlay count on a titlebar glyph. Hidden when count is 0/undefined. */ +function withCountBadge(icon: ReactNode, count: number | undefined): ReactNode { + if (!count) { + return icon + } + + return ( + + {icon} + + + {compactNumber(count)} + + + + ) +} + /** Live ⌘/Ctrl tracking — mod-click affordances telegraph themselves (the * layout button morphs into its reset form while the modifier is down). */ function useModifierHeld(): boolean { @@ -109,7 +133,11 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: const modHeld = useModifierHeld() const hapticsMuted = useStore($hapticsMuted) const fileBrowserOpen = useStore($fileBrowserOpen) + const panesFlipped = useStore($panesFlipped) const sidebarOpen = useStore($sidebarOpen) + const unreadCount = useStore($unreadSessionCount) + const unreadBadge = unreadCount > 0 ? unreadCount : undefined + const unreadHint = unreadBadge ? ` · ${t.titlebar.unreadSessions(unreadBadge)}` : '' const toggleHaptics = () => { if (!hapticsMuted) { @@ -130,13 +158,16 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: // show/hide affordances. const leftEdge = { open: sidebarOpen, toggle: toggleSidebarOpen } const rightEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen } + const leftLabel = leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar + const rightLabel = rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar const leftToolbarTools: TitlebarTool[] = [ { actionId: 'view.toggleSidebar', + badge: panesFlipped ? undefined : unreadBadge, icon: , id: 'sidebar', - label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar, + label: `${leftLabel}${panesFlipped ? '' : unreadHint}`, onSelect: () => { triggerHaptic('tap') leftEdge.toggle() @@ -157,9 +188,10 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: const rightSidebarTool: TitlebarTool = { actionId: 'view.toggleRightSidebar', + badge: panesFlipped ? unreadBadge : undefined, icon: , id: 'right-sidebar', - label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar, + label: `${rightLabel}${panesFlipped ? unreadHint : ''}`, onSelect: () => { triggerHaptic('tap') rightEdge.toggle() @@ -306,7 +338,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType - {tool.icon} + {withCountBadge(tool.icon, tool.badge)} @@ -332,7 +364,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType - {tool.icon} + {withCountBadge(tool.icon, tool.badge)} ) diff --git a/apps/desktop/src/components/ui/badge.tsx b/apps/desktop/src/components/ui/badge.tsx index c4e46e5236..d3a8a50449 100644 --- a/apps/desktop/src/components/ui/badge.tsx +++ b/apps/desktop/src/components/ui/badge.tsx @@ -15,11 +15,14 @@ const badgeVariants = cva( muted: 'bg-muted text-muted-foreground', warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300', destructive: 'bg-destructive/10 text-destructive', - outline: 'border border-(--ui-stroke-secondary) text-muted-foreground' + outline: 'border border-(--ui-stroke-secondary) text-muted-foreground', + // Solid fill — icon-corner counts (titlebar unread, etc.). + solid: 'bg-primary text-primary-foreground' }, size: { default: 'px-1.5 py-0.5 text-[0.65rem] [&_svg]:size-3', - xs: 'px-1 py-px text-[0.6rem] [&_svg]:size-2.5' + xs: 'px-1 py-px text-[0.6rem] [&_svg]:size-2.5', + overlay: 'h-2 min-w-2 justify-center rounded-[2px] px-px text-[7px] font-semibold tabular-nums' } }, defaultVariants: { variant: 'default', size: 'default' } diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 5ed5db5686..2280ab4a4c 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -181,6 +181,7 @@ export const ar = defineLocale({ swapSidebarSides: 'تبديل جانبي الأشرطة', hideRightSidebar: 'إخفاء الشريط الأيمن', showRightSidebar: 'إظهار الشريط الأيمن', + unreadSessions: count => (count === 1 ? 'جلسة واحدة غير مقروءة' : `${count} جلسات غير مقروءة`), muteHaptics: 'كتم الاهتزازات', unmuteHaptics: 'تفعيل الاهتزازات', openSettings: 'فتح الإعدادات', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 5e94e4c4f0..f5f0664aa4 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -211,6 +211,7 @@ export const en: Translations = { swapSidebarSides: 'Swap sidebar sides', hideRightSidebar: 'Hide right sidebar', showRightSidebar: 'Show right sidebar', + unreadSessions: count => (count === 1 ? '1 unread session' : `${count} unread sessions`), muteHaptics: 'Mute haptics', unmuteHaptics: 'Unmute haptics', openSettings: 'Open settings', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 7864578edc..034dc6a631 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -212,6 +212,7 @@ export const ja = defineLocale({ swapSidebarSides: 'サイドバーの向きを切り替え', hideRightSidebar: '右サイドバーを非表示', showRightSidebar: '右サイドバーを表示', + unreadSessions: count => (count === 1 ? '未読セッション 1 件' : `未読セッション ${count} 件`), muteHaptics: '触覚フィードバックをオフ', unmuteHaptics: '触覚フィードバックをオン', openSettings: '設定を開く', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index afd3fda150..dcba755c64 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -253,6 +253,7 @@ export interface Translations { swapSidebarSides: string hideRightSidebar: string showRightSidebar: string + unreadSessions: (count: number) => string muteHaptics: string unmuteHaptics: string openSettings: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 23a3232625..c68b3e2f73 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -206,6 +206,7 @@ export const zhHant = defineLocale({ swapSidebarSides: '交換側邊欄位置', hideRightSidebar: '隱藏右側邊欄', showRightSidebar: '顯示右側邊欄', + unreadSessions: count => (count === 1 ? '1 個未讀工作階段' : `${count} 個未讀工作階段`), muteHaptics: '靜音觸感回饋', unmuteHaptics: '開啟觸感回饋', openSettings: '開啟設定', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 613679cd72..44620ac17e 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -206,6 +206,7 @@ export const zh: Translations = { swapSidebarSides: '交换侧边栏位置', hideRightSidebar: '隐藏右侧栏', showRightSidebar: '显示右侧栏', + unreadSessions: count => (count === 1 ? '1 个未读会话' : `${count} 个未读会话`), muteHaptics: '关闭触感反馈', unmuteHaptics: '开启触感反馈', openSettings: '打开设置',