From 0f721aa6901493b94a1fcabfa3b5652166ecc4ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:25:38 +0000 Subject: [PATCH 1/7] Initial plan From 94ae9e7642347480b3162074053ad49a5b99be2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:31:04 +0000 Subject: [PATCH 2/7] feat: pin icons show-on-hover, search contrast, Recent section position, percent/datetime inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NavigationRenderer: Add showOnHover={!item.pinned} to SidebarMenuAction for both action and leaf item types - AppSidebar: Improve search icon contrast (opacity-50 → opacity-70) - AppSidebar: Move Recent section above Record Favorites for quicker access - ObjectGrid: Add percent field auto-inference (probability, percent, progress, etc.) - ObjectGrid: Add fallback ISO datetime string detection from data values Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/console/src/components/AppSidebar.tsx | 56 +++++++++++----------- packages/layout/src/NavigationRenderer.tsx | 2 + packages/plugin-grid/src/ObjectGrid.tsx | 19 ++++++++ 3 files changed, 49 insertions(+), 28 deletions(-) diff --git a/apps/console/src/components/AppSidebar.tsx b/apps/console/src/components/AppSidebar.tsx index bed4999f..f30c125d 100644 --- a/apps/console/src/components/AppSidebar.tsx +++ b/apps/console/src/components/AppSidebar.tsx @@ -393,7 +393,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri {/* Navigation Search */} - + - {/* Record Favorites */} - {favorites.length > 0 && ( + {/* Recent Items (elevated position for quick access) */} + {recentItems.length > 0 && ( - - - Favorites + setRecentExpanded(prev => !prev)} + > + + + Recent + {recentExpanded && ( - {favorites.slice(0, 8).map(item => ( + {recentItems.slice(0, 5).map(item => ( - {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋'} + {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄'} {item.label} - { e.stopPropagation(); removeFavorite(item.id); }} - aria-label={`Remove ${item.label} from favorites`} - > - - ))} + )} )} - {/* Recent Items (default collapsed) */} - {recentItems.length > 0 && ( + {/* Record Favorites */} + {favorites.length > 0 && ( - setRecentExpanded(prev => !prev)} - > - - - Recent + + + Favorites - {recentExpanded && ( - {recentItems.slice(0, 5).map(item => ( + {favorites.slice(0, 8).map(item => ( - {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄'} + {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋'} {item.label} + { e.stopPropagation(); removeFavorite(item.id); }} + aria-label={`Remove ${item.label} from favorites`} + > + + ))} - )} )} diff --git a/packages/layout/src/NavigationRenderer.tsx b/packages/layout/src/NavigationRenderer.tsx index 407cdc16..ba86bb4a 100644 --- a/packages/layout/src/NavigationRenderer.tsx +++ b/packages/layout/src/NavigationRenderer.tsx @@ -396,6 +396,7 @@ function NavigationItemRenderer({ {enablePinning && onPinToggle && ( onPinToggle(item.id, !item.pinned)} aria-label={item.pinned ? `Unpin ${resolveLabel(item.label)}` : `Pin ${resolveLabel(item.label)}`} > @@ -447,6 +448,7 @@ function NavigationItemRenderer({ {enablePinning && onPinToggle && ( onPinToggle(item.id, !item.pinned)} aria-label={item.pinned ? `Unpin ${resolveLabel(item.label)}` : `Pin ${resolveLabel(item.label)}`} > diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 5598bf8c..1cdfab49 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -449,6 +449,17 @@ export const ObjectGrid: React.FC = ({ return 'date'; } + // Infer percent fields from name patterns + const percentFields = ['probability', 'percent', 'percentage', 'completion', 'progress', 'rate']; + if (percentFields.some(f => fieldLower.includes(f))) { + if (data.length > 0) { + const sample = data.find(row => row[col.field] != null)?.[col.field]; + if (typeof sample === 'number') { + return 'percent'; + } + } + } + // Infer select/badge fields (status, priority, category, etc.) const selectFields = ['status', 'priority', 'category', 'stage', 'type', 'severity', 'level']; if (selectFields.some(f => fieldLower.includes(f))) { @@ -477,6 +488,14 @@ export const ObjectGrid: React.FC = ({ } } + // Fallback: detect ISO date strings in data values (catch-all for unmatched field names) + if (data.length > 0) { + const sample = data.find(row => row[col.field] != null)?.[col.field]; + if (typeof sample === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(sample)) { + return 'datetime'; + } + } + return null; }; From 426f54911f0716296338eb7a732f80438a540de2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:32:45 +0000 Subject: [PATCH 3/7] docs: update ROADMAP.md with P2.9 Platform UI Navigation & Table Optimization Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index dbb3eb43..f1647911 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ObjectUI Development Roadmap -> **Last Updated:** February 26, 2026 +> **Last Updated:** February 27, 2026 > **Current Version:** v0.5.x > **Spec Version:** @objectstack/spec v3.0.10 > **Client Version:** @objectstack/client v3.0.10 @@ -972,6 +972,28 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th - [x] 411 ListView + ViewTabBar tests passing (255 plugin-list tests including 9 new toolbar/collapse tests) - [x] 11 AppSidebar tests passing +### P2.9 Platform UI Navigation & Table Optimization ✅ + +> Platform-level sidebar, navigation, and grid/table UX improvements (Issue #XX). + +**Sidebar Navigation:** +- [x] Pin icons show-on-hover: `SidebarMenuAction` in `NavigationRenderer` now uses `showOnHover={!item.pinned}` — unpinned items show pin icon only on hover; pinned items always show unpin icon. Applied to both `action` and leaf navigation item types. +- [x] Search placeholder contrast: Search icon in AppSidebar improved from `opacity-50` → `opacity-70` for better readability. +- [x] Recent section position: Recent items section moved above Record Favorites in AppSidebar for quicker access to recently visited items. +- [x] Favorites section: Already hides automatically when no pinned items exist (verified). + +**Grid/Table Field Inference:** +- [x] Percent field auto-inference: `inferColumnType()` in ObjectGrid now detects fields with names containing `probability`, `percent`, `percentage`, `completion`, `progress`, `rate` and assigns `PercentCellRenderer` with progress bar display. +- [x] ISO datetime fallback: ObjectGrid `inferColumnType()` now detects ISO 8601 datetime strings (`YYYY-MM-DDTHH:MM`) in data values as a catch-all for fields whose names don't match date/datetime patterns. +- [x] Date/datetime human-friendly display: `DateCellRenderer` (relative format) and `DateTimeCellRenderer` (split date/time) already registered in field registry for all grid/table views. +- [x] Currency/status/boolean renderers: Already implemented with proper formatting (currency symbol, Badge colors, checkbox display). + +**Tests:** +- [x] 46 NavigationRenderer tests passing (pin/favorites/search/reorder) +- [x] 75 field cell renderer tests passing (date/datetime/select/boolean/percent) +- [x] 263 ObjectGrid tests passing (inference, rendering, accessibility) +- [x] 28 DataTable tests passing + ### P2.5 PWA & Offline (Real Sync) - [ ] Background sync queue → real server sync (replace simulation) From 82ac432dfaf686d6e32035e370f4fb7c031016b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:38:26 +0000 Subject: [PATCH 4/7] feat(i18n): add breadcrumb keys to all remaining locale files Add translated breadcrumb entries (dashboards, pages, reports, system) to ja, ko, de, fr, es, pt, ru, and ar locale files, matching the existing structure in en.ts and zh.ts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/i18n/src/locales/ar.ts | 6 ++++++ packages/i18n/src/locales/de.ts | 6 ++++++ packages/i18n/src/locales/es.ts | 6 ++++++ packages/i18n/src/locales/fr.ts | 6 ++++++ packages/i18n/src/locales/ja.ts | 6 ++++++ packages/i18n/src/locales/ko.ts | 6 ++++++ packages/i18n/src/locales/pt.ts | 6 ++++++ packages/i18n/src/locales/ru.ts | 6 ++++++ 8 files changed, 48 insertions(+) diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index bebd2b2e..475fb631 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -273,6 +273,12 @@ const ar = { console: { title: 'وحدة تحكم ObjectStack', initializing: 'جاري تهيئة التطبيق...', + breadcrumb: { + dashboards: 'لوحات المعلومات', + pages: 'الصفحات', + reports: 'التقارير', + system: 'النظام', + }, loadingSteps: { connecting: 'جاري الاتصال بمصدر البيانات', loadingConfig: 'جاري تحميل الإعدادات', diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 7d5a855a..a73b29c5 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -272,6 +272,12 @@ const de = { console: { title: 'ObjectStack Konsole', initializing: 'Anwendung wird initialisiert...', + breadcrumb: { + dashboards: 'Dashboards', + pages: 'Seiten', + reports: 'Berichte', + system: 'System', + }, loadingSteps: { connecting: 'Verbindung zur Datenquelle herstellen', loadingConfig: 'Konfiguration laden', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index f8028bfa..76c9d06b 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -272,6 +272,12 @@ const es = { console: { title: 'Consola ObjectStack', initializing: 'Inicializando aplicación...', + breadcrumb: { + dashboards: 'Paneles', + pages: 'Páginas', + reports: 'Informes', + system: 'Sistema', + }, loadingSteps: { connecting: 'Conectando a la fuente de datos', loadingConfig: 'Cargando configuración', diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 1df9c577..9fab28b6 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -272,6 +272,12 @@ const fr = { console: { title: 'Console ObjectStack', initializing: "Initialisation de l'application...", + breadcrumb: { + dashboards: 'Tableaux de bord', + pages: 'Pages', + reports: 'Rapports', + system: 'Système', + }, loadingSteps: { connecting: 'Connexion à la source de données', loadingConfig: 'Chargement de la configuration', diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 73df456d..4cce1d87 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -272,6 +272,12 @@ const ja = { console: { title: 'ObjectStack コンソール', initializing: 'アプリケーションを初期化中...', + breadcrumb: { + dashboards: 'ダッシュボード', + pages: 'ページ', + reports: 'レポート', + system: 'システム', + }, loadingSteps: { connecting: 'データソースに接続中', loadingConfig: '設定を読み込み中', diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index ea977d6d..95aa22db 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -272,6 +272,12 @@ const ko = { console: { title: 'ObjectStack 콘솔', initializing: '애플리케이션 초기화 중...', + breadcrumb: { + dashboards: '대시보드', + pages: '페이지', + reports: '보고서', + system: '시스템', + }, loadingSteps: { connecting: '데이터 소스에 연결 중', loadingConfig: '설정 로드 중', diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 2363dbf2..e6ade96e 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -272,6 +272,12 @@ const pt = { console: { title: 'Console ObjectStack', initializing: 'Inicializando aplicação...', + breadcrumb: { + dashboards: 'Painéis', + pages: 'Páginas', + reports: 'Relatórios', + system: 'Sistema', + }, loadingSteps: { connecting: 'Conectando à fonte de dados', loadingConfig: 'Carregando configuração', diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 04968c0d..2f0eb7ec 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -272,6 +272,12 @@ const ru = { console: { title: 'Консоль ObjectStack', initializing: 'Инициализация приложения...', + breadcrumb: { + dashboards: 'Панели', + pages: 'Страницы', + reports: 'Отчёты', + system: 'Система', + }, loadingSteps: { connecting: 'Подключение к источнику данных', loadingConfig: 'Загрузка конфигурации', From 5e98e4880cc01ac6c2f45eaf41f1051f921e0a0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:44:53 +0000 Subject: [PATCH 5/7] feat: resizable sidebar width, breadcrumb i18n for AppHeader and renderers - SidebarProvider: Add sidebarWidth state persisted to localStorage, exposed via useSidebar() - SidebarRail: Drag-to-resize (pointer events), click to toggle, double-click to reset - AppHeader: Breadcrumb labels use t() translation (Dashboards, Pages, Reports, System) - i18n: Add console.breadcrumb keys to all 11 locales (en, zh, ja, ko, de, fr, es, pt, ru, ar) - header-bar renderer: resolveCrumbLabel() handles I18nLabel objects - breadcrumb renderer: resolveItemLabel() handles I18nLabel objects Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/console/src/components/AppHeader.tsx | 10 ++- .../src/renderers/data-display/breadcrumb.tsx | 11 ++- .../src/renderers/navigation/header-bar.tsx | 11 ++- packages/components/src/ui/sidebar.tsx | 81 +++++++++++++++++-- packages/i18n/src/locales/en.ts | 6 ++ packages/i18n/src/locales/zh.ts | 6 ++ 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/apps/console/src/components/AppHeader.tsx b/apps/console/src/components/AppHeader.tsx index 1e725355..7055f7e8 100644 --- a/apps/console/src/components/AppHeader.tsx +++ b/apps/console/src/components/AppHeader.tsx @@ -35,6 +35,7 @@ import { ConnectionStatus } from './ConnectionStatus'; import { ActivityFeed, type ActivityItem } from './ActivityFeed'; import type { ConnectionState } from '../dataSource'; import { useAdapter } from '../context/AdapterProvider'; +import { useObjectTranslation } from '@object-ui/i18n'; /** Convert a slug like "crm_dashboard" or "audit-log" to "Crm Dashboard" / "Audit Log" */ function humanizeSlug(slug: string): string { @@ -55,6 +56,7 @@ export function AppHeader({ appName, objects, connectionState, presenceUsers, ac const params = useParams(); const { isOnline } = useOffline(); const dataSource = useAdapter(); + const { t } = useObjectTranslation(); const [apiPresenceUsers, setApiPresenceUsers] = useState(null); const [apiActivities, setApiActivities] = useState(null); @@ -110,22 +112,22 @@ export function AppHeader({ appName, objects, connectionState, presenceUsers, ac ]; if (routeType === 'dashboard') { - breadcrumbItems.push({ label: 'Dashboards', href: `${breadcrumbItems[0].href}` }); + breadcrumbItems.push({ label: t('console.breadcrumb.dashboards'), href: `${breadcrumbItems[0].href}` }); if (pathParts[3]) { breadcrumbItems.push({ label: humanizeSlug(pathParts[3]) }); } } else if (routeType === 'page') { - breadcrumbItems.push({ label: 'Pages', href: `${breadcrumbItems[0].href}` }); + breadcrumbItems.push({ label: t('console.breadcrumb.pages'), href: `${breadcrumbItems[0].href}` }); if (pathParts[3]) { breadcrumbItems.push({ label: humanizeSlug(pathParts[3]) }); } } else if (routeType === 'report') { - breadcrumbItems.push({ label: 'Reports', href: `${breadcrumbItems[0].href}` }); + breadcrumbItems.push({ label: t('console.breadcrumb.reports'), href: `${breadcrumbItems[0].href}` }); if (pathParts[3]) { breadcrumbItems.push({ label: humanizeSlug(pathParts[3]) }); } } else if (routeType === 'system') { - breadcrumbItems.push({ label: 'System' }); + breadcrumbItems.push({ label: t('console.breadcrumb.system') }); if (pathParts[3]) { breadcrumbItems.push({ label: humanizeSlug(pathParts[3]) }); } diff --git a/packages/components/src/renderers/data-display/breadcrumb.tsx b/packages/components/src/renderers/data-display/breadcrumb.tsx index 150bfbcb..67b99f20 100644 --- a/packages/components/src/renderers/data-display/breadcrumb.tsx +++ b/packages/components/src/renderers/data-display/breadcrumb.tsx @@ -11,6 +11,13 @@ import type { BreadcrumbSchema } from '@object-ui/types'; import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from '../../ui/breadcrumb'; import { renderChildren } from '../../lib/utils'; +/** Resolve a label that may be a plain string or an I18nLabel object */ +function resolveItemLabel(label: string | { key?: string; defaultValue?: string } | undefined): string { + if (!label) return ''; + if (typeof label === 'string') return label; + return label.defaultValue || label.key || ''; +} + ComponentRegistry.register('breadcrumb', ({ schema, ...props }: { schema: BreadcrumbSchema; [key: string]: any }) => { const { @@ -31,9 +38,9 @@ ComponentRegistry.register('breadcrumb',
{idx === (schema.items?.length || 0) - 1 ? ( - {item.label} + {resolveItemLabel(item.label)} ) : ( - {item.label} + {resolveItemLabel(item.label)} )} {idx < (schema.items?.length || 0) - 1 && } diff --git a/packages/components/src/renderers/navigation/header-bar.tsx b/packages/components/src/renderers/navigation/header-bar.tsx index 1881cb55..cfcc649d 100644 --- a/packages/components/src/renderers/navigation/header-bar.tsx +++ b/packages/components/src/renderers/navigation/header-bar.tsx @@ -20,6 +20,13 @@ import { BreadcrumbPage } from '../../ui'; +/** Resolve a label that may be a plain string or an I18nLabel object */ +function resolveCrumbLabel(label: string | { key?: string; defaultValue?: string } | undefined): string { + if (!label) return ''; + if (typeof label === 'string') return label; + return label.defaultValue || label.key || ''; +} + ComponentRegistry.register('header-bar', ({ schema }: { schema: HeaderBarSchema }) => (
@@ -31,9 +38,9 @@ ComponentRegistry.register('header-bar', {idx === schema.crumbs.length - 1 ? ( - {crumb.label} + {resolveCrumbLabel(crumb.label)} ) : ( - {crumb.label} + {resolveCrumbLabel(crumb.label)} )} {idx < schema.crumbs.length - 1 && } diff --git a/packages/components/src/ui/sidebar.tsx b/packages/components/src/ui/sidebar.tsx index 914e3d4f..0e9076fb 100644 --- a/packages/components/src/ui/sidebar.tsx +++ b/packages/components/src/ui/sidebar.tsx @@ -39,6 +39,9 @@ const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_KEYBOARD_SHORTCUT = "b" +const SIDEBAR_WIDTH_STORAGE_KEY = "sidebar_width" +const SIDEBAR_MIN_WIDTH = 200 // px +const SIDEBAR_MAX_WIDTH = 480 // px type SidebarContextProps = { state: "expanded" | "collapsed" @@ -48,6 +51,10 @@ type SidebarContextProps = { setOpenMobile: (open: boolean) => void isMobile: boolean toggleSidebar: () => void + /** Current sidebar width in pixels (only used when resizable) */ + sidebarWidth: number | null + /** Update sidebar width (only used when resizable) */ + setSidebarWidth: (width: number | null) => void } const SidebarContext = React.createContext(null) @@ -84,6 +91,24 @@ const SidebarProvider = React.forwardRef< const isMobile = useIsMobile() const [openMobile, setOpenMobile] = React.useState(false) + // Resizable sidebar width state (persisted to localStorage) + const [sidebarWidth, setSidebarWidthState] = React.useState(() => { + try { + const stored = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY) + return stored ? Number(stored) : null + } catch { return null } + }) + const setSidebarWidth = React.useCallback((width: number | null) => { + setSidebarWidthState(width) + try { + if (width != null) { + localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(width)) + } else { + localStorage.removeItem(SIDEBAR_WIDTH_STORAGE_KEY) + } + } catch { /* ignore */ } + }, []) + // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. const [_open, _setOpen] = React.useState(defaultOpen) @@ -139,8 +164,10 @@ const SidebarProvider = React.forwardRef< openMobile, setOpenMobile, toggleSidebar, + sidebarWidth, + setSidebarWidth, }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, sidebarWidth, setSidebarWidth] ) return ( @@ -149,7 +176,7 @@ const SidebarProvider = React.forwardRef<
>(({ className, ...props }, ref) => { - const { toggleSidebar } = useSidebar() + const { toggleSidebar, setSidebarWidth } = useSidebar() + const dragging = React.useRef(false) + const startX = React.useRef(0) + const startWidth = React.useRef(0) + + const handlePointerDown = React.useCallback((e: React.PointerEvent) => { + // Only initiate resize on left mouse button + if (e.button !== 0) return + e.preventDefault() + dragging.current = true + startX.current = e.clientX + + // Get the current sidebar width from computed CSS variable + const wrapper = (e.target as HTMLElement).closest('[style*="--sidebar-width"]') as HTMLElement | null + startWidth.current = wrapper + ? parseInt(getComputedStyle(wrapper).getPropertyValue('--sidebar-width')) || 256 + : 256 + + const onPointerMove = (ev: PointerEvent) => { + if (!dragging.current) return + const delta = ev.clientX - startX.current + const newWidth = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, startWidth.current + delta)) + setSidebarWidth(newWidth) + } + + const onPointerUp = () => { + dragging.current = false + document.removeEventListener('pointermove', onPointerMove) + document.removeEventListener('pointerup', onPointerUp) + } + + document.addEventListener('pointermove', onPointerMove) + document.addEventListener('pointerup', onPointerUp) + }, [setSidebarWidth]) + + const handleClick = React.useCallback((e: React.MouseEvent) => { + // Only toggle on click (not at end of drag) + if (Math.abs(e.clientX - startX.current) < 5) { + toggleSidebar() + } + }, [toggleSidebar]) return (