Major UI refresh on the nav-design-improvements branch. Drops 2013-era
inline styles and consolidates patterns behind shared primitives.
Foundation
- New Wulf brand layer in app/styles/brand.css repointing --primary to
the standards-guide blue (#0075AD) with utility classes for numerics
(.num / .num-lg / .num-xl), metric labels, surface tints, and the
wolf-mark watermark
- Switch primary face to IBM Plex Sans + IBM Plex Mono via next/font;
Helvetica/Arial stays in the fallback chain for brand fidelity
- Wordmark subtitle changed from "PSA Management System" to
"Operations console" everywhere it appeared
- Tagline footer ("Don't be afraid to cry") on every non-mobile page
Status moved out of /dashboard
- New /status route with integration tiles grouped by category, sync
health table, worker pulse cards (analyzer / RMM / sync scheduler),
token-expiry section, conditional alert banner
- Top-bar StatusIndicator polls integration health every 60s and links
to /status
- INTEGRATIONS_DISABLED env var suppresses operator-disabled
integrations (e.g. SentinelOne) — no failure noise from broken-on-
purpose entries. Aliases supported (sentinelone → s1, etc.)
Dashboard rebuilt around KPIs
- /api/dashboard/overview adds today snapshot (opened, resolved, open
total, SLA breaches) with delta math
- /api/dashboard/trends backs queue × priority heatmap, 30-day volume
area chart, 30-day mean resolution time line chart, today's active
engineers leaderboard
Components
- StatusBadge driven by lib/status-registry.ts (priority, ticket
status, classification, source, company type, publish, active /
yes-no / billable / approved registries)
- StatusLight (8px geometric square, five states, three sizes)
- EmptyState (shared dashed panel with icon + headline + optional CTA)
- KpiCard with delta indicator and tonal left border
- WulfMark (mark / wordmark variants from /public/branding)
- Skeleton helpers (SkeletonRow / Rows / Card / Chart / Header / Table)
Navigation
- Admin flat link → dropdown with seven shortcuts
- New UserMenu (initials avatar, role badge, settings + sign-out)
- Active-route highlight is now a 2px Wulf-blue underline echoing the
PageHeader rule (consistent across flat links and submenu triggers);
active children inside dropdowns use bg-primary/10
- Submenu width is content-driven (min-w 320 / max-w 440, single col)
- Mobile hamburger via Sheet, reuses the same nav config
Pages migrated
- 16 admin sub-pages adopt PageHeader (with accent prop)
- /addigy-devices: shadcn Table + Checkbox; PageHeader; status badges
- 10 raw <table> blocks across admin/sync/* migrated to shadcn Table
- /veeam-analysis migrated to shadcn Table (kept its expansion logic)
- Detail routes (analyzer ticket, analyzer analysis) get breadcrumbs
DataTable
- Rewritten on @tanstack/react-table v8 in manual mode; external API
unchanged so all 10+ data-browser pages keep working
- New optional props for drill-down rows: getRowCanExpand + renderSubRow
Mobile
- Multi-select Popover gets max-w-[calc(100vw-1rem)] and
collisionPadding so dropdowns can't overflow narrow viewports
- CI filter bar wraps and shrinks; stat pill flows below
Docs
- New ARCHITECTURE.md (load-bearing reference for runtime, data flow,
workers, analyzer pipeline, auth, deployment, gotchas)
- New DESIGN.md (tokens, layout, navigation IA, component vocabulary,
rolling backlog of remaining cleanup)
- CLAUDE.md refreshed with pointers to the two new docs and the
INTEGRATIONS_DISABLED operator config note
- shadcn registry registered as project-level MCP server (.mcp.json)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
19 KiB
Pulse — Design System
This file documents how the UI is put together today: the tokens, the
component vocabulary, the navigation IA, and the patterns each page is
expected to follow. It also calls out the rough edges that the
nav-design-improvements branch exists to address — treat the "Open
improvements" section as the working backlog for visual/UX cleanup.
System overview lives in ARCHITECTURE.md. Per-feature notes live in docs/.
1. Design principles
- Information density over decoration. Pulse is an internal operations console. Tables, dashboards, and admin tools win over hero spacing.
- One source of truth per pattern. If shadcn/ui has a primitive, use it —
don't re-implement (
<table>raw, custom skeletons, ad-hoc dropdowns). - Tokens, not hex. Colors come from CSS variables in
app/globals.css, not Tailwind's full color palette. The same goes for radius and spacing. - Roles shape the nav, not the layout. Hide items the user can't access; don't restructure the page shell per role.
- Read-first, write-confirmed. Destructive or external-effect actions (RMM execute, IT Glue write) live behind explicit dialogs with confirmation, not inline buttons.
2. Tokens
App tokens are defined in app/globals.css (shadcn defaults). The Wulf
brand layer in app/styles/brand.css is imported last and repoints
--primary / --accent / --ring / --chart-2 to the standards-guide
blue (#0075AD) and provides Wulf gray ramps (--wulf-gray-100/300/700).
Tailwind 4 reads them via @theme inline; never hard-code colors or
radii in components.
Colors (OKLch)
| Token | Light | Dark | Use |
|---|---|---|---|
--background / --foreground |
white / near-black | near-black / near-white | Page surface and primary text |
--card / --card-foreground |
white | oklch(0.205 0 0) |
Card surfaces |
--primary / --primary-foreground |
logo blue oklch(0.55 0.16 220) |
brighter blue oklch(0.62 0.17 220) |
Primary actions, active nav, focus ring |
--secondary / --muted |
oklch(0.97 0 0) |
oklch(0.269 0 0) |
Subtle surfaces, table headers |
--muted-foreground |
oklch(0.556 0 0) |
oklch(0.708 0 0) |
Secondary text, descriptions |
--accent |
same as primary | same as primary | Hover/selection accents |
--destructive |
oklch(0.577 0.245 27.325) |
oklch(0.704 0.191 22.216) |
Errors, destructive buttons |
--border / --input |
oklch(0.922 0 0) |
oklch(1 0 0 / 10%) |
Borders, input outlines |
--ring |
logo blue | brighter blue | Focus ring |
--chart-1 … --chart-5 |
distinct hues | distinct hues | Recharts series |
--sidebar* |
mirrors page tokens | mirrors page tokens | Reserved for a future sidebar nav |
Status hues outside this set (success greens, warning ambers, info blues)
should still go through Tailwind's named palette at -500 or -600 and use
the muted background pair bg-{hue}-500/15 text-{hue}-600 for badges so the
contrast stays acceptable in both modes.
Radius
--radius: 0.625rem. Derived: radius-sm (-4px), radius-md (-2px),
radius-lg (=), radius-xl (+4px). Use rounded-md / rounded-lg —
don't introduce custom radii.
Type
Brand mandate (per docs/StandardsGuide (1).pdf, 2013): Helvetica /
Arial, Bold for headers and Light for the tagline. As of 2026-05 the
app uses IBM Plex Sans instead — same spirit (engineered sans, Light
weight available), reliably hosted, and pairs with Plex Mono for
numerics. Helvetica / Arial remain in the fallback chain.
- Sans: IBM Plex Sans via
next/font/google, weights 300/400/500/600/700. Wired via--font-plex-sansinapp/layout.tsx. - Mono: IBM Plex Mono via
next/font/google, weights 400/500/600. Used for numerics, IDs, timestamps — not for body text.
Type scale: stick to Tailwind defaults. Reserve text-2xl font-bold tracking-tight for page titles (matches PageHeader); use text-lg font-semibold for card titles and text-sm text-muted-foreground for
descriptions. Numerics go through the .num / .num-lg / .num-xl
utilities defined in app/styles/brand.css.
Dark mode
CSS-class strategy (.dark on <html>). Toggled by ThemeToggle in the
top bar; persisted via next-themes. Component-level dark variants should be
unnecessary if you stick to tokens.
3. Layout
Page shell
┌─ <AppNavigation /> (sticky h-16, backdrop blur, z-50) ─┐
├─ <PageHeader /> (optional, bordered, container-aligned)
└─ <main> (container mx-auto px-6 py-6)
├─ Cards / sections, separated by space-y-6
└─ …
PageHeader lives at the bottom of components/navigation/app-navigation.tsx.
It owns the title (h1.text-2xl.font-bold.tracking-tight), description
(text-muted-foreground), optional breadcrumbs (/-separated), and an
actions slot.
Container & spacing rules
| Rule | Value |
|---|---|
| Horizontal container | container mx-auto px-6 |
| Vertical page padding | py-6 (default), py-8 only on auth/landing |
| Section gap | space-y-6 for stacked sections, gap-6 for grid layouts |
| Card body padding | shadcn default — don't override unless wrapping a table (p-0) |
| Item-level gap | gap-2 for inline rows, gap-4 for form rows |
These are the targets. Today's pages don't all conform — see "Open improvements" below.
4. Navigation IA
The top bar (components/navigation/app-navigation.tsx) is the only nav. It
is sticky, full-width, and structured left-to-right as:
- Brand — logo + "Pulse" wordmark (
hidden sm:blockfor the wordmark) - Primary menu (
<NavigationMenu>) - Right-side controls —
<ThemeToggle />
Items today
| Label | Route | Visibility |
|---|---|---|
| Dashboard | / |
All |
| Configuration Items | /configuration-items |
All |
| Backup Status ▾ | submenu | All |
| → Backup Status | /backup-status |
|
| → RPO Comparison | /veeam-comparison |
|
| → Ticket Analysis | /veeam-analysis |
|
| Engagement ▾ | submenu | super-admin |
| → Overview | /engagement |
|
| → Employee Profile | /engagement/profile |
|
| Analyzer ▾ | submenu | All |
| → Browse Tickets | /analyzer/tickets |
|
| → Aggregate Reports | /analyzer/reports |
|
| → Needs Review | /analyzer/queue |
|
| → IT Glue — Applications | /analyzer/itglue/applications |
|
| → IT Glue — Configurations | /analyzer/itglue/configurations |
|
| Admin | /admin |
super-admin |
Role gating is hardcoded against session.user.role. Hidden items are not
rendered (no greyed-out variants).
/admin is the entry tile for everything admin-only — sync schedules, rules,
mappings, RMM Overshell, IT Glue write log, device-link conflicts. Sub-pages
don't appear in the top nav; they're reached from the admin landing grid.
Mobile
AppNavigation returns null for any path under /mobile — kiosk and field
flows have their own shell. The desktop nav doesn't currently collapse to a
hamburger; under sm the brand wordmark hides and the menu items wrap.
Mobile-friendly behavior on small viewports is on the improvements list.
5. Components
shadcn/ui primitives (in components/ui/)
| Available | Used heavily | Rare / underused |
|---|---|---|
accordion |
yes (collapsible filters could use it) | |
alert, alert-dialog |
yes (errors, confirmations) | |
badge |
yes (status, counts) | |
button |
yes | |
calendar, popover |
engagement profile | |
card, card-header/title/content |
yes (page sections) | |
checkbox, switch |
yes | |
collapsible |
yes (CI page) | |
dialog, alert-dialog |
yes (modals) | |
dropdown-menu |
yes (actions) | |
form (react-hook-form bridge) |
admin/auth only | |
input, label, textarea |
yes | |
multi-select |
analyzer filters | |
navigation-menu |
top bar only | |
progress |
yes (could replace ad-hoc bars) | |
scroll-area |
yes | |
select |
yes | |
separator |
yes | |
skeleton |
yes (loading states) | |
slider |
rare | |
sonner (toast) |
yes — richColors, top-right |
|
table |
inconsistent — many pages use raw <table> |
|
tabs |
yes (Engagement, CI, DetailModal) |
Feature components
| Path | Purpose |
|---|---|
components/navigation/app-navigation.tsx |
Top nav + PageHeader |
components/admin/DataTable.tsx |
Custom paginated/sortable table (not @tanstack) |
components/admin/DetailModal.tsx |
Ticket detail shell, tabs (status/priority maps now in lib/status-registry.ts) |
components/branding/wulf-mark.tsx |
<WulfMark /> — W glyph or full wordmark |
components/branding/tagline-footer.tsx |
"Don't be afraid to cry · Wulf Consulting" footer line |
components/dashboard/kpi-card.tsx |
KpiCard with delta indicator and tonal left border |
components/navigation/page-header.tsx |
<PageHeader /> — title, breadcrumbs, actions; supports accent and watermark |
components/navigation/status-indicator.tsx |
Top-bar StatusLight that links to /status |
components/ui/empty-state.tsx |
Shared zero-data placeholder |
components/ui/status-badge.tsx |
Small rounded pill driven by lib/status-registry.ts |
components/ui/status-light.tsx |
8 px square indicator (5 states, 3 sizes) |
components/admin/SyncScheduler.tsx |
Schedule editor on /admin |
components/analyzer/analyze-button.tsx |
Trigger analysis from a ticket |
components/analyzer/share-modal.tsx |
Email share dialog |
components/analyzer/provider-toggle.tsx |
Anthropic/OpenRouter switch |
components/analyzer/related-tickets-panel.tsx |
Bundle members on analysis page |
components/analyzer/itglue-suggestions-panel.tsx |
Inline IT Glue cross-refs |
components/rmm/rmm-dispatch-dialog.tsx |
Pick + execute RMM script |
components/rmm/rmm-script-picker.tsx |
Script registry browser |
components/rmm/rmm-execution-stream.tsx |
Live tail of an execution |
components/configuration-items/config-item-modal.tsx |
CI detail shell |
Anything not in those two directories — and not a one-off page-local subcomponent — should probably move there.
6. Icons
lucide-react, imported per-icon. Conventions:
- Inline with text:
h-4 w-4(16 px), withmr-2if leading. - Standalone tile/card icons:
h-6 w-6. - Admin landing tile icons:
h-8 w-8plus a muted color (text-muted-foreground). - Color: inherit from text; status icons (
text-orange-600,text-green-600) only when they carry semantic meaning, not for decoration.
Mixed size-4 vs h-4 w-4 exists today. Prefer h-4 w-4 to match shadcn.
7. Tables, modals, forms
Tables
There are two patterns in the codebase. Pick the leftmost that fits:
components/admin/DataTable.tsx— paginated, sortable, searchable. Backed by@tanstack/react-tablev8 in manual mode. Use this for any list ≥ 25 rows or where users need to filter / sort. PassgetRowCanExpand+renderSubRowfor drill-down rows.- shadcn
Tableprimitive incomponents/ui/table.tsx— for short static lists wrapped in a<Card>with<CardContent className="p-0">.
Raw <table> markup is no longer in use anywhere under app/. If you find
yourself reaching for it, lift the layout into one of the two primitives.
Modals & dialogs
Dialog— most cases (forms, confirmations, RMM dispatch).AlertDialog— destructive confirmations only.DetailModal— tabbed read-only deep-dive on a ticket. Hardcoded status/priority color map; if you need that map elsewhere, lift it out rather than copy.- A "panel" (e.g.,
related-tickets-panel) is a card laid out like a side panel — it is not a modal and shouldn't trap focus.
Forms
- react-hook-form + Zod resolver with the shadcn
Formprimitive — used on auth and admin forms (invites, role edits). Use this for any new form with ≥ 3 fields or any field that needs validation. - Controlled inputs (
useState+onChange) — acceptable for one-off filter bars. Don't introduce a third pattern.
Error display today is inconsistent (toast vs inline). New forms should
display field errors inline via <FormMessage> and use toasts only for
submit-time outcomes.
8. Feedback
- Toasts —
sonnerconfigured top-right withrichColors(app/layout.tsx). Usetoast.success/toast.error/toast.info. Don't render error banners inside the page when a toast fits. - Loading —
<Skeleton>for content placeholders,<Loader2 />from lucide withanimate-spinfor inline button spinners. Don't roll a custom spinner div. - Empty states — today: a centered
text-muted-foregroundline. Aim: a small icon (lucide), a one-line headline, an optional CTA. There is no sharedEmptyStatecomponent yet — adding one is on the improvements list.
9. Charts
recharts, themed via the --chart-1 … --chart-5 CSS variables (Tailwind
exposes them as text-chart-1 etc., so colors flip with light/dark
automatically). Common margins { top: 5, right: 8, bottom: 24, left: 0 }.
Examples: app/dashboard/page.tsx, app/engagement/page.tsx.
10. Open improvements (this branch)
The nav-design-improvements branch tracks visual/UX cleanup. The list
below is the working backlog; expand as we go.
Navigation
Top-nav "Admin" item is a flat link— now a dropdown with the seven most-used admin pages (Sync, Workflow, RMM Overshell, IT Glue Writes, Device Conflicts, Users & roles).No user menu / avatar / sign-out in the top bar—<UserMenu />now sits next toThemeToggle. Shows initials, name/email, role badge, links to/settings+/settings/security, and sign-out.No mobile-collapsed (hamburger) version—<MobileNav />in a Sheet. Reuses the desktop nav config so IA stays in sync. Desktop nav hides undermd.Submenu width is fixed— now content-driven viamin-w-[320px] max-w-[440px], single column with denser rows, so 7-item Admin and 5-item Analyzer menus fit without forced two-column awkwardness.Active-route highlighting on submenu items— every top-bar item (flat or trigger) marks active state with a 2 px Wulf-blue underline that echoes the PageHeader rule. Active children inside dropdowns get a tintedbg-primary/10background.
Page shell
Adopt—PageHeadereverywhere/dashboard,/status, all/admin/*sub-pages,/addigy-devices,/analyzer/ticket/[…],/analyzer/analysis/[…]now use it. A handful of pages still need it (kiosk, settings, sentinelone/* — low priority).- Standardize container + padding (
container mx-auto px-6 py-6). Pages usingpx-4,py-8,max-w-2xletc. should justify the deviation. - Standardize section spacing (
space-y-6between cards; pickgap-6for grids). Add breadcrumbs to ticket / analysis detail routes. Still open for/configuration-items/[id]and/analyzer/itglue/{applications, configurations}/[id].
Components
Build a shared—<EmptyState />components/ui/empty-state.tsx.Build a shared— registry in<StatusBadge />that consumes the priority/status maps fromDetailModallib/status-registry.ts, badge incomponents/ui/status-badge.tsx, DetailModal migrated.Extract—PageHeaderand addaccent+watermarkpropscomponents/navigation/page-header.tsx. Adopted on/dashboardand/status.Replace raw— Addigy, /status, all admin/sync/* pages, backup-status, engagement/profile, veeam-comparison migrated. Only<table>markup on admin and Addigy pages with shadcnTable/veeam-analysisremains (DataTable territory; filter + expand + paginate).Replace raw— done; also caught one in<input type="checkbox">on/addigy-devices/admin/sync/mimecast./veeam-analysis raw— migrated to shadcn<table>Tableprimitive. The page keeps its own pagination + category filter + row expansion (DataTable doesn't support row expansion yet, so a deeper DataTable migration is folded into the long-term decision below).Decide on— migrated toDataTablelong-term@tanstack/react-tablev8 in manual mode. The external API stays stable (existing data-browser pages keep working without changes); internally TanStack drives sort + expansion. New optional props for consumers:getRowCanExpandandrenderSubRowenable expandable-row patterns (useful for/veeam-analysis-style drill-downs).
Status & dashboard split (2026-05-03)
- Move integration health + sync health off
/dashboardonto a dedicated/statusroute. Top-bar<StatusIndicator />links there. - Rebuild
/dashboardaround KPI cards (today snapshot + needs attention) and recent activity. Drop the integration / sync cards. Add queue × priority heatmap to—/dashboardQueueHeatmapbacked by/api/dashboard/trends.Add 30-day volume + mean resolution charts—VolumeTrendandResolutionTrendcomponents, recharts, brand-blue series.Add active engineers panel—ActiveEngineers(today's hours logged, ticket touch count).Worker pulse section on— analyzer / RMM / sync scheduler heartbeats via/status/api/status/workersandWorkerPulse.
Tokens & theming
- Audit places that hard-code Tailwind palette colors (
text-orange-600,bg-blue-500/15) and either keep them as semantic status colors or move them behind a token. - Verify dark-mode contrast on status badges and chart legends; the 10%- opacity borders in dark mode are subtle and may need lifting.
Loading & empty
Standardize Skeleton heights— helpers incomponents/ui/skeleton-helpers.tsx:SkeletonRow,SkeletonRows,SkeletonCard,SkeletonChart,SkeletonHeader,SkeletonTable.- Adopt the helpers across pages (still scattering
h-12/h-24in pages built before the helpers landed). - Loading shells should match the post-load layout — skeletons inside Cards, not a single full-width bar.
Mobile
CI filter bar overflows on small viewports— company selector now wraps and shrinks; the stat pill flows below.Analyzer multi-select dropdowns clip on narrow widths— Popover getsmax-w-[calc(100vw-1rem)]andcollisionPadding={8}.- Tables horizontally scroll without a sticky first column; consider responsive card-list fallbacks for narrow screens.
11. When in doubt
- Use a token, not a hex value.
- Use the shadcn primitive, not a custom one.
- Match the surrounding page's spacing scale rather than introducing a new one.
- If the same thing exists in two shapes (e.g., raw vs DataTable), pick the shape this doc documents and migrate the other.