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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@
.logoImg {
width: 28px;
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%);
}
@@ -28,7 +28,7 @@
font-family: var(--font-mono);
font-weight: 600;
font-size: 15px;
color: var(--amber-light);
color: var(--amber);
letter-spacing: -0.3px;
}
@@ -151,7 +151,7 @@
.item.active {
background: var(--sidebar-active);
color: var(--amber-light);
color: var(--amber);
border-left-color: var(--amber);
}
@@ -164,7 +164,7 @@
}
.item.active .navIcon {
color: var(--amber-light);
color: var(--amber);
}
.routeArrow {
@@ -249,11 +249,11 @@
}
.treeSectionLabel:hover {
color: var(--amber-light);
color: var(--amber);
}
.treeSectionLabelActive {
color: var(--amber-light);
color: var(--amber);
}
.tree {
@@ -290,13 +290,13 @@
.treeRowActive {
background: var(--sidebar-active);
color: var(--amber-light);
color: var(--amber);
border-left-color: var(--amber);
}
.treeRowActive .treeBadge {
background: rgba(198, 130, 14, 0.2);
color: var(--amber-light);
color: var(--amber);
}
/* Chevron */
@@ -380,7 +380,7 @@
}
.treeStar:hover {
color: var(--amber-light);
color: var(--amber);
}
/* ── Starred section ─────────────────────────────────────────────────────── */
@@ -500,7 +500,7 @@
.bottomItemActive {
background: var(--sidebar-active);
color: var(--amber-light);
color: var(--amber);
border-left-color: var(--amber);
}

View File

@@ -81,6 +81,27 @@
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 {
font-family: var(--font-mono);
font-size: 10px;

View File

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

View File

@@ -1,60 +1,59 @@
.group {
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);
overflow: hidden;
border: 1px solid var(--border);
background: var(--bg-surface);
}
/* Vertical */
.vertical {
flex-direction: column;
.btn {
display: inline-flex;
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(*) {
border-radius: 0;
margin-top: -1px;
position: relative;
.btn:last-child {
border-right: none;
}
.vertical > :global(*:first-child) {
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
margin-top: 0;
.btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.vertical > :global(*:last-child) {
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
.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) {
.btn:focus-visible {
outline: 2px solid var(--amber);
outline-offset: -2px;
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 styles from './ButtonGroup.module.css'
export interface ButtonGroupItem {
value: string
label: ReactNode
/** Optional color for dot indicator and active tint */
color?: string
}
interface ButtonGroupProps {
children: ReactNode
orientation?: 'horizontal' | 'vertical'
items: ButtonGroupItem[]
/** Currently selected values (multi-select) */
value: Set<string>
onChange: (value: Set<string>) => void
className?: string
}
export function ButtonGroup({
children,
orientation = 'horizontal',
className,
}: ButtonGroupProps) {
export function ButtonGroup({ items, value, onChange, className }: ButtonGroupProps) {
function handleClick(itemValue: string) {
const next = new Set(value)
if (next.has(itemValue)) {
next.delete(itemValue)
} else {
next.add(itemValue)
}
onChange(next)
}
return (
<div
className={`${styles.group} ${styles[orientation]} ${className ?? ''}`}
role="group"
>
{children}
<div className={`${styles.group} ${className ?? ''}`} role="group">
{items.map((item) => {
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}
>
{item.color && (
<span
className={`${styles.dot} ${active ? '' : styles.dotMuted}`}
style={{ background: item.color }}
/>
)}
{item.label}
</button>
)
})}
</div>
)
}

View File

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

View File

@@ -10,8 +10,8 @@
--sidebar-bg: #2C2520;
--sidebar-hover: #3A322C;
--sidebar-active: #4A3F38;
--sidebar-text: #BFB5A8;
--sidebar-muted: #7A6F63;
--sidebar-text: #D8D0C6;
--sidebar-muted: #9C9184;
/* Text */
--text-primary: #1A1612;
@@ -58,6 +58,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);
/* Accent: purple (for choice/router elements) */
--purple: #7C3AED;
--purple-bg: #F3EEFA;
/* Chart palette */
--chart-1: #C6820E;
--chart-2: #3D7C47;
@@ -80,7 +84,7 @@
--sidebar-bg: #141210;
--sidebar-hover: #1E1B17;
--sidebar-active: #28241E;
--sidebar-text: #A89E92;
--sidebar-text: #CCC4B8;
--sidebar-muted: #6A6058;
--text-primary: #E8E0D6;
@@ -109,6 +113,9 @@
--running-bg: #1A2628;
--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-md: 0 2px 8px rgba(0, 0, 0, 0.3);
--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 6h', value: 'last-6h' },
{ label: 'Today', value: 'today' },
{ label: 'This shift', value: 'shift' },
{ label: 'Last 24h', value: 'last-24h' },
{ label: 'Last 7d', value: 'last-7d' },
{ label: 'Custom', value: 'custom' },
@@ -23,7 +22,6 @@ export const PRESET_SHORT_LABELS: Record<string, string> = {
'last-3h': '3h',
'last-6h': '6h',
'today': 'Today',
'shift': 'Shift',
'last-24h': '24h',
'last-7d': '7d',
'custom': 'Custom',
@@ -45,10 +43,6 @@ export function computePresetRange(preset: string): DateRange {
start.setHours(0, 0, 0, 0)
return { start, end }
}
case 'shift': {
// "This shift" = last 8 hours
return { start: new Date(now.getTime() - 8 * 60 * 60 * 1000), end }
}
case 'last-24h':
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
case 'last-7d':

View File

@@ -20,7 +20,7 @@ export interface MetricSeries {
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(
baseValue: number,
variance: number,
@@ -44,12 +44,12 @@ function generateTimeSeries(
// KPI stat cards data
export const kpiMetrics: KpiMetric[] = [
{
label: 'Exchanges (shift)',
label: 'Exchanges',
value: '3,241',
trend: 'up',
trendValue: '+12%',
trendSentiment: 'good',
detail: '97.1% success since 06:00',
detail: '97.1% success rate',
accent: 'amber',
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],
},
{
label: 'Errors (shift)',
label: 'Errors',
value: 38,
trend: 'up',
trendValue: '+5',
trendSentiment: 'bad',
detail: '23 overnight · 15 since 06:00',
detail: '38 errors in selected period',
accent: 'error',
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: 'Badge', href: '#badge' },
{ label: 'Button', href: '#button' },
{ label: 'ButtonGroup', href: '#buttongroup' },
{ label: 'Card', href: '#card' },
{ label: 'Checkbox', href: '#checkbox' },
{ label: 'CodeBlock', href: '#codeblock' },

View File

@@ -76,7 +76,7 @@ export function LayoutSection() {
>
<div className={styles.shellDiagram}>
<div className={styles.shellDiagramTop}>
TopBar breadcrumb · search · env badge · shift · user avatar
TopBar breadcrumb · search · filters · time range · env badge · user avatar
</div>
<div className={styles.shellDiagramBody}>
<div className={styles.shellDiagramSide}>
@@ -110,7 +110,7 @@ export function LayoutSection() {
<DemoCard
id="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}>
<TopBar

View File

@@ -5,6 +5,7 @@ import {
Avatar,
Badge,
Button,
ButtonGroup,
Card,
Checkbox,
CodeBlock,
@@ -72,6 +73,9 @@ export function PrimitivesSection() {
// Alert state
const [alertDismissed, setAlertDismissed] = useState(false)
// ButtonGroup state
const [bgSelection, setBgSelection] = useState<Set<string>>(new Set(['warn']))
// Checkbox state
const [checked1, setChecked1] = useState(false)
const [checked2, setChecked2] = useState(true)
@@ -178,6 +182,24 @@ export function PrimitivesSection() {
</div>
</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 */}
<DemoCard
id="card"

View File

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

View File

@@ -59,7 +59,7 @@ function KpiHeader({ scopedMetrics }: { scopedMetrics: RouteMetricRow[] }) {
<div className={styles.kpiLabel}>Total Throughput</div>
<div className={styles.kpiValueRow}>
<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>
</div>
<div className={styles.kpiDetail}>
@@ -483,7 +483,7 @@ export function Routes() {
<span className={styles.tableTitle}>Processor Performance</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{processorMetrics.length} processors</span>
<Badge label="SHIFT" color="primary" />
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable