Prometheus Design System v0.2.0 β€” WIP

Action Items

Concrete tasks to bring the Prometheus UI into compliance with the design system and improve visual polish.

Compliance Tasks

These changes are required to make the Prometheus UI compatible with the design system. Each item addresses a concrete inconsistency found during the codebase audit.

#1
Replace hardcoded header colors with design tokens
inconsistencytokens easy

The header uses inline bg="rgb(65, 73, 81)" and c="#fff" for both AppShell.Header and AppShell.Navbar. These hardcoded values do not adapt to the user's light/dark mode preference β€” the header is permanently dark regardless of theme. Replace with CSS custom properties from the design system.

Before / After

Before β€” hardcoded, theme-blind
Prometheus
Page content β€” always dark header
After β€” adapts to theme via tokens
Prometheus
Page content β€” header adapts to theme

Code Diff

src/App.tsx
- <AppShell.Header bg="rgb(65, 73, 81)" c="#fff"> + <AppShell.Header bg="var(--prom-header-bg)" c="var(--prom-header-text)"> - <AppShell.Navbar bg="rgb(65, 73, 81)" c="#fff"> + <AppShell.Navbar bg="var(--prom-header-bg)" c="var(--prom-header-text)"> - <Anchor style={{ color: "white" }} ...> + <Anchor c="var(--prom-header-text)" ...>
Files changed: src/App.tsx
#2
Unify health status badges with StatusBadge
inconsistencycomponents medium

Three separate health badge implementations exist across the codebase, each rendering differently: AlertsPage uses Badge.module.css classes, RulesPage uses a Mantine color prop, and ScrapePoolsList uses a conditional color prop. Replace all three with the canonical <StatusBadge> component.

Before / After

Before β€” 3 different implementations
AlertsPage: firing pending
RulesPage: ok err
Targets: up down
After β€” choose approach

Use Mantine <Badge color="red"> prop directly. Simpler API but uses Mantine's default shade (step-6 filled), less control over dark mode colors.

ok err pending

Conditional color prop: color={health === "up" ? "green" : "red"}. Minimal code but no nuance for warning/unknown states, no dark mode optimization.

up down

Code Diff

src/pages/AlertsPage.tsx
- <Badge className={badgeClasses.healthErr}>firing</Badge> + <StatusBadge status="firing" /> - import badgeClasses from "./Badge.module.css"; + import { StatusBadge } from "../components/StatusBadge";
State vocabulary normalization
<StatusBadge> internally maps all three vocabularies (firing/pending/inactive, ok/err/unknown, up/down/unknown) to canonical health states via a single centralized lookup.
Files changed: src/pages/AlertsPage.tsx, src/pages/RulesPage.tsx, src/pages/targets/ScrapePoolsList.tsx
#3
Unify panel borders with HealthPanel
inconsistencycomponents medium

AlertsPage uses Panel.module.css classes with step-3 colors for left-border health indicators. RulesPage uses inline styles with step-4 colors β€” the same visual metaphor implemented twice with different shades. Replace both with <HealthPanel> and delete the now-redundant CSS modules.

Before / After

Before β€” different border shades per page
node_cpu_seconds (RulesPage, green-4)
ok Β· inline style
disk_read_errors (AlertsPage, red-3)
err Β· CSS module
After β€” choose color steps

Step-4 (light) / step-7 (dark) from RulesPage. Bolder borders, more prominent. Uses inline styles.

node_cpu_seconds (green-4)
ok Β· inline style
disk_read_errors (red-4)
err Β· inline style

Code Diff

src/pages/AlertsPage.tsx
- <Accordion.Item className={panelClasses.panelHealthErr}> + <HealthPanel status="err"> - import panelClasses from "./Panel.module.css"; + import { HealthPanel } from "../components/HealthPanel";
Files changed: src/pages/AlertsPage.tsx, src/pages/RulesPage.tsx, src/Badge.module.css (delete), src/Panel.module.css (delete)
#4
Extract shared FilterToolbar component
inconsistencycomponents medium

Four pages (AlertsPage, RulesPage, TargetsPage, ServiceDiscoveryPage) each copy-paste the same filter bar pattern: Group + StateMultiSelect + TextInput + optional ActionIcon. Extract into a shared <FilterToolbar> component with an optional showCollapseAll prop.

Before / After

Before β€” duplicated per page
All states β–Ύ
πŸ” Search alerts…
Repeated identically in AlertsPage, RulesPage, TargetsPage, ServiceDiscoveryPage
After β€” choose approach

Keep the 4 separate implementations but normalize them to use the same spacing, sizing, and state classes. Less coupling, but duplication persists.

All states β–Ύ
πŸ” Search alerts…
Γ— 4 pages, each slightly different

Code Diff

src/pages/targets/TargetsPage.tsx
- <Group> - <StateMultiSelect options={...} onChange={setHealthFilter} /> - <TextInput flex={1} leftSection={<IconSearch />} onChange={setSearchFilter} /> - <ActionIcon onClick={collapseAll}><IconLayoutNavbarCollapse /></ActionIcon> - </Group> + <FilterToolbar + options={healthOptions} + onFilterChange={setHealthFilter} + onSearchChange={setSearchFilter} + showCollapseAll + onCollapseAll={collapseAll} + />
Files changed: src/pages/AlertsPage.tsx, src/pages/RulesPage.tsx, src/pages/targets/TargetsPage.tsx, src/pages/service-discovery/ServiceDiscoveryPage.tsx
#5
Fix localStorage key collision between AlertsPage and RulesPage
inconsistencyfix easy

Both AlertsPage and RulesPage use "alertsPage.showEmptyGroups" as the localStorage key for the "show empty groups" toggle. This means toggling the option on one page accidentally affects the other page β€” a real functional bug. The fix is a one-line change: rename RulesPage's key to "rulesPage.showEmptyGroups".

Migration note
Existing users will lose their RulesPage toggle preference on upgrade. The default value (false) is the correct initial state, so this side effect is acceptable.

Code Diff

src/pages/RulesPage.tsx
const [showEmptyGroups, setShowEmptyGroups] = useLocalStorage({ - key: "alertsPage.showEmptyGroups", + key: "rulesPage.showEmptyGroups", defaultValue: false, });
Files changed: src/pages/RulesPage.tsx
#6
Fix SeriesName dark mode hover colors
inconsistencyfix easy

The label pair hover state in SeriesName uses hardcoded light-mode-only colors: #add6ffa0 (a semi-transparent light blue) and #495057 (dark gray text). In dark mode these produce a washed-out, low-contrast highlight. Switch to light-dark() to support both themes properly while preserving the exact light-mode appearance.

Code Diff

src/pages/query/SeriesName.module.css
.labelPair:hover { - background: #add6ffa0; - color: #495057; + background-color: light-dark(rgba(173, 214, 255, 0.63), rgba(100, 149, 200, 0.3)); + color: light-dark(#495057, var(--mantine-color-gray-3)); border-radius: 3px; }
Files changed: src/pages/query/SeriesName.module.css
#7
Fix Prometheus logo text visibility gap between sm and md breakpoints
inconsistencyfix easy

The "Prometheus" logo text is rendered by two separate Text elements: one with hiddenFrom="sm" and one with visibleFrom="md". Between breakpoints sm and md, neither text element is visible β€” the brand name disappears from the header. Replace with a single element that avoids this gap.

Code Diff

src/App.tsx
- <Text hiddenFrom="sm" fz={20}>Prometheus</Text> - <Text visibleFrom="md" fz={20}>Prometheus</Text> + <Text + fz="var(--prom-logo-font-size)" + visibleFrom="sm" + > + Prometheus + </Text>
Files changed: src/App.tsx
#8
Replace hardcoded spacing values with Mantine spacing tokens
inconsistencytokens easy

The header in App.tsx uses numeric pixel values for gap props: gap={40} for the logo-to-nav spacing, gap={12} for the nav links row, and gap={10} for the logo group. These should use Mantine's semantic spacing tokens so they benefit from the theme's spacing scale and scale consistently.

Code Diff

src/App.tsx
{/* Logo + nav group */} - <Group gap={40}> + <Group gap="xl"> {/* Nav links row */} - <Group gap={12}> + <Group gap="sm"> {/* Logo brand group */} - <Group gap={10}> + <Group gap="xs">
Files changed: src/App.tsx
#9
Convert logo font size from unitless px to rem token
inconsistencytokens easy

The Prometheus logo text uses fz={20} β€” a bare number which Mantine interprets as a pixel value. This bypasses user font-size preferences (e.g. browser zoom or accessibility font scale). Define a --prom-logo-font-size token at 1.25rem (equivalent at 16px root) and reference it.

Code Diff

src/App.tsx
- <Text fz={20}>Prometheus</Text> + <Text fz="var(--prom-logo-font-size)">Prometheus</Text>
src/App.module.css (add token)
+ :root { + --prom-logo-font-size: 1.25rem; + }
Files changed: src/App.tsx, src/App.module.css
#10
Replace inline icon style objects with shared constants
inconsistencytokens easy

The icon style { width: "0.9rem", height: "0.9rem" } is defined locally in at least 3 files (QueryPanel, RangeInput, TimeInput). The source code even has a // TODO: This is duplicated everywhere, unify it. comment acknowledging this. The existing styles.ts already exports icon size constants β€” add queryIconStyle to it and import from there.

Code Diff

src/styles.ts
export const menuIconStyle = { width: rem(14), height: rem(14) }; + export const queryIconStyle = { width: "0.9rem", height: "0.9rem" };
src/pages/targets/TargetsPage.tsx
- const iconStyle = { width: "0.9rem", height: "0.9rem" }; - // TODO: This is duplicated everywhere, unify it. + import { queryIconStyle } from "../../styles"; ... - style={iconStyle} + style={queryIconStyle}
Files changed: src/pages/targets/TargetsPage.tsx, src/pages/service-discovery/ServiceDiscoveryPage.tsx, src/pages/query/QueryPanel.tsx, src/styles.ts

Polish Proposals

Small, non-controversial improvements from a principal UI designer perspective. These do not change the overall look dramatically β€” each makes the UI feel more intentional and professional. All are low-risk and easy to review.

#11
Add subtle border-bottom to the header for visual separation
polish easy

The header floats directly above the content area with no visual separator. On light backgrounds this creates an ambiguous boundary β€” especially once PR #1 removes the dark hardcoded background. Adding a 1px border-bottom at rgba(0,0,0,0.12) creates a clean, subtle edge without adding visual weight.

Before / After

Before β€” no border, ambiguous edge
Prometheus
Content bleeds into header visually
After β€” subtle 1px separator
Prometheus
Clean visual boundary

Code Diff

src/App.module.css
+ .header { + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + } + + [data-mantine-color-scheme="dark"] .header { + border-bottom-color: rgba(255, 255, 255, 0.08); + }
Files changed: src/App.module.css
#12
Improve empty state presentation on Alerts page
polish easy

When no alert rules are found, the Alerts page shows a bare Mantine Alert component with the text "No rules found." This is functional but sparse. A centered empty state with an icon and brief helpful message communicates intent more clearly and feels more intentional.

Before / After

Before β€” bare info alert
β„Ή
No results
No rules found.
After β€” proper empty state
πŸ””
No matching alerts
No alert rules match the current filter.
Try adjusting your search or state filter.

Code Diff

src/pages/AlertsPage.tsx
- <Alert color="blue">No rules found.</Alert> + <EmptyState + icon={<IconBell size={40} stroke={1.2} />} + title="No matching alerts" + description="No alert rules match the current filter. Try adjusting your search or state filter." + />
Files changed: src/pages/AlertsPage.tsx
#13
Increase info page card title font weight from 600 to 700
polish easy

The card titles on TSDB Status, Runtime Info, and other info pages use fw={600}. At typical body sizes, fw={600} creates insufficient contrast against body text β€” the title doesn't read as clearly "heavier." Bumping to fw={700} sharpens the hierarchy without changing the visual style.

Code Diff

src/components/InfoPageCard.tsx
<Group mb="xs"> {icon && <ThemeIcon ...></ThemeIcon>} - <Text fz="xl" fw={600}>{title}</Text> + <Text fz="xl" fw={700}>{title}</Text> </Group>
Files changed: src/components/InfoPageCard.tsx
#14
Add row hover highlight to the Flags table
polish easy

The Flags page table has no hover state β€” rows feel static and it's hard to track your position when scanning. Adding a subtle background highlight on hover makes the table feel interactive and improves scanability, particularly for the many rows a typical Prometheus flags table contains.

Code Diff

src/pages/FlagsPage.module.css (create)
+ .flagsTable tbody tr:hover td { + background: light-dark( + var(--mantine-color-gray-0), + var(--mantine-color-dark-5) + ); + }
src/pages/FlagsPage.tsx
+ import classes from "./FlagsPage.module.css"; ... - <Table> + <Table className={classes.flagsTable}>
Files changed: src/pages/FlagsPage.module.css (create), src/pages/FlagsPage.tsx
#15
Refine settings panel layout with consistent section spacing
polish easy

The settings popover uses Mantine Fieldset components but with mixed padding and no visual separator between the Query Settings and Alerts Settings columns. Standardizing padding to p="sm" across all fieldsets and adding a subtle column divider brings the panel into visual harmony.

Before / After

Before β€” mixed padding
Query settings
Enable autocomplete
βœ“
Enable linting
βœ“
Alerts settings
Show annotations
 
After β€” consistent spacing + divider
Query settings
Enable autocomplete
βœ“
Enable linting
βœ“
Alerts settings
Show annotations
 

Code Diff

src/components/SettingsMenu.tsx
- <Fieldset legend="Query settings"> + <Fieldset legend="Query settings" p="sm"> ... + <Divider my="sm" /> - <Fieldset legend="Alerts settings"> + <Fieldset legend="Alerts settings" p="sm">
Files changed: src/components/SettingsMenu.tsx
#16
Reduce label badge visual noise with softer background
polish easy

Label badges on the Targets and Service Discovery pages use light-dark(#eee, #3a3a3a) backgrounds. When many badges are visible simultaneously (as is common on dense target pages), the relatively strong backgrounds compete with the target metadata. Softening to Mantine's canonical gray-1/dark-6 equivalents reduces visual noise.

Before / After

Before β€” stronger backgrounds
__scheme__="http" instance="localhost:9090" job="prometheus" __metrics_path__="/metrics" cluster="prod-us-east-1"
After β€” softer, less competing backgrounds
__scheme__="http" instance="localhost:9090" job="prometheus" __metrics_path__="/metrics" cluster="prod-us-east-1"

Code Diff

src/pages/targets/ScrapePoolsList.module.css
.labelBadge { - background: light-dark(#eee, #3a3a3a); + background: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-6) + ); }
Files changed: src/pages/targets/ScrapePoolsList.module.css
#17
Add keyboard navigation support to the TreeNode component
a11y medium

The TreeNode component used for displaying PromQL AST relabeling rules has interactive elements that only respond to mouse clicks β€” there is no keyboard support. Users who navigate by keyboard (or assistive technology) cannot expand or collapse tree nodes. Add tabIndex={0}, role="treeitem", and Enter/Space key handlers.

Code Diff

src/components/TreeNode.tsx
<Box className={classes.nodeText} - onClick={handleToggle} + onClick={handleToggle} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleToggle(); + } + }} + role="treeitem" + aria-expanded={isExpanded} + tabIndex={0} >
Files changed: src/components/TreeNode.tsx
#18
Standardize loading skeleton widths across pages
polish easy

The global Suspense fallback in App.tsx uses width={1000} for skeleton lines. On narrower viewports (tablets, small laptops) this causes the skeleton to overflow the visible area horizontally, producing an ugly scroll during the loading state. Switch to width="100%" with maw={1000} so skeletons are bounded by the container but never wider.

Code Diff

src/App.tsx
{Array.from(Array(20), (_, i) => ( - <Skeleton key={i} width={1000} height={40} mb={15} /> + <Skeleton key={i} width="100%" maw={1000} height={40} mb={15} /> ))}
Files changed: src/App.tsx

Migration Guide

How to adopt the Prometheus Design System incrementally. These phases can be followed in order, each building on the previous.

Incremental Adoption

The design system can be adopted incrementally. You don't need to rewrite everything at once. Follow these phases in order, each building on the previous.

Phase 1: Design Tokens

Effort: Low β€” Replace hardcoded values with CSS variables and TS constants.

Before / After: Header Colors

// Before β€” hardcoded
<AppShell.Header style={{ background: "rgb(65, 73, 81)" }}>
  <Text c="#fff">Prometheus</Text>

// After β€” tokens
<AppShell.Header style={{ background: "var(--prom-header-bg)" }}>
  <Text c="var(--prom-header-text)">Prometheus</Text>

Before / After: Font Sizes

// Before β€” unitless number
<Text fz={20}>Prometheus</Text>  // won't scale with user font prefs

// After β€” rem-scaled
<Text style={{ fontSize: "var(--prom-logo-font-size)" }}>Prometheus</Text>

Before / After: Spacing

// Before β€” hardcoded numbers
<Group gap={40}>  // hardcoded pixel value
<Group gap={12}>  // hardcoded pixel value

// After β€” named tokens
<Group gap="xl">   // 32px β€” semantic
<Group gap="sm">   // 12px β€” semantic

Phase 2: Shared Components

Effort: Medium β€” Replace the three highest-impact inconsistencies.

StatusBadge (replaces 3+ implementations)

// Before β€” AlertsPage.tsx
import badgeClasses from "../../Badge.module.css";
<Badge className={badgeClasses.healthErr}>firing</Badge>

// Before β€” RulesPage.tsx (different approach!)
<Badge color="red">err</Badge>

// Before β€” ScrapePoolsList.tsx (yet another approach!)
<Badge color={target.health === "up" ? "green" : "red"}>
  {target.health}
</Badge>

// After β€” unified
<StatusBadge status="firing" />
<StatusBadge status="err" />
<StatusBadge status={target.health} />

HealthPanel (replaces 2 inconsistent implementations)

// Before β€” AlertsPage (CSS modules, step-3 colors)
<Accordion.Item className={panelClasses[`panelHealth\${capitalize(health)}`]}>

// Before β€” RulesPage (inline styles, step-4 colors β€” different!)
<Accordion.Item style={{ borderLeft: `5px solid var(--mantine-color-red-4)` }}>

// After β€” consistent step-3/step-8 colors
<HealthPanel value={name} status={health}>...</HealthPanel>

FilterToolbar (replaces 4 separate implementations)

// Before β€” copy-pasted Group + StateMultiSelect + TextInput in 4 files

// After
<FilterToolbar
  searchValue={search}
  onSearchChange={setSearch}
  stateOptions={["firing", "pending", "inactive"]}
  selectedStates={states}
  onStatesChange={setStates}
/>

Phase 3: Domain Components

Effort: Medium β€” Adopt remaining components.

Phase 4: Layout Standardization

Effort: Low-Medium β€” Adopt layout components.

Component Mapping

Source ComponentDesign System Component
InfoPageCardInfoCard
InfoPageStackInfoPageLayout
LabelBadgesLabelBadgeSet
ThemeSelectorThemeToggle
SettingsMenuSettingsPanel
ScrapePoolList (accordion)PoolAccordion
Panel.module.css classesHealthPanel
Badge.module.css health classesStatusBadge
Inline border styles (RulesPage)HealthPanel

CSS Variable Migration

Hardcoded ValueCSS Variable
rgb(65, 73, 81)var(--prom-header-bg)
#fff (header text)var(--prom-header-text)
"DejaVu Sans Mono"var(--prom-font-mono)
1000px (info page max)var(--prom-info-page-max-width)
5px solid ... (panel border)var(--prom-panel-border-width) solid var(--prom-panel-border-ok)
fz={20} (logo)var(--prom-logo-font-size)

Bug Fix: localStorage Key Collision

Breaking Change

Both AlertsPage and RulesPage used "alertsPage.showEmptyGroups" as the localStorage key. RulesPage now correctly uses "rulesPage.showEmptyGroups".

Existing users will lose their RulesPage toggle state on upgrade, but the default value (false) is the correct initial state.