Skip to content

feat(dashboard): redesign with new dark/mono visual language#37

Merged
hoootan merged 1 commit into
mainfrom
feat/dashboard-redesign
Apr 28, 2026
Merged

feat(dashboard): redesign with new dark/mono visual language#37
hoootan merged 1 commit into
mainfrom
feat/dashboard-redesign

Conversation

@hoootan

@hoootan hoootan commented Apr 28, 2026

Copy link
Copy Markdown
Owner

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

  • CSS tokens rebuilt as a mono-first dark palette (--ink-*, --bg-*,
    --accent, density / sidebar / radius scales) with the shadcn/Tailwind
    @theme inline bridge preserved so unconverted shadcn primitives keep
    working with the new palette mapped through --card, --primary, etc.
  • Reusable UI primitives: kpi, heatmap, sparkline, var-strip,
    load-bar, section-label, code-block, shell-effects.
  • Shell store (stores/shell-store.ts) — Zustand-backed
    density/theme/sidebar state, replacing next-themes for the redesigned
    pages.
  • Page redesigns: Overview, Functions list, Runs list + detail, Agents
    list, layout/sidebar/header, login. Functions list went 741 → 480 lines;
    Runs list 583 → 367; Run detail 1149 → 705 — most savings from dropping
    shadcn Card boilerplate and recharts in favor of CSS sparklines.
  • Restyled badge.tsx, button.tsx, sonner.tsx to map onto the new
    tokens.

Compatibility with #36 (Concurrency / Notifications / DangerZone tabs)

The Settings page still uses shadcn Tabs + Card (the redesign of that
specific 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 code
changes needed on those components.

How this branch was produced

Cherry-picked 6b7be78 from the long-lived local redesign/dashboard-v2
branch onto current main. The only file overlap was
dashboard/src/app/(dashboard)/settings/page.tsx, where git auto-merged
the redesign's tiny useThemeuseShellStore swap with main's tabs
additions cleanly. The earlier 3a0571b Phase 2 commit on that branch was
intentionally dropped — its content already shipped on main as #36.

Test plan

  • pnpm build — all routes generate, no type errors
  • pnpm dev and visit /, /runs, /runs/[id], /functions, /agents,
    /settings — dark-mono palette applied, layout intact
  • /settings → Concurrency / Notifications / Danger Zone tabs render
    and PATCH endpoints work as before (feat(settings): wire workspace settings to backend (concurrency, notifications, danger zone) #36 surface unchanged)
  • Theme toggle in topbar flips data-theme and persists across reloads
  • Sidebar collapse mode persists across reloads

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.
Copilot AI review requested due to automatic review settings April 28, 2026 22:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +161 to +172
<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>

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +18
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';

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +78
<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>

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 379 to 383
{mounted && (
<RadioGroup
value={theme}
onValueChange={setTheme}
onValueChange={(v) => setTheme(v === 'light' ? 'light' : 'dark')}
className="grid gap-4 md:grid-cols-3"

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
</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`;

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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`;

Copilot uses AI. Check for mistakes.
<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}`)}>

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
<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}`)}>

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +57
<div className="page-hd-right">
<button className="btn"><RefreshCw size={12} /> Refresh</button>
<button className="btn btn-primary"><Plus size={12} /> New agent</button>

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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`;

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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`;

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +101
<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>

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +98 to 109
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>
);

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@hoootan hoootan merged commit cbba3f9 into main Apr 28, 2026
8 checks passed
@hoootan hoootan deleted the feat/dashboard-redesign branch April 28, 2026 22:20
hoootan added a commit that referenced this pull request Apr 28, 2026
#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.
hoootan added a commit that referenced this pull request Apr 28, 2026
* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants