UI Design System
This document defines the design system rules and conventions for the Hegemony UI. All UI changes must follow these rules to maintain consistency and prevent drift.
Purpose
This design system serves as the contract for all UI development:
- Prevents ad-hoc styling that creates technical debt
- Ensures consistency across all pages
- Makes the codebase maintainable and predictable
- Provides clear patterns for new features
Core Principles
1. Component-Based Architecture
- Pages must compose shared primitives (components)
- No page should implement its own versions of common UI patterns
- Reuse over recreation
2. Tailwind v4 + shadcn/ui Only
- All styling must use Tailwind v4 utilities
- Use shadcn/ui components for interactive elements
- No bespoke CSS inside pages (see "No Bespoke CSS Rule" below)
Licensing note. shadcn/ui is not an npm dependency — its components are copied into
apps/ui/src/components/ui/by the CLI and edited in place, so no package-manager licence check can see them. The scaffolded files are derived from shadcn/ui (MIT, © 2023 shadcn) and carry a dual header:text// SPDX-FileCopyrightText: 2023 shadcn <https://ui.shadcn.com> // SPDX-FileCopyrightText: 2025-2026 Jakub Trávník <[email protected]> // // SPDX-License-Identifier: MIT AND AGPL-3.0-or-laterKeep that header when adding a component with
npx shadcn add, and use the plainAGPL-3.0-or-laterheader only for components written here from scratch (error-banner,multi-select,permission-denied,searchable-select,chip-input,boolean-segmented-control). Retaining the notice is what MIT asks for in exchange; dropping it is the one way to turn a free component into a licence violation.
3. Semantic Tokens
- All colors must use semantic design tokens:
bg-background,bg-card,bg-mutedtext-foreground,text-muted-foregroundborder-border,border-inputaccent,accent-foreground- Never hardcode colors like
#646cfforrgba(100, 108, 255, 0.2)
4. Accessibility First
- Use Radix/shadcn components for built-in accessibility
- Prefer role-based selectors in tests (
getByRole,getByLabel) - Add
data-testidonly when necessary
Layout System
App Shell Structure
The application uses a consistent shell layout:
┌─────────────────────────────────────────────────┐
│ Sidebar Nav │ Page Content │
│ - Flows │ ┌─────────────┐ │
│ - Runs │ │ PageHeader │ │
│ - Inventory │ └─────────────┘ │
│ - Sites │ │
│ - Devices │ │
│ - Credentials │ Main Content │
│ │ │
└─────────────────────────────────────────────────┘Rules:
- Left sidebar for primary navigation
- Inventory is a parent navigation trigger, not a route; it reveals Sites, Devices and Objects when clicked and opens automatically while a child route is active.
- Inventory's entries are fixed. The menu does not grow one entry per object type: a plugin registers every type it can serve while a provider reads only the types its configuration names, so that shape made the menu a list of mostly empty pages whose length changed as syncs ran. Which type you are looking at is chosen on the Objects page itself, from a picker in its heading.
- The Objects picker is data-driven where the menu is not: it offers the types that have synced rows, following
GET /inventory/object-types/countsrather than the registry. While those counts are still loading, or if that request fails, every registered type is offered instead — a picker that is briefly too long is better than one that hides a type someone synced. The type named in the URL is always offered, however few rows it has, so a bookmark opens on itself. - A page whose subject is chosen on the page belongs in the heading, not beside it:
PageHeadertakes a node so the control sits in theh1("Objects — Prefixes"), and/inventory/<type>stays the address of that view. - Content area with consistent padding
- PageHeader component at top of each page
- Dark-first theme with subtle borders
Page Structure
Every page must follow this structure:
<div className="page-container">
<PageHeader title="Page Title">{/* Optional action buttons */}</PageHeader>
<div className="page-content">{/* Main content */}</div>
</div>Rules:
- Use
PageHeadercomponent for title + actions - Consistent padding via utility classes
- No custom page-level CSS files
Spacing Scale
Use Tailwind's spacing scale consistently:
| Use Case | Spacing | Tailwind Class |
|---|---|---|
| Component gap | 4px | gap-1 |
| Small gap | 8px | gap-2 |
| Standard gap | 12px | gap-3 |
| Large gap | 16px | gap-4 |
| Section spacing | 24px | gap-6 |
| Page padding | 32px | p-8 |
Rules:
- Use consistent spacing throughout
- Prefer
gapfor flex/grid layouts - Use
space-ysparingly, prefer explicit gaps
Typography Hierarchy
| Element | Size | Weight | Usage |
|---|---|---|---|
| Page Title (h1) | 2rem | 600 | PageHeader title |
| Section Heading (h2) | 1.5rem | 600 | Major sections |
| Subsection (h3) | 1.25rem | 600 | Card headers |
| Body | 0.875rem | 400 | Default text |
| Small | 0.75rem | 400 | Meta info, labels |
Rules:
- Use semantic HTML elements (
<h1>,<h2>, etc.) - Style via Tailwind classes (
text-2xl,font-semibold) - Maintain visual hierarchy through size and weight
Table-First Lists
All list views must use the DataTable primitive:
DataTable Rules
Use DataTable for all lists
- Devices, Runs, Flows, Sites, Credentials
- Consistent column structure
- Built-in sorting, filtering (when needed)
Column Structure
tsx{ header: "Name", accessor: "name", cell: (row) => <span className="font-medium">{row.name}</span> }Status Columns
- Use
StatusBadgecomponent - Consistent color coding
- Semantic status names
- Use
Actions Column
- Right-aligned
- Use
RowActionscomponent (dropdown or icon group) - Max 3 visible actions, rest in dropdown
Table Styling
<Table>
<TableHeader>
<TableRow>
<TableHead>Column Name</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Cell Content</TableCell>
</TableRow>
</TableBody>
</Table>Rules:
- Use shadcn
Tablecomponents - No custom table CSS
- Hover states built-in
- Responsive by default
CRUD List Soft-Delete Convention
For entities that support soft delete, list pages must use the same interaction model:
Tabs
- Use
TableTabswithActiveandDeletedtabs. - Show per-tab counts.
- Use
Deleted actions
- Use
DeletedTabActionswith two actions:- Restore (moves row back to Active).
- Permanently delete (irreversible).
- Use
Confirm dialogs
- Keep separate confirm states for soft delete and permanent delete.
- Include entity name in dialog description whenever available.
- Soft-delete messaging must explicitly mention recovery via Deleted tab.
Loading and errors
- Track operation state per item (
deletingId,restoringId). - Surface failures in a visible inline error banner (no console-only errors).
- Track operation state per item (
Row interactions
- Active-tab rows may navigate to detail on row click.
- Deleted-tab row actions must prevent accidental row navigation where needed.
This convention applies to pages like Devices, Sites, Credentials, Variables, Notifications, and Schedules when soft-delete is enabled.
Row Grouping Convention
Long client-side lists may group their rows under collapsible headings by passing grouping to DataTable:
<DataTable
columns={columns}
data={flows}
keyAccessor="id"
grouping={{
// key identifies the group, label/hint render the heading, rank orders
// groups (catch-all groups last).
groupOf: (flow) => ({ key: 'local', label: 'Local', hint: 'not in git', rank: 2 }),
}}
/>Rules:
- Grouping replaces paging. A grouped table renders the whole filtered, sorted list in one pass and shows a count summary with
Expand all/Collapse allinstead of page controls — a page break inside a collapsed group has no sensible meaning. - Grouping runs after sorting, so the column sort still orders rows inside each group.
- Client-side tables only: with
remoteOperationsthe data holds a single page, so its groups would be misleading. - The group a row belongs to must be derivable from data the list already loaded. Do not fetch per row to build a heading.
- Offer grouping through a
Group byselector next to the list's search box, and keep the mode in the query string (see the Flows page andflowListUrlState), so a grouped view can be linked and bookmarked.
List View Convention
A list page may offer a second, card-based rendering of the same data (the Flows page's Library). When it does:
- Whichever view answers the page's everyday question is the default. On the Flows page that is the library, because the list's daily job is "find the flow I want and start it"; the table remains the dense, sortable, bulk-friendly view behind one click. Switching views must never be the only way to reach an action.
- Both views read the same search, filter and grouping state, from the query string. Do not give the card view its own filter controls: two ways to filter one list is one too many.
- Cards carry what a row cannot - a full description, tags, live signals - and promote the page's primary verb (Run, Open) to a button, with the rest in the same
CrudActionsmenu the table row uses. - The chosen view is part of the list's shareable state, so it belongs in the query string with the search and grouping.
- Cards have no column headers to click, so a card view that needs an order carries its own
Sort byselector next to theGroup byone, and that choice joins the rest in the query string. Do not offer it in the table, where the columns already sort. - Decide where a card affordance's state lives by asking who it is for. Pinning on the Flows page is the whole organization's ordering of its own list and has to survive an import, so it is a field on the flow and a request to the server, not local storage. Reach for local storage only for something genuinely personal to one browser, and keep the read tolerant of a missing or unparsable value.
Bulk Selection Convention
For table pages that support multi-record mutations:
- Use
DataTableselection props for standard tables (selectedRowIds,onSelectedRowIdsChange,isRowSelectable) instead of custom checkbox wiring. - Use
BulkActionToolbardirectly above the affected table and only render it when at least one row is selected. - Keep destructive bulk actions behind
ConfirmDialogand describe whether the action is restorable or permanent. - Preserve partial-success feedback in the page error area and keep failed row IDs selected so users can retry or inspect them.
- Exclude provider-backed/read-only records from selection with disabled checkboxes and continue to expose row-level read-only affordances.
Inventory list pages that mix local and provider-backed records should expose compact source filter buttons (All Sources, local, provider ID) above the active table, matching the Devices and Sites pages.
Form Composition
Forms must use shadcn form primitives:
Form Structure
<form className="space-y-4">
<div className="space-y-2">
<Label htmlFor="field">Field Label</Label>
<Input id="field" type="text" placeholder="Enter value..." />
</div>
<div className="flex gap-2">
<Button type="submit">Save</Button>
<Button type="button" variant="secondary">
Cancel
</Button>
</div>
</form>Form Mode Detection Convention
Form mode (create, edit, duplicate) must be derived from router state (e.g., useLocation) rather than direct window.location access. This keeps behavior consistent with SPA routing and test environments.
Rules:
- Use shadcn
Label,Input,Select,Textarea - Associate labels with inputs via
htmlFor/id - Group form actions at bottom
- Use
space-yfor vertical spacing
Form Fields
| Field Type | Component | Notes |
|---|---|---|
| Text input | <Input type="text" /> | Standard text |
| Number | <Input type="number" /> | Numeric values |
| Select | <Select> | Dropdown selection |
| Boolean | <BooleanSegmentedControl /> | True/False or Enabled/Disabled |
| Checkbox | <Checkbox> | Simple on/off acknowledgements |
| Textarea | <Textarea> | Multi-line text |
Shared Primitives (Components)
All pages must use these shared components:
Required Primitives
Use these primitives for all UI development:
PageHeader (
@/layout/PageHeader)- Title (left)
- Actions slot (right)
- Consistent styling
tsximport { PageHeader } from '@/layout'; <PageHeader title="Devices"> <Button>Add Device</Button> </PageHeader>;DataTable (
@/components/DataTable)- List views
- Generic columns with accessors
- Optional row click handler
tsximport { DataTable, type Column } from '@/components'; const columns: Column<Device>[] = [ { header: 'Name', accessor: 'name' }, { header: 'Status', accessor: (row) => <StatusBadge status={row.status}>{row.status}</StatusBadge>, }, ]; <DataTable columns={columns} data={devices} keyAccessor="id" />;RowActions (
@/components/RowActions)- Edit/Delete/View actions
- Dropdown menu for actions
- Destructive variant for delete
tsximport { RowActions } from '@/components'; <RowActions actions={[ { label: 'Edit', onClick: () => handleEdit(item) }, { label: 'Delete', onClick: () => handleDelete(item), variant: 'destructive' }, ]} />;EmptyState (
@/components/EmptyState)- When no data exists
- Icon + message + optional action
- Centered layout
tsximport { EmptyState } from '@/components'; <EmptyState title="No devices found" description="Create a device to get started." action={<Button>Add Device</Button>} />;StatusBadge (
@/components/StatusBadge)- Status indicators
- Color-coded (running, success, failed, etc.)
- Optional dot indicator
tsximport { StatusBadge } from '@/components'; <StatusBadge status="running" showDot> Running </StatusBadge>;ConfirmDialog (
@/components/ConfirmDialog)- Destructive actions (delete, cancel)
- Clear action + cancel
- Accessible
tsximport { ConfirmDialog } from '@/components'; <ConfirmDialog open={showDelete} onOpenChange={setShowDelete} title="Delete Device" description="Are you sure? This cannot be undone." variant="destructive" onConfirm={handleDelete} />;BulkActionToolbar (
@/components/BulkActionToolbar)
Appears above a table when rows are selected
Shows selected count, clear-selection control, and one or more bulk actions
Pair destructive actions with
ConfirmDialogtsximport { BulkActionToolbar } from '@/components'; <BulkActionToolbar selectedCount={selectedIds.size} itemLabel="device" onClearSelection={() => setSelectedIds(new Set())} actions={[{ label: 'Delete selected', variant: 'destructive', onClick: openConfirm }]} />;
PermissionDenied (
@/components/ui/permission-denied)- Standardized access-denied banner for gated pages
- Optional
messageprop for section-specific wording - Use instead of duplicating inline destructive alert markup in
hasRole()guards
tsximport { PermissionDenied } from '@/components/ui/permission-denied'; if (!hasRole('admin')) { return <PermissionDenied message="You do not have permission to manage API tokens." />; }Prefer the capability-driven guards where a page maps to an API route:
RequirePermission(@/auth) for whole pages andPermissionButtonfor controls. The Settings hub is the model: it is open to every role, and each tile names theGETroute its page loads, so a caller sees only the pages they may open in the active organization and is never led to a 403. A lockedPermissionButtonwords its tooltip from the route's policy and scope (both come from/auth/capabilities): an org-scoped route asks for the role "in this organization", a platform-scoped one (/settings/*,/orgs, …) for the platform-wide role, because organization roles never grant those.ErrorBanner (
@/components/ui/error-banner)
Standardized inline error banner for page and panel failures
Accepts a string
errorprop and renders an accessible destructive alertUse instead of duplicating AlertCircle + destructive border markup in pages
tsximport { ErrorBanner } from '@/components/ui/error-banner'; <ErrorBanner error="Failed to load configuration." />;
Detail Page Action Error Convention
Detail pages with destructive actions should follow this pattern:
- Wrap destructive mutations in
try/catch - Store local action error state (
actionError) - Render an inline destructive alert/banner near the page header
This avoids silent failures and keeps action feedback consistent across detail views.
TagBadge (
@/components/TagBadge)- Key=value tag display
- Subtle background differentiation
- Used in tables for tags columns
tsximport { TagBadge } from '@/components'; <TagBadge tagKey="env" value="production" />;TagList (
@/components/TagBadge)- Display multiple tags with truncation
- Shows "+N" overflow indicator
- Wraps TagBadge components
tsximport { TagList } from '@/components'; <TagList tags={{ env: 'prod', role: 'core' }} maxVisible={3} />;MultiSelect (
@/components/ui/multi-select)- Multi-value selection with search
- Uses Command palette (cmdk)
- Badge display for selected items
tsximport { MultiSelect } from '@/components/ui/multi-select'; <MultiSelect options={[{ value: 'dev-1', label: 'Device 1' }]} selected={selectedDevices} onChange={setSelectedDevices} placeholder="Select devices..." />;BooleanSegmentedControl (
@/components/ui/boolean-segmented-control)- True/False or Enabled/Disabled selections
- RadioGroup-based segmented control
- Preferred for explicit binary choices
tsximport { BooleanSegmentedControl } from '@/components/ui/boolean-segmented-control'; <BooleanSegmentedControl value={enabled} onValueChange={setEnabled} trueLabel="Enabled" falseLabel="Disabled" ariaLabel="Status" />;Checkbox (
@/components/ui/checkbox)- shadcn/Radix checkbox
- Accessible with label association
- Use for simple on/off acknowledgements
tsximport { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; <div className="flex items-center space-x-2"> <Checkbox id="enabled" checked={enabled} onCheckedChange={setEnabled} /> <Label htmlFor="enabled">Enable feature</Label> </div>;
When to Create a New Primitive
Before creating a new component, check:
- Does this pattern exist in 2+ pages? → Create primitive
- Is this a one-off? → Consider if it's really needed
- Can existing primitive be extended? → Prefer composition
Process:
- Create component in
src/components/ - Export from
src/components/index.ts - Document usage in this file
- Refactor existing pages to use it
No Bespoke CSS Rule
CRITICAL: Pages must NOT have their own CSS files.
❌ FORBIDDEN:
- Creating
MyPage.css - Adding
<style>tags - Inline styles (except for dynamic values)
- Custom CSS classes in pages
✅ ALLOWED:
- Tailwind utility classes
- shadcn component classes
- CSS variables for theming
- Dynamic inline styles (e.g.,
style={{ width: dynamicValue }})
Rationale
- Prevents style drift
- Ensures consistency
- Makes refactoring safe
- Reduces maintenance burden
Sanctioned exceptions
Two legacy stylesheets predate this rule and remain as documented exceptions. Both map theme tokens to local CSS variables and contain rendering-heavy styles that Tailwind utilities cannot express well:
apps/ui/src/pages/RunDetail.css— step card, event timeline, and artifact rendering for the run detail view.apps/ui/src/components/MonitorPanel.css— chart-specific overrides for the connectivity monitor visualizations.
Do not use these as precedent. New work in these areas should migrate styles to Tailwind/shadcn where practical and must not grow the files. Stylelint covers both files, and any new .css file under src/pages/ or src/components/ will be rejected in review.
Import/Export Modal
The Import/Export modal is a global feature:
Requirements:
- Uses shadcn
Dialogcomponent - Tabs for Import/Export sections
- Monospace editor for YAML
- Result summary after operation
- Accessible keyboard navigation
Rules:
- Preserve existing API behavior
- No backend changes
- YAML schema unchanged
- E2E tests must pass
Theme and Colors
Dark-First Theme
Hegemony uses a dark theme with oklch() color space:
- Dark background (
#131313) - Slightly lighter panels/headers (
#1b1b1b) - Subtle borders (
oklch(1 0 0 / 10%)- white at 10% opacity) - Strong accent color (
#ff543a- orange-red, used sparingly)
CSS Variables
Use these semantic tokens (defined in src/index.css using oklch() color space):
/* Dark theme (primary) */
.dark {
--background: #131313;
--foreground: oklch(0.985 0 0);
--card: #131313;
--card-header: #1b1b1b;
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--destructive: #ff543a;
--strong-accent: #ff543a;
}Rules:
- Never hardcode colors
- Use semantic tokens only
- Dark theme is primary (
.darkclass on root)
Navigation Labels (Stability Contract)
The following navigation labels are STABLE and must not change without updating E2E tests:
- Home
- Flows
- Runs
- Triggers (schedules and webhooks, as tabs on one page)
- Artifacts (submenu trigger; reveals Terraform States, Container Images and, for platform admins, Binary Artifacts on click)
- Inventory (submenu trigger; reveals Sites, Devices and Objects on click; the menu does not grow an entry per object type)
- Settings (every role; the hub filters its tiles by the caller's capabilities, see the PermissionDenied note above)
A submenu trigger is a button with aria-expanded and aria-controls, never a link; its items are compact links nested in a role="group". A submenu opens on its own when one of its pages is the current one, one is open at a time, and it stays as the user left it otherwise (navSubmenus in layout/AppShell.tsx declares them). The nav's height decides whether the sidebar is shown at all, so a submenu must stay short enough for a deep link into one of its pages to keep the sidebar on a 720px-tall display.
Schedules and webhooks are two ways to start the same flow, so they share one Triggers entry rather than two sidebar rows; the page tabs between them and the old /schedules and /webhooks URLs redirect there. Detail and edit pages keep their own routes.
Home is the landing route: the index route redirects / to /home, and the brand wordmark links there. Its sidebar entry needs no end: true flag — the path is /home, not /, so the default prefix match only ever fires on Home itself.
Home renders links that repeat sidebar labels (its Resources card links to Sites, Devices, and so on). Navigation-shape assertions in E2E tests must therefore be scoped to the navigation landmark rather than the whole page.
If you change navigation:
- Update E2E tests in same PR
- Update this document
- Notify team of navigation change
Help Affordance
The app shell mounts a Help trigger (a HelpCircle button labeled "Help") in its own sidebar row above the user-info footer, and icon-only in the mobile header. It opens a right-side sheet (components/help/HelpDrawer.tsx) showing route-aware documentation bundled into the build from docs/docs-map.yaml (the generated src/generated/help-manifest.ts module, lazy-loaded on first open). Keep the trigger reachable in every shell mode; new routes get help content by mapping docs in the docs-map, not by editing the drawer.
Organization Switcher
Users in more than one organization get a switcher (components/OrgSwitcher.tsx) in its own sidebar row under the brand. It must never read as part of the nav, which it sits next to:
- The trigger is a bordered selector (org badge, an "Organization" caption, the active org's name) rather than a ghost row like the nav links. It is
h-10in both sidebar modes so collapsing does not shift the nav below it. - Each organization is shown by a coloured initials badge, hashed from its id so the colour survives a rename, never by a nav-style icon.
- In the desktop sidebar the menu opens beside the sidebar (
menuSide="right", offset past the sidebar edge), not down over the nav links. The mobile drawer keeps it below the trigger. - The menu header stays pinned while a long list scrolls, capped to the room Radix reports, and the active organization is highlighted with a check rather than dimmed as disabled.
Testing Requirements
E2E Testing Rules
Smoke Tests (@smoke)
- Quick checks for deployment issues
- Verify navigation works
- Verify pages load
- Run before full suite
Page Tests
- Full CRUD operations
- Form validation
- Error handling
- Mock API responses
Locator Strategy
- Prefer
getByRole,getByLabel,getByText - Add
data-testidonly when role-based fails - Never use CSS selectors
- Prefer
When to Update E2E
- Navigation structure changes
- Form fields added/removed
- Page layouts significantly change
- New pages added
Command to verify:
task ui:e2e:smokeQuality Gates
All UI changes must pass:
Required Commands
Format + Lint + Typecheck:
bashtask ui:allBuild:
bashnpm --prefix apps/ui run buildE2E Smoke Tests:
bashtask ui:e2e:smokeFull Verification (for layout/component/routing changes):
bashtask ui:verify
CI Expectations
- All checks must pass
- Playwright E2E runs on PRs
- HTML report uploaded on failure
- No console errors in tests
Copilot Instructions Integration
This design system is enforced via .github/copilot-instructions.md:
Require shadcn + Tailwind usage
- No custom CSS files
- Use semantic tokens
Forbid new page CSS
- Pages compose primitives
- No
PageName.cssfiles
Require E2E updates
- Update tests when nav/routes change
- Verify smoke tests pass
Summary
This design system ensures:
- Consistency: All pages look and feel cohesive
- Maintainability: Shared components reduce duplication
- Predictability: Clear patterns for new features
- Quality: Enforced via Copilot instructions and CI
Golden Rule: If you need a new visual pattern, create a primitive, document it, and reuse it. Never style ad hoc inside a page.