Add a mobile layout and stop shipping Perspective to every page

Below md: the sidebar is replaced by a fixed bottom bar carrying the same
destinations plus the theme toggle. NAV moved to navItems.jsx so the two
can't drift apart.

Perspective is now lazy-loaded. It was ~90% of the bundle and only Pivot
uses it, so the initial download drops from 4.9 MB gzipped to 173 kB and
the rest arrives only when a pivot is opened. Desktop benefits as much as
phones do.

Bridge's balance table becomes stacked cards under sm: with the same
subtotals, source tabs scroll rather than wrap, and page gutters tighten
on small screens.

Rules, Mappings, and Records are deliberately untouched — they are wide
data tables and belong on a desktop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
This commit is contained in:
Paul Trowbridge 2026-08-02 14:09:06 -04:00
parent 4eda1c48b7
commit 356e61d043
16 changed files with 173 additions and 90 deletions

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ui</title> <title>Dataflow</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1,7 +1,8 @@
import { useState, useEffect, createElement } from 'react' import { useState, useEffect, createElement, lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom'
import { api, setCredentials, clearCredentials } from './api' import { api, setCredentials, clearCredentials } from './api'
import Sidebar from './components/Sidebar.jsx' import Sidebar from './components/Sidebar.jsx'
import BottomNav from './components/BottomNav.jsx'
import SourceTabs from './components/SourceTabs.jsx' import SourceTabs from './components/SourceTabs.jsx'
import Login from './pages/Login' import Login from './pages/Login'
import SourceList from './pages/SourceList' import SourceList from './pages/SourceList'
@ -13,7 +14,7 @@ import Rules from './pages/Rules'
import Mappings from './pages/Mappings' import Mappings from './pages/Mappings'
import Records from './pages/Records' import Records from './pages/Records'
import Log from './pages/Log' import Log from './pages/Log'
import Pivot from './pages/Pivot' const Pivot = lazy(() => import('./pages/Pivot'))
import Remap from './pages/Remap' import Remap from './pages/Remap'
import Stacks from './pages/Stacks' import Stacks from './pages/Stacks'
@ -134,12 +135,14 @@ export default function App() {
<BrowserRouter> <BrowserRouter>
<div className="flex h-screen"> <div className="flex h-screen">
<div className="hidden md:flex">
<Sidebar <Sidebar
expanded={sidebarExpanded} expanded={sidebarExpanded}
setExpanded={setSidebarExpanded} setExpanded={setSidebarExpanded}
loginUser={loginUser} loginUser={loginUser}
onLogout={handleLogout} onLogout={handleLogout}
/> />
</div>
{/* Main */} {/* Main */}
<div className="flex-1 overflow-hidden flex flex-col min-w-0"> <div className="flex-1 overflow-hidden flex flex-col min-w-0">
@ -191,7 +194,8 @@ export default function App() {
</div> </div>
)} )}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto pb-14 md:pb-0">
<Suspense fallback={<div className="p-6 text-sm text-muted">Loading</div>}>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/sources" replace />} /> <Route path="/" element={<Navigate to="/sources" replace />} />
@ -212,9 +216,11 @@ export default function App() {
<Route path="/remap" element={<Remap />} /> <Route path="/remap" element={<Remap />} />
<Route path="/log" element={<Log />} /> <Route path="/log" element={<Log />} />
</Routes> </Routes>
</Suspense>
</div> </div>
</div> </div>
</div> </div>
<BottomNav />
</BrowserRouter> </BrowserRouter>
) )
} }

View File

@ -0,0 +1,40 @@
import { NavLink } from 'react-router-dom'
import useTheme from '../theme.jsx'
import { NAV } from './navItems.jsx'
// Phone-sized replacement for the sidebar: same destinations, thumb-reachable.
// Hidden from md: upward, where the sidebar takes over.
export default function BottomNav() {
const { dark, setDark } = useTheme()
return (
<nav className="md:hidden fixed bottom-0 inset-x-0 z-20 bg-surface border-t border-line flex justify-around items-stretch h-14 pb-[env(safe-area-inset-bottom)]">
{NAV.map(({ to, label, icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] ${
isActive ? 'text-accent' : 'text-muted'
}`
}
>
<span>{icon}</span>
{label}
</NavLink>
))}
<button
onClick={() => setDark(d => !d)}
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] text-muted"
title={dark ? 'Light mode' : 'Dark mode'}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{dark
? <><circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="22"/><line x1="2" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="22" y2="12"/></>
: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>}
</svg>
Theme
</button>
</nav>
)
}

View File

@ -1,75 +1,6 @@
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import useTheme from '../theme.jsx' import useTheme from '../theme.jsx'
import { NAV } from './navItems.jsx'
const NAV = [
{
to: '/sources',
label: 'Sources',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="10" cy="5.5" rx="7" ry="2.5"/>
<path d="M3 5.5v9c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-9"/>
<path d="M3 10.5c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>
</svg>
),
},
{
to: '/import',
label: 'Import',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<line x1="10" y1="3" x2="10" y2="14"/>
<polyline points="6,10 10,14 14,10"/>
<line x1="3" y1="18" x2="17" y2="18"/>
</svg>
),
},
{
to: '/bridge',
label: 'Bridge',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 13a8 8 0 0 1 16 0"/>
<line x1="2" y1="13" x2="18" y2="13"/>
<line x1="7" y1="13" x2="7" y2="9"/>
<line x1="13" y1="13" x2="13" y2="9"/>
</svg>
),
},
{
to: '/remap',
label: 'Remap',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2,8 2,4 6,4"/>
<path d="M2 4a8 8 0 0 1 14 2"/>
<polyline points="18,12 18,16 14,16"/>
<path d="M18 16a8 8 0 0 1-14-2"/>
</svg>
),
},
{
to: '/stacks',
label: 'Stacks',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<polygon points="10,2 18,6 10,10 2,6"/>
<polyline points="2,10 10,14 18,10"/>
<polyline points="2,14 10,18 18,14"/>
</svg>
),
},
{
to: '/log',
label: 'Log',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<circle cx="10" cy="10" r="8"/>
<polyline points="10,5 10,10 14,12"/>
</svg>
),
},
]
export default function Sidebar({ expanded, setExpanded, loginUser, onLogout }) { export default function Sidebar({ expanded, setExpanded, loginUser, onLogout }) {
const { dark, setDark } = useTheme() const { dark, setDark } = useTheme()

View File

@ -18,7 +18,7 @@ export default function SourceTabs({ sources }) {
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<div className="px-6 pt-5 shrink-0"> <div className="px-4 sm:px-6 pt-4 sm:pt-5 shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link to="/sources" className="text-xs text-muted hover:text-ink-soft">Sources</Link> <Link to="/sources" className="text-xs text-muted hover:text-ink-soft">Sources</Link>
<span className="text-muted text-xs">/</span> <span className="text-muted text-xs">/</span>
@ -30,7 +30,7 @@ export default function SourceTabs({ sources }) {
)} )}
</div> </div>
<nav className="flex gap-1 mt-3 border-b border-line"> <nav className="flex gap-1 mt-3 border-b border-line overflow-x-auto whitespace-nowrap">
{TABS.map(({ to, label, end }) => ( {TABS.map(({ to, label, end }) => (
<NavLink <NavLink
key={label} key={label}

View File

@ -0,0 +1,70 @@
// Top-level destinations, shared by the desktop sidebar and the mobile bar
export const NAV = [
{
to: '/sources',
label: 'Sources',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="10" cy="5.5" rx="7" ry="2.5"/>
<path d="M3 5.5v9c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-9"/>
<path d="M3 10.5c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>
</svg>
),
},
{
to: '/import',
label: 'Import',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<line x1="10" y1="3" x2="10" y2="14"/>
<polyline points="6,10 10,14 14,10"/>
<line x1="3" y1="18" x2="17" y2="18"/>
</svg>
),
},
{
to: '/bridge',
label: 'Bridge',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 13a8 8 0 0 1 16 0"/>
<line x1="2" y1="13" x2="18" y2="13"/>
<line x1="7" y1="13" x2="7" y2="9"/>
<line x1="13" y1="13" x2="13" y2="9"/>
</svg>
),
},
{
to: '/remap',
label: 'Remap',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2,8 2,4 6,4"/>
<path d="M2 4a8 8 0 0 1 14 2"/>
<polyline points="18,12 18,16 14,16"/>
<path d="M18 16a8 8 0 0 1-14-2"/>
</svg>
),
},
{
to: '/stacks',
label: 'Stacks',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<polygon points="10,2 18,6 10,10 2,6"/>
<polyline points="2,10 10,14 18,10"/>
<polyline points="2,14 10,18 18,14"/>
</svg>
),
},
{
to: '/log',
label: 'Log',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<circle cx="10" cy="10" r="8"/>
<polyline points="10,5 10,10 14,12"/>
</svg>
),
},
]

View File

@ -49,7 +49,7 @@ export default function Bridge({ sources }) {
const ordered = [...banking, ...retirement] const ordered = [...banking, ...retirement]
return ( return (
<div className="p-6 max-w-5xl space-y-4"> <div className="p-4 sm:p-6 max-w-5xl space-y-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h1 className="text-xl font-semibold text-ink">Bridge</h1> <h1 className="text-xl font-semibold text-ink">Bridge</h1>
<button <button
@ -82,7 +82,7 @@ export default function Bridge({ sources }) {
title="SimpleFIN accounts" title="SimpleFIN accounts"
description="Every account behind the bridge credential, and which source pulls from it." description="Every account behind the bridge credential, and which source pulls from it."
> >
<table className="w-full text-xs"> <table className="w-full text-xs hidden sm:table">
<thead> <thead>
<tr className="text-left text-muted border-b border-line-soft"> <tr className="text-left text-muted border-b border-line-soft">
<th className="pb-1 font-medium">Account</th> <th className="pb-1 font-medium">Account</th>
@ -132,6 +132,42 @@ export default function Bridge({ sources }) {
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
{/* Stacked cards for narrow screens */}
<div className="sm:hidden divide-y divide-line-soft">
{ordered.map(a => {
const src = sourceFor(a.id)
return (
<div key={a.id} className="py-2">
<div className="flex justify-between gap-2">
<span className="text-xs text-ink-soft">{a.name}</span>
<span className="text-xs font-mono text-ink">{money(a.balance)}</span>
</div>
<div className="flex justify-between gap-2 text-xs text-muted">
<span>{a.organization}</span>
<span>{a.balance_date}</span>
</div>
<div className="text-xs mt-0.5">
{src
? <Link to={`/sources/${encodeURIComponent(src.name)}`} className="text-accent">{src.name}</Link>
: <span className="text-muted">not linked</span>}
</div>
</div>
)
})}
<div className="pt-2 flex justify-between text-xs">
<span className="text-muted">Banking and cards</span>
<span className="font-mono text-ink-soft">{money(sum(banking))}</span>
</div>
<div className="pt-1 flex justify-between text-xs border-0">
<span className="text-muted">Retirement</span>
<span className="font-mono text-ink-soft">{money(sum(retirement))}</span>
</div>
<div className="pt-2 flex justify-between text-xs font-medium">
<span className="text-ink-soft">Total</span>
<span className="font-mono text-ink">{money(sum(accounts))}</span>
</div>
</div>
{accounts.length === 0 && <p className="text-xs text-muted">No accounts returned.</p>} {accounts.length === 0 && <p className="text-xs text-muted">No accounts returned.</p>}
</Section> </Section>
)} )}

View File

@ -168,10 +168,10 @@ export default function Import({ source }) {
} }
} }
if (!source) return <div className="p-6 text-sm text-muted">Select a source first.</div> if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
return ( return (
<div className="p-6 max-w-2xl"> <div className="p-4 sm:p-6 max-w-2xl">
<h1 className="text-xl font-semibold text-ink mb-6">Import {source}</h1> <h1 className="text-xl font-semibold text-ink mb-6">Import {source}</h1>
{/* Stats */} {/* Stats */}

View File

@ -97,7 +97,7 @@ export default function ImportHub({ sources }) {
} }
return ( return (
<div className="p-6 max-w-4xl space-y-6"> <div className="p-4 sm:p-6 max-w-4xl space-y-6">
<h1 className="text-xl font-semibold text-ink">Import</h1> <h1 className="text-xl font-semibold text-ink">Import</h1>
{feeds.length > 0 && ( {feeds.length > 0 && (

View File

@ -354,7 +354,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
} }
} }
if (!source) return <div className="p-6 text-sm text-muted">Select a source first.</div> if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
const displayRows = sortedRows(filteredRows) const displayRows = sortedRows(filteredRows)

View File

@ -370,7 +370,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
viewer.restore({ table: selectedView, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG }) viewer.restore({ table: selectedView, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG })
} }
if (!source) return <div className="p-6 text-sm text-muted">Select a source first.</div> if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : [] const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : []

View File

@ -283,7 +283,7 @@ export default function Records({ source }) {
} }
} }
if (!source) return <div className="p-6 text-sm text-muted">Select a source first.</div> if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
const displayCols = gridCols(rows.length > 0 ? Object.keys(rows[0]) : cols) const displayCols = gridCols(rows.length > 0 ? Object.keys(rows[0]) : cols)
const visCols = gridCols(cols) const visCols = gridCols(cols)

View File

@ -73,7 +73,7 @@ export default function Remap() {
} }
return ( return (
<div className="p-6 max-w-4xl"> <div className="p-4 sm:p-6 max-w-4xl">
<h1 className="text-base font-semibold text-ink mb-4">Remap Output Values</h1> <h1 className="text-base font-semibold text-ink mb-4">Remap Output Values</h1>
{/* Search */} {/* Search */}

View File

@ -310,10 +310,10 @@ export default function Rules({ source, onStale }) {
} }
} }
if (!source) return <div className="p-6 text-sm text-muted">Select a source first.</div> if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
return ( return (
<div className="p-6 max-w-3xl"> <div className="p-4 sm:p-6 max-w-3xl">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold text-ink">Rules {source}</h1> <h1 className="text-xl font-semibold text-ink">Rules {source}</h1>
<button onClick={startCreate} <button onClick={startCreate}

View File

@ -156,10 +156,10 @@ export default function SourceDetail({ sources, setSources }) {
} }
} }
if (!sourceObj) return <div className="p-6 text-sm text-muted">Source not found.</div> if (!sourceObj) return <div className="p-4 sm:p-6 text-sm text-muted">Source not found.</div>
return ( return (
<div className="p-6 max-w-5xl space-y-4"> <div className="p-4 sm:p-6 max-w-5xl space-y-4">
{stats && ( {stats && (
<div className="flex gap-4 text-xs"> <div className="flex gap-4 text-xs">
<span className="text-muted"><span className="font-medium text-ink">{stats.total_records}</span> total</span> <span className="text-muted"><span className="font-medium text-ink">{stats.total_records}</span> total</span>

View File

@ -124,7 +124,7 @@ export default function SourceList({ sources, setSources, setSource }) {
} }
return ( return (
<div className="p-6 max-w-5xl"> <div className="p-4 sm:p-6 max-w-5xl">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold text-ink">Sources</h1> <h1 className="text-xl font-semibold text-ink">Sources</h1>
{!creating && ( {!creating && (