feat: add ButtonGroup, theme toggle, dark theme fixes, remove shift references
Some checks failed
Build & Publish / publish (push) Failing after 45s

- Add ButtonGroup primitive: multi-select toggle with colored dot indicators
- Replace FilterPill status filters with ButtonGroup in TopBar and EventFeed
- Add light/dark mode toggle to TopBar (moon/sun icon)
- Fix dark theme: add --purple/--purple-bg tokens, replace all hardcoded
  #F3EEFA/#7C3AED with tokens, fix --amber-light text contrast in sidebar,
  brighten --sidebar-text/--sidebar-muted tokens, use color-mix for
  ProcessorTimeline bar fills
- Remove all "shift" references (presets, labels, badges)
- Shrink SegmentedTabs height to match search bar and ButtonGroup
- Update COMPONENT_GUIDE.md with new components and updated descriptions
- Add ButtonGroup demo to Inventory
- Add README.md with setup instructions and navigation guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-19 16:33:34 +01:00
parent 5bd965e59a
commit 91788737b0
21 changed files with 361 additions and 158 deletions

View File

@@ -55,7 +55,8 @@
- Categorical comparison → **BarChart** - Categorical comparison → **BarChart**
- Inline trend → **Sparkline** - Inline trend → **Sparkline**
- Event log → **EventFeed** - Event log → **EventFeed**
- Processing pipeline → **ProcessorTimeline** - Processing pipeline (Gantt view)**ProcessorTimeline**
- Processing pipeline (flow diagram) → **RouteFlow**
### "I need to organize content" ### "I need to organize content"
- Collapsible sections (standalone) → **Collapsible** - Collapsible sections (standalone) → **Collapsible**
@@ -77,11 +78,13 @@
- Single user avatar → **Avatar** - Single user avatar → **Avatar**
- Stacked user avatars → **AvatarGroup** - Stacked user avatars → **AvatarGroup**
### "I need to group buttons" ### "I need to group buttons or toggle selections"
- Connected button strip (toggle group, segmented control)**ButtonGroup** (horizontal or vertical) - Multi-select toggle group with colored indicators**ButtonGroup** (e.g., status filters)
- Tab switching with pill/segment style → **SegmentedTabs**
### "I need filtering" ### "I need filtering"
- Filter pill/chip → **FilterPill** - Multi-select status/category filter → **ButtonGroup** (toggle items on/off)
- Filter pill/chip → **FilterPill** (individual toggleable pills)
- Full filter bar with search → **FilterBar** - Full filter bar with search → **FilterBar**
- Select multiple from a list → **MultiSelect** - Select multiple from a list → **MultiSelect**
@@ -114,9 +117,11 @@ Below: charts (AreaChart, LineChart, BarChart)
### Detail/inspector pattern ### Detail/inspector pattern
``` ```
DetailPanel (right slide) with Tabs for sections DetailPanel (right slide) with Tabs for sections OR children for scrollable content
Each tab: Cards with data, CodeBlock for payloads, Tabbed: use tabs prop for multiple panels
ProcessorTimeline for exchange flow Scrollable: use children for stacked sections (overview, errors, route flow, timeline)
Each section: Cards with data, CodeBlock for payloads,
ProcessorTimeline or RouteFlow for exchange flow
``` ```
### Feedback flow ### Feedback flow
@@ -156,7 +161,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| BarChart | composite | Categorical data comparison, optional stacking | | BarChart | composite | Categorical data comparison, optional stacking |
| Breadcrumb | composite | Navigation path showing current location | | Breadcrumb | composite | Navigation path showing current location |
| Button | primitive | Action trigger (primary, secondary, danger, ghost) | | Button | primitive | Action trigger (primary, secondary, danger, ghost) |
| ButtonGroup | primitive | Groups buttons into a connected strip with shared borders (horizontal/vertical) | | ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange |
| Card | primitive | Content container with optional accent border | | Card | primitive | Content container with optional accent border |
| Checkbox | primitive | Boolean input with label | | Checkbox | primitive | Boolean input with label |
| CodeBlock | primitive | Syntax-highlighted code/JSON display | | CodeBlock | primitive | Syntax-highlighted code/JSON display |
@@ -166,7 +171,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius | | DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
| DateRangePicker | primitive | Date range selection with presets | | DateRangePicker | primitive | Date range selection with presets |
| DateTimePicker | primitive | Single date/time input | | DateTimePicker | primitive | Single date/time input |
| DetailPanel | composite | Slide-in side panel with tabs | | DetailPanel | composite | Slide-in side panel with tabs or children for scrollable content |
| Dropdown | composite | Action menu triggered by any element | | Dropdown | composite | Action menu triggered by any element |
| EmptyState | primitive | Placeholder for empty content areas | | EmptyState | primitive | Placeholder for empty content areas |
| EventFeed | composite | Chronological event log with severity | | EventFeed | composite | Chronological event log with severity |
@@ -186,7 +191,8 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| MonoText | primitive | Inline monospace text (xs, sm, md) | | MonoText | primitive | Inline monospace text (xs, sm, md) |
| Pagination | primitive | Page navigation controls | | Pagination | primitive | Page navigation controls |
| Popover | composite | Click-triggered floating panel with arrow | | Popover | composite | Click-triggered floating panel with arrow |
| ProcessorTimeline | composite | Pipeline exchange visualization | | ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows. Props: processors, totalMs, onProcessorClick?, selectedIndex? |
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, and click support. Props: nodes, onNodeClick?, selectedIndex? |
| ProgressBar | primitive | Determinate/indeterminate progress indicator | | ProgressBar | primitive | Determinate/indeterminate progress indicator |
| RadioGroup | primitive | Single-select option group (use with RadioItem) | | RadioGroup | primitive | Single-select option group (use with RadioItem) |
| RadioItem | primitive | Individual radio option within RadioGroup | | RadioItem | primitive | Individual radio option within RadioGroup |
@@ -212,8 +218,8 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| Component | Purpose | | Component | Purpose |
|-----------|---------| |-----------|---------|
| AppShell | Page shell: sidebar + topbar + main + optional detail panel | | AppShell | Page shell: sidebar + topbar + main + optional detail panel |
| Sidebar | Hierarchical navigation with Applications/Agents trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) | | Sidebar | Hierarchical navigation with Applications/Agents/Routes trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) |
| TopBar | Header bar with breadcrumb, environment, user info | | TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
## Import Paths ## Import Paths

111
README.md Normal file
View File

@@ -0,0 +1,111 @@
# Cameleer3 Design System
A component library and interactive UI prototype for the Cameleer3 monitoring platform. This project contains both the reusable design system (primitives, composites, layout components) and a fully functional mock application demonstrating all pages and interactions.
## Prerequisites
- **Node.js** 20 or later (tested with 22.x) — [https://nodejs.org](https://nodejs.org)
- **Git** — [https://git-scm.com](https://git-scm.com)
No other tools, accounts, or access to external registries are required. All dependencies are published on the public npm registry.
## Getting Started
```bash
# 1. Clone the repository
git clone <repo-url> cameleer-design-system
cd cameleer-design-system
# 2. Install dependencies
npm install
# 3. Start the development server
npm run dev
```
The dev server will start at **http://localhost:5173** (Vite will print the exact URL).
## Available Scripts
| Command | Description |
|---------|-------------|
| `npm run dev` | Start the development server with hot reload |
| `npm run build` | Type-check and build the production bundle |
| `npm run preview` | Serve the production build locally |
| `npm test` | Run the test suite (Vitest, 332 tests) |
| `npm run lint` | Run ESLint |
## Navigating the Prototype
Once the dev server is running, open **http://localhost:5173** in your browser. The application includes these sections:
### Sidebar Navigation
- **Applications** — Exchange monitoring dashboard
- `/apps` — All exchanges across all applications
- `/apps/:appId` — Filtered by application
- `/apps/:appId/:routeId` — Filtered by application and route
- Click the ↗ icon on any row to open the full **Exchange Detail** page
- **Agents** — JVM agent health monitoring
- `/agents` — Overview of all agent instances grouped by application
- `/agents/:appId` — Single application's agents
- `/agents/:appId/:instanceId` — Instance detail with CPU, memory, threads, GC charts
- **Routes** — Per-route performance metrics
- `/routes` — Aggregated KPI cards, route performance table, charts
- `/routes/:appId` — Filtered by application
- `/routes/:appId/:routeId` — Per-processor statistics and route flow diagram
- **Admin** — User management (RBAC), OIDC configuration, audit log
- `/admin/rbac` — Users, groups, roles with inline editing
- `/admin/oidc` — OIDC provider configuration form
- `/admin/audit` — Searchable audit log table
- **Inventory** — Component showcase
- `/inventory` — Interactive demos of every design system component
### Top Bar Controls
- **Search** (Ctrl+K) — Full-text search across applications, routes, agents, exchanges
- **Status Filters** — Toggle OK / Warn / Error / Running to filter exchanges
- **Time Range** — Preset time ranges (1h, 3h, 6h, Today, 24h, 7d) or custom date/time picker
- **Theme Toggle** (☾/☀) — Switch between light and dark mode
### Key Interactions
- **Exchange slide-in panel** — Click any row in the exchanges table to open a detail panel on the right showing overview, errors, route flow, and processor timeline
- **Exchange detail page** — Click the ↗ icon or "Open full details" link for the full inspector with Message IN/OUT panels and correlation chain
- **Processor selection** — On the exchange detail page, click any processor in the timeline or flow diagram to see its message snapshots
- **Starring** — Hover any item in the sidebar trees and click the star to pin it to the Starred section
- **Dark mode** — Click the moon/sun icon in the top bar to toggle themes
## Project Structure
```
src/
design-system/
primitives/ # Atomic components (Button, Input, Badge, StatusDot, ...)
composites/ # Composed components (DataTable, Modal, EventFeed, RouteFlow, ...)
layout/ # Page-level layout (AppShell, Sidebar, TopBar)
providers/ # React context providers (Theme, CommandPalette, GlobalFilter)
tokens.css # Design tokens (colors, spacing, typography, shadows)
utils/ # Shared utilities (hashColor, timePresets)
pages/ # Application pages (Dashboard, Routes, AgentHealth, Admin, ...)
mocks/ # Static mock data (exchanges, routes, agents, metrics, sidebar)
```
## Tech Stack
- **React 19** + **TypeScript**
- **Vite** for development and bundling
- **CSS Modules** for styling (all colors via design tokens)
- **Vitest** + **React Testing Library** for tests
- No runtime CSS-in-JS, no Tailwind, no external component libraries
## Notes
- All data is static mock data — no backend or API calls required
- The prototype is fully self-contained and works offline after `npm install`
- Light and dark themes are supported throughout
- Fonts (DM Sans, JetBrains Mono) are loaded from Google Fonts and require an internet connection on first load

View File

@@ -1,6 +1,7 @@
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react' import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
import styles from './EventFeed.module.css' import styles from './EventFeed.module.css'
import { FilterPill } from '../../primitives/FilterPill/FilterPill' import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
export interface FeedEvent { export interface FeedEvent {
id: string id: string
@@ -140,21 +141,15 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
)} )}
</div> </div>
<div className={styles.filters}> <div className={styles.filters}>
{allSeverities.map((sev) => { <ButtonGroup
const count = events.filter((e) => e.severity === sev).length items={allSeverities.map((sev): ButtonGroupItem => ({
return ( value: sev,
<FilterPill label: SEVERITY_LABELS[sev],
key={sev} color: SEVERITY_COLORS[sev],
label={SEVERITY_LABELS[sev]} }))}
count={count} value={activeFilters as Set<string>}
dot onChange={(next) => setActiveFilters(next as Set<SeverityFilter>)}
dotColor={SEVERITY_COLORS[sev]}
activeColor={SEVERITY_COLORS[sev]}
active={activeFilters.has(sev)}
onClick={() => toggleFilter(sev)}
/> />
)
})}
{activeFilters.size > 0 && ( {activeFilters.size > 0 && (
<button <button
className={styles.clearBtn} className={styles.clearBtn}

View File

@@ -16,12 +16,12 @@
.item:hover { .item:hover {
background: var(--sidebar-hover); background: var(--sidebar-hover);
color: #E8DFD4; color: var(--sidebar-text);
} }
.item.active { .item.active {
background: var(--sidebar-active); background: var(--sidebar-active);
color: var(--amber-light); color: var(--amber);
border-left-color: var(--amber); border-left-color: var(--amber);
} }
@@ -69,5 +69,5 @@
.item.active .count { .item.active .count {
background: rgba(198, 130, 14, 0.2); background: rgba(198, 130, 14, 0.2);
color: var(--amber-light); color: var(--amber);
} }

View File

@@ -69,15 +69,15 @@
} }
.ok { .ok {
background: rgba(61, 124, 71, 0.5); background: color-mix(in srgb, var(--success) 50%, transparent);
} }
.slow { .slow {
background: rgba(194, 117, 22, 0.5); background: color-mix(in srgb, var(--warning) 50%, transparent);
} }
.fail { .fail {
background: rgba(192, 57, 43, 0.5); background: color-mix(in srgb, var(--error) 50%, transparent);
} }
.dur { .dur {

View File

@@ -72,8 +72,8 @@
} }
.iconChoice { .iconChoice {
background: #F3EEFA; background: var(--purple-bg);
color: #7C3AED; color: var(--purple);
} }
.iconErrorHandler { .iconErrorHandler {
@@ -81,10 +81,6 @@
color: var(--error); color: var(--error);
} }
[data-theme="dark"] .iconChoice {
background: rgba(124, 58, 237, 0.15);
}
/* Node info */ /* Node info */
.info { .info {
flex: 1; flex: 1;

View File

@@ -4,15 +4,15 @@
align-items: center; align-items: center;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--bg-inset); background: var(--bg-inset);
padding: 3px; padding: 2px;
gap: 1px; gap: 1px;
} }
/* Sliding indicator behind the active tab */ /* Sliding indicator behind the active tab */
.indicator { .indicator {
position: absolute; position: absolute;
top: 3px; top: 2px;
bottom: 3px; bottom: 2px;
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: calc(var(--radius-md) - 2px); border-radius: calc(var(--radius-md) - 2px);
@@ -28,8 +28,8 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
padding: 6px 14px; padding: 3px 12px;
font-size: 13px; font-size: 12px;
font-weight: 500; font-weight: 500;
font-family: var(--font-body); font-family: var(--font-body);
color: var(--text-muted); color: var(--text-muted);
@@ -39,7 +39,7 @@
cursor: pointer; cursor: pointer;
transition: color 0.15s; transition: color 0.15s;
white-space: nowrap; white-space: nowrap;
line-height: 1.2; line-height: 1.5;
} }
.tab:hover { .tab:hover {
@@ -75,5 +75,5 @@
.trailingTab { .trailingTab {
cursor: default; cursor: default;
gap: 4px; gap: 4px;
padding: 6px 10px; padding: 3px 10px;
} }

View File

@@ -20,7 +20,7 @@
.logoImg { .logoImg {
width: 28px; width: 28px;
height: 24px; height: 24px;
color: var(--amber-light); color: var(--amber);
filter: brightness(0) saturate(100%) invert(76%) sepia(30%) saturate(400%) hue-rotate(5deg) brightness(95%); filter: brightness(0) saturate(100%) invert(76%) sepia(30%) saturate(400%) hue-rotate(5deg) brightness(95%);
} }
@@ -28,7 +28,7 @@
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: 600; font-weight: 600;
font-size: 15px; font-size: 15px;
color: var(--amber-light); color: var(--amber);
letter-spacing: -0.3px; letter-spacing: -0.3px;
} }
@@ -151,7 +151,7 @@
.item.active { .item.active {
background: var(--sidebar-active); background: var(--sidebar-active);
color: var(--amber-light); color: var(--amber);
border-left-color: var(--amber); border-left-color: var(--amber);
} }
@@ -164,7 +164,7 @@
} }
.item.active .navIcon { .item.active .navIcon {
color: var(--amber-light); color: var(--amber);
} }
.routeArrow { .routeArrow {
@@ -249,11 +249,11 @@
} }
.treeSectionLabel:hover { .treeSectionLabel:hover {
color: var(--amber-light); color: var(--amber);
} }
.treeSectionLabelActive { .treeSectionLabelActive {
color: var(--amber-light); color: var(--amber);
} }
.tree { .tree {
@@ -290,13 +290,13 @@
.treeRowActive { .treeRowActive {
background: var(--sidebar-active); background: var(--sidebar-active);
color: var(--amber-light); color: var(--amber);
border-left-color: var(--amber); border-left-color: var(--amber);
} }
.treeRowActive .treeBadge { .treeRowActive .treeBadge {
background: rgba(198, 130, 14, 0.2); background: rgba(198, 130, 14, 0.2);
color: var(--amber-light); color: var(--amber);
} }
/* Chevron */ /* Chevron */
@@ -380,7 +380,7 @@
} }
.treeStar:hover { .treeStar:hover {
color: var(--amber-light); color: var(--amber);
} }
/* ── Starred section ─────────────────────────────────────────────────────── */ /* ── Starred section ─────────────────────────────────────────────────────── */
@@ -500,7 +500,7 @@
.bottomItemActive { .bottomItemActive {
background: var(--sidebar-active); background: var(--sidebar-active);
color: var(--amber-light); color: var(--amber);
border-left-color: var(--amber); border-left-color: var(--amber);
} }

View File

@@ -81,6 +81,27 @@
flex-shrink: 0; flex-shrink: 0;
} }
.themeToggle {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
font-size: 16px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s, border-color 0.15s;
line-height: 1;
}
.themeToggle:hover {
color: var(--amber);
border-color: var(--amber);
}
.env { .env {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 10px;

View File

@@ -1,10 +1,12 @@
import styles from './TopBar.module.css' import styles from './TopBar.module.css'
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb' import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
import { Avatar } from '../../primitives/Avatar/Avatar' import { Avatar } from '../../primitives/Avatar/Avatar'
import { FilterPill } from '../../primitives/FilterPill/FilterPill' import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown' import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
import { useGlobalFilters, type ExchangeStatus } from '../../providers/GlobalFilterProvider' import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
import { useCommandPalette } from '../../providers/CommandPaletteProvider' import { useCommandPalette } from '../../providers/CommandPaletteProvider'
import { useTheme } from '../../providers/ThemeProvider'
interface BreadcrumbItem { interface BreadcrumbItem {
label: string label: string
@@ -18,11 +20,11 @@ interface TopBarProps {
className?: string className?: string
} }
const STATUS_PILLS: { status: ExchangeStatus; label: string; color: string }[] = [ const STATUS_ITEMS: ButtonGroupItem[] = [
{ status: 'completed', label: 'OK', color: 'var(--success)' }, { value: 'completed', label: 'OK', color: 'var(--success)' },
{ status: 'warning', label: 'Warn', color: 'var(--warning)' }, { value: 'warning', label: 'Warn', color: 'var(--warning)' },
{ status: 'failed', label: 'Error', color: 'var(--error)' }, { value: 'failed', label: 'Error', color: 'var(--error)' },
{ status: 'running', label: 'Running', color: 'var(--running)' }, { value: 'running', label: 'Running', color: 'var(--running)' },
] ]
export function TopBar({ export function TopBar({
@@ -33,6 +35,7 @@ export function TopBar({
}: TopBarProps) { }: TopBarProps) {
const globalFilters = useGlobalFilters() const globalFilters = useGlobalFilters()
const commandPalette = useCommandPalette() const commandPalette = useCommandPalette()
const { theme, toggleTheme } = useTheme()
return ( return (
<header className={`${styles.topbar} ${className ?? ''}`}> <header className={`${styles.topbar} ${className ?? ''}`}>
@@ -56,20 +59,21 @@ export function TopBar({
<span className={styles.kbd}>Ctrl+K</span> <span className={styles.kbd}>Ctrl+K</span>
</button> </button>
{/* Status pills */} {/* Status filter group */}
<div className={styles.filters}> <ButtonGroup
{STATUS_PILLS.map(({ status, label, color }) => ( items={STATUS_ITEMS}
<FilterPill value={globalFilters.statusFilters}
key={status} onChange={(selected) => {
label={label} // Sync with global filter by toggling the diff
dot const current = globalFilters.statusFilters
dotColor={color} for (const v of selected) {
activeColor={color} if (!current.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
active={globalFilters.statusFilters.has(status)} }
onClick={() => globalFilters.toggleStatus(status)} for (const v of current) {
if (!selected.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
}}
/> />
))}
</div>
{/* Time range pills */} {/* Time range pills */}
<TimeRangeDropdown <TimeRangeDropdown
@@ -77,8 +81,17 @@ export function TopBar({
onChange={globalFilters.setTimeRange} onChange={globalFilters.setTimeRange}
/> />
{/* Right: env badge, user */} {/* Right: theme toggle, env badge, user */}
<div className={styles.right}> <div className={styles.right}>
<button
className={styles.themeToggle}
onClick={toggleTheme}
type="button"
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? '\u263E' : '\u2600'}
</button>
{environment && ( {environment && (
<span className={styles.env}>{environment}</span> <span className={styles.env}>{environment}</span>
)} )}

View File

@@ -1,60 +1,59 @@
.group { .group {
display: inline-flex; display: inline-flex;
isolation: isolate;
}
/* Horizontal (default) */
.horizontal {
flex-direction: row;
}
.horizontal > :global(*) {
border-radius: 0;
margin-left: -1px;
position: relative;
}
.horizontal > :global(*:first-child) {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
margin-left: 0;
}
.horizontal > :global(*:last-child) {
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.horizontal > :global(*:only-child) {
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--border);
background: var(--bg-surface);
} }
/* Vertical */ .btn {
.vertical { display: inline-flex;
flex-direction: column; align-items: center;
gap: 5px;
padding: 5px 10px;
border: none;
border-right: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
font-family: var(--font-body);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.12s, color 0.12s;
white-space: nowrap;
line-height: 1.5;
} }
.vertical > :global(*) { .btn:last-child {
border-radius: 0; border-right: none;
margin-top: -1px;
position: relative;
} }
.vertical > :global(*:first-child) { .btn:hover {
border-radius: var(--radius-sm) var(--radius-sm) 0 0; background: var(--bg-hover);
margin-top: 0; color: var(--text-primary);
} }
.vertical > :global(*:last-child) { .btn:focus-visible {
border-radius: 0 0 var(--radius-sm) var(--radius-sm); outline: 2px solid var(--amber);
} outline-offset: -2px;
.vertical > :global(*:only-child) {
border-radius: var(--radius-sm);
}
/* Active/hovered items sit above siblings so their borders win */
.group > :global(*:hover),
.group > :global(*:focus-visible),
.group > :global(*[data-active="true"]),
.group > :global(*.active) {
z-index: 1; z-index: 1;
} }
/* Active state — default (no color override) */
.active {
background: var(--amber-bg);
color: var(--amber);
font-weight: 600;
}
/* Dot indicator */
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.dotMuted {
opacity: 0.4;
}

View File

@@ -1,23 +1,60 @@
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import styles from './ButtonGroup.module.css' import styles from './ButtonGroup.module.css'
export interface ButtonGroupItem {
value: string
label: ReactNode
/** Optional color for dot indicator and active tint */
color?: string
}
interface ButtonGroupProps { interface ButtonGroupProps {
children: ReactNode items: ButtonGroupItem[]
orientation?: 'horizontal' | 'vertical' /** Currently selected values (multi-select) */
value: Set<string>
onChange: (value: Set<string>) => void
className?: string className?: string
} }
export function ButtonGroup({ export function ButtonGroup({ items, value, onChange, className }: ButtonGroupProps) {
children, function handleClick(itemValue: string) {
orientation = 'horizontal', const next = new Set(value)
className, if (next.has(itemValue)) {
}: ButtonGroupProps) { next.delete(itemValue)
} else {
next.add(itemValue)
}
onChange(next)
}
return ( return (
<div <div className={`${styles.group} ${className ?? ''}`} role="group">
className={`${styles.group} ${styles[orientation]} ${className ?? ''}`} {items.map((item) => {
role="group" const active = value.has(item.value)
return (
<button
key={item.value}
type="button"
className={`${styles.btn} ${active ? styles.active : ''}`}
style={active && item.color ? {
borderColor: item.color,
color: item.color,
background: `color-mix(in srgb, ${item.color} 10%, transparent)`,
} : undefined}
onClick={() => handleClick(item.value)}
aria-pressed={active}
> >
{children} {item.color && (
<span
className={`${styles.dot} ${active ? '' : styles.dotMuted}`}
style={{ background: item.color }}
/>
)}
{item.label}
</button>
)
})}
</div> </div>
) )
} }

View File

@@ -3,6 +3,7 @@ export { Avatar } from './Avatar/Avatar'
export { Badge } from './Badge/Badge' export { Badge } from './Badge/Badge'
export { Button } from './Button/Button' export { Button } from './Button/Button'
export { ButtonGroup } from './ButtonGroup/ButtonGroup' export { ButtonGroup } from './ButtonGroup/ButtonGroup'
export type { ButtonGroupItem } from './ButtonGroup/ButtonGroup'
export { Card } from './Card/Card' export { Card } from './Card/Card'
export { Checkbox } from './Checkbox/Checkbox' export { Checkbox } from './Checkbox/Checkbox'
export { CodeBlock } from './CodeBlock/CodeBlock' export { CodeBlock } from './CodeBlock/CodeBlock'

View File

@@ -10,8 +10,8 @@
--sidebar-bg: #2C2520; --sidebar-bg: #2C2520;
--sidebar-hover: #3A322C; --sidebar-hover: #3A322C;
--sidebar-active: #4A3F38; --sidebar-active: #4A3F38;
--sidebar-text: #BFB5A8; --sidebar-text: #D8D0C6;
--sidebar-muted: #7A6F63; --sidebar-muted: #9C9184;
/* Text */ /* Text */
--text-primary: #1A1612; --text-primary: #1A1612;
@@ -58,6 +58,10 @@
--shadow-lg: 0 4px 16px rgba(44, 37, 32, 0.10); --shadow-lg: 0 4px 16px rgba(44, 37, 32, 0.10);
--shadow-card: 0 1px 3px rgba(44, 37, 32, 0.04), 0 0 0 1px rgba(44, 37, 32, 0.04); --shadow-card: 0 1px 3px rgba(44, 37, 32, 0.04), 0 0 0 1px rgba(44, 37, 32, 0.04);
/* Accent: purple (for choice/router elements) */
--purple: #7C3AED;
--purple-bg: #F3EEFA;
/* Chart palette */ /* Chart palette */
--chart-1: #C6820E; --chart-1: #C6820E;
--chart-2: #3D7C47; --chart-2: #3D7C47;
@@ -80,7 +84,7 @@
--sidebar-bg: #141210; --sidebar-bg: #141210;
--sidebar-hover: #1E1B17; --sidebar-hover: #1E1B17;
--sidebar-active: #28241E; --sidebar-active: #28241E;
--sidebar-text: #A89E92; --sidebar-text: #CCC4B8;
--sidebar-muted: #6A6058; --sidebar-muted: #6A6058;
--text-primary: #E8E0D6; --text-primary: #E8E0D6;
@@ -109,6 +113,9 @@
--running-bg: #1A2628; --running-bg: #1A2628;
--running-border: #243A3E; --running-border: #243A3E;
--purple: #A78BFA;
--purple-bg: rgba(124, 58, 237, 0.15);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3); --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4); --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4);

View File

@@ -12,7 +12,6 @@ export const DEFAULT_PRESETS: Preset[] = [
{ label: 'Last 1h', value: 'last-1h' }, { label: 'Last 1h', value: 'last-1h' },
{ label: 'Last 6h', value: 'last-6h' }, { label: 'Last 6h', value: 'last-6h' },
{ label: 'Today', value: 'today' }, { label: 'Today', value: 'today' },
{ label: 'This shift', value: 'shift' },
{ label: 'Last 24h', value: 'last-24h' }, { label: 'Last 24h', value: 'last-24h' },
{ label: 'Last 7d', value: 'last-7d' }, { label: 'Last 7d', value: 'last-7d' },
{ label: 'Custom', value: 'custom' }, { label: 'Custom', value: 'custom' },
@@ -23,7 +22,6 @@ export const PRESET_SHORT_LABELS: Record<string, string> = {
'last-3h': '3h', 'last-3h': '3h',
'last-6h': '6h', 'last-6h': '6h',
'today': 'Today', 'today': 'Today',
'shift': 'Shift',
'last-24h': '24h', 'last-24h': '24h',
'last-7d': '7d', 'last-7d': '7d',
'custom': 'Custom', 'custom': 'Custom',
@@ -45,10 +43,6 @@ export function computePresetRange(preset: string): DateRange {
start.setHours(0, 0, 0, 0) start.setHours(0, 0, 0, 0)
return { start, end } return { start, end }
} }
case 'shift': {
// "This shift" = last 8 hours
return { start: new Date(now.getTime() - 8 * 60 * 60 * 1000), end }
}
case 'last-24h': case 'last-24h':
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end } return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
case 'last-7d': case 'last-7d':

View File

@@ -20,7 +20,7 @@ export interface MetricSeries {
data: TimeSeriesPoint[] data: TimeSeriesPoint[]
} }
// Generate a realistic time series for the past shift (06:00 - now ~09:15) // Generate a realistic time series for the past hours (06:00 - now ~09:15)
function generateTimeSeries( function generateTimeSeries(
baseValue: number, baseValue: number,
variance: number, variance: number,
@@ -44,12 +44,12 @@ function generateTimeSeries(
// KPI stat cards data // KPI stat cards data
export const kpiMetrics: KpiMetric[] = [ export const kpiMetrics: KpiMetric[] = [
{ {
label: 'Exchanges (shift)', label: 'Exchanges',
value: '3,241', value: '3,241',
trend: 'up', trend: 'up',
trendValue: '+12%', trendValue: '+12%',
trendSentiment: 'good', trendSentiment: 'good',
detail: '97.1% success since 06:00', detail: '97.1% success rate',
accent: 'amber', accent: 'amber',
sparkline: [28, 32, 29, 35, 38, 41, 37, 44, 42, 47, 45, 51, 48, 52], sparkline: [28, 32, 29, 35, 38, 41, 37, 44, 42, 47, 45, 51, 48, 52],
}, },
@@ -64,12 +64,12 @@ export const kpiMetrics: KpiMetric[] = [
sparkline: [98.2, 97.9, 98.1, 97.8, 97.5, 97.6, 97.4, 97.2, 97.3, 97.1, 97.0, 97.1, 97.2, 97.1], sparkline: [98.2, 97.9, 98.1, 97.8, 97.5, 97.6, 97.4, 97.2, 97.3, 97.1, 97.0, 97.1, 97.2, 97.1],
}, },
{ {
label: 'Errors (shift)', label: 'Errors',
value: 38, value: 38,
trend: 'up', trend: 'up',
trendValue: '+5', trendValue: '+5',
trendSentiment: 'bad', trendSentiment: 'bad',
detail: '23 overnight · 15 since 06:00', detail: '38 errors in selected period',
accent: 'error', accent: 'error',
sparkline: [1, 2, 1, 3, 2, 4, 3, 5, 4, 6, 5, 7, 6, 8], sparkline: [1, 2, 1, 3, 2, 4, 3, 5, 4, 6, 5, 7, 6, 8],
}, },

View File

@@ -13,6 +13,7 @@ const NAV_SECTIONS = [
{ label: 'Avatar', href: '#avatar' }, { label: 'Avatar', href: '#avatar' },
{ label: 'Badge', href: '#badge' }, { label: 'Badge', href: '#badge' },
{ label: 'Button', href: '#button' }, { label: 'Button', href: '#button' },
{ label: 'ButtonGroup', href: '#buttongroup' },
{ label: 'Card', href: '#card' }, { label: 'Card', href: '#card' },
{ label: 'Checkbox', href: '#checkbox' }, { label: 'Checkbox', href: '#checkbox' },
{ label: 'CodeBlock', href: '#codeblock' }, { label: 'CodeBlock', href: '#codeblock' },

View File

@@ -76,7 +76,7 @@ export function LayoutSection() {
> >
<div className={styles.shellDiagram}> <div className={styles.shellDiagram}>
<div className={styles.shellDiagramTop}> <div className={styles.shellDiagramTop}>
TopBar breadcrumb · search · env badge · shift · user avatar TopBar breadcrumb · search · filters · time range · env badge · user avatar
</div> </div>
<div className={styles.shellDiagramBody}> <div className={styles.shellDiagramBody}>
<div className={styles.shellDiagramSide}> <div className={styles.shellDiagramSide}>
@@ -110,7 +110,7 @@ export function LayoutSection() {
<DemoCard <DemoCard
id="topbar" id="topbar"
title="TopBar" title="TopBar"
description="Top navigation bar with breadcrumb, search trigger, environment badge, shift info, and user avatar." description="Top navigation bar with breadcrumb, search trigger, status filters, time range, environment badge, and user avatar."
> >
<div className={styles.topbarPreview}> <div className={styles.topbarPreview}>
<TopBar <TopBar

View File

@@ -5,6 +5,7 @@ import {
Avatar, Avatar,
Badge, Badge,
Button, Button,
ButtonGroup,
Card, Card,
Checkbox, Checkbox,
CodeBlock, CodeBlock,
@@ -72,6 +73,9 @@ export function PrimitivesSection() {
// Alert state // Alert state
const [alertDismissed, setAlertDismissed] = useState(false) const [alertDismissed, setAlertDismissed] = useState(false)
// ButtonGroup state
const [bgSelection, setBgSelection] = useState<Set<string>>(new Set(['warn']))
// Checkbox state // Checkbox state
const [checked1, setChecked1] = useState(false) const [checked1, setChecked1] = useState(false)
const [checked2, setChecked2] = useState(true) const [checked2, setChecked2] = useState(true)
@@ -178,6 +182,24 @@ export function PrimitivesSection() {
</div> </div>
</DemoCard> </DemoCard>
{/* 4b. ButtonGroup */}
<DemoCard
id="buttongroup"
title="ButtonGroup"
description="Multi-select toggle group with optional colored dot indicators. Used for status filters."
>
<ButtonGroup
items={[
{ value: 'ok', label: 'OK', color: 'var(--success)' },
{ value: 'warn', label: 'Warn', color: 'var(--warning)' },
{ value: 'error', label: 'Error', color: 'var(--error)' },
{ value: 'running', label: 'Running', color: 'var(--running)' },
]}
value={bgSelection}
onChange={setBgSelection}
/>
</DemoCard>
{/* 5. Card */} {/* 5. Card */}
<DemoCard <DemoCard
id="card" id="card"

View File

@@ -333,8 +333,8 @@
} }
.typeRouter { .typeRouter {
background: #F3EEFA; background: var(--purple-bg);
color: #7C3AED; color: var(--purple);
} }
.typeProcessor { .typeProcessor {

View File

@@ -59,7 +59,7 @@ function KpiHeader({ scopedMetrics }: { scopedMetrics: RouteMetricRow[] }) {
<div className={styles.kpiLabel}>Total Throughput</div> <div className={styles.kpiLabel}>Total Throughput</div>
<div className={styles.kpiValueRow}> <div className={styles.kpiValueRow}>
<span className={`${styles.kpiValue} ${styles.kpiValueAmber}`}>{totalExchanges.toLocaleString()}</span> <span className={`${styles.kpiValue} ${styles.kpiValueAmber}`}>{totalExchanges.toLocaleString()}</span>
<span className={styles.kpiUnit}>msg/shift</span> <span className={styles.kpiUnit}>exchanges</span>
<span className={`${styles.kpiTrend} ${styles.trendUpGood}`}>&#9650; +8%</span> <span className={`${styles.kpiTrend} ${styles.trendUpGood}`}>&#9650; +8%</span>
</div> </div>
<div className={styles.kpiDetail}> <div className={styles.kpiDetail}>
@@ -483,7 +483,7 @@ export function Routes() {
<span className={styles.tableTitle}>Processor Performance</span> <span className={styles.tableTitle}>Processor Performance</span>
<div className={styles.tableRight}> <div className={styles.tableRight}>
<span className={styles.tableMeta}>{processorMetrics.length} processors</span> <span className={styles.tableMeta}>{processorMetrics.length} processors</span>
<Badge label="SHIFT" color="primary" /> <Badge label="LIVE" color="success" />
</div> </div>
</div> </div>
<DataTable <DataTable