Files
bellsystems-cp/DESIGN.md

29 KiB
Raw Permalink Blame History

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-wrapper padding 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--centered on a list page, data table page, or any page where content should grow with the viewport
  • Never hardcode a pixel max-width in 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 Condensed has an industrial/engineering quality — feels like instrument panel labelling. Makes page titles immediately distinctive.
  • Onest is a Ukrainian geometric grotesque with slightly unusual proportions and excellent numerics. Clean at 14px. Not the overused Inter/Space Grotesk.
  • JetBrains Mono is 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 in global.css
  • H3H6: 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 loading prop 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.


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-theme attribute 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 in tokens.css as 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:

  • --4 collapses to 3 cols at 1024px, 1 col at 768px
  • --3 collapses to 2 cols at 1024px, 1 col at 768px
  • --2 collapses to 1 col at 768px

Rules

  • Use .masonry-grid by default on all content pages with 2+ variable-height sections
  • Do NOT use display: grid with gridTemplateColumns for variable-height card layouts — this creates uneven whitespace when cards differ in height
  • Do NOT use .masonry-grid for DataTable pages — tables span full width on their own
  • Do NOT use .masonry-grid when 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 Card component already has break-inside: avoid so it will never be split across columns

12. What Claude Code Must NEVER Do

  • Write a hex color, rgb(), or hsl() 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.css or 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-wrapper in 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