diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 460ddbd5..d87f71a8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -23,21 +23,35 @@ export type Page = | 'trading-as-git' | 'settings' | 'dev' -/** Track whether we're at a desktop viewport (md+ in Tailwind = ≥768px). */ -function useIsDesktop(): boolean { - const query = '(min-width: 768px)' +/** Subscribe to a CSS media query, SSR-safe (defaults to matched). */ +function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState(() => typeof window !== 'undefined' ? window.matchMedia(query).matches : true, ) useEffect(() => { const mq = window.matchMedia(query) const handler = () => setMatches(mq.matches) + setMatches(mq.matches) // re-sync in case the query changed between renders mq.addEventListener('change', handler) return () => mq.removeEventListener('change', handler) - }, []) + }, [query]) return matches } +/** + * Two breakpoints drive a three-tier responsive shell: + * - <768 (phone): rail = drawer (hamburger), sidebar = drawer (drill-in) + * - 768–1024 (tablet/narrow): rail = static column, sidebar = drawer + * (tap a rail icon to slide it in) — keeps the main pane full-width- + * minus-rail so its `md:` content layouts have real room + * - ≥1024 (desktop): rail + sidebar both static (classic 3-pane) + * The middle tier is what kills the old 768–~1000px dead zone where two + * static left columns (216+200) crushed the main pane and its md:-keyed + * content (stat grids, tables) overflowed/overlapped. + */ +const useIsDesktop = () => useMediaQuery('(min-width: 768px)') // rail static +const useIsWide = () => useMediaQuery('(min-width: 1024px)') // sidebar static + export function App() { return ( @@ -56,8 +70,9 @@ function AppShell() { const selectedSidebar = useWorkspace((state) => state.selectedSidebar) const focusedTabId = useWorkspace((state) => getFocusedTab(state)?.id ?? null) const section = findSectionForActivity(selectedSidebar) - const isDesktop = useIsDesktop() - const showSidebarPanel = isDesktop && section != null + const isDesktop = useIsDesktop() // ≥768 — rail is a static column + const isWide = useIsWide() // ≥1024 — sidebar is a static panel + const showSidebarPanel = isWide && section != null // Auto-close the mobile secondary drawer once the user picks a sub-item. // We snapshot the focused tab at drawer-open time (see openSecondaryDrawer @@ -76,14 +91,27 @@ function AppShell() { } }, [focusedTabId, secondaryOpen]) - // If we cross into desktop while a mobile drawer is open, drop the drawer - // state — the static columns now own the rendering. + // When a tier's static column takes over, drop its drawer state. The rail + // goes static at ≥768 (drop the activity drawer); the sidebar goes static + // at ≥1024 (drop the secondary drawer). Kept as two effects so the middle + // tier — rail static, sidebar still a drawer — settles correctly. useEffect(() => { - if (isDesktop) { - setSidebarOpen(false) - setSecondaryOpen(false) - } + if (isDesktop) setSidebarOpen(false) }, [isDesktop]) + useEffect(() => { + if (isWide) setSecondaryOpen(false) + }, [isWide]) + + // Lock body scroll while a drawer is open so the page behind doesn't drift + // under the backdrop. Restores the previous value on close/unmount. + useEffect(() => { + if (!sidebarOpen && !secondaryOpen) return + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = prev + } + }, [sidebarOpen, secondaryOpen]) // Persist the user's resized layout to localStorage. `panelIds` scopes the // saved layout to the current panel set — sidebar+main and main-only get @@ -130,12 +158,14 @@ function AppShell() { setSidebarOpen(false)} + sidebarVisible={showSidebarPanel || secondaryOpen} onItemActivated={(landedOn) => { - // Mobile drill-down: close the activity drawer and slide in the - // secondary navigator for the landed-on section. If the user - // toggled the current section off (landedOn === null), just close. + // Drill-down for any viewport without a static sidebar (<1024): + // close the activity drawer and slide in the secondary navigator + // for the landed-on section. If the user toggled the current + // section off (landedOn === null), just close. setSidebarOpen(false) - if (!isDesktop && landedOn != null) { + if (!isWide && landedOn != null) { // Snapshot post-click state — `defaultTab` may have just changed // the focused tab synchronously via Zustand, and we want THAT to // be the baseline (not the pre-click value the closure captured). @@ -155,7 +185,13 @@ function AppShell() { > {showSidebarPanel && section && ( <> - + : undefined} @@ -171,18 +207,21 @@ function AppShell() { - {/* Mobile-only secondary sidebar drawer — drills in after the user - picks an activity in the ActivityBar drawer. Desktop renders the - sidebar as a static Panel above; this branch is gated on !isDesktop + {/* Secondary sidebar drawer for any non-wide viewport (<1024) — + drills in after the user picks an activity (from the rail drawer + on phones, or the static rail at 768–1024). At ≥1024 the sidebar + renders as a static Panel above; this branch is gated on !isWide so the two never co-exist. */} - {!isDesktop && section && ( + {!isWide && section && ( setSecondaryOpen(false)} onBack={() => { setSecondaryOpen(false) - setSidebarOpen(true) + // Only re-open the rail drawer when the rail is itself a drawer + // (<768). At 768–1024 the rail is static, so just close. + if (!isDesktop) setSidebarOpen(true) }} /> )} @@ -205,7 +244,7 @@ function MobileSecondaryDrawer({ open, section, onClose, onBack }: MobileSeconda return ( <>
void + /** + * Whether the secondary sidebar is actually on screen right now (a static + * panel on wide, or the open drawer on narrow). Re-clicking the active + * item only *collapses* the sidebar when it's visible; if it's hidden + * (e.g. landed on /portfolio at a tablet width with the drawer closed), + * re-clicking re-opens it instead of toggling the selection off. Defaults + * to true so the collapse gesture works when the prop isn't wired. + */ + sidebarVisible?: boolean /** * Called after the user activates an item. Receives the activity the user * landed on (or null if they collapsed the current one by re-clicking it). @@ -149,19 +158,23 @@ const NAV_SECTIONS: NavSection[] = [ // ==================== ActivityBar ==================== /** - * Linear-style left nav. 200px wide on all viewports; on mobile ( state.selectedSidebar) const setSidebar = useWorkspace((state) => state.setSidebar) @@ -185,20 +198,21 @@ export function ActivityBar({ open, onClose, onItemActivated }: ActivityBarProps * page with backdrop. Desktop: static column flush left. */} diff --git a/ui/src/components/PageHeader.tsx b/ui/src/components/PageHeader.tsx index 82619a06..8613199d 100644 --- a/ui/src/components/PageHeader.tsx +++ b/ui/src/components/PageHeader.tsx @@ -18,7 +18,7 @@ export function PageHeader({ title, description, right, live }: PageHeaderProps)
-

{title}

+

{title}

{live && ( void + /** + * Optional leading glyph (shrink-0), e.g. an entity-type icon. Pass the + * fully-styled node — the row doesn't impose a size or colour so callers + * keep control (e.g. ``). + */ + icon?: ReactNode /** * Right-aligned content slot — status badges, counts, hover-revealed * action buttons. The row uses `group` so consumers can apply * `opacity-0 group-hover:opacity-100` to reveal-on-hover affordances. */ trail?: ReactNode + /** Optional native tooltip for the whole row (e.g. an entity description). */ + title?: string /** Optional disabled / dimmed presentation, e.g. for off-by-default rows. */ dim?: boolean } @@ -32,12 +40,13 @@ interface SidebarRowProps { * can nest action buttons inside `trail` (HTML disallows nested buttons). * Enter / Space activate the row for keyboard users. */ -export function SidebarRow({ label, active = false, onClick, trail, dim = false }: SidebarRowProps) { +export function SidebarRow({ label, active = false, onClick, icon, trail, title, dim = false }: SidebarRowProps) { return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() @@ -56,6 +65,7 @@ export function SidebarRow({ label, active = false, onClick, trail, dim = false className="absolute left-0 top-0 bottom-0 w-[2px] bg-accent" /> )} + {icon && {icon}} {label} {trail &&
{trail}
}
diff --git a/ui/src/components/Toast.tsx b/ui/src/components/Toast.tsx index 3f7dbf65..8990da45 100644 --- a/ui/src/components/Toast.tsx +++ b/ui/src/components/Toast.tsx @@ -71,7 +71,7 @@ function ToastContainer({ toasts, onDismiss }: { toasts: ToastItem[]; onDismiss: if (toasts.length === 0) return null return ( -
+
{toasts.map((toast) => ( onDismiss(toast.id)} /> ))} diff --git a/ui/src/components/TrackedSidebar.tsx b/ui/src/components/TrackedSidebar.tsx index c3f1d1d7..54d28a38 100644 --- a/ui/src/components/TrackedSidebar.tsx +++ b/ui/src/components/TrackedSidebar.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import { TrendingUp, Hash } from 'lucide-react' import { entitiesLive } from '../live/entities' import { useTrackedSelection } from '../live/tracked-selection' +import { SidebarRow } from './SidebarRow' /** * Tracked sidebar — the watchlist. A flat list of entities (assets + topics), @@ -50,27 +51,24 @@ export function TrackedSidebar() { const active = e.name === selected const Icon = e.type === 'asset' ? TrendingUp : Hash return ( - + icon={} + label={{e.name}} + trail={ + e.backlinkCount > 0 ? ( + + {e.backlinkCount} + + ) : undefined + } + /> ) })}
diff --git a/ui/src/components/market/QuoteHeader.tsx b/ui/src/components/market/QuoteHeader.tsx index 0fd491cc..45809d2d 100644 --- a/ui/src/components/market/QuoteHeader.tsx +++ b/ui/src/components/market/QuoteHeader.tsx @@ -67,7 +67,7 @@ export function QuoteHeader({ symbol }: Props) { {/* Bid / ask intentionally omitted — they're real-time L1 quote data that belongs at the execution layer (UTA), not in analysis. */} -
+
diff --git a/ui/src/components/uta/Dialog.tsx b/ui/src/components/uta/Dialog.tsx index b26c56a4..c09eade2 100644 --- a/ui/src/components/uta/Dialog.tsx +++ b/ui/src/components/uta/Dialog.tsx @@ -16,9 +16,10 @@ export function Dialog({ onClose, width, children }: { }, [handleKeyDown]) return ( -
+ // z-[60] keeps dialogs above the mobile nav drawers (z-50). +
-
+
{children}
diff --git a/ui/src/components/workspace/WorkspaceAIConfigModal.tsx b/ui/src/components/workspace/WorkspaceAIConfigModal.tsx index f13b6845..26dcc84a 100644 --- a/ui/src/components/workspace/WorkspaceAIConfigModal.tsx +++ b/ui/src/components/workspace/WorkspaceAIConfigModal.tsx @@ -305,7 +305,7 @@ export function WorkspaceAIConfigModal({ wsId, onClose }: Props) { return (
+
e.stopPropagation()}>

{title}

@@ -352,7 +352,7 @@ function CredentialModal({ mode, cred, presets, onClose, onSaved }: {
{!preset ? ( -
+
{presets.map((p) => (