29 KiB
DESIGN SYSTEM — BellSystems Control Panel v2
Single source of truth for all UI/UX decisions. Read before writing any page, component, or modal. Never override these rules inline. Change the rule here first, then propagate.
Live reference:
/dev/styleguide— every component, every variant, every state.
1. Core Philosophy
- Consistency over creativity. Every page must feel like it belongs to the same product.
- Tokens over hardcoded values. Never write a raw color, spacing value, or font size. Always
var(--token). - Components over repetition. If you write the same pattern twice, it becomes a shared component.
- Page layout is global. All pages share the same padding and spacing anchors. Content always starts at the same position.
- Accessible by default. ARIA labels, keyboard navigation, visible focus states on all interactive elements.
2. Page Layout Anatomy
Every authenticated page lives inside MainLayout, which provides:
┌─────────────┬────────────────────────────────────────────┐
│ │ HEADER (height: 56px / --header-height) │
│ │────────────────────────────────────────────┤
│ │ │
│ SIDEBAR │ CONTENT AREA │
│ (224px / │ ┌──────────────────────────────────────┐ │
│ --sidebar- │ │ .page-wrapper │ │
│ width) │ │ padding: 48px (--space-12) │ │
│ │ │ gap: 24px between sections │ │
│ │ │ │ │
│ │ │ <PageHeader> ← always first │ │
│ │ │ <content...> │ │
│ │ └──────────────────────────────────────┘ │
└─────────────┴────────────────────────────────────────────┘
The consistency guarantee
.page-wrapper is defined once in global.css. It is the only wrapper used on every page:
.page-wrapper {
flex: 1;
display: flex;
flex-direction: column;
padding: var(--space-12); /* 48px — desktop */
gap: var(--space-6); /* 24px between direct children */
min-width: 0;
}
/* Mobile: padding drops to --space-8 (32px), gap to --space-4 (16px) */
Rules:
- Every page's root element is
<div className="page-wrapper">— no exceptions - Never add extra padding, margin, or wrapper divs that shift content relative to
.page-wrapper - Never override
.page-wrapperpadding per-page - The
<PageHeader>is always the first child inside.page-wrapper - This ensures that on every page, the title starts at exactly 48px from the top-left corner of the content area
Content width modes
Every page falls into one of two modes:
1. Full-width (default)
Content expands to fill the entire available area. Use this for all data-heavy pages: lists, tables, dashboards, detail views.
<div className="page-wrapper">
{/* content fills the full content area */}
</div>
2. Centered (narrow content)
For pages with a small number of elements that would look lost spanning the full viewport — e.g. settings forms, auth pages, single-entity configuration screens.
<div className="page-wrapper page-wrapper--centered">
{/* every direct child is capped at --content-max-width-sm (640px) and centered */}
</div>
Default max-width is --content-max-width-sm (640px). Override per-page only when necessary:
<div
className="page-wrapper page-wrapper--centered"
style={{ '--page-content-max-width': 'var(--content-max-width-md)' }}
>
Available width tokens:
| Token | Value | Use |
|---|---|---|
--content-max-width-xs |
480px | Tiny forms, login, auth |
--content-max-width-sm |
640px | Small forms, simple settings (default) |
--content-max-width-md |
800px | Medium forms, detail-light pages |
--content-max-width-lg |
1024px | Moderate-width constrained pages |
Rules:
- Never use
page-wrapper--centeredon a list page, data table page, or any page where content should grow with the viewport - Never hardcode a pixel
max-widthin a page file — always use a token
3. Color Tokens
All colors are CSS custom properties defined in frontend/src/styles/tokens.css.
The system is dark-first: :root = dark theme. [data-theme="light"] overrides exist as a placeholder.
Rule: never write a raw color value in any component or page file. Always var(--token).
Background Surfaces (7-step tonal ladder)
| Token | Value | Use |
|---|---|---|
--color-bg-abyss |
#0a0e14 |
Deepest well: code blocks, input backgrounds |
--color-bg-base |
#10141a |
Page background (viewport fill) |
--color-bg-void |
#181c22 |
Sidebar, header |
--color-bg-surface |
#1c2026 |
Default card / panel background |
--color-bg-elevated |
#262a31 |
Raised cards, hovered rows, dropdowns |
--color-bg-island |
#31353c |
Active states, selected rows, pressed buttons |
--color-bg-float |
rgba(53,57,64,0.80) |
Glassmorphism: modals, floating panels |
Brand / Primary (Indigo Glow)
| Token | Value | Use |
|---|---|---|
--color-primary |
#c0c1ff |
CTAs, active nav, key accent |
--color-primary-hover |
#d2bbff |
Hover, gradient endpoint |
--color-primary-container |
#8083ff |
Container fills |
--color-primary-subtle |
rgba(128,131,255,0.12) |
Hover backgrounds, tinted areas |
--gradient-primary |
linear-gradient(135deg, #c0c1ff, #d2bbff) |
Primary button fill |
Semantic / State Colors
| Token | Value | Use |
|---|---|---|
--color-success |
#4ade80 |
Online, active, confirmed |
--color-success-bg |
rgba(74,222,128,0.12) |
Success badge / button resting background |
--color-warning |
#fbbf24 |
Pending, needs attention |
--color-warning-bg |
rgba(251,191,36,0.12) |
Warning badge / button resting background |
--color-danger |
#ff5c5c |
Error text, destructive actions |
--color-danger-bg |
rgba(255,92,92,0.12) |
Danger badge / button resting background |
--color-info |
#7bd0ff |
Informational, aqua-sky accent |
--color-info-bg |
rgba(123,208,255,0.12) |
Info badge background |
Text Colors (4-step hierarchy)
| Token | Value | Use |
|---|---|---|
--color-text-primary |
#dfe2eb |
Body copy, data values, headings |
--color-text-secondary |
#c7c4d7 |
Labels, metadata, inactive nav |
--color-text-muted |
#908fa0 |
Placeholders, disabled, category headers |
--color-text-inverse |
#10141a |
Text on primary/accent backgrounds (dark on light) |
--color-text-accent |
#c0c1ff |
Active nav items, links |
Borders
| Token | Value | Use |
|---|---|---|
--color-border |
rgba(70,69,84,0.20) |
Resting inputs, card outlines |
--color-border-strong |
rgba(70,69,84,0.45) |
Secondary buttons, stronger dividers |
--color-border-focus |
rgba(192,193,255,0.40) |
Focus ring halo on inputs |
4. Typography
Two-font system. Three families total.
Font Families
| Token | Font | Role |
|---|---|---|
--font-family-display |
Barlow Condensed |
H1, H2, page titles, modal titles |
--font-family-base |
Onest |
All UI text, body, labels, buttons, table rows |
--font-family-mono |
JetBrains Mono |
Serial numbers, IDs, code, API keys, terminal |
Why this pairing:
Barlow Condensedhas an industrial/engineering quality — feels like instrument panel labelling. Makes page titles immediately distinctive.Onestis a Ukrainian geometric grotesque with slightly unusual proportions and excellent numerics. Clean at 14px. Not the overused Inter/Space Grotesk.JetBrains Monois the standard for developer-facing data.
Font Sizes
| Token | Value | Use |
|---|---|---|
--font-size-xs |
0.6875rem (11px) |
Labels, sidebar category headers, chips |
--font-size-sm |
0.75rem (12px) |
Captions, helper text, table headers |
--font-size-base |
0.875rem (14px) |
Body text, table rows (default) |
--font-size-md |
1rem (16px) |
Card titles, module headers |
--font-size-lg |
1.125rem (18px) |
Section subheadings |
--font-size-xl |
1.5rem (24px) |
Page headings (h1/h2) |
--font-size-2xl |
3.5rem (56px) |
Hero KPI numbers, dashboard metrics |
Font Weights
| Token | Value | Use |
|---|---|---|
--font-weight-normal |
400 | Body copy |
--font-weight-medium |
500 | Emphasized body, table values |
--font-weight-semibold |
600 | Headings, button labels, field labels |
--font-weight-bold |
700 | Strong emphasis, hero metrics |
Typography Usage Rules
- Page titles (
<PageHeader>):Barlow Condensed,1.75rem, weight 600 (handled by.v2-page-header-title) - Modal titles:
Barlow Condensed,1.125rem, weight 600 (handled by.v2-modal-title) - H1, H2 globally:
Barlow Condensed,var(--font-size-xl), weight 600 — set inglobal.css - H3–H6:
Onest(body font), normal heading weights - Card titles:
Onest,--font-size-base, weight 600 - Table headers:
Onest,--font-size-sm, weight 600, uppercase,--tracking-wide - Body / cell text:
Onest,--font-size-base, weight 400 - Muted / helper text:
Onest,--font-size-sm,--color-text-muted - Serials, IDs, codes:
JetBrains Mono,--font-size-sm
Letter Spacing
| Token | Value | Use |
|---|---|---|
--tracking-normal |
0em |
Default |
--tracking-tight |
-0.01em |
Barlow Condensed headings |
--tracking-wide |
0.08em |
Uppercase labels, sidebar category headers |
--tracking-display |
-0.02em |
Hero KPI numbers at 56px |
4b. Date, Time & Currency Formatting
All dates use Greek/European style (day-first). Never use US-style MM/DD/YYYY anywhere in the app.
All formatting is centralized in frontend/src/lib/formatters.js. Never use raw toLocaleDateString(), Intl.DateTimeFormat, toLocaleString(), or toISOString().slice() in pages or modals — always import from @/lib/formatters.
Available formatters
| Function | Output example | Use for |
|---|---|---|
fmtDate |
05/03/2026 |
Short numeric dates (tables, lists) |
fmtDateMedium |
5 Mar 2026 |
Medium dates (cards, details) |
fmtDateLong |
5 March 2026 |
Long dates (headings, summaries) |
fmtDateFull |
Wednesday, 5 March 2026 |
Dashboard, full context |
fmtDateTime |
5 March 2026, 2:30 pm |
Date + 12h time |
fmtDateTimeMedium |
5 Mar 2026, 14:30 |
Date + 24h time (compact) |
fmtDateTimeFull |
Wed, 5 Mar 2026, 2:30 pm |
Emails, comms |
fmtRelative |
5 minutes ago |
Relative timestamps |
fmtEuro |
1.250,00 € |
Euro currency (Greek locale) |
Form input helpers
| Function | Output example | Use for |
|---|---|---|
toDatetimeLocal |
2026-03-05T14:30 |
Populating datetime-local inputs (local time) |
nowLocal |
2026-03-05T14:30 |
Current time for form defaults |
toDateInput |
2026-03-05 |
Populating date inputs |
Critical rule: no toISOString().slice() for form inputs
toISOString() converts to UTC, which shifts the time by the user's timezone offset (e.g. 3 hours for Greece). Always use toDatetimeLocal() or nowLocal() instead.
5. Spacing System
4px base unit. All spacing must use tokens — no arbitrary pixel values.
| Token | Value | Common use |
|---|---|---|
--space-1 |
4px | Tight gaps, icon padding |
--space-2 |
8px | Between label and input, inline gaps |
--space-3 |
12px | Table cell padding, compact button padding |
--space-4 |
16px | Between form fields, mobile page padding |
--space-5 |
20px | Tab item spacing |
--space-6 |
24px | Page padding, card padding, section gap |
--space-8 |
32px | Between major sections |
--space-10 |
40px | Large section gap |
--space-12 |
48px | Extra large spacing |
--space-16 |
64px | Maximum spacing, hero sections |
6. Border Radius & Shadows
Border Radius
| Token | Value | Use |
|---|---|---|
--radius-sm |
4px | Tags, small chips, select option rows |
--radius-md |
6px | Buttons, inputs, table badges |
--radius-lg |
8px | Cards, panels, dropdown menus |
--radius-xl |
12px | Modals, large containers |
--radius-full |
9999px | Status badge pills, avatars |
Shadows
| Token | Value | Use |
|---|---|---|
--shadow-card |
inset 0 1px 0 rgba(192,193,255,0.05) |
Card top-edge glass reflection |
--shadow-sm |
0 2px 8px rgba(10,14,20,0.40) |
Subtle lift |
--shadow-md |
0 4px 16px rgba(10,14,20,0.50) |
Elevated cards |
--shadow-lg |
0 8px 24px rgba(13,17,23,0.60) |
Modals, dropdowns |
--shadow-focus |
0 0 0 3px rgba(192,193,255,0.20) |
Focus ring glow |
--shadow-primary-glow |
0 4px 16px rgba(192,193,255,0.28) |
Primary button hover halo |
--shadow-danger-glow |
0 4px 16px rgba(255,92,92,0.40) |
Danger button hover halo |
--shadow-success-glow |
0 4px 16px rgba(74,222,128,0.35) |
Success button hover halo |
7. Component Rules
Button
Import: @/components/ui/Button
Variants:
| Variant | Resting state | Hover state |
|---|---|---|
primary |
Indigo→violet gradient, dark text | +brightness(1.06) + --shadow-primary-glow halo |
secondary |
Island bg, ghost border | Elevated bg + focus border + subtle indigo glow |
ghost |
Transparent | Elevated bg + whisper indigo glow |
danger |
Coral tint bg, coral text | Solid coral fill, dark text + --shadow-danger-glow |
success |
Emerald tint bg, emerald text | Solid emerald fill, dark text + --shadow-success-glow |
table-actions |
Fully transparent, muted text | Island bg + strong border (identical to secondary) — also activates on tr:hover |
Sizes: sm, md (default), lg
Rules:
- Never use a raw
<button>element for a styled action - Always pass
loadingprop for async actions (shows spinner, disables interaction) - Icon-only buttons must have
aria-label - Active/press state:
filter: brightness(0.94), glow removed
FormField
Import: @/components/ui/FormField
Wraps every form control: label + input/textarea/select + hint + error message.
Never place a raw <input> on a page.
Types: text, email, password, number, tel, url, textarea, select
For type="select": pass <option> elements as children. FormField uses the custom Select component internally — the native <select> is never rendered.
<FormField label="Status" name="status" type="select" value={val} onChange={handleChange}>
<option value="">Choose…</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</FormField>
Input appearance: "cutout" inset-shadow treatment — the field appears recessed into the surface. Background: --color-bg-abyss. Focus: --color-border-focus ring.
Select (standalone)
Import: @/components/ui/Select
Fully custom dropdown replacing native <select>. Floating menu via portal, keyboard navigation, checkmark on selected item. Usually consumed via FormField type="select". Use directly when you need a select outside a form label context.
DataTable
Import: @/components/ui/DataTable
Always include: column headers, loading skeleton, empty state, pagination.
Rows: alternate tint via --color-tint-row (rgba(192,193,255,0.015)). Hover: --color-bg-island.
Status columns: always <StatusBadge> — never raw text.
Row actions: last column, right-aligned, use portal-based action menu.
Modal
Import: @/components/ui/Modal
Sizes: sm (480px), md (640px — default), lg (800px), xl (60vw/60vh), xxl (85vw/85vh), full (calc(100vw/100vh − 64px)).
Rules:
- Always has: title, close (×) button, footer action buttons
- Closes on Escape + backdrop click unless
persistent={true} - Destructive prompts use
<ConfirmDialog>instead - Modal JSX never lives inside a page file — always in
frontend/src/modals/[domain]/
ConfirmDialog
Import: @/components/ui/ConfirmDialog
Wraps <Modal size="sm"> with a centred icon + message. Use for any action that is destructive or hard to reverse.
Variants: danger (coral circle + triangle icon), primary (indigo circle + info icon).
PageHeader
Import: @/components/ui/PageHeader
Always the first element inside .page-wrapper. Creates the page title block.
Props: title (required), subtitle, breadcrumbs, children (action buttons slot).
The title renders as <h1> with class .v2-page-header-title — uses Barlow Condensed at 1.75rem / weight 600.
<PageHeader title="Device Inventory" subtitle="All registered Bell units">
<Button variant="primary">Add Device</Button>
</PageHeader>
Card
Import: @/components/ui/Card
Variants: flat (default), elevated, outlined.
Props: title, subtitle, footer, padding (bool, default true), children.
Card header has a faint indigo gradient ceiling (linear-gradient from top).
Tabs
Import: @/components/ui/Tabs
Variants: line (default — underline indicator), pill (filled background).
Props: tabs (array of {key, label, icon?, count?}), active, onChange, variant.
Line variant uses a sliding indicator measured with useLayoutEffect. Pill variant uses filled backgrounds.
Spacing: line tabs have gap: --space-5 between items, pill tabs gap: --space-4.
Toast
Import: @/components/ui/Toast → { ToastProvider, useToast }
Setup: wrap the app (or router) with <ToastProvider>. Then in any component:
const toast = useToast()
toast.success('Saved', 'Device updated successfully.')
toast.danger('Error', 'Failed to connect.')
toast.warning('Warning', 'Firmware is outdated.')
toast.info('Info', 'Sync in progress.')
Toasts auto-dismiss after 4000ms. Hover pauses the timer. Stack appears in the bottom-right corner.
SearchBar
Import: @/components/ui/SearchBar
Supports controlled (value + onChange) or uncontrolled mode.
Debounced by default (300ms). Clear button appears when text is present.
Appearance matches the FormField cutout treatment.
Breadcrumbs
Import: @/components/ui/Breadcrumbs
Use on detail pages only (not list pages). Items: array of { label, href? }. Last item has no href — it is the current page.
Spinner
Import: @/components/ui/Spinner
Props: size (sm, md, lg), color (defaults to --color-primary).
Use inside loading states. Buttons show their own spinner via loading prop — do not add a separate <Spinner> inside buttons.
StatusBadge
Import: @/components/ui/StatusBadge
Never use a raw <span> with a background color for status. Always <StatusBadge>.
Variants: success, warning, danger, info, neutral.
Icon
Import: @/components/ui/Icon
Renders an inline SVG by name. 35 named icons available (see Style Guide /dev/styleguide → Icon section for the full list).
<Icon name="edit" size={16} />
<Icon name="delete" size={20} color="var(--color-danger)" />
Asset SVGs (from /assets/ folders) are displayed via <img> tags in the Style Guide, not via <Icon>. These are pre-rendered SVG files used for sidebar icons, comms icons, customer status icons, etc. Use them as image sources, not as Icon component names.
8. Icons
Three sources:
| Source | Use case | How to render |
|---|---|---|
<Icon name="..." /> |
Action icons, UI chrome | @/components/ui/Icon |
assets/side-menu-icons/*.svg |
Sidebar navigation | <img src={...} /> |
assets/comms/*.svg |
Communication type indicators | <img src={...} /> |
assets/customer-status/*.svg |
CRM status icons | <img src={...} /> |
assets/global-icons/*.svg |
Legacy action icons (prefer <Icon>) |
<img src={...} /> |
assets/other-icons/*.svg |
Misc UI icons | <img src={...} /> |
Never add a new icon library (e.g. heroicons, lucide). Use the existing sources.
9. Theming Rules
- Theme is controlled by
data-themeattribute on<html> - Default is dark (
:root= dark theme) - Never use Tailwind's
dark:prefix — theming is handled entirely via CSS tokens [data-theme="light"]overrides exist intokens.cssas a future placeholder
10. Responsive Breakpoints
| Token | Value | Behaviour |
|---|---|---|
--breakpoint-sm |
640px | |
--breakpoint-md |
768px | Sidebar collapses; page padding drops to --space-4 |
--breakpoint-lg |
1024px | Full sidebar shown |
--breakpoint-xl |
1280px |
Mobile (< 768px): single column, sidebar hidden (drawer), tables may become card lists.
11. Section Layout — Masonry Grid
Default layout for ALL content pages with multiple variable-height sections.
Sections on a content page must flow like physical objects stacked in columns — the next section always drops into the shortest column. This is CSS column masonry.
How it works
Column 1 | Column 2 | Column 3
────────────┼─────────────┼────────────
Section A | Section B | Section C
(300px) | (250px) | (350px)
│ │
Section E | Section D |
(200px) | (200px) |
Sections fill left-to-right across the top, then each new section drops into whichever column is currently shortest. This is automatic — the browser handles placement via CSS columns.
Usage
{/* 2 columns */}
<div className="masonry-grid masonry-grid--2">
<Card title="Account Info">…</Card>
<Card title="Profile">…</Card>
<Card title="Security">…</Card> {/* auto-drops into shortest column */}
</div>
{/* 3 columns */}
<div className="masonry-grid masonry-grid--3">
{sections.map(s => <Card key={s.id}>…</Card>)}
</div>
Available variants: masonry-grid--2, masonry-grid--3, masonry-grid--4
Responsive behaviour:
--4collapses to 3 cols at 1024px, 1 col at 768px--3collapses to 2 cols at 1024px, 1 col at 768px--2collapses to 1 col at 768px
Rules
- Use
.masonry-gridby default on all content pages with 2+ variable-height sections - Do NOT use
display: gridwithgridTemplateColumnsfor variable-height card layouts — this creates uneven whitespace when cards differ in height - Do NOT use
.masonry-gridfor DataTable pages — tables span full width on their own - Do NOT use
.masonry-gridwhen sections must align horizontally (e.g. two fields that are semantically paired side-by-side within a card) — that's an internal card layout, not page-level masonry - The
Cardcomponent already hasbreak-inside: avoidso it will never be split across columns
12. What Claude Code Must NEVER Do
- ❌ Write a hex color,
rgb(), orhsl()value directly in any component or page file - ❌ Write a pixel spacing or size value that isn't a
--space-*token - ❌ Use a raw
<button>,<input>, or<select>for anything styled — always use the wrapper component - ❌ Create a
.module.cssor any per-page CSS file - ❌ Use Tailwind's
dark:prefix — theming is via CSS tokens only - ❌ Place modal JSX inside a page file — modals live in
frontend/src/modals/ - ❌ Wrap
.page-wrapperin additional divs that shift content alignment - ❌ Override
.page-wrapper's padding to make a single page "different" - ❌ Skip loading, error, and empty states on any data-fetching component
- ❌ Import from
_archive/anywhere except@/lib/api.js,@/hooks/useAuth.js, and@/providers/AuthProvider.jsx - ❌ Install a new icon library or introduce new SVG icons outside of
assets/ - ❌ Invent new color values not in
tokens.css