feat(dashboard): redesign with new dark/mono visual language#37
Conversation
Apply the new design system across Overview, Functions, Runs, Agents, Settings (header only), and the auth/layout shells. Adds reusable UI primitives (heatmap, kpi, sparkline, var-strip, load-bar, section-label, code-block, shell-effects) and a shell-store for density/theme/accent state. Overview: 2fr/1fr split for both rows, capped Recent activity scroll height, Fleet panel-body flex so it equalizes with Top functions.
There was a problem hiding this comment.
Pull request overview
Applies a new dark/mono “command center” visual language across the dashboard and replaces next-themes-driven theming with a persisted Zustand “shell” store that controls theme/density/sidebar state.
Changes:
- Introduces a persisted shell store + effects layer to drive
data-theme, density, and sidebar layout attributes. - Rebuilds global CSS tokens and adds new UI primitives (KPI, heatmap, sparkline, var-strip, load-bar, code-block, etc.).
- Redesigns major dashboard pages (Overview, Runs list/detail, Functions list, Agents list) and updates login + core layout chrome.
Reviewed changes
Copilot reviewed 26 out of 26 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| dashboard/src/stores/shell-store.ts | New persisted Zustand store for theme/density/sidebar shell state. |
| dashboard/src/components/ui/var-strip.tsx | New segmented control primitive used across redesigned pages. |
| dashboard/src/components/ui/sparkline.tsx | New lightweight SVG sparkline primitive. |
| dashboard/src/components/ui/sonner.tsx | Wires toast theming to the new shell store. |
| dashboard/src/components/ui/section-label.tsx | New section label primitive for the mono design language. |
| dashboard/src/components/ui/load-bar.tsx | New 8-unit load bar primitive for fleet/health style displays. |
| dashboard/src/components/ui/kpi.tsx | New KPI card primitive using CSS + sparkline. |
| dashboard/src/components/ui/heatmap.tsx | New grid heatmap primitive for activity density. |
| dashboard/src/components/ui/code-block.tsx | New code snippet block with minimal JSON/Python highlighting. |
| dashboard/src/components/ui/button.tsx | Remaps shadcn button variants to new .btn* classes. |
| dashboard/src/components/ui/badge.tsx | Remaps shadcn badge variants to new .tag* classes. |
| dashboard/src/components/layout/shell-effects.tsx | Applies shell state to data-* attributes post-hydration. |
| dashboard/src/components/layout/header.tsx | Redesigns top bar (crumbs/search/toggles/health) using shell store. |
| dashboard/src/components/layout/app-sidebar.tsx | Redesigns sidebar navigation and user footer controls. |
| dashboard/src/components/kbar/use-theme-switching.tsx | Updates command palette theme/density/sidebar actions to shell store. |
| dashboard/src/components/active-theme.tsx | Replaces legacy theme provider/hook with a shell-store shim. |
| dashboard/src/app/layout.tsx | Removes next-themes provider and adds pre-hydration shell init script. |
| dashboard/src/app/globals.css | Replaces token system + implements new shell/layout/primitives CSS. |
| dashboard/src/app/(dashboard)/settings/page.tsx | Swaps next-themes usage to shell-store theme state. |
| dashboard/src/app/(dashboard)/runs/page.tsx | Redesigns runs list with table/cards/graph and new filtering UI. |
| dashboard/src/app/(dashboard)/runs/[id]/page.tsx | Redesigns run detail: timeline + steps table + inspector. |
| dashboard/src/app/(dashboard)/page.tsx | Redesigns overview with KPI grid, heatmap, top functions, fleet. |
| dashboard/src/app/(dashboard)/layout.tsx | Replaces old providers/sidebar layout with new shell grid + effects. |
| dashboard/src/app/(dashboard)/functions/page.tsx | Redesigns functions list with new KPI + grid/table/list views. |
| dashboard/src/app/(dashboard)/agents/page.tsx | Redesigns agents page into roster + detail inspector layout. |
| dashboard/src/app/(auth)/login/page.tsx | Restyles login/2FA flow to match new dark/mono shell. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <button | ||
| type="button" | ||
| onClick={() => { | ||
| logout(); | ||
| router.push('/login'); | ||
| }} | ||
| className="btn btn-ghost btn-icon btn-sm ff-side-foot-text" | ||
| aria-label="Sign out" | ||
| title="Sign out" | ||
| > | ||
| <LogOut /> | ||
| </button> |
There was a problem hiding this comment.
The logout button triggers an async logout() but doesn’t await it before navigating. Since logout() clears the auth cookie via a fetch, not awaiting can race with navigation/state updates and leave the cookie set briefly (middleware may still consider the user authenticated). Make the handler async and await logout() before router.push('/login') (and consider handling errors).
| type View = 'table' | 'cards' | 'graph'; | ||
| type StatusFilter = 'all' | 'completed' | 'failed' | 'running' | 'pending' | 'cancelled'; | ||
|
|
||
| return ( | ||
| <Badge variant={variant} className={className}> | ||
| {status} | ||
| </Badge> | ||
| ) | ||
| function statusTag(s: string) { | ||
| if (s === 'completed') return 'tag tag-ok'; | ||
| if (s === 'failed') return 'tag tag-fail'; | ||
| if (s === 'running') return 'tag tag-running'; | ||
| if (s === 'pending' || s === 'paused') return 'tag tag-warn'; |
There was a problem hiding this comment.
StatusFilter and the status filter UI dropped the paused status, but the API type for Run.status includes paused. As a result, paused runs won’t be countable/filterable (and any status: 'paused' from the API will never match the filter union). Include paused in StatusFilter/filter list (or explicitly map it to pending if that’s the intended behavior).
| <div className="page-hd-right"> | ||
| <VarStrip | ||
| value={view} | ||
| onChange={setView} | ||
| options={[ | ||
| { value: 'grid', label: 'Grid' }, | ||
| { value: 'table', label: 'Table' }, | ||
| { value: 'list', label: 'List' } | ||
| ]} | ||
| /> | ||
| <button className="btn"><RefreshCw size={12} /> Refresh</button> | ||
| <Link href="/functions/new" className="btn btn-primary"><Plus size={12} /> New function</Link> |
There was a problem hiding this comment.
The “Refresh” button doesn’t trigger any refresh logic (no onClick and data is only fetched once on mount). Either wire this to re-run the API fetch (and optionally reset loading) or remove the control until implemented.
| {mounted && ( | ||
| <RadioGroup | ||
| value={theme} | ||
| onValueChange={setTheme} | ||
| onValueChange={(v) => setTheme(v === 'light' ? 'light' : 'dark')} | ||
| className="grid gap-4 md:grid-cols-3" |
There was a problem hiding this comment.
Theme selection still includes a “System” option, but useShellStore only supports 'light' | 'dark' and the handler coerces anything other than 'light' to 'dark'. This makes “System” non-functional/misleading and can never be persisted as selected. Either add a 'system' mode to ThemeMode (with matchMedia handling) or remove/disable the System radio option here.
| </div> | ||
| ); | ||
| if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; | ||
| return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; |
There was a problem hiding this comment.
This fmtDuration() has the same seconds-rounding edge case as the runs list (can display Xm 60s). Consider deriving minutes/seconds from a single total-second value to avoid rollover issues.
| return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; | |
| const totalSeconds = Math.round(ms / 1000); | |
| const minutes = Math.floor(totalSeconds / 60); | |
| const seconds = totalSeconds % 60; | |
| return `${minutes}m ${seconds}s`; |
| <tr><td colSpan={4} style={{ padding: 14 }} className="hint">No functions yet.</td></tr> | ||
| )} | ||
| {topFunctions.map((f) => ( | ||
| <tr key={f.id} className="is-clickable" onClick={() => (window.location.href = `/functions/${f.id}`)}> |
There was a problem hiding this comment.
Row click navigates to /functions/${f.id} (UUID). The function detail route + backend API expect the function_id string (same value used by Runs and existing Function detail page). Use f.function_id for navigation to avoid 404s.
| <tr key={f.id} className="is-clickable" onClick={() => (window.location.href = `/functions/${f.id}`)}> | |
| <tr key={f.id} className="is-clickable" onClick={() => (window.location.href = `/functions/${f.function_id}`)}> |
| <div className="page-hd-right"> | ||
| <button className="btn"><RefreshCw size={12} /> Refresh</button> | ||
| <button className="btn btn-primary"><Plus size={12} /> New agent</button> |
There was a problem hiding this comment.
The “Refresh” button is rendered without any onClick, so it won’t re-fetch agents (data only updates on the 30s interval). Wire this to trigger the same fetch logic (and consider making the interval optional) or remove the control until implemented. Same for “New agent” if creation is intended to remain supported.
| const ms = end - start; | ||
| if (ms < 1000) return `${ms}ms`; | ||
| if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; | ||
| return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; |
There was a problem hiding this comment.
fmtDuration() rounds the seconds component, which can yield values like 1m 60s when the remainder is near 59.6s+. Consider computing duration from total seconds (floor/round once) and then deriving minutes/seconds to avoid 60s rollover edge cases.
| return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; | |
| const totalSeconds = Math.round(ms / 1000); | |
| const minutes = Math.floor(totalSeconds / 60); | |
| const seconds = totalSeconds % 60; | |
| return `${minutes}m ${seconds}s`; |
| <div className="split-3"> | ||
| {enriched.length === 0 && <div className="hint">No functions yet.</div>} | ||
| {enriched.map(({ fn, total, successRate, bars }) => ( | ||
| <Link href={`/functions/${fn.id}`} key={fn.id} className="panel" style={{ textDecoration: 'none' }}> | ||
| <div className="panel-head"> | ||
| <div> | ||
| <div className="panel-title mono">{fn.name}</div> | ||
| <div className="panel-sub" style={{ display: 'flex', alignItems: 'center', gap: 4 }}> | ||
| {triggerIcon(fn.trigger_type)} {fn.trigger_type}{fn.trigger_value ? ` · ${fn.trigger_value}` : ''} | ||
| </div> |
There was a problem hiding this comment.
Links to the function detail page use fn.id (UUID), but the functions API routes and the existing [id] page expect the path param to be function_id (the human/function identifier). Navigating from this page will 404. Use fn.function_id in the URLs (and align any per-function run aggregation to that same identifier).
| if (loading || !run) { | ||
| return ( | ||
| <div className="space-y-6"> | ||
| <div className="flex items-center gap-4"> | ||
| <Link href="/runs"> | ||
| <Button variant="ghost" size="icon"> | ||
| <ArrowLeft className="h-4 w-4" /> | ||
| </Button> | ||
| </Link> | ||
| <div> | ||
| <div className="page-hd"> | ||
| <div> | ||
| <Link href="/runs" className="btn btn-sm"><ArrowLeft size={12} /> Runs</Link> | ||
| <h1 style={{ marginTop: 12 }}>Loading run…</h1> | ||
| </div> | ||
| </div> | ||
| <NotFoundState resource="Run" /> | ||
| <div className="hint">Fetching run details…</div> | ||
| </div> | ||
| ); |
There was a problem hiding this comment.
If the run fetch returns null (e.g. 404), the component falls into the loading || !run branch and renders a permanent “Loading run…” state instead of a not-found/error state. Distinguish loading from notFound and render an appropriate empty/error UI when run is null after loading completes.
#38) * feat(settings): adopt set-grid dark/mono shell with all sections wired Brings the Settings page in line with the rest of the redesigned dashboard. The earlier merge (#37) only updated Settings' theme hook because the page wasn't part of the redesign commit; this completes the job. CSS: - Restore the set-* primitives in globals.css: set-grid (sticky left nav + content), set-nav (grouped headings + is-on highlight), set-card (with .danger-card variant), set-row (200/1fr dashed-divider rows), set-input, set-help, .toggle, .key-row. Settings page (rewritten): - Replace shadcn Tabs/Card with the set-grid shell. Left nav grouped under Workspace / Runtime / Integrations / Notifications. Only the active section renders. - Sections wired to real APIs: * Concurrency — GET/PATCH /api/v1/tenant/concurrency (PATCH on blur for numbers, click for the idempotency toggle). * Notifications — GET/PATCH /api/v1/tenant/notifications + per-channel Test buttons hitting POST /tenant/notifications/test. All 7 fields surfaced (Slack URL/channel, PD enabled+key, two trigger toggles, email digest). * Danger zone — Pause all (POST /tenant/pause-all), Transfer ownership (POST /tenant/transfer-ownership with user-picker), Delete workspace (DELETE /tenant with typed-slug confirmation). Each behind a shadcn Dialog. * Members & access, Billing, Audit, API keys, Model providers, Secrets sections host the existing sub-components inside set-section blocks without an outer set-card wrapper (the sub-components carry their own Card chrome — avoids the card-in-card we hit earlier). - General workspace identity stays localStorage-only with a help-line noting the backend PATCH /tenant endpoint hasn't shipped yet. Cleanup: - Drop concurrency-tab.tsx, notifications-tab.tsx, danger-zone.tsx — their content is inlined in the page now and no other consumers exist. * fix(settings): address Copilot review on PR #38 - (#1 a11y) Sidebar nav items rendered as <a> without href — convert to <button type="button"> with aria-current="page" on the active item. globals.css updated so .set-nav { a, button } selectors share styles. - (#2 access) Notifications and Danger zone are admin-only on the server (GET /tenant/notifications returns secrets, danger actions are destructive). Mark both NAV items adminOnly so members no longer see them, and skip the notifications GET fetch entirely for non-admins to avoid a noisy 403 on mount. - (#3 transfer) Filter the current user out of the transfer-ownership dropdown — the server explicitly rejects self-transfer. - (#4 destructive) Delete-workspace button could enable when tenantSlug was still empty (initial state, both strings ""). Disable the destructive action and add an explicit guard in handleDeleteWorkspace. - (#5 a11y) Add role="switch" + aria-checked to all six .toggle buttons so assistive tech can read the on/off state. - (#6 copy) Page subtitle was hard-coded to "flowforge · workspace settings". Derive from tenantSlug (with general.workspaceSlug fallback) so the header tracks the actual workspace.
* fix(dashboard): cap getRuns page_size at 100 to match server limit After the dashboard redesign (#37/#38), three pages requested `getRuns({ page_size: 200 })`. The server caps `page_size` at 100 (`server/src/flowforge_server/api/routes/runs.py:76` — `Query(50, ge=1, le=100)`), so every one of those calls returns: 422 Unprocessable Entity {"validation_errors":[{"field":"query.page_size", "message":"Input should be less than or equal to 100"}]} The runs list, dashboard home, and functions page all render empty because the primary fetch errors. Reproduce by visiting any of the three pages with at least one run in the system and watching the network tab — `GET /api/v1/runs?page_size=200` 422s. Fix: drop the three callsites to 100 (the server max). A future follow-up should paginate properly when there are >100 runs, but this restores visibility immediately. Files changed: - dashboard/src/app/(dashboard)/page.tsx - dashboard/src/app/(dashboard)/functions/page.tsx - dashboard/src/app/(dashboard)/runs/page.tsx * fix(dashboard): rename remaining inline-style var(--accent) → var(--brand) Copilot caught 4 stray var(--accent) / var(--accent-ink) references in TSX inline styles that the globals.css rename missed: - (auth)/login/page.tsx — logo background + 2FA Shield icon - (dashboard)/runs/page.tsx — bar-chart OK fraction - (dashboard)/runs/[id]/page.tsx — Output section kicker All four were brand-intent (the redesign was using --accent to mean brand). They would have rendered as the new neutral surface (var(--bg-3)) after this PR's earlier --accent → --brand rename, losing the green. Switching them to --brand restores the intent.
Summary
Applies the new dark/mono "engineering command center" design across the
dashboard: Overview, Functions, Runs, Agents, the auth/layout shells.
Settings keeps its existing tabbed shape (and the Concurrency / Notifications
/ DangerZone tabs that landed in #36) — only its theme hook is updated to
read from the new shell store.
What's new
--ink-*,--bg-*,--accent, density / sidebar / radius scales) with the shadcn/Tailwind@theme inlinebridge preserved so unconverted shadcn primitives keepworking with the new palette mapped through
--card,--primary, etc.kpi,heatmap,sparkline,var-strip,load-bar,section-label,code-block,shell-effects.stores/shell-store.ts) — Zustand-backeddensity/theme/sidebar state, replacing
next-themesfor the redesignedpages.
list, layout/sidebar/header, login. Functions list went 741 → 480 lines;
Runs list 583 → 367; Run detail 1149 → 705 — most savings from dropping
shadcn
Cardboilerplate and recharts in favor of CSS sparklines.badge.tsx,button.tsx,sonner.tsxto map onto the newtokens.
Compatibility with #36 (Concurrency / Notifications / DangerZone tabs)
The Settings page still uses shadcn
Tabs+Card(the redesign of thatspecific page was abandoned in favor of the simpler tabbed shape with the
new section tabs). Those new tabs render against the preserved
--card,--popover, etc. tokens and pick up the new accent automatically — no codechanges needed on those components.
How this branch was produced
Cherry-picked
6b7be78from the long-lived localredesign/dashboard-v2branch onto current
main. The only file overlap wasdashboard/src/app/(dashboard)/settings/page.tsx, where git auto-mergedthe redesign's tiny
useTheme→useShellStoreswap with main's tabsadditions cleanly. The earlier
3a0571bPhase 2 commit on that branch wasintentionally dropped — its content already shipped on main as #36.
Test plan
pnpm build— all routes generate, no type errorspnpm devand visit /, /runs, /runs/[id], /functions, /agents,/settings — dark-mono palette applied, layout intact
and PATCH endpoints work as before (feat(settings): wire workspace settings to backend (concurrency, notifications, danger zone) #36 surface unchanged)