Compare commits

..

97 Commits

Author SHA1 Message Date
hsiegeln
a3afe3cb1b feat(assets): add high-detail cameleer3-logo.svg traced from PNG via Inkscape
All checks were successful
Build & Publish / publish (push) Successful in 1m4s
32-scan potrace vector trace with transparent background. Added to brand
assets inventory, documentation, and package exports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:58:44 +02:00
hsiegeln
4841a7ad7c refactor(assets): replace named exports with wildcard ./assets/* export
All checks were successful
Build & Publish / publish (push) Successful in 1m10s
Consumers now import via @cameleer/design-system/assets/<filename> instead
of named aliases like /logo-32. Simpler, more flexible, and supports any
future assets without adding new export entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:43:01 +02:00
hsiegeln
32a49690fa style(assets): make all logo PNGs transparent using Inkscape trace
All checks were successful
Build & Publish / publish (push) Successful in 1m7s
Traced the original logo with Inkscape (16-scan potrace, background removal)
and re-exported all PNG variants with transparent backgrounds. Also reduces
total asset size from ~1.7MB to ~630KB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:14:50 +02:00
hsiegeln
20f7b2f5aa feat(assets): ship Cameleer3 logo and favicon variants with the design system
All checks were successful
Build & Publish / publish (push) Successful in 1m5s
Add brand assets as static package exports (logo, logo-16 through logo-512, logo-svg)
with pre-generated PNG sizes for favicons, PWA icons, Apple touch icons, and social images.
Includes inventory showcase section and updated documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:17:36 +02:00
hsiegeln
5cb51e65be style(topbar): make .env badge match neutral button style of liveToggle/themeToggle
All checks were successful
Build & Publish / publish (push) Successful in 1m42s
SonarQube Analysis / sonarqube (push) Successful in 2m26s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:59:00 +02:00
hsiegeln
4dcd4aaa27 feat(topbar): change environment prop from string to ReactNode
All checks were successful
Build & Publish / publish (push) Successful in 1m21s
Allows consuming apps to pass a custom dropdown or any interactive
element instead of a static string label. Rendering changed from
<span> to <div> to support block-level children.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:25:20 +02:00
hsiegeln
58320b9762 fix(topbar): rename refresh toggle labels from LIVE/PAUSED to AUTO/MANUAL
All checks were successful
Build & Publish / publish (push) Successful in 54s
SonarQube Analysis / sonarqube (push) Successful in 3m3s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:30:17 +02:00
hsiegeln
c48dffaef2 feat(global-filter): add refreshTimeRange() for manual refresh in paused mode
All checks were successful
Build & Publish / publish (push) Successful in 55s
Revert auto-sliding when paused — time range only advances with
auto-refresh on. Add refreshTimeRange() to useGlobalFilters for
on-demand recomputation from the active preset.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:01:47 +02:00
hsiegeln
3ef4c5686e fix(global-filter): keep time range sliding when auto-refresh is paused
All checks were successful
Build & Publish / publish (push) Successful in 1m6s
The preset time window now advances on a 10s interval regardless of
auto-refresh state. Pausing only stops query polling — the window
stays current so manual refreshes see up-to-date data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:16:11 +02:00
hsiegeln
78e28789a5 docs: update CommandPalette entry for open SearchCategory type
All checks were successful
Build & Publish / publish (push) Successful in 1m36s
SonarQube Analysis / sonarqube (push) Successful in 2m52s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:23:32 +02:00
hsiegeln
03ec34bb5c feat(command-palette): open SearchCategory to arbitrary strings
All checks were successful
Build & Publish / publish (push) Successful in 1m33s
Widen SearchCategory from a closed union to string. Known categories
(application, exchange, attribute, route, agent) keep their labels.
Unknown categories render with title-cased labels and appear as
dynamic tabs derived from the data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:21:28 +02:00
hsiegeln
2f1df869db docs: update spec and guide for search position and chevron removal
All checks were successful
Build & Publish / publish (push) Successful in 1m7s
- COMPONENT_GUIDE: note search renders between Header and Sections,
  no chevrons on section headers
- Spec: update rendering diagrams and description to match
  implemented behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:43:00 +02:00
hsiegeln
0cf696cded fix(sidebar): move search below Header, remove section chevrons
All checks were successful
Build & Publish / publish (push) Successful in 1m3s
- Search input now renders between Sidebar.Header and first Section
  instead of above Header (fixes cameleer3-server#120)
- Remove ChevronRight/ChevronDown from section headers — the entire
  row is already clickable, chevrons added visual noise

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:40:18 +02:00
hsiegeln
50a1296a9d fix(sidebar): make entire section header row clickable
All checks were successful
Build & Publish / publish (push) Successful in 2m1s
The toggle was only on the chevron button. Now the full row
(chevron + icon + label) triggers onToggle on click or Enter/Space.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 21:59:47 +02:00
hsiegeln
9b8739b5d8 fix(a11y): add keyboard listeners to clickable elements (S1082)
All checks were successful
Build & Publish / publish (push) Successful in 1m2s
Add onKeyDown (Enter/Space) to the CommandPalette overlay backdrop div and
result item divs to satisfy SonarQube S1082. RouteFlow and ProcessorTimeline
already had the fixes in place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:41:11 +02:00
hsiegeln
ba6028c2ea refactor: extract nested handlers to fix function depth violations (S2004)
All checks were successful
Build & Publish / publish (push) Successful in 1m52s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:35:46 +02:00
hsiegeln
93776944b9 refactor: extract keyboard handlers to reduce cognitive complexity (S3776)
Extract per-key arrow handler logic into standalone functions outside the
component in SidebarTree.tsx and TreeView.tsx, reducing handleKeyDown
cognitive complexity from 31 to below the 15-unit maximum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:35:27 +02:00
hsiegeln
9240acddb6 docs: update CLAUDE.md and COMPONENT_GUIDE.md for composable Sidebar
All checks were successful
Build & Publish / publish (push) Successful in 1m26s
- Add Sidebar, SidebarTree, useStarred to import paths
- Update navigation decision tree with compound component entries
- Replace old Sidebar props description with compound API
- Add standard page layout composition pattern for new Sidebar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:30:27 +02:00
hsiegeln
912adb1070 feat: composable sidebar refactor
All checks were successful
Build & Publish / publish (push) Successful in 57s
Replaces monolithic Sidebar with compound component API:
Sidebar, Sidebar.Header, Sidebar.Section, Sidebar.Footer,
Sidebar.FooterLink. Exports SidebarTree, useStarred publicly.
Migrates mock app to LayoutShell with React Router layout routes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:16:30 +02:00
hsiegeln
eeb2713612 fix: strip Sidebar wrapper from RouteDetail + fix StatusDot prop in LayoutSection
- RouteDetail.tsx was missed in page stripping pass — remove AppShell
  + Sidebar wrapper, replace with fragment
- LayoutSection.tsx used StatusDot status= instead of variant=

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:13:03 +02:00
hsiegeln
18bf644040 refactor(inventory): update Sidebar demo to compound API
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:10:09 +02:00
hsiegeln
9fa7eb129d refactor: strip AppShell+Sidebar wrappers from all page components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:09:16 +02:00
hsiegeln
8cd3c3a99d refactor: wrap routes in LayoutShell layout route
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:05:49 +02:00
hsiegeln
36999941c0 feat(layout): create LayoutShell with compound Sidebar composition
Move all application-specific sidebar logic (tree builders, starred items,
section collapse state, sidebarReveal handling) out of the DS Sidebar into
a shared LayoutShell that wraps Outlet for route-level layout sharing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:04:08 +02:00
hsiegeln
5a91875723 test(sidebar): rewrite Sidebar tests for compound component API
Replace legacy monolithic Sidebar test suite with 16 tests covering the
new compound component API (Sidebar.Header, Section, Footer, FooterLink)
including icon-rail collapsed mode, search input visibility, and active
state highlighting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:01:39 +02:00
hsiegeln
c401516b2d fix(sidebar): add icon to expanded section, fix icon-rail callbacks, fix active border
- Render icon between chevron and label in expanded SidebarSection
- Remove !open guard from icon-rail click — always fire both callbacks
- Add border-left: 3px solid transparent to .treeSection so
  .treeSectionActive border-left-color takes effect
- Remove duplicate .treeSectionLabel CSS declaration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:59:25 +02:00
hsiegeln
d2c2b92183 feat(sidebar): update barrel exports for composable sidebar
Export SidebarTree, SidebarTreeNode, and useStarred from the layout
barrel. Remove old app-domain type exports (SidebarApp, SidebarRoute,
SidebarAgent) that no longer exist in the rewritten Sidebar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:55:29 +02:00
hsiegeln
357e497220 feat(sidebar): update CSS for composable compound component
Add collapsed state styles (sidebarCollapsed, collapseToggle), icon-rail
mode (sectionRailItem, sectionIcon), and width transition. Remove old
monolithic classes (navArea, section, items, item, navIcon, routeArrow,
all starred-section styles). Pin footer with margin-top: auto.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:55:20 +02:00
hsiegeln
1173b3e363 feat(sidebar): rewrite Sidebar as composable compound component
Replace the monolithic Sidebar (560 lines of app-specific logic) with
a composable shell exposing Sidebar.Header, Sidebar.Section,
Sidebar.Footer, and Sidebar.FooterLink sub-components. Application
logic (tree builders, starred items, domain types) is removed — those
responsibilities move to the consuming app layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:54:18 +02:00
hsiegeln
7092271fdc feat(sidebar): add SidebarContext for composable sidebar
Create context and hook to share collapsed state and toggle callback
between compound component sub-components.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:53:49 +02:00
hsiegeln
3561147b42 docs: add composable sidebar implementation plan
10-task plan covering compound component, CSS, exports, tests,
LayoutShell, route migration, and page wrapper stripping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:50:56 +02:00
hsiegeln
9afe626a58 docs: update composable sidebar spec with clarified decisions
Add searchValue prop for controlled input, SidebarContext for
collapsed state propagation, LayoutShell migration plan, and
icon-rail simultaneous callback behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:42:09 +02:00
hsiegeln
7758962564 docs: clarify search ownership and icon-rail click behavior
Search: DS renders dumb controlled input, app owns state and passes
filterQuery to SidebarTree instances. Icon-rail click: fires both
onCollapseToggle and onToggle simultaneously, no navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:41:36 +02:00
hsiegeln
4e2d5b2b2f docs: composable sidebar refactor spec
Compound component API replacing monolithic Sidebar. DS provides
shell (Sidebar, Sidebar.Header, Sidebar.Section, Sidebar.Footer,
Sidebar.FooterLink) + standalone SidebarTree and useStarred exports.
Application controls all content, icons, sections. Adds icon-rail
collapse mode. Breaking change — coordinate with server UI migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:38:04 +02:00
hsiegeln
af48bd2fa0 fix: sidebar highlighting works on all tabs, not just exchanges
All checks were successful
Build & Publish / publish (push) Successful in 1m0s
SonarQube Analysis / sonarqube (push) Successful in 2m17s
Remove the /exchanges/ path guard so sidebarRevealPath is used whenever
available, enabling correct sidebar selection on dashboard, runtime, and
all other tabs when navigating via Cmd-K.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:59:15 +02:00
hsiegeln
592b60c5fe feat: export Recharts theme preset for consuming apps
All checks were successful
Build & Publish / publish (push) Successful in 55s
SonarQube Analysis / sonarqube (push) Successful in 2m23s
Add rechartsTheme config object that maps design tokens to Recharts
component props, ensuring visual consistency without adding Recharts
as a dependency. Also export CHART_COLORS, ChartSeries, and DataPoint
types for public use. Document charting strategy in COMPONENT_GUIDE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:31:02 +02:00
hsiegeln
0bb49b83e5 feat: DataTable scrollable layout with 200+ mock exchanges
All checks were successful
Build & Publish / publish (push) Successful in 1m55s
SonarQube Analysis / sonarqube (push) Successful in 1m52s
Make Dashboard table fill viewport height with sticky header/footer
and internal scrolling. Expand mock exchange data from 15 to 200
records and Inventory showcase from 5 to 500 records.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:43:03 +02:00
hsiegeln
8070fdea7c fix(ci): read SONAR_HOST_URL from secrets instead of vars
All checks were successful
Build & Publish / publish (push) Successful in 1m6s
SonarQube Analysis / sonarqube (push) Successful in 2m0s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:38:09 +01:00
hsiegeln
7830ac5e0d fix(ci): validate SONAR_HOST_URL before running scanner
All checks were successful
Build & Publish / publish (push) Successful in 1m35s
Fail early with a clear message if the variable is missing or lacks
an http(s) scheme.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:28:59 +01:00
hsiegeln
fdccca5378 fix(ci): detect arm64 arch for sonar-scanner download
All checks were successful
Build & Publish / publish (push) Successful in 1m15s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:21:31 +01:00
hsiegeln
0d4215678a fix(ci): use native sonar-scanner CLI instead of npm wrapper
All checks were successful
Build & Publish / publish (push) Successful in 1m11s
The npm sonarqube-scanner bootstrapper was ignoring the host URL and
defaulting to sonarcloud.io. Switch to the official sonar-scanner-cli
binary which respects -D flags directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:15:49 +01:00
hsiegeln
28690b2a7a fix(ci): add @vitest/coverage-v8 for SonarQube workflow
All checks were successful
Build & Publish / publish (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:12:45 +01:00
hsiegeln
5eb807c572 ci: add nightly SonarQube analysis workflow
All checks were successful
Build & Publish / publish (push) Successful in 1m40s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:59:24 +01:00
hsiegeln
f359a2ba3d feat: add Sidebar onNavigate callback and DataTable fillHeight prop
All checks were successful
Build & Publish / publish (push) Successful in 1m3s
Sidebar: add optional onNavigate prop so consuming apps can intercept
and remap navigation paths instead of relying on internal React Router
links.

DataTable: add fillHeight prop for flex-fill layouts with scrolling
body. Make the table header sticky by default so it stays visible
during vertical scroll.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:28:49 +01:00
hsiegeln
384ee97643 chore: npm audit fix
All checks were successful
Build & Publish / publish (push) Successful in 1m39s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:33:37 +01:00
hsiegeln
a12b374fb2 feat: add onSubmit prop to CommandPalette for full-text search
All checks were successful
Build & Publish / publish (push) Successful in 58s
When Enter is pressed without explicit arrow/mouse navigation,
fires onSubmit with the raw query instead of selecting the
focused result. Enables using the palette as a search filter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:30:36 +01:00
hsiegeln
433d582da6 feat: migrate all icons to Lucide React
All checks were successful
Build & Publish / publish (push) Successful in 1m2s
Replace unicode characters, emoji, and inline SVGs with lucide-react
components across the entire design system and page layer. Update
tests to assert on SVG elements instead of text content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:25:43 +01:00
hsiegeln
2ffc268b44 feat: add attribute search category to CommandPalette
All checks were successful
Build & Publish / publish (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:03:57 +01:00
hsiegeln
99ae66315b feat: add trace log level to LogViewer
All checks were successful
Build & Publish / publish (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:04:50 +01:00
hsiegeln
26de5ec58f feat: add click-to-close backdrop behind DetailPanel
All checks were successful
Build & Publish / publish (push) Successful in 58s
Adds a subtle semi-transparent backdrop (rgba(0,0,0,0.15)) behind the
overlay panel. Clicking the backdrop closes the panel. Fades in with
the panel animation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:57:34 +01:00
hsiegeln
d26dc6a8a5 fix: make DetailPanel overlay instead of taking flex space
All checks were successful
Build & Publish / publish (push) Successful in 1m1s
DetailPanel now uses position: fixed to overlay the content area rather
than participating in the AppShell flex row. This prevents the TopBar
from being compressed when the panel opens. Added box-shadow for depth
separation and z-index: 100 for stacking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:55:02 +01:00
hsiegeln
c0b1cbdc5b fix: remove inset background from LogViewer container
All checks were successful
Build & Publish / publish (push) Successful in 1m30s
LogViewer was using --bg-inset which created a visual mismatch with the
EventFeed timeline panel. Now inherits the parent card's background
(--bg-surface) for consistent appearance in side-by-side layouts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:24:35 +01:00
hsiegeln
d101d883a9 fix: auto-scroll to top for EventFeed and LogViewer
All checks were successful
Build & Publish / publish (push) Successful in 52s
Newest entries appear at the top in descending sort, so auto-scroll
should snap to top instead of bottom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:45:30 +01:00
hsiegeln
2a020c1e15 feat: add BreadcrumbProvider and e2e test suite
All checks were successful
Build & Publish / publish (push) Successful in 53s
Add BreadcrumbProvider context so pages can override TopBar breadcrumbs
dynamically. Add Playwright e2e tests for dashboard, agents, routes,
exchanges, and admin pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:33:55 +01:00
hsiegeln
19303eefad feat: auto-slide time range forward when preset is active with auto-refresh
All checks were successful
Build & Publish / publish (push) Successful in 50s
Adds a 10s interval that recomputes the preset time range boundaries
so dashboards stay current without manual refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:54:24 +01:00
hsiegeln
5fe6321d30 feat: add multi-flow support to RouteFlow via flows prop
All checks were successful
Build & Publish / publish (push) Successful in 50s
Allow rendering multiple named flow segments (e.g., main route +
onException handler) within a single RouteFlow component. Each segment
gets a labeled section with variant-based color theming. Fully backward
compatible — existing nodes prop continues to work unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:10:27 +01:00
hsiegeln
90e3de2cdf feat: fix category counts and add matchContext for search highlights
All checks were successful
Build & Publish / publish (push) Successful in 58s
- Category tab counts now reflect query-filtered results, not total data
- Added matchContext field to SearchResult for server-side match snippets
- Renders <em>-tagged highlight text below meta in muted style

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:26:55 +01:00
hsiegeln
499c86b680 feat: add onQueryChange prop and serverFiltered flag to CommandPalette
All checks were successful
Build & Publish / publish (push) Successful in 51s
Enables server-side exchange search from the command palette. Server-filtered
results skip client-side text matching since they matched on content not
visible in title/meta.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:51:16 +01:00
hsiegeln
63e16d2685 feat: add generic badge system to RouteFlow and ProcessorTimeline
All checks were successful
Build & Publish / publish (push) Successful in 49s
New NodeBadge interface with variant colors (info/success/warning/error)
and optional onClick. Replaces single-purpose bottleneckBadge with a
flexible badges array. Backwards compatible: isBottleneck still works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:06:55 +01:00
hsiegeln
19dccb8685 feat: add registerable actions to ProcessorTimeline and RouteFlow
All checks were successful
Build & Publish / publish (push) Successful in 51s
Both components now accept `actions` (static) or `getActions` (dynamic
per-item) props. When provided, a "⋮" trigger appears on hover that
opens a Dropdown menu. Click propagation is stopped so action clicks
don't fire the row/node click handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:49:07 +01:00
hsiegeln
4b873194c9 fix(ci): add --allow-same-version to npm version in publish workflow
All checks were successful
Build & Publish / publish (push) Successful in 51s
The tag push job fails with "Version not changed" when package.json
already has the correct version from the bump commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:05:08 +01:00
hsiegeln
5f1b039056 chore: bump version to 0.1.6
Some checks failed
Build & Publish / publish (push) Failing after 50s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:01:53 +01:00
hsiegeln
095abe1751 feat: self-portaling DetailPanel via AppShell portal target
Some checks failed
Build & Publish / publish (push) Failing after 50s
DetailPanel now uses createPortal to render itself into
#cameleer-detail-panel-root, a div that AppShell places as a
direct sibling of .main in the top-level flex row. This means
pages can render <DetailPanel> anywhere in their JSX and it
automatically appears at the correct layout position.

AppShell's detail prop is deprecated and ignored — the portal
handles positioning automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:58:06 +01:00
hsiegeln
e8859e53ce chore: sync package-lock.json
All checks were successful
Build & Publish / publish (push) Successful in 49s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:12:21 +01:00
hsiegeln
021f6c7811 chore: bump version to 0.1.3
Some checks failed
Build & Publish / publish (push) Failing after 6s
2026-03-24 18:04:51 +01:00
hsiegeln
c18ba7d085 fix: exclude e2e tests from CI vitest run
Some checks failed
Build & Publish / publish (push) Failing after 7s
Playwright e2e tests need a browser and can't run in the CI container.
Exclude e2e/ directory from vitest so only unit tests run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:01:26 +01:00
hsiegeln
795ffef9dc feat: add auto-refresh toggle to TopBar and GlobalFilterProvider
Some checks failed
Build & Publish / publish (push) Failing after 5s
Add autoRefresh/setAutoRefresh to GlobalFilterContext, persisted in
localStorage. TopBar shows a LIVE/PAUSED toggle button with pulsing
dot indicator. Consumers can use useGlobalFilters().autoRefresh to
conditionally enable/disable polling intervals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:58:20 +01:00
hsiegeln
039f2fa5fe feat: add onSortChange callback to DataTable for server-side sorting
All checks were successful
Build & Publish / publish (push) Successful in 1m21s
When onSortChange is provided, DataTable operates in controlled mode:
it renders sort indicators and fires the callback on header clicks,
but skips client-side sorting — the caller provides pre-sorted data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:51:22 +01:00
hsiegeln
ac2fb9608f refactor: migrate pages to use new design system components
All checks were successful
Build & Publish / publish (push) Successful in 54s
Dashboard: KpiStrip replaces StatCard strip
Routes: KpiStrip + Card title + removed ~250 lines of KPI/chart CSS
AgentInstance: LogViewer replaces custom log rendering
Admin RBAC: SplitPane + EntityList replaces custom split-pane layout
CLAUDE.md: updated import examples

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:31:14 +01:00
hsiegeln
8926627c5c docs: update CLAUDE.md import examples with new components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:29:57 +01:00
hsiegeln
bd4e22eafb refactor: use SplitPane and EntityList in Admin RBAC tabs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:22:56 +01:00
hsiegeln
eb62c80daf refactor: use LogViewer in AgentInstance page
Replace custom log entry rendering (MonoText, Badge, per-entry HTML) with
the LogViewer composite component. Map mock data to LogEntry interface,
remove formatLogTime helper, and clean up unused CSS classes and imports
(Card, CodeBlock, ProgressBar, sectionHeaderRow, sectionTitle, fdRow).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:22:41 +01:00
hsiegeln
043f631eac refactor: use KpiStrip, StatusText, and Card title in Routes page
Replace the custom KpiHeader function with KpiStrip composite, swap
chart wrapper divs with Card title prop, and remove ~190 lines of
now-redundant CSS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:21:51 +01:00
hsiegeln
2a78f1535e refactor: use KpiStrip and StatusText in Dashboard page
Replace StatCard loop with KpiStrip composite, map KpiMetric mock data
to KpiItem interface. Remove unused .healthStrip CSS class.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:20:35 +01:00
hsiegeln
65ad955b97 feat: promote mock UI patterns into design system
New components: KpiStrip, SplitPane, EntityList, LogViewer, StatusText
Extended: Card (title prop)
Refactored: AgentHealth to use DataTable
Updated: COMPONENT_GUIDE.md, Inventory demos

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:16:03 +01:00
hsiegeln
80678a0d61 docs: add COMPONENT_GUIDE entries and Inventory demos for KpiStrip, SplitPane, EntityList, LogViewer, StatusText, Card title
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:35:11 +01:00
hsiegeln
08bac437f7 refactor: replace raw HTML tables in AgentHealth with DataTable composite
Replace hand-rolled <table>/<thead>/<tbody> markup in the AgentHealth page
with the existing DataTable composite, using column definitions with custom
render functions for StatusDot, Badge, and MonoText cells. Uses flush prop
for seamless GroupCard integration and pageSize=50 to avoid pagination.
Removes unused table-specific CSS classes (.instanceTable, .instanceRow,
.thStatus, .tdStatus, .instanceRowActive, .instanceCountBadge).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:29:45 +01:00
hsiegeln
8c1c953259 feat: add LogViewer composite for timestamped, severity-colored log display
Scrollable log viewer with auto-scroll behavior, level badges (info/warn/
error/debug) with semantic colors, monospace font, and role="log" for
accessibility. Includes 7 tests and barrel exports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:27:03 +01:00
hsiegeln
4abf80144e feat: add EntityList composite for searchable, selectable item lists
Generic list component with render props for item content, search input,
add button, selection highlighting, and keyboard navigation support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:23:56 +01:00
hsiegeln
5fe7752b46 feat: add SplitPane composite for two-column list/detail layouts
Two-column grid layout with configurable ratio (1:1, 1:2, 2:3), list/detail
slots, and empty state message. Uses CSS custom property for dynamic grid
columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:20:53 +01:00
hsiegeln
22c098f9b6 feat: add KpiStrip composite for horizontal metric card rows
Horizontal grid of KPI cards with labels, values, trend indicators,
subtitles, and optional sparklines. Uses CSS custom property for
per-card accent border color. 12 tests included.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:17:33 +01:00
hsiegeln
c89c163068 feat(Card): add optional title prop with uppercase monospace header
When a title string is provided, renders an uppercase monospace h3 header
with a subtle border separator above the card body. Children are wrapped
in a padded body div when title is present; without title, children render
directly as before (no breaking change).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:14:08 +01:00
hsiegeln
f00dc797f2 feat: add StatusText primitive with semantic color variants
Inline <span> component supporting success, warning, error, running, and
muted variants with optional bold styling. Includes 7 unit tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:12:20 +01:00
hsiegeln
e664e449c3 docs: add 4 implementation plans for mock deviation cleanup
Plan 1: KpiStrip + StatusText + Card title (metrics)
Plan 2: SplitPane + EntityList (admin)
Plan 3: LogViewer + AgentHealth DataTable refactor (observability)
Plan 4: COMPONENT_GUIDE.md + Inventory updates (documentation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:10:00 +01:00
hsiegeln
b168d7c867 docs: add EntityList, docs updates, and inventory to spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:54:52 +01:00
hsiegeln
c4cb2b2e31 docs: fix KpiStrip trend coloring and Card relationship in spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:51:17 +01:00
hsiegeln
ef28c0b546 docs: add mock UI deviations design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:48:04 +01:00
hsiegeln
a62b69b8e2 feat: add LoginForm and LoginDialog to component inventory
All checks were successful
Build & Publish / publish (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:26:40 +01:00
hsiegeln
ff4ba9bb91 feat: add LoginForm and LoginDialog components
Composable login components with social provider support,
client-side validation, and full dark mode compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:23:39 +01:00
hsiegeln
c1cb9fa536 feat: export LoginForm and LoginDialog from composites barrel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:18:40 +01:00
hsiegeln
fd9b5e4fef feat: add LoginDialog modal wrapper component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:16:30 +01:00
hsiegeln
ec0db5a011 test: add validation and interaction tests for LoginForm
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:14:05 +01:00
hsiegeln
bda0d11fde feat: add LoginForm component with rendering tests
Implements the LoginForm composite with social login, email/password
credentials, remember me, forgot password, and sign up sections.
All sections conditionally render based on provided props.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:07:13 +01:00
hsiegeln
5c02b52cb0 docs: add login dialog implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:00:44 +01:00
hsiegeln
be23161582 docs: fix onSubmit optionality in login dialog spec
Make onSubmit optional so the social-only variant works cleanly
without requiring a no-op callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:47:50 +01:00
hsiegeln
6521bbcf44 docs: add login dialog design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:46:27 +01:00
hsiegeln
b959edd6c7 feat: add logout dropdown to user menu in TopBar
All checks were successful
Build & Publish / publish (push) Successful in 47s
Click user name/avatar to open dropdown with Logout option.
TopBar accepts optional onLogout callback prop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:14:40 +01:00
125 changed files with 12801 additions and 2670 deletions

View File

@@ -15,7 +15,21 @@
"Bash(echo \"EXIT:$?\")",
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.4\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
"Bash(echo \"EXIT_CODE=$?\")",
"Bash(echo \"EXIT=$?\")"
"Bash(echo \"EXIT=$?\")",
"mcp__gitea__actions_config_read",
"mcp__gitea__search_repos",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(node -e \"console.log\\(JSON.parse\\(require\\(''fs''\\).readFileSync\\(''package.json'',''utf8''\\)\\).devDependencies[''vite-plugin-dts'']\\)\")",
"Bash(npx vite:*)",
"Bash(cd:*)",
"mcp__gitea__actions_run_read",
"mcp__gitea__get_file_contents",
"WebFetch(domain:ui.shadcn.com)",
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.5\\\\skills\\\\brainstorming\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
"Bash(bash \"C:/Users/Hendrik/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.5/skills/brainstorming/scripts/stop-server.sh\" \"C:/Users/Hendrik/Documents/projects/design-system/.superpowers/brainstorm/470-1774344716\")",
"Bash(npm test:*)",
"Bash(grep:*)",
"Bash(xargs cat:*)"
]
}
}

View File

@@ -17,7 +17,7 @@ jobs:
run: npm ci
- name: Run tests
run: npx vitest run
run: npx vitest run --exclude 'e2e/**'
- name: Build library
run: npm run build:lib
@@ -28,7 +28,7 @@ jobs:
case "$GITHUB_REF" in
refs/tags/v*)
VERSION="${GITHUB_REF_NAME#v}"
npm version "$VERSION" --no-git-tag-version
npm version "$VERSION" --no-git-tag-version --allow-same-version
TAG="latest"
;;
*)

View File

@@ -0,0 +1,62 @@
name: SonarQube Analysis
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
jobs:
sonarqube:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npx vitest run --exclude 'e2e/**' --coverage --coverage.reporter=lcov
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Install sonar-scanner
run: |
SONAR_SCANNER_VERSION=6.2.1.4610
ARCH=$(uname -m)
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
PLATFORM="linux-aarch64"
else
PLATFORM="linux-x64"
fi
curl -sSLo sonar-scanner.zip "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_SCANNER_VERSION}-${PLATFORM}.zip"
unzip -q sonar-scanner.zip
echo "$PWD/sonar-scanner-${SONAR_SCANNER_VERSION}-${PLATFORM}/bin" >> "$GITHUB_PATH"
- name: Run SonarQube analysis
env:
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
if [ -z "$SONAR_HOST_URL" ] || ! echo "$SONAR_HOST_URL" | grep -qE '^https?://'; then
echo "::error::SONAR_HOST_URL is missing or invalid (got: '$SONAR_HOST_URL'). Set it as a repo variable with full URL (e.g. https://sonar.example.com)."
exit 1
fi
sonar-scanner \
-Dsonar.host.url="$SONAR_HOST_URL" \
-Dsonar.login="$SONAR_TOKEN" \
-Dsonar.projectKey=cameleer-design-system \
-Dsonar.projectName="Cameleer Design System" \
-Dsonar.sources=src/design-system \
-Dsonar.tests=src/design-system \
-Dsonar.test.inclusions="**/*.test.tsx,**/*.test.ts" \
-Dsonar.javascript.lcov.reportPaths=coverage/lcov.info \
-Dsonar.exclusions="**/node_modules/**,**/dist/**"

3
.gitignore vendored
View File

@@ -2,3 +2,6 @@ node_modules/
dist/
.superpowers/
.worktrees/
test-results/
screenshots/
.playwright-mcp/

View File

@@ -37,9 +37,13 @@ Always read `COMPONENT_GUIDE.md` before building any UI feature. It contains dec
### Import Paths
```tsx
import { Button, Input } from '../design-system/primitives'
import { Modal, DataTable } from '../design-system/composites'
import type { Column } from '../design-system/composites'
import { Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer } from '../design-system/composites'
import type { Column, KpiItem, LogEntry } from '../design-system/composites'
import { AppShell } from '../design-system/layout/AppShell'
import { Sidebar } from '../design-system/layout/Sidebar/Sidebar'
import { SidebarTree } from '../design-system/layout/Sidebar/SidebarTree'
import type { SidebarTreeNode } from '../design-system/layout/Sidebar/SidebarTree'
import { useStarred } from '../design-system/layout/Sidebar/useStarred'
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
```
@@ -91,10 +95,14 @@ import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
```tsx
// All components from single entry
import { Button, Input, Modal, DataTable, AppShell } from '@cameleer/design-system'
import { Button, Input, Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, StatusText, AppShell } from '@cameleer/design-system'
// Sidebar (compound component — compose your own navigation)
import { Sidebar, SidebarTree, useStarred } from '@cameleer/design-system'
import type { SidebarTreeNode } from '@cameleer/design-system'
// Types
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
import type { Column, DataTableProps, SearchResult, KpiItem, LogEntry } from '@cameleer/design-system'
// Providers
import { ThemeProvider, useTheme } from '@cameleer/design-system'
@@ -104,6 +112,19 @@ import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
// Utils
import { hashColor } from '@cameleer/design-system'
// Recharts theme (for advanced charts — treemap, radar, heatmap, etc.)
import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
import type { ChartSeries, DataPoint } from '@cameleer/design-system'
// Styles (once, at app root)
import '@cameleer/design-system/style.css'
// Brand assets (static files via ./assets/* export)
import logo from '@cameleer/design-system/assets/cameleer3-logo.png' // full resolution
import logo32 from '@cameleer/design-system/assets/cameleer3-32.png' // 32×32 favicon
import logo180 from '@cameleer/design-system/assets/cameleer3-180.png' // Apple touch icon
import logo192 from '@cameleer/design-system/assets/cameleer3-192.png' // Android/PWA icon
import logo512 from '@cameleer/design-system/assets/cameleer3-512.png' // PWA splash, og:image
import logoSvg from '@cameleer/design-system/assets/cameleer3-logo.svg' // high-detail SVG logo
import camelSvg from '@cameleer/design-system/assets/camel-logo.svg' // simplified camel SVG
```

View File

@@ -33,14 +33,17 @@
### "I need to show status"
- Dot indicator → **StatusDot** (live, stale, dead, success, warning, error, running)
- Inline colored status value → **StatusText** (success, warning, error, running, muted — with optional bold)
- Labeled status → **Badge** with semantic color
- Removable label → **Tag**
### "I need navigation"
- App-level sidebar nav → **Sidebar** (via AppShell) — hierarchical trees with starring
- App-level sidebar nav → **Sidebar** (compound component — compose sections, trees, footer links)
- Sidebar tree section → **SidebarTree** (data-driven tree with expand/collapse, starring, keyboard nav)
- Starred items persistence → **useStarred** hook (localStorage-backed)
- Breadcrumb trail → **Breadcrumb**
- Paginated data → **Pagination** (standalone) or **DataTable** (built-in pagination)
- Hierarchical tree navigation → **TreeView** (generic) or **SidebarTree** (sidebar-specific, internal)
- Hierarchical tree navigation → **TreeView** (generic)
### "I need floating content"
- Tooltip on hover → **Tooltip**
@@ -54,9 +57,13 @@
- Time series → **LineChart**, **AreaChart**
- Categorical comparison → **BarChart**
- Inline trend → **Sparkline**
- Advanced charts (treemap, radar, heatmap, pie, etc.) → **Recharts** with `rechartsTheme` (see [Charting Strategy](#charting-strategy))
- Event log → **EventFeed**
- Processing pipeline (Gantt view) → **ProcessorTimeline**
- Processing pipeline (flow diagram) → **RouteFlow**
- Row of summary KPIs → **KpiStrip** (horizontal strip with colored borders, trends, sparklines)
- Scrollable log output → **LogViewer** (timestamped, severity-colored monospace entries)
- Searchable, selectable entity list → **EntityList** (search header, selection highlighting, pairs with SplitPane)
### "I need to organize content"
- Collapsible sections (standalone) → **Collapsible**
@@ -64,15 +71,17 @@
- Tabbed content → **Tabs**
- Tab switching with pill/segment style → **SegmentedTabs**
- Side panel inspector → **DetailPanel**
- Master/detail split layout → **SplitPane** (list on left, detail on right, configurable ratio)
- Section with title + action → **SectionHeader**
- Empty content placeholder → **EmptyState**
- Grouped content box → **Card** (with optional accent)
- Grouped content box → **Card** (with optional accent and title)
- Grouped items with header + meta + footer → **GroupCard** (e.g., app instances)
### "I need to display text"
- Code/JSON payload → **CodeBlock** (with line numbers, copy button)
- Monospace inline text → **MonoText**
- Keyboard shortcut hint → **KeyboardHint**
- Colored inline status text → **StatusText** (semantic color + optional bold, see also "I need to show status")
### "I need to show people/users"
- Single user avatar → **Avatar**
@@ -92,7 +101,24 @@
### Standard page layout
```
AppShell → Sidebar + TopBar + main content + optional DetailPanel
AppShell → Sidebar (compound) + TopBar + main content + optional DetailPanel
Sidebar compound API:
<Sidebar collapsed={bool} onCollapseToggle={fn} searchValue={str} onSearchChange={fn}>
<Sidebar.Header logo={node} title="str" version="str" />
<Sidebar.Section label="str" icon={node} open={bool} onToggle={fn} active={bool}>
<SidebarTree nodes={[...]} selectedPath="..." filterQuery="..." ... />
</Sidebar.Section>
<Sidebar.Footer>
<Sidebar.FooterLink icon={node} label="str" onClick={fn} active={bool} />
</Sidebar.Footer>
</Sidebar>
Notes:
- Search input auto-renders between Header and first Section (not above Header)
- Section headers have no chevron — the entire row is clickable to toggle
- The app controls all content — sections, order, tree data, collapse state
- Sidebar provides the frame, search input, and icon-rail collapse mode
```
### Data page pattern
@@ -115,6 +141,13 @@ Row of StatCard components (each with optional Sparkline and trend)
Below: charts (AreaChart, LineChart, BarChart)
```
### Master/detail management pattern
```
SplitPane + EntityList for CRUD list/detail screens (users, groups, roles)
EntityList provides: search header, add button, selectable list
SplitPane provides: responsive two-column layout with empty state
```
### Detail/inspector pattern
```
DetailPanel (right slide) with Tabs for sections OR children for scrollable content
@@ -147,6 +180,53 @@ StatCard strip (top, recalculates per scope)
URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/:instanceId
```
## Charting Strategy
The design system includes built-in **AreaChart**, **BarChart**, **LineChart**, and **Sparkline** components for standard use cases. For advanced chart types (treemap, radar, heatmap, pie, sankey, etc.), consuming apps should use **Recharts** directly with the design system's theme preset for visual consistency.
**Recharts is the app's dependency, not the design system's.** The design system only exports a theme config object.
### Setup in consuming app
```bash
npm install recharts
```
### Usage with theme preset
```tsx
import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
import {
ResponsiveContainer, LineChart, Line,
CartesianGrid, XAxis, YAxis, Tooltip, Legend,
} from 'recharts'
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid {...rechartsTheme.cartesianGrid} />
<XAxis dataKey="name" {...rechartsTheme.xAxis} />
<YAxis {...rechartsTheme.yAxis} />
<Tooltip {...rechartsTheme.tooltip} />
<Legend {...rechartsTheme.legend} />
<Line dataKey="value" stroke={CHART_COLORS[0]} strokeWidth={2} dot={false} />
<Line dataKey="other" stroke={CHART_COLORS[1]} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
```
### Exports
| Export | Description |
|--------|-------------|
| `rechartsTheme.cartesianGrid` | Dashed gridlines, subtle stroke |
| `rechartsTheme.xAxis` | Mono font axis ticks, subtle color |
| `rechartsTheme.yAxis` | Mono font axis ticks, no axis line |
| `rechartsTheme.tooltip` | Surface bg, border, shadow, monospace values |
| `rechartsTheme.legend` | Matching text size and color |
| `rechartsTheme.colors` | The 8 `CHART_COLORS` (CSS variables with light/dark support) |
| `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` |
| `ChartSeries` / `DataPoint` | Type interfaces for chart data |
## Component Index
| Component | Layer | When to use |
@@ -162,11 +242,11 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| Breadcrumb | composite | Navigation path showing current location |
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
| 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 and title header |
| Checkbox | primitive | Boolean input with label |
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
| Collapsible | primitive | Single expand/collapse section |
| CommandPalette | composite | Full-screen search and command interface |
| CommandPalette | composite | Full-screen search and command interface. `SearchCategory` is an open `string` type — known categories (application, exchange, attribute, route, agent) have built-in labels; custom categories render with title-cased labels and appear as dynamic tabs. |
| ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
| 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 |
@@ -174,6 +254,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| DetailPanel | composite | Slide-in side panel with tabs or children for scrollable content |
| Dropdown | composite | Action menu triggered by any element |
| EmptyState | primitive | Placeholder for empty content areas |
| EntityList | composite | Searchable, selectable entity list with add button. Pair with SplitPane for CRUD management screens |
| EventFeed | composite | Chronological event log with severity |
| FilterBar | composite | Search + filter controls for data views |
| GroupCard | composite | Card with header, meta row, children, and optional footer/alert. Used for grouping instances by application. |
@@ -183,16 +264,18 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| InlineEdit | primitive | Click-to-edit text field. Enter saves, Escape/blur cancels. Props: value, onSave, placeholder, disabled, className |
| Input | primitive | Single-line text input with optional icon |
| KeyboardHint | primitive | Keyboard shortcut display |
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
| Label | primitive | Form label with optional required asterisk |
| LineChart | composite | Time series line visualization |
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
| MenuItem | composite | Sidebar navigation item with health/count |
| Modal | composite | Generic dialog overlay with backdrop |
| MultiSelect | composite | Dropdown with searchable checkbox list and Apply action. Props: options, value, onChange, placeholder, searchable, disabled, className |
| MonoText | primitive | Inline monospace text (xs, sm, md) |
| Pagination | primitive | Page navigation controls |
| Popover | composite | Click-triggered floating panel with arrow |
| 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? |
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows and optional action menus. Props: processors, totalMs, onProcessorClick?, selectedIndex?, actions?, getActions?. Use `actions` for static menus or `getActions` for per-processor dynamic actions. |
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, click support, and optional action menus. Props: nodes, onNodeClick?, selectedIndex?, actions?, getActions?. Same action pattern as ProcessorTimeline. |
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
| RadioItem | primitive | Individual radio option within RadioGroup |
@@ -202,9 +285,11 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
| Sparkline | primitive | Inline mini chart for trends |
| SplitPane | composite | Two-column master/detail layout with configurable ratio and empty state |
| Spinner | primitive | Animated loading indicator |
| StatCard | primitive | KPI card with value, trend, optional sparkline |
| StatusDot | primitive | Colored dot for status indication |
| StatusText | primitive | Inline colored status span (success, warning, error, running, muted) with optional bold |
| Tabs | composite | Tabbed content switcher with optional counts |
| Tag | primitive | Removable colored label |
| Textarea | primitive | Multi-line text input with resize control |
@@ -218,8 +303,10 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| Component | Purpose |
|-----------|---------|
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
| 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, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
| Sidebar | Composable compound sidebar shell with icon-rail collapse mode. Sub-components: `Sidebar.Header`, `Sidebar.Section`, `Sidebar.Footer`, `Sidebar.FooterLink`. The app controls all content via children — the DS provides the frame. |
| SidebarTree | Data-driven tree for sidebar sections. Accepts `nodes: SidebarTreeNode[]` with expand/collapse, starring, keyboard nav, search filter, and path-based selection highlighting. |
| useStarred | Hook for localStorage-backed starred item IDs. Returns `{ starredIds, isStarred, toggleStar }`. |
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment slot (`ReactNode` — pass a string for a static label or a custom dropdown for interactive selection), user avatar |
## Import Paths
@@ -230,6 +317,10 @@ import { Button, Input, Badge } from './design-system/primitives'
import { DataTable, Modal, Toast } from './design-system/composites'
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
import { AppShell } from './design-system/layout/AppShell'
import { Sidebar } from './design-system/layout/Sidebar/Sidebar'
import { SidebarTree } from './design-system/layout/Sidebar/SidebarTree'
import type { SidebarTreeNode } from './design-system/layout/Sidebar/SidebarTree'
import { useStarred } from './design-system/layout/Sidebar/useStarred'
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
```
@@ -243,6 +334,35 @@ import type { Column, DataTableProps, SearchResult } from '@cameleer/design-syst
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
## Brand Assets
The design system ships logo assets as static files via the `./assets/*` package export. These are not React components — they resolve to file URLs when imported via a bundler. All PNGs have transparent backgrounds.
| File | Size | Use case |
|------|------|----------|
| `cameleer3-logo.png` | Original | Full resolution for print/marketing |
| `cameleer3-16.png` | 16×16 | Browser tab favicon |
| `cameleer3-32.png` | 32×32 | Standard favicon, bookmarks |
| `cameleer3-48.png` | 48×48 | Windows taskbar |
| `cameleer3-180.png` | 180×180 | Apple touch icon |
| `cameleer3-192.png` | 192×192 | Android/PWA icon |
| `cameleer3-512.png` | 512×512 | PWA splash, og:image |
| `cameleer3-logo.svg` | Vector | High-detail SVG logo (traced from PNG, transparent) |
| `camel-logo.svg` | Vector | Simplified camel SVG logo |
### Usage
```tsx
import logo from '@cameleer/design-system/assets/cameleer3-512.png'
<img src={logo} alt="Cameleer3" />
```
```html
<!-- Favicons in index.html -->
<link rel="icon" type="image/png" sizes="32x32" href="/cameleer3-32.png">
<link rel="apple-touch-icon" sizes="180x180" href="/cameleer3-180.png">
```
## Styling Rules
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)

3
assets/camel-logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
assets/cameleer3-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 983 B

BIN
assets/cameleer3-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
assets/cameleer3-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
assets/cameleer3-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
assets/cameleer3-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
assets/cameleer3-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

BIN
assets/cameleer3-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

144
assets/cameleer3-logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@@ -0,0 +1,573 @@
# Admin Components Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add SplitPane and EntityList composites to provide reusable master/detail layout and searchable entity list patterns, replacing ~150 lines of duplicated CSS and structure across admin RBAC tabs.
**Architecture:** SplitPane is a layout-only component providing a two-column grid with configurable ratio. EntityList provides a searchable, selectable list with render props for item content. They compose together naturally: EntityList slots into SplitPane's list panel.
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 2, 2b)
---
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `src/design-system/composites/SplitPane/SplitPane.tsx` | Create | Two-column grid layout with list/detail slots and empty state |
| `src/design-system/composites/SplitPane/SplitPane.module.css` | Create | Grid layout, scrollable panels, empty state styling |
| `src/design-system/composites/SplitPane/SplitPane.test.tsx` | Create | 5 test cases for SplitPane |
| `src/design-system/composites/EntityList/EntityList.tsx` | Create | Generic searchable, selectable list with render props |
| `src/design-system/composites/EntityList/EntityList.module.css` | Create | Header, scrollable list, item hover/selected states |
| `src/design-system/composites/EntityList/EntityList.test.tsx` | Create | 11 test cases for EntityList |
| `src/design-system/composites/index.ts` | Modify | Add SplitPane and EntityList exports |
---
### Task 1: SplitPane composite
**Files:**
- Create: `src/design-system/composites/SplitPane/SplitPane.tsx`
- Create: `src/design-system/composites/SplitPane/SplitPane.module.css`
- Create: `src/design-system/composites/SplitPane/SplitPane.test.tsx`
- [ ] **Step 1: Write SplitPane tests**
Create `src/design-system/composites/SplitPane/SplitPane.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { SplitPane } from './SplitPane'
describe('SplitPane', () => {
it('renders list and detail content', () => {
render(
<SplitPane
list={<div>User list</div>}
detail={<div>User detail</div>}
/>,
)
expect(screen.getByText('User list')).toBeInTheDocument()
expect(screen.getByText('User detail')).toBeInTheDocument()
})
it('shows default empty message when detail is null', () => {
render(
<SplitPane
list={<div>User list</div>}
detail={null}
/>,
)
expect(screen.getByText('Select an item to view details')).toBeInTheDocument()
})
it('shows custom empty message when detail is null', () => {
render(
<SplitPane
list={<div>User list</div>}
detail={null}
emptyMessage="Pick a user to see info"
/>,
)
expect(screen.getByText('Pick a user to see info')).toBeInTheDocument()
})
it('renders with different ratios', () => {
const { container, rerender } = render(
<SplitPane list={<div>List</div>} detail={<div>Detail</div>} ratio="1:1" />,
)
const pane = container.firstChild as HTMLElement
expect(pane.style.getPropertyValue('--split-columns')).toBe('1fr 1fr')
rerender(
<SplitPane list={<div>List</div>} detail={<div>Detail</div>} ratio="2:3" />,
)
expect(pane.style.getPropertyValue('--split-columns')).toBe('2fr 3fr')
})
it('accepts className', () => {
const { container } = render(
<SplitPane
list={<div>List</div>}
detail={<div>Detail</div>}
className="custom"
/>,
)
expect(container.firstChild).toHaveClass('custom')
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run src/design-system/composites/SplitPane/SplitPane.test.tsx`
Expected: FAIL — module not found
- [ ] **Step 3: Create SplitPane CSS module**
Create `src/design-system/composites/SplitPane/SplitPane.module.css`:
CSS extracted from `src/pages/Admin/UserManagement/UserManagement.module.css` (`.splitPane`, `.listPane`, `.detailPane`, `.emptyDetail`), generalized with a CSS custom property for the column ratio.
```css
.splitPane {
display: grid;
grid-template-columns: var(--split-columns, 1fr 2fr);
gap: 1px;
background: var(--border-subtle);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
min-height: 0;
height: 100%;
box-shadow: var(--shadow-card);
}
.listPane {
background: var(--bg-surface);
display: flex;
flex-direction: column;
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
overflow-y: auto;
}
.detailPane {
background: var(--bg-raised);
overflow-y: auto;
padding: 20px;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
}
.emptyDetail {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-faint);
font-size: 13px;
font-family: var(--font-body);
font-style: italic;
}
```
- [ ] **Step 4: Create SplitPane component**
Create `src/design-system/composites/SplitPane/SplitPane.tsx`:
```tsx
import type { ReactNode } from 'react'
import styles from './SplitPane.module.css'
interface SplitPaneProps {
list: ReactNode
detail: ReactNode | null
emptyMessage?: string
ratio?: '1:1' | '1:2' | '2:3'
className?: string
}
const ratioMap: Record<string, string> = {
'1:1': '1fr 1fr',
'1:2': '1fr 2fr',
'2:3': '2fr 3fr',
}
export function SplitPane({
list,
detail,
emptyMessage = 'Select an item to view details',
ratio = '1:2',
className,
}: SplitPaneProps) {
return (
<div
className={`${styles.splitPane} ${className ?? ''}`}
style={{ '--split-columns': ratioMap[ratio] } as React.CSSProperties}
>
<div className={styles.listPane}>{list}</div>
<div className={styles.detailPane}>
{detail !== null ? detail : (
<div className={styles.emptyDetail}>{emptyMessage}</div>
)}
</div>
</div>
)
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `npx vitest run src/design-system/composites/SplitPane/SplitPane.test.tsx`
Expected: 5 tests PASS
- [ ] **Step 6: Commit**
```bash
git add src/design-system/composites/SplitPane/SplitPane.tsx \
src/design-system/composites/SplitPane/SplitPane.module.css \
src/design-system/composites/SplitPane/SplitPane.test.tsx
git commit -m "feat: add SplitPane composite for master/detail layouts"
```
---
### Task 2: EntityList composite
**Files:**
- Create: `src/design-system/composites/EntityList/EntityList.tsx`
- Create: `src/design-system/composites/EntityList/EntityList.module.css`
- Create: `src/design-system/composites/EntityList/EntityList.test.tsx`
- [ ] **Step 1: Write EntityList tests**
Create `src/design-system/composites/EntityList/EntityList.test.tsx`:
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { EntityList } from './EntityList'
interface TestItem {
id: string
name: string
}
const items: TestItem[] = [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
{ id: '3', name: 'Charlie' },
]
const defaultProps = {
items,
renderItem: (item: TestItem) => <span>{item.name}</span>,
getItemId: (item: TestItem) => item.id,
}
describe('EntityList', () => {
it('renders all items', () => {
render(<EntityList {...defaultProps} />)
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
expect(screen.getByText('Charlie')).toBeInTheDocument()
})
it('calls onSelect when item clicked', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
render(<EntityList {...defaultProps} onSelect={onSelect} />)
await user.click(screen.getByText('Bob'))
expect(onSelect).toHaveBeenCalledWith('2')
})
it('highlights selected item', () => {
render(<EntityList {...defaultProps} selectedId="2" />)
const selectedOption = screen.getByText('Bob').closest('[role="option"]')
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
expect(selectedOption).toHaveClass(/selected/i)
})
it('renders search input when onSearch provided', () => {
render(<EntityList {...defaultProps} onSearch={vi.fn()} searchPlaceholder="Search users..." />)
expect(screen.getByPlaceholderText('Search users...')).toBeInTheDocument()
})
it('calls onSearch when typing in search', async () => {
const onSearch = vi.fn()
const user = userEvent.setup()
render(<EntityList {...defaultProps} onSearch={onSearch} />)
await user.type(screen.getByPlaceholderText('Search...'), 'alice')
expect(onSearch).toHaveBeenLastCalledWith('alice')
})
it('renders add button when onAdd provided', () => {
render(<EntityList {...defaultProps} onAdd={vi.fn()} addLabel="+ Add user" />)
expect(screen.getByRole('button', { name: '+ Add user' })).toBeInTheDocument()
})
it('calls onAdd when add button clicked', async () => {
const onAdd = vi.fn()
const user = userEvent.setup()
render(<EntityList {...defaultProps} onAdd={onAdd} addLabel="+ Add user" />)
await user.click(screen.getByRole('button', { name: '+ Add user' }))
expect(onAdd).toHaveBeenCalledOnce()
})
it('hides header when no search or add', () => {
const { container } = render(<EntityList {...defaultProps} />)
// No header element should be rendered (no search input, no add button)
expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument()
expect(container.querySelector('[class*="listHeader"]')).not.toBeInTheDocument()
})
it('shows empty message when items is empty', () => {
render(
<EntityList
items={[]}
renderItem={() => <span />}
getItemId={() => ''}
/>,
)
expect(screen.getByText('No items found')).toBeInTheDocument()
})
it('shows custom empty message', () => {
render(
<EntityList
items={[]}
renderItem={() => <span />}
getItemId={() => ''}
emptyMessage="No users match your search"
/>,
)
expect(screen.getByText('No users match your search')).toBeInTheDocument()
})
it('accepts className', () => {
const { container } = render(<EntityList {...defaultProps} className="custom" />)
expect(container.firstChild).toHaveClass('custom')
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run src/design-system/composites/EntityList/EntityList.test.tsx`
Expected: FAIL — module not found
- [ ] **Step 3: Create EntityList CSS module**
Create `src/design-system/composites/EntityList/EntityList.module.css`:
CSS extracted from `src/pages/Admin/UserManagement/UserManagement.module.css` (`.listHeader`, `.listHeaderSearch`, `.entityList`, `.entityItem`, `.entityItemSelected`), generalized for reuse.
```css
.entityListRoot {
display: flex;
flex-direction: column;
height: 100%;
}
.listHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.listHeaderSearch {
flex: 1;
}
.list {
flex: 1;
overflow-y: auto;
}
.entityItem {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
transition: background 0.1s;
border-bottom: 1px solid var(--border-subtle);
}
.entityItem:hover {
background: var(--bg-hover);
}
.entityItemSelected {
background: var(--amber-bg);
border-left: 3px solid var(--amber);
}
.emptyMessage {
padding: 32px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}
```
- [ ] **Step 4: Create EntityList component**
Create `src/design-system/composites/EntityList/EntityList.tsx`:
The component uses `role="listbox"` / `role="option"` for accessibility, matching the pattern in `UsersTab.tsx`. It delegates search input and add button to the existing `Input` and `Button` primitives.
```tsx
import { useState, type ReactNode } from 'react'
import { Input } from '../../primitives/Input/Input'
import { Button } from '../../primitives/Button/Button'
import styles from './EntityList.module.css'
interface EntityListProps<T> {
items: T[]
renderItem: (item: T, isSelected: boolean) => ReactNode
getItemId: (item: T) => string
selectedId?: string
onSelect?: (id: string) => void
searchPlaceholder?: string
onSearch?: (query: string) => void
addLabel?: string
onAdd?: () => void
emptyMessage?: string
className?: string
}
export function EntityList<T>({
items,
renderItem,
getItemId,
selectedId,
onSelect,
searchPlaceholder = 'Search...',
onSearch,
addLabel,
onAdd,
emptyMessage = 'No items found',
className,
}: EntityListProps<T>) {
const [searchValue, setSearchValue] = useState('')
const showHeader = !!onSearch || !!onAdd
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value
setSearchValue(value)
onSearch?.(value)
}
function handleSearchClear() {
setSearchValue('')
onSearch?.('')
}
return (
<div className={`${styles.entityListRoot} ${className ?? ''}`}>
{showHeader && (
<div className={styles.listHeader}>
{onSearch && (
<Input
placeholder={searchPlaceholder}
value={searchValue}
onChange={handleSearchChange}
onClear={handleSearchClear}
className={styles.listHeaderSearch}
/>
)}
{onAdd && addLabel && (
<Button size="sm" variant="secondary" onClick={onAdd}>
{addLabel}
</Button>
)}
</div>
)}
<div className={styles.list} role="listbox">
{items.map((item) => {
const id = getItemId(item)
const isSelected = id === selectedId
return (
<div
key={id}
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
onClick={() => onSelect?.(id)}
role="option"
tabIndex={0}
aria-selected={isSelected}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect?.(id)
}
}}
>
{renderItem(item, isSelected)}
</div>
)
})}
{items.length === 0 && (
<div className={styles.emptyMessage}>{emptyMessage}</div>
)}
</div>
</div>
)
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `npx vitest run src/design-system/composites/EntityList/EntityList.test.tsx`
Expected: 11 tests PASS
- [ ] **Step 6: Commit**
```bash
git add src/design-system/composites/EntityList/EntityList.tsx \
src/design-system/composites/EntityList/EntityList.module.css \
src/design-system/composites/EntityList/EntityList.test.tsx
git commit -m "feat: add EntityList composite for searchable, selectable lists"
```
---
### Task 3: Barrel exports & full test suite
**Files:**
- Modify: `src/design-system/composites/index.ts`
- [ ] **Step 1: Add exports to barrel**
Add these lines to `src/design-system/composites/index.ts` in alphabetical position.
After the `DetailPanel` export (line 13), add:
```ts
export { EntityList } from './EntityList/EntityList'
```
After the `LineChart` export (line 19), before `LoginDialog`, add:
```ts
// (no change needed here — LoginDialog is already present)
```
After the `ShortcutsBar` export (line 33), before `SegmentedTabs`, add:
```ts
export { SplitPane } from './SplitPane/SplitPane'
```
The resulting new lines in `index.ts` (in their alphabetical positions):
```ts
export { EntityList } from './EntityList/EntityList'
```
```ts
export { SplitPane } from './SplitPane/SplitPane'
```
- [ ] **Step 2: Run the full component test suite**
Run: `npx vitest run src/design-system/composites/SplitPane/ src/design-system/composites/EntityList/`
Expected: All 16 tests PASS (5 SplitPane + 11 EntityList)
- [ ] **Step 3: Run the full project test suite to check for regressions**
Run: `npx vitest run`
Expected: All tests PASS
- [ ] **Step 4: Commit**
```bash
git add src/design-system/composites/index.ts
git commit -m "feat: export SplitPane and EntityList from composites barrel"
```

View File

@@ -0,0 +1,431 @@
# Documentation Updates Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Update COMPONENT_GUIDE.md and Inventory page with entries and demos for all new components: KpiStrip, SplitPane, EntityList, LogViewer, StatusText, and Card title extension.
**Architecture:** COMPONENT_GUIDE.md gets new decision tree entries and component index rows. Inventory page gets DemoCard sections with realistic sample data for each new component.
**Tech Stack:** React, TypeScript, CSS Modules
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Documentation Updates section)
---
## Task 1: Update COMPONENT_GUIDE.md
**File:** `COMPONENT_GUIDE.md`
### Steps
- [ ] **1a.** In the `"I need to show status"` decision tree (line ~34), add StatusText entry after StatusDot:
```markdown
- Inline colored status value → **StatusText** (success, warning, error, running, muted — with optional bold)
```
- [ ] **1b.** In the `"I need to display data"` decision tree (line ~51), add three entries after the EventFeed line:
```markdown
- Row of summary KPIs → **KpiStrip** (horizontal strip with colored borders, trends, sparklines)
- Scrollable log output → **LogViewer** (timestamped, severity-colored monospace entries)
- Searchable, selectable entity list → **EntityList** (search header, selection highlighting, pairs with SplitPane)
```
- [ ] **1c.** In the `"I need to organize content"` decision tree (line ~62), add SplitPane entry after DetailPanel and update the Card entry:
After the `- Side panel inspector → **DetailPanel**` line, add:
```markdown
- Master/detail split layout → **SplitPane** (list on left, detail on right, configurable ratio)
```
Update the existing Card line from:
```markdown
- Grouped content box → **Card** (with optional accent)
```
to:
```markdown
- Grouped content box → **Card** (with optional accent and title)
```
- [ ] **1d.** In the `"I need to display text"` decision tree (line ~72), add StatusText cross-reference:
```markdown
- Colored inline status text → **StatusText** (semantic color + optional bold, see also "I need to show status")
```
- [ ] **1e.** Add a new composition pattern after the existing "KPI dashboard" pattern (line ~113):
```markdown
### Master/detail management pattern
```
SplitPane + EntityList for CRUD list/detail screens (users, groups, roles)
EntityList provides: search header, add button, selectable list
SplitPane provides: responsive two-column layout with empty state
```
```
- [ ] **1f.** Add five new rows to the Component Index table (maintaining alphabetical order):
After the `EventFeed` row:
```markdown
| EntityList | composite | Searchable, selectable entity list with add button. Pair with SplitPane for CRUD management screens |
```
After the `KeyboardHint` row:
```markdown
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
```
After the `LineChart` row:
```markdown
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
```
After the `Sparkline` row:
```markdown
| SplitPane | composite | Two-column master/detail layout with configurable ratio and empty state |
| StatusText | primitive | Inline colored status span (success, warning, error, running, muted) with optional bold |
```
- [ ] **1g.** Update the existing `Card` row in the Component Index from:
```markdown
| Card | primitive | Content container with optional accent border |
```
to:
```markdown
| Card | primitive | Content container with optional accent border and title header |
```
---
## Task 2: Add StatusText demo to PrimitivesSection
**File:** `src/pages/Inventory/sections/PrimitivesSection.tsx`
### Steps
- [ ] **2a.** Add `StatusText` to the import from `'../../../design-system/primitives'` (insert alphabetically after `StatCard`):
```tsx
StatusText,
```
- [ ] **2b.** Add a new DemoCard after the StatusDot demo (after line ~560, before the Tag demo). Insert this block:
```tsx
{/* 29. StatusText */}
<DemoCard
id="statustext"
title="StatusText"
description="Inline coloured text for status values — five semantic variants with optional bold."
>
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
<div className={styles.demoAreaRow}>
<StatusText variant="success">99.8% uptime</StatusText>
<StatusText variant="warning">SLA at risk</StatusText>
<StatusText variant="error">BREACH</StatusText>
<StatusText variant="running">Processing</StatusText>
<StatusText variant="muted">N/A</StatusText>
</div>
<div className={styles.demoAreaRow}>
<StatusText variant="success" bold>99.8% uptime</StatusText>
<StatusText variant="warning" bold>SLA at risk</StatusText>
<StatusText variant="error" bold>BREACH</StatusText>
<StatusText variant="running" bold>Processing</StatusText>
<StatusText variant="muted" bold>N/A</StatusText>
</div>
</div>
</DemoCard>
```
Note: Renumber subsequent demos (Tag becomes 30, Textarea becomes 31, Toggle becomes 32, Tooltip becomes 33).
---
## Task 3: Update Card demo in PrimitivesSection
**File:** `src/pages/Inventory/sections/PrimitivesSection.tsx`
### Steps
- [ ] **3a.** Update the Card DemoCard description from:
```tsx
description="Surface container with optional left-border accent colour."
```
to:
```tsx
description="Surface container with optional left-border accent colour and title header."
```
- [ ] **3b.** Add a title prop example to the Card demo. After the existing `Card accent="error"` line (~212), add:
```tsx
<Card title="Throughput (msg/s)">
<div style={{ padding: '8px 12px', fontSize: 13 }}>Card with title header and separator</div>
</Card>
<Card accent="amber" title="Error Rate">
<div style={{ padding: '8px 12px', fontSize: 13 }}>Title + accent combined</div>
</Card>
```
---
## Task 4: Add composite demos to CompositesSection
**File:** `src/pages/Inventory/sections/CompositesSection.tsx`
### Steps
- [ ] **4a.** Add new imports. Add `KpiStrip`, `SplitPane`, `EntityList`, `LogViewer` to the import from `'../../../design-system/composites'` (insert alphabetically):
```tsx
EntityList,
KpiStrip,
LogViewer,
SplitPane,
```
Also add `Badge` and `Avatar` to the import from `'../../../design-system/primitives'` (needed for EntityList demo renderItem):
```tsx
import { Avatar, Badge, Button } from '../../../design-system/primitives'
```
- [ ] **4b.** Add sample data constants after the existing sample data section (before the `CompositesSection` function). Add:
```tsx
// ── Sample data for new composites ───────────────────────────────────────────
const KPI_ITEMS = [
{
label: 'Exchanges',
value: '12,847',
trend: { label: '↑ +8.2%', variant: 'success' as const },
subtitle: 'Last 24h',
sparkline: [40, 55, 48, 62, 70, 65, 78],
borderColor: 'var(--amber)',
},
{
label: 'Error Rate',
value: '0.34%',
trend: { label: '↑ +0.12pp', variant: 'error' as const },
subtitle: 'Above threshold',
sparkline: [10, 12, 11, 15, 18, 22, 19],
borderColor: 'var(--error)',
},
{
label: 'Avg Latency',
value: '142ms',
trend: { label: '↓ -12ms', variant: 'success' as const },
subtitle: 'P95: 380ms',
borderColor: 'var(--success)',
},
{
label: 'Active Routes',
value: '37',
trend: { label: '±0', variant: 'muted' as const },
subtitle: '3 paused',
borderColor: 'var(--running)',
},
]
const ENTITY_LIST_ITEMS = [
{ id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin' },
{ id: '2', name: 'Bob Chen', email: 'bob@example.com', role: 'Editor' },
{ id: '3', name: 'Carol Smith', email: 'carol@example.com', role: 'Viewer' },
{ id: '4', name: 'David Park', email: 'david@example.com', role: 'Editor' },
{ id: '5', name: 'Eva Martinez', email: 'eva@example.com', role: 'Admin' },
]
const LOG_ENTRIES = [
{ timestamp: '2026-03-24T10:00:01Z', level: 'info' as const, message: 'Route timer-aggregator started successfully' },
{ timestamp: '2026-03-24T10:00:03Z', level: 'debug' as const, message: 'Polling endpoint https://api.internal/health — 200 OK' },
{ timestamp: '2026-03-24T10:00:15Z', level: 'warn' as const, message: 'Retry queue depth at 847 — approaching threshold (1000)' },
{ timestamp: '2026-03-24T10:00:22Z', level: 'error' as const, message: 'Exchange failed: Connection refused to jdbc:postgresql://db-primary:5432/orders' },
{ timestamp: '2026-03-24T10:00:23Z', level: 'info' as const, message: 'Failover activated — routing to db-secondary' },
{ timestamp: '2026-03-24T10:00:30Z', level: 'info' as const, message: 'Exchange completed in 142ms via fallback route' },
{ timestamp: '2026-03-24T10:00:45Z', level: 'debug' as const, message: 'Metrics flush: 328 data points written to InfluxDB' },
{ timestamp: '2026-03-24T10:01:00Z', level: 'warn' as const, message: 'Memory usage at 78% — GC scheduled' },
]
```
- [ ] **4c.** Add state variables inside the `CompositesSection` function for EntityList demo:
```tsx
// EntityList state
const [selectedEntityId, setSelectedEntityId] = useState<string | undefined>('1')
const [entitySearch, setEntitySearch] = useState('')
```
- [ ] **4d.** Add KpiStrip demo after the existing GroupCard demo. Insert a new DemoCard:
```tsx
{/* KpiStrip */}
<DemoCard
id="kpistrip"
title="KpiStrip"
description="Horizontal row of KPI cards with coloured left border, trend indicator, subtitle, and optional sparkline."
>
<div style={{ width: '100%' }}>
<KpiStrip items={KPI_ITEMS} />
</div>
</DemoCard>
```
- [ ] **4e.** Add SplitPane demo:
```tsx
{/* SplitPane */}
<DemoCard
id="splitpane"
title="SplitPane"
description="Two-column master/detail layout with configurable ratio and empty-state placeholder."
>
<div style={{ width: '100%', height: 200 }}>
<SplitPane
list={
<div style={{ padding: 16, fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>Items</div>
<div>Item A</div>
<div>Item B</div>
<div>Item C</div>
</div>
}
detail={
<div style={{ padding: 16, fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>Detail View</div>
<div>Select an item on the left to see its details here.</div>
</div>
}
ratio="1:2"
/>
</div>
</DemoCard>
```
- [ ] **4f.** Add EntityList demo:
```tsx
{/* EntityList */}
<DemoCard
id="entitylist"
title="EntityList"
description="Searchable, selectable entity list with add button — designed to pair with SplitPane."
>
<div style={{ width: '100%', height: 260 }}>
<EntityList
items={ENTITY_LIST_ITEMS.filter(u =>
u.name.toLowerCase().includes(entitySearch.toLowerCase())
)}
renderItem={(item, isSelected) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar name={item.name} size="sm" />
<div>
<div style={{ fontSize: 13, fontWeight: isSelected ? 600 : 400 }}>{item.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.email}</div>
</div>
<Badge label={item.role} style={{ marginLeft: 'auto' }} />
</div>
)}
getItemId={(item) => item.id}
selectedId={selectedEntityId}
onSelect={setSelectedEntityId}
searchPlaceholder="Search users..."
onSearch={setEntitySearch}
addLabel="+ Add user"
onAdd={() => {}}
/>
</div>
</DemoCard>
```
- [ ] **4g.** Add LogViewer demo:
```tsx
{/* LogViewer */}
<DemoCard
id="logviewer"
title="LogViewer"
description="Scrollable log output with timestamped, severity-coloured monospace entries and auto-scroll."
>
<div style={{ width: '100%' }}>
<LogViewer entries={LOG_ENTRIES} maxHeight={240} />
</div>
</DemoCard>
```
- [ ] **4h.** Verify all four new DemoCards are placed in alphabetical order among existing demos — EntityList after EventFeed, KpiStrip after GroupCard, LogViewer after LoginForm, SplitPane after ShortcutsBar. Adjust comment numbering accordingly.
---
## Task 5: Update Inventory nav
**File:** `src/pages/Inventory/Inventory.tsx`
### Steps
- [ ] **5a.** Add `StatusText` to the Primitives nav components array (insert alphabetically after `StatusDot`):
```tsx
{ label: 'StatusText', href: '#statustext' },
```
- [ ] **5b.** Add four entries to the Composites nav components array (insert alphabetically):
After `EventFeed`:
```tsx
{ label: 'EntityList', href: '#entitylist' },
```
After `GroupCard`:
```tsx
{ label: 'KpiStrip', href: '#kpistrip' },
```
After `LoginForm`:
```tsx
{ label: 'LogViewer', href: '#logviewer' },
```
After `ShortcutsBar`:
```tsx
{ label: 'SplitPane', href: '#splitpane' },
```
---
## Task 6: Commit all documentation
### Steps
- [ ] **6a.** Run `npx vitest run src/pages/Inventory` to verify Inventory page has no import/type errors (if tests exist for it).
- [ ] **6b.** Stage changed files:
- `COMPONENT_GUIDE.md`
- `src/pages/Inventory/Inventory.tsx`
- `src/pages/Inventory/sections/PrimitivesSection.tsx`
- `src/pages/Inventory/sections/CompositesSection.tsx`
- [ ] **6c.** Commit with message: `docs: add COMPONENT_GUIDE entries and Inventory demos for KpiStrip, SplitPane, EntityList, LogViewer, StatusText, Card title`
---
## Dependency Notes
- **Tasks 1-5 are independent** and can be worked in any order.
- **Task 6 depends on Tasks 1-5** being complete.
- **All tasks depend on the components already existing** — StatusText, Card title extension, KpiStrip, SplitPane, EntityList, and LogViewer must be built and exported from their barrel files before the Inventory demos will compile.
## Files Modified
| File | Change |
|------|--------|
| `COMPONENT_GUIDE.md` | Decision tree entries + component index rows |
| `src/pages/Inventory/Inventory.tsx` | 5 new nav entries (1 primitive + 4 composites) |
| `src/pages/Inventory/sections/PrimitivesSection.tsx` | StatusText demo + Card title demo update |
| `src/pages/Inventory/sections/CompositesSection.tsx` | KpiStrip, SplitPane, EntityList, LogViewer demos with sample data |

View File

@@ -0,0 +1,770 @@
# Login Dialog Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add composable `LoginForm` and `LoginDialog` components to the Cameleer3 design system with credential + social login support, client-side validation, and full dark mode compatibility.
**Architecture:** `LoginForm` is the core content component with all form logic, validation, and layout. `LoginDialog` is a thin wrapper that renders `LoginForm` inside `Modal size="sm"`. Both live in `src/design-system/composites/LoginForm/` and are exported from the composites barrel.
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
**Spec:** `docs/superpowers/specs/2026-03-24-login-dialog-design.md`
---
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `src/design-system/composites/LoginForm/LoginForm.tsx` | Create | Core form component with validation, social providers, all layout |
| `src/design-system/composites/LoginForm/LoginForm.module.css` | Create | All styles using design tokens |
| `src/design-system/composites/LoginForm/LoginForm.test.tsx` | Create | 21 test cases for LoginForm |
| `src/design-system/composites/LoginForm/LoginDialog.tsx` | Create | Thin Modal wrapper |
| `src/design-system/composites/LoginForm/LoginDialog.test.tsx` | Create | 5 test cases for LoginDialog |
| `src/design-system/composites/index.ts` | Modify | Add LoginForm, LoginDialog, and type exports |
---
### Task 1: LoginForm — Rendering Tests & Basic Structure
**Files:**
- Create: `src/design-system/composites/LoginForm/LoginForm.tsx`
- Create: `src/design-system/composites/LoginForm/LoginForm.module.css`
- Create: `src/design-system/composites/LoginForm/LoginForm.test.tsx`
- [ ] **Step 1: Write rendering tests**
Create `src/design-system/composites/LoginForm/LoginForm.test.tsx`:
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { LoginForm } from './LoginForm'
const socialProviders = [
{ label: 'Continue with Google', onClick: vi.fn() },
{ label: 'Continue with GitHub', onClick: vi.fn() },
]
const allProps = {
logo: <div data-testid="logo">Logo</div>,
title: 'Welcome back',
socialProviders,
onSubmit: vi.fn(),
onForgotPassword: vi.fn(),
onSignUp: vi.fn(),
}
describe('LoginForm', () => {
describe('rendering', () => {
it('renders all elements when all props provided', () => {
render(<LoginForm {...allProps} />)
expect(screen.getByTestId('logo')).toBeInTheDocument()
expect(screen.getByText('Welcome back')).toBeInTheDocument()
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
expect(screen.getByText('Continue with GitHub')).toBeInTheDocument()
expect(screen.getByText('or')).toBeInTheDocument()
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument()
expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument()
expect(screen.getByText(/sign up/i)).toBeInTheDocument()
})
it('renders default title when title prop omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.getByText('Sign in')).toBeInTheDocument()
})
it('hides social section when socialProviders is empty', () => {
render(<LoginForm onSubmit={vi.fn()} socialProviders={[]} />)
expect(screen.queryByText('or')).not.toBeInTheDocument()
})
it('hides social section when socialProviders is omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.queryByText('or')).not.toBeInTheDocument()
})
it('hides forgot password link when onForgotPassword omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.queryByText(/forgot password/i)).not.toBeInTheDocument()
})
it('hides sign up link when onSignUp omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.queryByText(/sign up/i)).not.toBeInTheDocument()
})
it('hides credentials section when onSubmit omitted (social only)', () => {
render(<LoginForm socialProviders={socialProviders} />)
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument()
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument()
expect(screen.queryByText('or')).not.toBeInTheDocument()
// Social buttons should still render
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
})
it('shows server error Alert when error prop set', () => {
render(<LoginForm onSubmit={vi.fn()} error="Invalid credentials" />)
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
})
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx`
Expected: FAIL — module not found
- [ ] **Step 3: Create LoginForm component with basic rendering**
Create `src/design-system/composites/LoginForm/LoginForm.module.css`:
```css
.loginForm {
display: flex;
flex-direction: column;
align-items: center;
font-family: var(--font-body);
width: 100%;
}
.logo {
margin-bottom: 8px;
}
.title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 20px;
}
.error {
width: 100%;
margin-bottom: 16px;
}
.socialSection {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-bottom: 20px;
}
.socialButton {
width: 100%;
justify-content: center;
}
.divider {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin-bottom: 20px;
}
.dividerLine {
flex: 1;
height: 1px;
background: var(--border);
}
.dividerText {
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.fields {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
}
.rememberRow {
display: flex;
justify-content: space-between;
align-items: center;
}
.forgotLink {
font-size: 11px;
color: var(--amber);
font-weight: 500;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-family: var(--font-body);
}
.forgotLink:hover {
text-decoration: underline;
}
.submitButton {
width: 100%;
}
.signUpText {
text-align: center;
font-size: 12px;
color: var(--text-secondary);
}
.signUpLink {
color: var(--amber);
font-weight: 500;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-family: var(--font-body);
font-size: 12px;
}
.signUpLink:hover {
text-decoration: underline;
}
```
Create `src/design-system/composites/LoginForm/LoginForm.tsx`:
```tsx
import { useEffect, useRef, useState, type ReactNode, type FormEvent } from 'react'
import { Button } from '../../primitives/Button/Button'
import { Input } from '../../primitives/Input/Input'
import { Checkbox } from '../../primitives/Checkbox/Checkbox'
import { FormField } from '../../primitives/FormField/FormField'
import { Alert } from '../../primitives/Alert/Alert'
import styles from './LoginForm.module.css'
export interface SocialProvider {
label: string
icon?: ReactNode
onClick: () => void
}
export interface LoginFormProps {
logo?: ReactNode
title?: string
socialProviders?: SocialProvider[]
onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void
onForgotPassword?: () => void
onSignUp?: () => void
error?: string
loading?: boolean
className?: string
}
interface FieldErrors {
email?: string
password?: string
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function validate(email: string, password: string): FieldErrors {
const errors: FieldErrors = {}
if (!email) {
errors.email = 'Email is required'
} else if (!EMAIL_REGEX.test(email)) {
errors.email = 'Please enter a valid email address'
}
if (!password) {
errors.password = 'Password is required'
} else if (password.length < 8) {
errors.password = 'Password must be at least 8 characters'
}
return errors
}
export function LoginForm({
logo,
title = 'Sign in',
socialProviders,
onSubmit,
onForgotPassword,
onSignUp,
error,
loading = false,
className,
}: LoginFormProps) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const [submitted, setSubmitted] = useState(false)
const emailRef = useRef<HTMLInputElement>(null)
// Auto-focus first input on mount
useEffect(() => {
emailRef.current?.focus()
}, [])
// Reset submitted flag when error prop changes (new server error from re-attempt)
useEffect(() => {
if (error) setSubmitted(false)
}, [error])
// Server error is shown from prop, hidden after next submit attempt
const showServerError = error && !submitted
const hasSocial = socialProviders && socialProviders.length > 0
const hasCredentials = !!onSubmit
const showDivider = hasSocial && hasCredentials
function handleSubmit(e: FormEvent) {
e.preventDefault()
setSubmitted(true)
const errors = validate(email, password)
setFieldErrors(errors)
if (Object.keys(errors).length === 0) {
onSubmit?.({ email, password, remember })
}
}
return (
<div className={`${styles.loginForm} ${className ?? ''}`}>
{logo && <div className={styles.logo}>{logo}</div>}
<h2 className={styles.title}>{title}</h2>
{showServerError && (
<div className={styles.error}>
<Alert variant="error">{error}</Alert>
</div>
)}
{hasSocial && (
<div className={styles.socialSection}>
{socialProviders.map((provider) => (
<Button
key={provider.label}
variant="secondary"
className={styles.socialButton}
onClick={provider.onClick}
disabled={loading}
type="button"
>
{provider.icon}
{provider.label}
</Button>
))}
</div>
)}
{showDivider && (
<div className={styles.divider}>
<div className={styles.dividerLine} />
<span className={styles.dividerText}>or</span>
<div className={styles.dividerLine} />
</div>
)}
{hasCredentials && (
<form
className={styles.fields}
onSubmit={handleSubmit}
aria-label="Sign in"
noValidate
>
<FormField label="Email" htmlFor="login-email" required error={fieldErrors.email}>
<Input
ref={emailRef}
id="login-email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => {
setEmail(e.target.value)
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }))
}}
disabled={loading}
/>
</FormField>
<FormField label="Password" htmlFor="login-password" required error={fieldErrors.password}>
<Input
id="login-password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }))
}}
disabled={loading}
/>
</FormField>
<div className={styles.rememberRow}>
<Checkbox
label="Remember me"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
disabled={loading}
/>
{onForgotPassword && (
<button
type="button"
className={styles.forgotLink}
onClick={onForgotPassword}
>
Forgot password?
</button>
)}
</div>
<Button
variant="primary"
type="submit"
loading={loading}
className={styles.submitButton}
>
Sign in
</Button>
{onSignUp && (
<div className={styles.signUpText}>
Don&apos;t have an account?{' '}
<button
type="button"
className={styles.signUpLink}
onClick={onSignUp}
>
Sign up
</button>
</div>
)}
</form>
)}
{!hasCredentials && onSignUp && (
<div className={styles.signUpText}>
Don&apos;t have an account?{' '}
<button
type="button"
className={styles.signUpLink}
onClick={onSignUp}
>
Sign up
</button>
</div>
)}
</div>
)
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx`
Expected: 8 tests PASS
- [ ] **Step 5: Commit**
```bash
git add src/design-system/composites/LoginForm/LoginForm.tsx \
src/design-system/composites/LoginForm/LoginForm.module.css \
src/design-system/composites/LoginForm/LoginForm.test.tsx
git commit -m "feat: add LoginForm component with rendering tests"
```
---
### Task 2: LoginForm — Validation Tests & Behavior
**Files:**
- Modify: `src/design-system/composites/LoginForm/LoginForm.test.tsx`
- [ ] **Step 1: Add validation and interaction tests**
Append to the `describe('LoginForm')` block in `LoginForm.test.tsx`:
```tsx
import userEvent from '@testing-library/user-event'
// Add these inside the existing describe('LoginForm') block, after the rendering describe:
describe('validation', () => {
it('validates required email', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Email is required')).toBeInTheDocument()
})
it('validates email format', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'notanemail')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument()
})
it('validates required password', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Password is required')).toBeInTheDocument()
})
it('validates password minimum length', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'short')
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument()
})
it('clears field errors on typing', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Email is required')).toBeInTheDocument()
await user.type(screen.getByLabelText(/email/i), 't')
expect(screen.queryByText('Email is required')).not.toBeInTheDocument()
})
it('calls onSubmit with credentials when valid', async () => {
const onSubmit = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByLabelText(/remember me/i))
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
remember: true,
})
})
it('does not call onSubmit when validation fails', async () => {
const onSubmit = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={onSubmit} />)
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe('loading state', () => {
it('disables form inputs when loading', () => {
render(<LoginForm {...allProps} loading />)
expect(screen.getByLabelText(/email/i)).toBeDisabled()
expect(screen.getByLabelText(/password/i)).toBeDisabled()
expect(screen.getByLabelText(/remember me/i)).toBeDisabled()
})
it('shows spinner on submit button when loading', () => {
render(<LoginForm {...allProps} loading />)
const submitBtn = screen.getByRole('button', { name: 'Sign in' })
expect(submitBtn).toBeDisabled()
// Button component renders Spinner when loading=true
expect(submitBtn.querySelector('[class*="spinner"]')).toBeInTheDocument()
})
it('disables social buttons when loading', () => {
render(<LoginForm {...allProps} loading />)
expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Continue with GitHub' })).toBeDisabled()
})
})
describe('callbacks', () => {
it('calls social provider onClick when clicked', async () => {
const onClick = vi.fn()
const user = userEvent.setup()
render(<LoginForm socialProviders={[{ label: 'Continue with Google', onClick }]} onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Continue with Google' }))
expect(onClick).toHaveBeenCalledOnce()
})
it('calls onForgotPassword when link clicked', async () => {
const onForgotPassword = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} onForgotPassword={onForgotPassword} />)
await user.click(screen.getByText(/forgot password/i))
expect(onForgotPassword).toHaveBeenCalledOnce()
})
it('calls onSignUp when link clicked', async () => {
const onSignUp = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} onSignUp={onSignUp} />)
await user.click(screen.getByText(/sign up/i))
expect(onSignUp).toHaveBeenCalledOnce()
})
})
```
- [ ] **Step 2: Run tests to verify they pass**
Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx`
Expected: 21 tests PASS (8 rendering + 7 validation + 3 loading + 3 callbacks)
- [ ] **Step 3: Commit**
```bash
git add src/design-system/composites/LoginForm/LoginForm.test.tsx
git commit -m "test: add validation and interaction tests for LoginForm"
```
---
### Task 3: LoginDialog — Component & Tests
**Files:**
- Create: `src/design-system/composites/LoginForm/LoginDialog.tsx`
- Create: `src/design-system/composites/LoginForm/LoginDialog.test.tsx`
- [ ] **Step 1: Write LoginDialog tests**
Create `src/design-system/composites/LoginForm/LoginDialog.test.tsx`:
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginDialog } from './LoginDialog'
const defaultProps = {
open: true,
onClose: vi.fn(),
onSubmit: vi.fn(),
}
describe('LoginDialog', () => {
it('renders Modal with LoginForm when open', () => {
render(<LoginDialog {...defaultProps} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Sign in')).toBeInTheDocument()
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
})
it('does not render when closed', () => {
render(<LoginDialog {...defaultProps} open={false} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('calls onClose on Esc', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<LoginDialog {...defaultProps} onClose={onClose} />)
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
it('calls onClose on backdrop click', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<LoginDialog {...defaultProps} onClose={onClose} />)
await user.click(screen.getByTestId('modal-backdrop'))
expect(onClose).toHaveBeenCalled()
})
it('passes LoginForm props through', () => {
render(
<LoginDialog
{...defaultProps}
title="Welcome"
socialProviders={[{ label: 'Continue with Google', onClick: vi.fn() }]}
error="Bad credentials"
/>,
)
expect(screen.getByText('Welcome')).toBeInTheDocument()
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
expect(screen.getByText('Bad credentials')).toBeInTheDocument()
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run src/design-system/composites/LoginForm/LoginDialog.test.tsx`
Expected: FAIL — module not found
- [ ] **Step 3: Create LoginDialog component**
Create `src/design-system/composites/LoginForm/LoginDialog.tsx`:
```tsx
import { Modal } from '../Modal/Modal'
import { LoginForm, type LoginFormProps } from './LoginForm'
export interface LoginDialogProps extends LoginFormProps {
open: boolean
onClose: () => void
}
export function LoginDialog({ open, onClose, className, ...formProps }: LoginDialogProps) {
return (
<Modal open={open} onClose={onClose} size="sm" className={className}>
<LoginForm {...formProps} />
</Modal>
)
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run src/design-system/composites/LoginForm/LoginDialog.test.tsx`
Expected: 5 tests PASS
- [ ] **Step 5: Commit**
```bash
git add src/design-system/composites/LoginForm/LoginDialog.tsx \
src/design-system/composites/LoginForm/LoginDialog.test.tsx
git commit -m "feat: add LoginDialog modal wrapper component"
```
---
### Task 4: Barrel Exports & Full Test Suite
**Files:**
- Modify: `src/design-system/composites/index.ts`
- [ ] **Step 1: Add exports to barrel**
Add these lines to `src/design-system/composites/index.ts` in alphabetical position (after the `LineChart` export, before `MenuItem`):
```ts
export { LoginForm } from './LoginForm/LoginForm'
export type { LoginFormProps, SocialProvider } from './LoginForm/LoginForm'
export { LoginDialog } from './LoginForm/LoginDialog'
export type { LoginDialogProps } from './LoginForm/LoginDialog'
```
- [ ] **Step 2: Run the full test suite**
Run: `npx vitest run src/design-system/composites/LoginForm/`
Expected: All tests PASS (21 LoginForm + 5 LoginDialog = 26 tests)
- [ ] **Step 3: Run the full project test suite to check for regressions**
Run: `npx vitest run`
Expected: All tests PASS
- [ ] **Step 4: Commit**
```bash
git add src/design-system/composites/index.ts
git commit -m "feat: export LoginForm and LoginDialog from composites barrel"
```

View File

@@ -0,0 +1,703 @@
# Metrics Components Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add StatusText primitive, Card title prop, and KpiStrip composite to eliminate ~320 lines of duplicated KPI layout code across Dashboard, Routes, and AgentHealth pages.
**Architecture:** StatusText is a tiny inline span primitive with semantic color variants. Card gets an optional title prop for a header row. KpiStrip is a new composite that renders a horizontal row of metric cards with labels, values, trends, subtitles, and sparklines.
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 1, 5, 6)
---
## File Map
| Action | File | Task |
|--------|------|------|
| CREATE | `src/design-system/primitives/StatusText/StatusText.tsx` | 1 |
| CREATE | `src/design-system/primitives/StatusText/StatusText.module.css` | 1 |
| CREATE | `src/design-system/primitives/StatusText/StatusText.test.tsx` | 1 |
| MODIFY | `src/design-system/primitives/index.ts` | 1 |
| MODIFY | `src/design-system/primitives/Card/Card.tsx` | 2 |
| MODIFY | `src/design-system/primitives/Card/Card.module.css` | 2 |
| CREATE | `src/design-system/primitives/Card/Card.test.tsx` | 2 |
| CREATE | `src/design-system/composites/KpiStrip/KpiStrip.tsx` | 3 |
| CREATE | `src/design-system/composites/KpiStrip/KpiStrip.module.css` | 3 |
| CREATE | `src/design-system/composites/KpiStrip/KpiStrip.test.tsx` | 3 |
| MODIFY | `src/design-system/composites/index.ts` | 3 |
---
## Task 1: StatusText Primitive
**Files:**
- CREATE `src/design-system/primitives/StatusText/StatusText.tsx`
- CREATE `src/design-system/primitives/StatusText/StatusText.module.css`
- CREATE `src/design-system/primitives/StatusText/StatusText.test.tsx`
- MODIFY `src/design-system/primitives/index.ts`
### Step 1.1 — Write test (RED)
- [ ] Create `src/design-system/primitives/StatusText/StatusText.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { StatusText } from './StatusText'
describe('StatusText', () => {
it('renders children text', () => {
render(<StatusText variant="success">OK</StatusText>)
expect(screen.getByText('OK')).toBeInTheDocument()
})
it('renders as a span element', () => {
render(<StatusText variant="success">OK</StatusText>)
expect(screen.getByText('OK').tagName).toBe('SPAN')
})
it('applies variant class', () => {
render(<StatusText variant="error">BREACH</StatusText>)
expect(screen.getByText('BREACH')).toHaveClass('error')
})
it('applies bold class when bold=true', () => {
render(<StatusText variant="warning" bold>HIGH</StatusText>)
expect(screen.getByText('HIGH')).toHaveClass('bold')
})
it('does not apply bold class by default', () => {
render(<StatusText variant="muted">idle</StatusText>)
expect(screen.getByText('idle')).not.toHaveClass('bold')
})
it('accepts custom className', () => {
render(<StatusText variant="running" className="custom">active</StatusText>)
expect(screen.getByText('active')).toHaveClass('custom')
})
it('renders all variant classes correctly', () => {
const { rerender } = render(<StatusText variant="success">text</StatusText>)
expect(screen.getByText('text')).toHaveClass('success')
rerender(<StatusText variant="warning">text</StatusText>)
expect(screen.getByText('text')).toHaveClass('warning')
rerender(<StatusText variant="error">text</StatusText>)
expect(screen.getByText('text')).toHaveClass('error')
rerender(<StatusText variant="running">text</StatusText>)
expect(screen.getByText('text')).toHaveClass('running')
rerender(<StatusText variant="muted">text</StatusText>)
expect(screen.getByText('text')).toHaveClass('muted')
})
})
```
- [ ] Run test — expect FAIL (module not found):
```bash
npx vitest run src/design-system/primitives/StatusText/StatusText.test.tsx
```
### Step 1.2 — Implement (GREEN)
- [ ] Create `src/design-system/primitives/StatusText/StatusText.module.css`:
```css
.statusText {
/* Inherits font-size from parent */
}
.success { color: var(--success); }
.warning { color: var(--warning); }
.error { color: var(--error); }
.running { color: var(--running); }
.muted { color: var(--text-muted); }
.bold { font-weight: 600; }
```
- [ ] Create `src/design-system/primitives/StatusText/StatusText.tsx`:
```tsx
import styles from './StatusText.module.css'
import type { ReactNode } from 'react'
interface StatusTextProps {
variant: 'success' | 'warning' | 'error' | 'running' | 'muted'
bold?: boolean
children: ReactNode
className?: string
}
export function StatusText({ variant, bold = false, children, className }: StatusTextProps) {
const classes = [
styles.statusText,
styles[variant],
bold ? styles.bold : '',
className ?? '',
].filter(Boolean).join(' ')
return <span className={classes}>{children}</span>
}
```
- [ ] Run test — expect PASS:
```bash
npx vitest run src/design-system/primitives/StatusText/StatusText.test.tsx
```
### Step 1.3 — Barrel export
- [ ] Add to `src/design-system/primitives/index.ts` (alphabetical, after `StatusDot`):
```ts
export { StatusText } from './StatusText/StatusText'
```
### Step 1.4 — Commit
```bash
git add src/design-system/primitives/StatusText/ src/design-system/primitives/index.ts
git commit -m "feat: add StatusText primitive with semantic color variants"
```
---
## Task 2: Card Title Extension
**Files:**
- MODIFY `src/design-system/primitives/Card/Card.tsx`
- MODIFY `src/design-system/primitives/Card/Card.module.css`
- CREATE `src/design-system/primitives/Card/Card.test.tsx`
### Step 2.1 — Write test (RED)
- [ ] Create `src/design-system/primitives/Card/Card.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Card } from './Card'
describe('Card', () => {
it('renders children', () => {
render(<Card>Card content</Card>)
expect(screen.getByText('Card content')).toBeInTheDocument()
})
it('renders title when provided', () => {
render(<Card title="Section Title">content</Card>)
expect(screen.getByText('Section Title')).toBeInTheDocument()
})
it('does not render title header when title is omitted', () => {
const { container } = render(<Card>content</Card>)
expect(container.querySelector('.titleHeader')).not.toBeInTheDocument()
})
it('wraps children in body div when title is provided', () => {
render(<Card title="Header">body text</Card>)
const body = screen.getByText('body text').closest('div')
expect(body).toHaveClass('body')
})
it('renders with accent and title together', () => {
const { container } = render(
<Card accent="success" title="Status">
details
</Card>
)
expect(container.firstChild).toHaveClass('accent-success')
expect(screen.getByText('Status')).toBeInTheDocument()
expect(screen.getByText('details')).toBeInTheDocument()
})
it('accepts className prop', () => {
const { container } = render(<Card className="custom">content</Card>)
expect(container.firstChild).toHaveClass('custom')
})
it('renders children directly when no title (no wrapper div)', () => {
const { container } = render(<Card><span data-testid="direct">hi</span></Card>)
expect(screen.getByTestId('direct')).toBeInTheDocument()
// Should not have a body wrapper when there is no title
expect(container.querySelector('.body')).not.toBeInTheDocument()
})
})
```
- [ ] Run test — expect FAIL (title prop not supported yet, body class missing):
```bash
npx vitest run src/design-system/primitives/Card/Card.test.tsx
```
### Step 2.2 — Implement (GREEN)
- [ ] Add to `src/design-system/primitives/Card/Card.module.css` (append after existing rules):
```css
.titleHeader {
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.titleText {
font-size: 11px;
text-transform: uppercase;
font-family: var(--font-mono);
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.5px;
margin: 0;
}
.body {
padding: 16px;
}
```
- [ ] Replace `src/design-system/primitives/Card/Card.tsx` with:
```tsx
import styles from './Card.module.css'
import type { ReactNode } from 'react'
interface CardProps {
children: ReactNode
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' | 'none'
title?: string
className?: string
}
export function Card({ children, accent = 'none', title, className }: CardProps) {
const classes = [
styles.card,
accent !== 'none' ? styles[`accent-${accent}`] : '',
className ?? '',
].filter(Boolean).join(' ')
return (
<div className={classes}>
{title && (
<div className={styles.titleHeader}>
<h3 className={styles.titleText}>{title}</h3>
</div>
)}
{title ? <div className={styles.body}>{children}</div> : children}
</div>
)
}
```
- [ ] Run test — expect PASS:
```bash
npx vitest run src/design-system/primitives/Card/Card.test.tsx
```
### Step 2.3 — Commit
```bash
git add src/design-system/primitives/Card/
git commit -m "feat: add optional title prop to Card primitive"
```
---
## Task 3: KpiStrip Composite
**Files:**
- CREATE `src/design-system/composites/KpiStrip/KpiStrip.tsx`
- CREATE `src/design-system/composites/KpiStrip/KpiStrip.module.css`
- CREATE `src/design-system/composites/KpiStrip/KpiStrip.test.tsx`
- MODIFY `src/design-system/composites/index.ts`
### Step 3.1 — Write test (RED)
- [ ] Create `src/design-system/composites/KpiStrip/KpiStrip.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { KpiStrip } from './KpiStrip'
const sampleItems = [
{
label: 'Total Throughput',
value: '12,847',
trend: { label: '\u25B2 +8%', variant: 'success' as const },
subtitle: '35.7 msg/s',
sparkline: [44, 46, 45, 47, 48, 46, 47],
borderColor: 'var(--amber)',
},
{
label: 'Error Rate',
value: '0.42%',
trend: { label: '\u25BC -0.1%', variant: 'success' as const },
subtitle: '54 errors / 12,847 total',
},
{
label: 'Active Routes',
value: 14,
},
]
describe('KpiStrip', () => {
it('renders all items', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('Total Throughput')).toBeInTheDocument()
expect(screen.getByText('Error Rate')).toBeInTheDocument()
expect(screen.getByText('Active Routes')).toBeInTheDocument()
})
it('renders labels and values', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('12,847')).toBeInTheDocument()
expect(screen.getByText('0.42%')).toBeInTheDocument()
expect(screen.getByText('14')).toBeInTheDocument()
})
it('renders trend with correct text', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('\u25B2 +8%')).toBeInTheDocument()
expect(screen.getByText('\u25BC -0.1%')).toBeInTheDocument()
})
it('applies variant class to trend', () => {
render(<KpiStrip items={sampleItems} />)
const trend = screen.getByText('\u25B2 +8%')
expect(trend).toHaveClass('trendSuccess')
})
it('hides trend when omitted', () => {
render(<KpiStrip items={[{ label: 'Routes', value: 14 }]} />)
// Should only have label and value, no trend element
const card = screen.getByText('Routes').closest('[class*="kpiCard"]')
expect(card?.querySelector('[class*="trend"]')).toBeNull()
})
it('renders subtitle', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('35.7 msg/s')).toBeInTheDocument()
expect(screen.getByText('54 errors / 12,847 total')).toBeInTheDocument()
})
it('renders sparkline when data provided', () => {
const { container } = render(<KpiStrip items={sampleItems} />)
// Sparkline renders an SVG with aria-hidden
const svgs = container.querySelectorAll('svg[aria-hidden="true"]')
expect(svgs.length).toBe(1) // Only first item has sparkline
})
it('accepts className prop', () => {
const { container } = render(<KpiStrip items={sampleItems} className="custom" />)
expect(container.firstChild).toHaveClass('custom')
})
it('handles empty items array', () => {
const { container } = render(<KpiStrip items={[]} />)
expect(container.firstChild).toBeInTheDocument()
// No cards rendered
expect(container.querySelectorAll('[class*="kpiCard"]').length).toBe(0)
})
it('uses default border color when borderColor is omitted', () => {
const { container } = render(
<KpiStrip items={[{ label: 'Test', value: 100 }]} />
)
const card = container.querySelector('[class*="kpiCard"]')
expect(card).toBeInTheDocument()
// The default borderColor is applied via inline style
expect(card).toHaveStyle({ '--kpi-border-color': 'var(--amber)' })
})
it('applies custom borderColor', () => {
const { container } = render(
<KpiStrip items={[{ label: 'Errors', value: 5, borderColor: 'var(--error)' }]} />
)
const card = container.querySelector('[class*="kpiCard"]')
expect(card).toHaveStyle({ '--kpi-border-color': 'var(--error)' })
})
it('renders trend with muted variant by default', () => {
render(
<KpiStrip items={[{ label: 'Test', value: 1, trend: { label: '~ stable' } }]} />
)
const trend = screen.getByText('~ stable')
expect(trend).toHaveClass('trendMuted')
})
})
```
- [ ] Run test — expect FAIL (module not found):
```bash
npx vitest run src/design-system/composites/KpiStrip/KpiStrip.test.tsx
```
### Step 3.2 — Implement (GREEN)
- [ ] Create `src/design-system/composites/KpiStrip/KpiStrip.module.css`:
```css
/* KpiStrip — horizontal row of metric cards */
.kpiStrip {
display: grid;
gap: 12px;
margin-bottom: 20px;
}
/* ── Individual card ─────────────────────────────────────────────── */
.kpiCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 16px 18px 12px;
box-shadow: var(--shadow-card);
position: relative;
overflow: hidden;
transition: box-shadow 0.15s;
}
.kpiCard:hover {
box-shadow: var(--shadow-md);
}
/* Top gradient border — color driven by CSS custom property */
.kpiCard::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--kpi-border-color), transparent);
}
/* ── Label ───────────────────────────────────────────────────────── */
.label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 6px;
}
/* ── Value row ───────────────────────────────────────────────────── */
.valueRow {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 4px;
}
.value {
font-family: var(--font-mono);
font-size: 26px;
font-weight: 600;
line-height: 1.2;
color: var(--text-primary);
}
/* ── Trend ────────────────────────────────────────────────────────── */
.trend {
font-family: var(--font-mono);
font-size: 11px;
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: auto;
}
.trendSuccess { color: var(--success); }
.trendWarning { color: var(--warning); }
.trendError { color: var(--error); }
.trendMuted { color: var(--text-muted); }
/* ── Subtitle ─────────────────────────────────────────────────────── */
.subtitle {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
/* ── Sparkline ────────────────────────────────────────────────────── */
.sparkline {
margin-top: 8px;
height: 32px;
}
```
- [ ] Create `src/design-system/composites/KpiStrip/KpiStrip.tsx`:
```tsx
import styles from './KpiStrip.module.css'
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
import type { CSSProperties, ReactNode } from 'react'
export interface KpiItem {
label: string
value: string | number
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
subtitle?: string
sparkline?: number[]
borderColor?: string
}
export interface KpiStripProps {
items: KpiItem[]
className?: string
}
const trendClassMap: Record<string, string> = {
success: styles.trendSuccess,
warning: styles.trendWarning,
error: styles.trendError,
muted: styles.trendMuted,
}
export function KpiStrip({ items, className }: KpiStripProps) {
const stripClasses = [styles.kpiStrip, className ?? ''].filter(Boolean).join(' ')
const gridStyle: CSSProperties = {
gridTemplateColumns: items.length > 0 ? `repeat(${items.length}, 1fr)` : undefined,
}
return (
<div className={stripClasses} style={gridStyle}>
{items.map((item) => {
const borderColor = item.borderColor ?? 'var(--amber)'
const cardStyle: CSSProperties & Record<string, string> = {
'--kpi-border-color': borderColor,
}
const trendVariant = item.trend?.variant ?? 'muted'
const trendClass = trendClassMap[trendVariant] ?? styles.trendMuted
return (
<div key={item.label} className={styles.kpiCard} style={cardStyle}>
<div className={styles.label}>{item.label}</div>
<div className={styles.valueRow}>
<span className={styles.value}>{item.value}</span>
{item.trend && (
<span className={`${styles.trend} ${trendClass}`}>
{item.trend.label}
</span>
)}
</div>
{item.subtitle && (
<div className={styles.subtitle}>{item.subtitle}</div>
)}
{item.sparkline && item.sparkline.length >= 2 && (
<div className={styles.sparkline}>
<Sparkline
data={item.sparkline}
color={borderColor}
width={200}
height={32}
/>
</div>
)}
</div>
)
})}
</div>
)
}
```
- [ ] Run test — expect PASS:
```bash
npx vitest run src/design-system/composites/KpiStrip/KpiStrip.test.tsx
```
### Step 3.3 — Barrel export
- [ ] Add to `src/design-system/composites/index.ts` (alphabetical, after `GroupCard`):
```ts
export { KpiStrip } from './KpiStrip/KpiStrip'
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
```
### Step 3.4 — Commit
```bash
git add src/design-system/composites/KpiStrip/ src/design-system/composites/index.ts
git commit -m "feat: add KpiStrip composite for reusable metric card rows"
```
---
## Task 4: Barrel Exports Verification & Full Test Run
**Files:**
- VERIFY `src/design-system/primitives/index.ts` (modified in Task 1)
- VERIFY `src/design-system/composites/index.ts` (modified in Task 3)
### Step 4.1 — Verify barrel exports
- [ ] Confirm `src/design-system/primitives/index.ts` contains:
```ts
export { StatusText } from './StatusText/StatusText'
```
- [ ] Confirm `src/design-system/composites/index.ts` contains:
```ts
export { KpiStrip } from './KpiStrip/KpiStrip'
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
```
### Step 4.2 — Run full test suite
- [ ] Run all tests to confirm nothing is broken:
```bash
npx vitest run
```
- [ ] Verify zero failures. If any test fails, fix and re-run before proceeding.
### Step 4.3 — Final commit (if barrel-only changes remain)
If the barrel export changes were not already committed in their respective tasks:
```bash
git add src/design-system/primitives/index.ts src/design-system/composites/index.ts
git commit -m "chore: add StatusText and KpiStrip to barrel exports"
```
---
## Summary of Expected Barrel Export Additions
**`src/design-system/primitives/index.ts`** — insert after `StatusDot` line:
```ts
export { StatusText } from './StatusText/StatusText'
```
**`src/design-system/composites/index.ts`** — insert after `GroupCard` line:
```ts
export { KpiStrip } from './KpiStrip/KpiStrip'
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
```
---
## Test Commands Quick Reference
| Scope | Command |
|-------|---------|
| StatusText only | `npx vitest run src/design-system/primitives/StatusText/StatusText.test.tsx` |
| Card only | `npx vitest run src/design-system/primitives/Card/Card.test.tsx` |
| KpiStrip only | `npx vitest run src/design-system/composites/KpiStrip/KpiStrip.test.tsx` |
| All tests | `npx vitest run` |

View File

@@ -0,0 +1,506 @@
# Observability Components Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add LogViewer composite for log display and refactor AgentHealth to use DataTable instead of raw HTML tables.
**Architecture:** LogViewer is a scrollable log display with timestamped, severity-colored entries and auto-scroll behavior. The AgentHealth refactor replaces raw `<table>` elements with the existing DataTable composite.
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 3, 4)
---
## Task 1: LogViewer composite
Create a new composite component that renders a scrollable log viewer with timestamped, severity-colored entries. This replaces the custom log rendering in `AgentInstance.tsx`.
### Files
- **Create** `src/design-system/composites/LogViewer/LogViewer.tsx`
- **Create** `src/design-system/composites/LogViewer/LogViewer.module.css`
- **Create** `src/design-system/composites/LogViewer/LogViewer.test.tsx`
### Steps
- [ ] **1.1** Create `src/design-system/composites/LogViewer/LogViewer.tsx` with the component and exported types
- [ ] **1.2** Create `src/design-system/composites/LogViewer/LogViewer.module.css` with all styles
- [ ] **1.3** Create `src/design-system/composites/LogViewer/LogViewer.test.tsx` with tests
- [ ] **1.4** Run `npx vitest run src/design-system/composites/LogViewer` and fix any failures
### API
```tsx
export interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error' | 'debug'
message: string
}
export interface LogViewerProps {
entries: LogEntry[]
maxHeight?: number | string // Default: 400
className?: string
}
```
### Component implementation — `LogViewer.tsx`
```tsx
import { useRef, useEffect, useCallback } from 'react'
import styles from './LogViewer.module.css'
export interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error' | 'debug'
message: string
}
export interface LogViewerProps {
entries: LogEntry[]
maxHeight?: number | string
className?: string
}
const LEVEL_CLASS: Record<LogEntry['level'], string> = {
info: styles.levelInfo,
warn: styles.levelWarn,
error: styles.levelError,
debug: styles.levelDebug,
}
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
} catch {
return iso
}
}
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true)
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
// Consider "at bottom" when within 20px of the end
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
}, [])
// Auto-scroll to bottom when entries change, but only if user hasn't scrolled up
useEffect(() => {
const el = scrollRef.current
if (el && isAtBottomRef.current) {
el.scrollTop = el.scrollHeight
}
}, [entries])
const heightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
return (
<div
ref={scrollRef}
className={[styles.container, className].filter(Boolean).join(' ')}
style={{ maxHeight: heightStyle }}
onScroll={handleScroll}
role="log"
>
{entries.map((entry, i) => (
<div key={i} className={styles.line}>
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
{entry.level.toUpperCase()}
</span>
<span className={styles.message}>{entry.message}</span>
</div>
))}
{entries.length === 0 && (
<div className={styles.empty}>No log entries.</div>
)}
</div>
)
}
```
### Styles — `LogViewer.module.css`
```css
/* Scrollable container */
.container {
overflow-y: auto;
background: var(--bg-inset);
border-radius: var(--radius-md);
padding: 8px 0;
font-family: var(--font-mono);
}
/* Each log line */
.line {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 3px 12px;
line-height: 1.5;
}
.line:hover {
background: var(--bg-hover);
}
/* Timestamp */
.timestamp {
flex-shrink: 0;
font-size: 11px;
color: var(--text-muted);
min-width: 56px;
}
/* Level badge — pill with tinted background */
.levelBadge {
flex-shrink: 0;
font-size: 9px;
font-weight: 600;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.3px;
padding: 1px 6px;
border-radius: 9999px;
line-height: 1.5;
white-space: nowrap;
}
.levelInfo {
color: var(--running);
background: color-mix(in srgb, var(--running) 12%, transparent);
}
.levelWarn {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
}
.levelError {
color: var(--error);
background: color-mix(in srgb, var(--error) 12%, transparent);
}
.levelDebug {
color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
}
/* Message text */
.message {
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-primary);
word-break: break-word;
line-height: 1.5;
}
/* Empty state */
.empty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}
```
### Tests — `LogViewer.test.tsx`
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { LogViewer, type LogEntry } from './LogViewer'
import { ThemeProvider } from '../../providers/ThemeProvider'
const wrap = (ui: React.ReactElement) => render(<ThemeProvider>{ui}</ThemeProvider>)
const sampleEntries: LogEntry[] = [
{ timestamp: '2026-03-24T10:00:00Z', level: 'info', message: 'Server started' },
{ timestamp: '2026-03-24T10:01:00Z', level: 'warn', message: 'Slow query detected' },
{ timestamp: '2026-03-24T10:02:00Z', level: 'error', message: 'Connection refused' },
{ timestamp: '2026-03-24T10:03:00Z', level: 'debug', message: 'Cache hit ratio: 0.95' },
]
describe('LogViewer', () => {
it('renders entries with timestamps and messages', () => {
wrap(<LogViewer entries={sampleEntries} />)
expect(screen.getByText('Server started')).toBeInTheDocument()
expect(screen.getByText('Slow query detected')).toBeInTheDocument()
expect(screen.getByText('Connection refused')).toBeInTheDocument()
expect(screen.getByText('Cache hit ratio: 0.95')).toBeInTheDocument()
})
it('renders level badges with correct text', () => {
wrap(<LogViewer entries={sampleEntries} />)
expect(screen.getByText('INFO')).toBeInTheDocument()
expect(screen.getByText('WARN')).toBeInTheDocument()
expect(screen.getByText('ERROR')).toBeInTheDocument()
expect(screen.getByText('DEBUG')).toBeInTheDocument()
})
it('renders with custom maxHeight', () => {
const { container } = wrap(<LogViewer entries={sampleEntries} maxHeight={200} />)
const el = container.querySelector('[role="log"]')
expect(el).toHaveStyle({ maxHeight: '200px' })
})
it('renders with string maxHeight', () => {
const { container } = wrap(<LogViewer entries={sampleEntries} maxHeight="50vh" />)
const el = container.querySelector('[role="log"]')
expect(el).toHaveStyle({ maxHeight: '50vh' })
})
it('handles empty entries', () => {
wrap(<LogViewer entries={[]} />)
expect(screen.getByText('No log entries.')).toBeInTheDocument()
})
it('accepts className prop', () => {
const { container } = wrap(<LogViewer entries={sampleEntries} className="custom-class" />)
const el = container.querySelector('[role="log"]')
expect(el?.className).toContain('custom-class')
})
it('has role="log" for accessibility', () => {
wrap(<LogViewer entries={sampleEntries} />)
expect(screen.getByRole('log')).toBeInTheDocument()
})
})
```
### Key design decisions
- **Auto-scroll behavior:** Uses a `useRef` to track whether the user is at the bottom of the scroll container. On new entries (via `useEffect` on `entries`), scrolls to bottom only if `isAtBottomRef.current` is `true`. Pauses when user scrolls up (more than 20px from bottom). Resumes when user scrolls back to bottom.
- **Level colors:** Map to existing design tokens: `info` -> `var(--running)`, `warn` -> `var(--warning)`, `error` -> `var(--error)`, `debug` -> `var(--text-muted)`. Pill backgrounds use `color-mix` with 12% opacity tint.
- **No Badge dependency:** The level badge is a styled `<span>` rather than using the `Badge` primitive. This avoids pulling in `hashColor`/`useTheme` and keeps the badge styling tightly scoped (9px pill vs Badge's larger size). The spec calls for a very compact pill at 9px mono — a custom element is cleaner.
- **`role="log"`** on the container for accessibility (indicates a log region to screen readers).
---
## Task 2: Barrel exports for LogViewer
Add LogViewer and its types to the composites barrel export.
### Files
- **Modify** `src/design-system/composites/index.ts`
### Steps
- [ ] **2.1** Add LogViewer export and type exports to `src/design-system/composites/index.ts`
### Changes
Add these lines to `src/design-system/composites/index.ts`, in alphabetical position (after the `LineChart` export):
```ts
export { LogViewer } from './LogViewer/LogViewer'
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
```
The full insertion point — after line 19 (`export { LineChart } from './LineChart/LineChart'`) and before line 20 (`export { LoginDialog } from './LoginForm/LoginDialog'`):
```ts
export { LineChart } from './LineChart/LineChart'
export { LogViewer } from './LogViewer/LogViewer'
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
export { LoginDialog } from './LoginForm/LoginDialog'
```
---
## Task 3: AgentHealth DataTable refactor
Replace the raw HTML `<table>` in `AgentHealth.tsx` with the existing `DataTable` composite. This is a **page-level refactor** — no design system components are changed.
### Files
- **Modify** `src/pages/AgentHealth/AgentHealth.tsx` — replace `<table>` with `<DataTable>`
- **Modify** `src/pages/AgentHealth/AgentHealth.module.css` — remove table CSS
### Steps
- [ ] **3.1** Add `DataTable` and `Column` imports to `AgentHealth.tsx`
- [ ] **3.2** Define the instance columns array
- [ ] **3.3** Replace the `<table>` block inside each `<GroupCard>` with `<DataTable>`
- [ ] **3.4** Remove unused table CSS classes from `AgentHealth.module.css`
- [ ] **3.5** Visually verify the page looks identical (run dev server, navigate to `/agents`)
### 3.1 — Add imports
Add to the composites import block in `AgentHealth.tsx`:
```tsx
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
import type { Column } from '../../design-system/composites/DataTable/types'
```
### 3.2 — Define columns
Add a column definition constant above the `AgentHealth` component function. The columns mirror the existing `<th>` headers. Custom `render` functions handle the StatusDot and Badge cells.
**Important:** DataTable requires rows with an `id: string` field. The `AgentHealthData` type already has `id`, so no transformation is needed.
```tsx
const instanceColumns: Column<AgentHealthData>[] = [
{
key: 'status',
header: '',
width: '12px',
render: (_value, row) => (
<StatusDot variant={row.status === 'live' ? 'live' : row.status === 'stale' ? 'stale' : 'dead'} />
),
},
{
key: 'name',
header: 'Instance',
render: (_value, row) => (
<MonoText size="sm" className={styles.instanceName}>{row.name}</MonoText>
),
},
{
key: 'state',
header: 'State',
render: (_value, row) => (
<Badge
label={row.status.toUpperCase()}
color={row.status === 'live' ? 'success' : row.status === 'stale' ? 'warning' : 'error'}
variant="filled"
/>
),
},
{
key: 'uptime',
header: 'Uptime',
render: (_value, row) => (
<MonoText size="xs" className={styles.instanceMeta}>{row.uptime}</MonoText>
),
},
{
key: 'tps',
header: 'TPS',
render: (_value, row) => (
<MonoText size="xs" className={styles.instanceMeta}>{row.tps.toFixed(1)}/s</MonoText>
),
},
{
key: 'errorRate',
header: 'Errors',
render: (_value, row) => (
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}>
{row.errorRate ?? '0 err/h'}
</MonoText>
),
},
{
key: 'lastSeen',
header: 'Heartbeat',
render: (_value, row) => (
<MonoText size="xs" className={
row.status === 'dead' ? styles.instanceHeartbeatDead :
row.status === 'stale' ? styles.instanceHeartbeatStale :
styles.instanceMeta
}>
{row.lastSeen}
</MonoText>
),
},
]
```
### 3.3 — Replace `<table>` with `<DataTable>`
Replace the entire `<table className={styles.instanceTable}>...</table>` block (lines 365-423 of `AgentHealth.tsx`) inside each `<GroupCard>` with:
```tsx
<DataTable
columns={instanceColumns}
data={group.instances}
flush
selectedId={selectedInstance?.id}
onRowClick={handleInstanceClick}
pageSize={50}
/>
```
Key props:
- `flush` — strips DataTable's outer border/radius/shadow so it sits seamlessly inside the GroupCard
- `selectedId` — highlights the currently selected row (replaces the manual `instanceRowActive` CSS class)
- `onRowClick` — replaces the manual `onClick` on `<tr>` elements
- `pageSize={50}` — high enough to avoid pagination for typical instance counts per app group
### 3.4 — Remove unused CSS
Remove these CSS classes from `AgentHealth.module.css` (they were only used by the raw `<table>`):
```
.instanceTable
.instanceTable thead th
.thStatus
.tdStatus
.instanceRow
.instanceRow td
.instanceRow:last-child td
.instanceRow:hover td
.instanceRowActive td
.instanceRowActive td:first-child
```
**Keep** these classes (still used by DataTable `render` functions):
```
.instanceName
.instanceMeta
.instanceError
.instanceHeartbeatStale
.instanceHeartbeatDead
```
### Visual verification checklist
After the refactor, verify at `/agents`:
- [ ] StatusDot column renders colored dots in the first column
- [ ] Instance name renders in mono bold
- [ ] State column shows Badge with correct color variant
- [ ] Uptime, TPS, Errors, Heartbeat columns show muted mono text
- [ ] Error values show in `var(--error)` red
- [ ] Stale/dead heartbeat timestamps show warning/error colors
- [ ] Row click opens the DetailPanel
- [ ] Selected row is visually highlighted
- [ ] Table sits flush inside GroupCard (no double borders)
- [ ] Alert banner still renders below the table for groups with dead instances
---
## Execution order
1. **Task 1** — LogViewer composite (no dependencies)
2. **Task 2** — Barrel exports (depends on Task 1)
3. **Task 3** — AgentHealth DataTable refactor (independent of Tasks 1-2)
Tasks 1+2 and Task 3 can be parallelized since they touch different parts of the codebase.
## Verification
```bash
# Run LogViewer tests
npx vitest run src/design-system/composites/LogViewer
# Run all tests to check nothing broke
npx vitest run
# Start dev server for visual verification
npm run dev
# Then navigate to /agents and /agents/{appId}/{instanceId}
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
# Login Dialog Design Spec
## Overview
A composable login component for the Cameleer3 design system. Provides a `LoginForm` content component and a `LoginDialog` wrapper that puts it inside a Modal. Supports username/password credentials, configurable social/SSO providers, and built-in client-side validation.
## Components
### LoginForm
Core form component. Lives in `src/design-system/composites/LoginForm/`.
```tsx
interface SocialProvider {
label: string // e.g. "Continue with Google"
icon?: ReactNode // SVG icon, optional
onClick: () => void
}
interface LoginFormProps {
logo?: ReactNode
title?: string // Default: "Sign in"
socialProviders?: SocialProvider[] // Omit or [] to hide social section + divider
onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void // Omit to hide credentials section
onForgotPassword?: () => void // Omit to hide link
onSignUp?: () => void // Omit to hide "Don't have an account?"
error?: string // Server-side error, rendered as Alert
loading?: boolean // Disables form, spinner on submit button
className?: string
}
```
### LoginDialog
Thin wrapper — passes all `LoginFormProps` through to `LoginForm`, adds Modal control.
```tsx
interface LoginDialogProps extends LoginFormProps {
open: boolean
onClose: () => void
}
```
Uses `Modal` with `size="sm"` (400px).
## Layout
Social-first ordering, top to bottom:
1. **Logo slot** — optional `ReactNode` rendered centered above title
2. **Title** — "Sign in" default, centered
3. **Server error**`Alert variant="error"` shown when `error` prop is set, between title and social buttons
4. **Social buttons** — stacked vertically, each is a `Button variant="secondary"` with icon + label. Hidden when `socialProviders` is empty/omitted.
5. **Divider** — horizontal rule with "or" text, centered. Hidden when social section is hidden.
6. **Email field**`FormField` + `Input`, required, placeholder "you@example.com"
7. **Password field**`FormField` + `Input type="password"`, required, placeholder "••••••••"
8. **Remember me / Forgot password row**`Checkbox` on the left, amber link on the right. Forgot password link hidden when `onForgotPassword` omitted.
9. **Submit button**`Button variant="primary"`, full width, label "Sign in"
10. **Sign up link** — "Don't have an account? Sign up" centered below. Hidden when `onSignUp` omitted.
### Configuration Variants
The form adapts automatically based on props:
- **Full** — `socialProviders` + `onSubmit` both provided. Social buttons, divider, and credentials all shown.
- **Credentials only** — `onSubmit` provided, no `socialProviders`. Social section and divider hidden.
- **Social only** — `socialProviders` provided, `onSubmit` omitted. Credentials section (email, password, remember me, submit button) and divider hidden.
## Validation
Client-side, triggered on form submit (not on blur):
| Field | Rule | Error message |
|----------|---------------------------------------------------|----------------------------------------|
| Email | Required | "Email is required" |
| Email | Basic format: `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` | "Please enter a valid email address" |
| Password | Required | "Password is required" |
| Password | Minimum 8 characters | "Password must be at least 8 characters" |
- `onSubmit` only fires when all validation passes
- Field errors displayed inline below each input using `FormField` error pattern (red border + message)
- Field errors clear when the user starts typing in that field
- Server `error` prop clears automatically on next submit attempt
## States
### Loading
When `loading={true}`:
- All inputs disabled
- All social buttons disabled
- Submit button shows `Spinner` component, text hidden (matches existing `Button loading` pattern)
- Form cannot be submitted
### Error
- Server error: `Alert variant="error"` rendered between title and social buttons
- Field errors: inline below each input via `FormField` error styling (red border, error text)
## Styling
- CSS Modules: `LoginForm.module.css`
- All colors via CSS custom properties from `tokens.css`
- Dark mode works automatically — no extra overrides needed
- Social buttons: `var(--bg-surface)` background, `var(--border)` border, hover uses `var(--bg-hover)`
- Divider: `var(--border)` line, `var(--text-muted)` "or" text
- Forgot password + Sign up links: `var(--amber)` color, `font-weight: 500`
- Form gap: 14px between fields
- Social button gap: 8px between buttons
## Accessibility
- `<form>` element with `aria-label="Sign in"`
- Labels tied to inputs via `htmlFor`/`id`
- Error messages linked with `aria-describedby`
- First input auto-focused on mount
- `LoginDialog` traps focus via Modal
- Social buttons are `<button>` elements, keyboard-navigable
- Alert uses `role="alert"` for screen readers
- Enter key submits form (standard `<form onSubmit>`)
## File Structure
```
src/design-system/composites/LoginForm/
LoginForm.tsx
LoginForm.module.css
LoginForm.test.tsx
LoginDialog.tsx
LoginDialog.test.tsx
```
Exports added to `src/design-system/composites/index.ts`.
## Primitives Reused
- `FormField` — label + error display wrapper
- `Input` — email and password fields
- `Checkbox` — remember me
- `Button` — submit (primary) + social buttons (secondary)
- `Alert` — server error display
- `Spinner` — loading state in submit button
- `Modal` — LoginDialog wrapper
## Testing
Tests with Vitest + React Testing Library, wrapped in `ThemeProvider`.
### LoginForm tests:
- Renders all elements when all props provided
- Hides social section when `socialProviders` is empty
- Hides divider when no social providers
- Hides forgot password link when `onForgotPassword` omitted
- Hides sign up link when `onSignUp` omitted
- Shows server error Alert when `error` prop set
- Validates required email
- Validates email format
- Validates required password
- Validates password minimum length
- Clears field errors on typing
- Calls `onSubmit` with credentials when valid
- Does not call `onSubmit` when validation fails
- Disables form when `loading={true}`
- Shows spinner on submit button when loading
- Calls social provider `onClick` when clicked
- Calls `onForgotPassword` when link clicked
- Calls `onSignUp` when link clicked
### LoginDialog tests:
- Renders Modal with LoginForm when `open={true}`
- Does not render when `open={false}`
- Calls `onClose` on backdrop click / Esc
- Passes all LoginForm props through

View File

@@ -0,0 +1,295 @@
# Mock UI Deviations — Design Spec
## Overview
The mock pages in `src/pages/` build several UI patterns using raw CSS and inline HTML that should either be promoted into the design system or refactored to use existing components. This spec captures each deviation and its resolution to minimize rework when transitioning to the real application.
## Decision Framework
A pattern is promoted to the design system when it:
- Appears on 2+ pages with the same structure
- Is visually distinctive and would be inconsistent if reimplemented
- Will be needed by the real application
A pattern stays in the pages when it is page-specific composition or a one-off layout.
---
## 1. KpiStrip — New Composite
**Problem:** Dashboard, Routes, and AgentHealth each build a custom KPI header strip (~320 lines of duplicated layout code). Same visual structure: horizontal row of cards with colored left border, uppercase label, large value, trend indicator, subtitle, and optional sparkline.
**Solution:** New composite `KpiStrip`.
```tsx
interface KpiItem {
label: string
value: string | number
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
subtitle?: string
sparkline?: number[]
borderColor?: string // CSS token, e.g. "var(--success)"
}
interface KpiStripProps {
items: KpiItem[]
className?: string
}
```
**Layout:**
- Horizontal flex row with equal-width cards
- Each card: 3px left border (colored via `borderColor`, default `var(--amber)`), padding 16px 20px
- Card surface: `var(--bg-surface)`, border: `var(--border-subtle)`, radius: `var(--radius-md)`
- Label: 11px uppercase, monospace weight 500, `var(--text-muted)`
- Value: 28px, weight 700, `var(--text-primary)`
- Trend: inline next to value, 11px. Color controlled by `trend.variant` (maps to semantic tokens). Default `'muted'`. The caller decides what color a trend should be — "↑ +12%" on error count is `'error'`, on throughput is `'success'`.
- Subtitle: 11px, `var(--text-secondary)`
- Sparkline: existing `Sparkline` primitive rendered top-right of card
**Note:** KpiStrip builds its own card-like containers internally. It does NOT reuse the `Card` primitive because `Card` uses a top accent border while KpiStrip needs a left border. The visual surface (bg, border, radius, shadow) uses the same tokens but the layout is distinct.
**File location:** `src/design-system/composites/KpiStrip/`
**Pages to refactor:** Dashboard.tsx, Routes.tsx, AgentHealth.tsx — replace inline `KpiHeader` functions with `<KpiStrip items={[...]} />`.
---
## 2. SplitPane — New Composite
**Problem:** Admin RBAC tabs (UsersTab, GroupsTab, RolesTab) each build a custom CSS grid split-pane layout with scrollable list, detail panel, and empty state placeholder.
**Solution:** New composite `SplitPane`.
```tsx
interface SplitPaneProps {
list: ReactNode
detail: ReactNode | null // null renders empty state
emptyMessage?: string // Default: "Select an item to view details"
ratio?: '1:1' | '1:2' | '2:3' // Default: '1:2'
className?: string
}
```
**Layout:**
- CSS grid with two columns at the specified ratio
- Left panel: scrollable, `var(--bg-surface)` background, right border `var(--border-subtle)`
- Right panel: scrollable, `var(--bg-raised)` background
- Empty state: centered text, `var(--text-muted)`, italic
- Both panels fill available height (the parent controls the overall height)
**File location:** `src/design-system/composites/SplitPane/`
**Pages to refactor:** UsersTab.tsx, GroupsTab.tsx, RolesTab.tsx — replace custom grid CSS with `<SplitPane>`.
---
## 2b. EntityList — New Composite
**Problem:** The left-side list panels in UsersTab, GroupsTab, and RolesTab all build the same frame: a search input + "Add" button header, a scrollable list of items (avatar + text + badges), and selection highlighting. Each tab re-implements this frame with ~50 lines of identical structure.
**Solution:** New composite `EntityList`.
```tsx
interface EntityListProps<T> {
items: T[]
renderItem: (item: T, isSelected: boolean) => ReactNode
getItemId: (item: T) => string
selectedId?: string
onSelect?: (id: string) => void
searchPlaceholder?: string // Default: "Search..."
onSearch?: (query: string) => void
addLabel?: string // e.g. "+ Add user" — omit to hide button
onAdd?: () => void
emptyMessage?: string // Default: "No items found"
className?: string
}
```
**Layout:**
- Header row: `Input` (search, with icon) on the left, `Button variant="secondary" size="sm"` (add) on the right. Header hidden when both `onSearch` and `onAdd` are omitted.
- Scrollable list below header, `var(--bg-surface)` background
- Each item: clickable row with `var(--bg-hover)` on hover, `var(--amber-bg)` + left amber border when selected
- Items rendered via `renderItem` — the component provides the clickable row wrapper, the caller provides the content
- `role="listbox"` on the list, `role="option"` on each item for accessibility
- Empty state: centered `emptyMessage` text when `items` is empty
**Typical item content (provided by caller via `renderItem`):**
- Avatar + name + subtitle + badge tags — but this is not prescribed by EntityList. The component is agnostic about item content.
**Combined usage with SplitPane:**
```tsx
<SplitPane
list={
<EntityList
items={filteredUsers}
renderItem={(user, isSelected) => (
<>
<Avatar name={user.name} size="sm" />
<div>
<div>{user.name}</div>
<div>{user.email}</div>
<div>{user.roles.map(r => <Badge key={r} label={r} />)}</div>
</div>
</>
)}
getItemId={(u) => u.id}
selectedId={selectedId}
onSelect={setSelectedId}
searchPlaceholder="Search users..."
onSearch={setSearchQuery}
addLabel="+ Add user"
onAdd={() => setAddDialogOpen(true)}
/>
}
detail={selectedUser ? <UserDetail user={selectedUser} /> : null}
/>
```
**File location:** `src/design-system/composites/EntityList/`
**Pages to refactor:** UsersTab.tsx, GroupsTab.tsx, RolesTab.tsx — replace custom list rendering with `<EntityList>`. Combined with SplitPane, each tab reduces from ~200 lines to ~50 lines of domain-specific render logic.
---
## 3. Refactor AgentHealth Instance Table to DataTable
**Problem:** AgentHealth builds instance tables using raw HTML `<table>` elements instead of the existing `DataTable` composite.
**Solution:** Refactor to use `DataTable` with column definitions and custom cell renderers. No design system changes needed.
**Refactor scope:**
- Replace `<table>` blocks in AgentHealth.tsx (~60 lines) with `<DataTable>` using `flush` prop
- Define columns with `render` functions for State (Badge) and StatusDot columns
- Remove associated table CSS from AgentHealth.module.css
---
## 4. LogViewer — New Composite
**Problem:** AgentInstance renders log entries as custom HTML with inline styling — timestamped lines with severity levels in monospace.
**Solution:** New composite `LogViewer`.
```tsx
interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error' | 'debug'
message: string
}
interface LogViewerProps {
entries: LogEntry[]
maxHeight?: number | string // Default: 400
className?: string
}
```
**Layout:**
- Scrollable container with `max-height`, `var(--bg-inset)` background, `var(--radius-md)` border-radius
- Each line: flex row with timestamp (muted, monospace, 11px) + level badge + message (monospace, 12px)
- Level badge colors: info=`var(--running)`, warn=`var(--warning)`, error=`var(--error)`, debug=`var(--text-muted)`
- Level badge: uppercase, 9px, `var(--font-mono)`, pill-shaped with tinted background
- Auto-scroll to bottom on new entries; pauses when user scrolls up; resumes on scroll-to-bottom
**File location:** `src/design-system/composites/LogViewer/`
**Pages to refactor:** AgentInstance.tsx — replace custom log rendering with `<LogViewer entries={logs} />`.
---
## 5. StatusText — New Primitive
**Problem:** Dashboard and Routes use inline `style={{ color: 'var(--error)', fontWeight: 600 }}` for status values like "BREACH", "OK", colored percentages.
**Solution:** New primitive `StatusText`.
```tsx
interface StatusTextProps {
variant: 'success' | 'warning' | 'error' | 'running' | 'muted'
bold?: boolean // Default: false
children: ReactNode
className?: string
}
```
**Styling:**
- Inline `<span>` element
- Color mapped to semantic tokens: success=`var(--success)`, warning=`var(--warning)`, error=`var(--error)`, running=`var(--running)`, muted=`var(--text-muted)`
- `bold` adds `font-weight: 600`
- Inherits font-size from parent
**File location:** `src/design-system/primitives/StatusText/`
**Pages to refactor:** Dashboard.tsx, Routes.tsx — replace inline style attributes with `<StatusText>`.
---
## 6. Card Title Extension
**Problem:** Routes page wraps charts in custom divs with uppercase titles. The existing `Card` component has no title support.
**Solution:** Add optional `title` prop to existing `Card` primitive.
```tsx
interface CardProps {
children: ReactNode
accent?: string // Existing
title?: string // NEW
className?: string // Existing
}
```
**When `title` is provided:**
- Renders a header div inside the card, above children
- Title: 11px uppercase, `var(--font-mono)`, weight 600, `var(--text-secondary)`, letter-spacing 0.5px
- Separated from content by 1px `var(--border-subtle)` bottom border and 12px padding-bottom
- Content area gets 16px padding-top
**File location:** Modify existing `src/design-system/primitives/Card/Card.tsx`
**Pages to refactor:** Routes.tsx — replace custom chart wrapper divs with `<Card title="Throughput (msg/s)">`.
---
## Implementation Priority
1. **KpiStrip** — highest impact, 3 pages, ~320 lines eliminated
2. **StatusText** — smallest scope, quick win, unblocks cleaner page code
3. **Card title** — small change to existing component, unblocks Routes cleanup
4. **SplitPane + EntityList** — 3 admin tabs, clean pattern. Build together since EntityList is the natural content for SplitPane's list slot.
5. **LogViewer** — 1 page but important for real app
6. **AgentHealth DataTable refactor** — pure page cleanup, no DS changes
## Testing
All new components tested with Vitest + React Testing Library, co-located test files. Page refactors verified by running existing tests + visual check that pages look identical before and after.
## Barrel Exports
New components added to respective barrel exports:
- `src/design-system/primitives/index.ts` — StatusText
- `src/design-system/composites/index.ts` — KpiStrip, SplitPane, EntityList, LogViewer
## Documentation Updates
### COMPONENT_GUIDE.md
Add entries for each new component to the appropriate decision trees:
- **Data Display section:** Add KpiStrip — "Use KpiStrip for a row of summary metrics at the top of a page (exchanges, error rate, latency, etc.)"
- **Data Display section:** Add LogViewer — "Use LogViewer for scrollable log output with timestamped, severity-colored entries"
- **Layout section:** Add SplitPane — "Use SplitPane for master/detail layouts: selectable list on the left, detail view on the right"
- **Data Display section:** Add EntityList — "Use EntityList for searchable, selectable lists of entities (users, groups, roles, etc.). Combine with SplitPane for CRUD management screens."
- **Text & Labels section:** Add StatusText — "Use StatusText for inline colored status values (success rates, SLA status, trend indicators). Use StatusDot for colored dot indicators."
- **Card section:** Document new `title` prop — "Pass `title` to Card for a titled content container (e.g., chart cards). Title renders as an uppercase header with separator."
### Inventory Page
Add demos for each new component to `src/pages/Inventory/sections/`:
- **CompositesSection.tsx:** Add KpiStrip, SplitPane, EntityList, LogViewer demos with realistic sample data
- **PrimitivesSection.tsx:** Add StatusText demo showing all variants
- **Card demo:** Update existing Card demo to show the `title` prop variant
Each demo follows the existing DemoCard pattern with `id` anchors, and nav entries are added to `Inventory.tsx`.

View File

@@ -0,0 +1,399 @@
# Composable Sidebar Refactor
**Date:** 2026-04-02
**Upstream issue:** cameleer3-server #112
## Why
The current `Sidebar` component is monolithic. It hardcodes three navigation sections (Applications, Agents, Routes), a starred items section, bottom links (Admin, API Docs), and all tree-building logic (`buildAppTreeNodes`, `buildRouteTreeNodes`, `buildAgentTreeNodes`). The consuming application can only pass `SidebarApp[]` data — it cannot control what sections exist, what order they appear in, or add new sections without modifying this package.
This blocks two features the consuming application needs:
1. **Admin accordion** — when the user enters admin context, the sidebar should expand an Admin section and collapse operational sections, all controlled by the application
2. **Icon-rail collapse** — the sidebar should collapse to a narrow icon strip, like modern app sidebars (Linear, VS Code, etc.)
## Goal
Refactor `Sidebar` into a composable compound component. The DS provides the frame and building blocks. The consuming application controls all content.
## Current Exports (to be replaced)
```typescript
// Current — monolithic
export { Sidebar } from './Sidebar/Sidebar'
export type { SidebarApp, SidebarRoute, SidebarAgent } from './Sidebar/Sidebar'
```
## New Exports
```typescript
// New — composable
export { Sidebar } from './Sidebar/Sidebar'
export { SidebarTree } from './Sidebar/SidebarTree'
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
export { useStarred } from './Sidebar/useStarred'
```
`SidebarApp`, `SidebarRoute`, `SidebarAgent` types are removed — they are application-domain types that move to the consuming app.
## Compound Component API
### `<Sidebar>`
The outer shell. Renders the sidebar frame with an optional search input and collapse toggle.
```tsx
<Sidebar
collapsed={false}
onCollapseToggle={() => {}}
searchValue=""
onSearchChange={(query) => {}}
className=""
>
<Sidebar.Header ... />
<Sidebar.Section ... />
<Sidebar.Section ... />
<Sidebar.Footer ... />
</Sidebar>
```
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `collapsed` | `boolean` | `false` | Render as ~48px icon rail |
| `onCollapseToggle` | `() => void` | - | Collapse/expand toggle clicked |
| `onSearchChange` | `(query: string) => void` | - | Search input changed. Omit to hide search. |
| `searchValue` | `string` | `''` | Controlled value for the search input |
| `children` | `ReactNode` | - | Sidebar.Header, Sidebar.Section, Sidebar.Footer |
| `className` | `string` | - | Additional CSS class |
**Search state ownership:** The DS renders the search input as a dumb controlled input and calls `onSearchChange` on every keystroke. The consuming application owns the search state and passes it to each `SidebarTree` as `filterQuery`. This lets the app control filtering behavior (e.g., clear search when switching sections, filter only certain sections). The DS does not hold any search state internally.
**Rendering rules:**
- Expanded: full width (~260px), all content visible
- Collapsed: ~48px wide, only icons visible, tooltips on hover
- Width transition: `transition: width 200ms ease`
- Collapse toggle button (`<<` / `>>` chevron) in top-right corner
- Search input hidden when collapsed
- Search input auto-positioned between `Sidebar.Header` and first `Sidebar.Section` (not above Header)
### `<Sidebar.Header>`
Logo, title, and version. In collapsed mode, renders only the logo centered.
```tsx
<Sidebar.Header
logo={<img src="..." />}
title="cameleer"
version="v3.2.1"
/>
```
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `logo` | `ReactNode` | - | Logo element |
| `title` | `string` | - | App name (hidden when collapsed) |
| `version` | `string` | - | Version text (hidden when collapsed) |
### `<Sidebar.Section>`
An accordion section with a collapsible header and content area.
```tsx
<Sidebar.Section
label="APPLICATIONS"
icon={<Box size={14} />}
collapsed={false}
onToggle={() => {}}
active={false}
>
<SidebarTree nodes={nodes} ... />
</Sidebar.Section>
```
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `label` | `string` | - | Section header text (rendered uppercase via CSS) |
| `icon` | `ReactNode` | - | Icon for header and collapsed rail |
| `collapsed` | `boolean` | `false` | Whether children are hidden |
| `onToggle` | `() => void` | - | Header clicked |
| `children` | `ReactNode` | - | Content when expanded |
| `active` | `boolean` | - | Override active highlight. If omitted, not highlighted. |
**Expanded rendering:**
```
[icon] APPLICATIONS
(children rendered here)
```
**Collapsed rendering:**
```
[icon] APPLICATIONS
```
**In sidebar icon-rail mode:**
```
[icon] <- centered, tooltip shows label on hover
```
Header has: icon and label (no chevron — the entire row is clickable). Active section gets the amber left-border accent (existing pattern). Clicking anywhere on the header row calls `onToggle`.
**Implementation detail:** `Sidebar.Section` and `Sidebar.Header` need to know the parent's `collapsed` state to switch between expanded and icon-rail rendering. The `<Sidebar>` component provides `collapsed` and `onCollapseToggle` via React context (`SidebarContext`). Sub-components read from context — no prop drilling needed.
**Icon-rail click behavior:** In collapsed mode, clicking a section icon fires both `onCollapseToggle` and `onToggle` simultaneously on the same click. The sidebar expands and the section opens in one motion. No navigation occurs — the user is expanding the sidebar to see what's inside, not committing to a destination. They click a tree item after the section is visible to navigate.
### `<Sidebar.Footer>`
Pinned to the bottom of the sidebar. Container for `Sidebar.FooterLink` items.
```tsx
<Sidebar.Footer>
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => {}} />
</Sidebar.Footer>
```
In collapsed mode, footer links render as centered icons with tooltips.
### `<Sidebar.FooterLink>`
A single bottom link.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `icon` | `ReactNode` | - | Link icon |
| `label` | `string` | - | Link text (hidden when collapsed, shown as tooltip) |
| `onClick` | `() => void` | - | Click handler |
| `active` | `boolean` | `false` | Active state highlight |
### `<SidebarTree>` (no changes, newly exported)
Already exists at `Sidebar/SidebarTree.tsx`. No modifications needed — it already accepts all data via props. Just export it from the package.
**Current props (unchanged):**
| Prop | Type | Description |
|------|------|-------------|
| `nodes` | `SidebarTreeNode[]` | Tree data |
| `selectedPath` | `string` | Currently active path for highlighting |
| `filterQuery` | `string` | Search filter text |
| `onNavigate` | `(path: string) => void` | Navigation callback |
| `persistKey` | `string` | localStorage key for expand state |
| `autoRevealPath` | `string \| null` | Path to auto-expand to |
| `isStarred` | `(id: string) => boolean` | Star state checker |
| `onToggleStar` | `(id: string) => void` | Star toggle callback |
### `useStarred` hook (no changes, newly exported)
Already exists at `Sidebar/useStarred.ts`. Export as-is.
**Returns:** `{ starredIds, isStarred, toggleStar }`
## What Gets Removed
All of this application-specific logic is deleted from the DS:
1. **`buildAppTreeNodes()`** (~30 lines) — transforms `SidebarApp[]` into `SidebarTreeNode[]`
2. **`buildRouteTreeNodes()`** (~20 lines) — transforms apps into route tree nodes
3. **`buildAgentTreeNodes()`** (~25 lines) — transforms apps into agent tree nodes with live-count badges
4. **`collectStarredItems()`** (~20 lines) — gathers starred items across types
5. **`StarredGroup`** sub-component (~30 lines) — renders grouped starred items
6. **Hardcoded sections** (~100 lines) — Applications, Agents, Routes section rendering with localStorage persistence
7. **Hardcoded bottom links** (~30 lines) — Admin and API Docs links
8. **Auto-reveal effect** (~20 lines) — `sidebarRevealPath` effect
9. **`SidebarApp`, `SidebarRoute`, `SidebarAgent` types** — domain types, not DS types
10. **`formatCount()` helper** — number formatting, moves to consuming app
Total: ~300 lines of application logic removed, replaced by ~150 lines of compound component shell.
## CSS Changes
### New styles needed
- `.sidebarCollapsed` — narrow width (48px), centered icons
- `.collapseToggle``<<` / `>>` button positioning
- `.sectionIcon` — icon rendering in section headers
- `.tooltip` — hover tooltips for collapsed mode
- Width transition: `transition: width 200ms ease` on `.sidebar`
### Styles that stay
- `.sidebar` (modified: width becomes conditional)
- `.searchWrap`, `.searchInput` (unchanged)
- `.navArea` (unchanged)
- All tree styles in `SidebarTree` (unchanged)
### Styles removed
- `.bottom`, `.bottomItem`, `.bottomItemActive` — replaced by `Sidebar.Footer` / `Sidebar.FooterLink` styles
- `.starredSection`, `.starredGroup`, `.starredItem`, `.starredRemove` — starred rendering moves to app
- `.section` — replaced by `Sidebar.Section` styles
## File Structure After Refactor
```
Sidebar/
├── Sidebar.tsx # Compound component: Sidebar, Sidebar.Header,
│ # Sidebar.Section, Sidebar.Footer, Sidebar.FooterLink
├── Sidebar.module.css # Updated styles (shell + section + footer + collapsed)
├── SidebarTree.tsx # Unchanged
├── SidebarTree.module.css # Unchanged (if separate, otherwise stays in Sidebar.module.css)
├── useStarred.ts # Unchanged
├── useStarred.test.ts # Unchanged
└── Sidebar.test.tsx # Updated for new compound API
```
## Testing
Update `Sidebar.test.tsx` to test the compound component API:
- Renders Header with logo, title, version
- Renders Sections with labels and icons
- Section toggle calls `onToggle`
- Collapsed sections hide children
- Sidebar collapsed mode renders icon rail
- Collapse toggle calls `onCollapseToggle`
- Footer links render with icons and labels
- Collapsed mode hides labels, shows tooltips
- Search input calls `onSearchChange`
- Search hidden when sidebar collapsed
- Section icon click in collapsed mode calls both `onCollapseToggle` and `onToggle`
`SidebarTree` tests are unaffected.
## Usage Example (for reference)
This is how the consuming application (cameleer3-server) will use the new API. This code does NOT live in the design system — it's shown for context only.
```tsx
// In LayoutShell.tsx (consuming app)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [filterQuery, setFilterQuery] = useState('');
const [appsCollapsed, setAppsCollapsed] = useState(false);
const [agentsCollapsed, setAgentsCollapsed] = useState(false);
const [routesCollapsed, setRoutesCollapsed] = useState(true);
const [adminCollapsed, setAdminCollapsed] = useState(true);
// Accordion: entering admin expands admin, collapses others
useEffect(() => {
if (isAdminPage) {
setAdminCollapsed(false);
setAppsCollapsed(true);
setAgentsCollapsed(true);
setRoutesCollapsed(true);
} else {
setAdminCollapsed(true);
// restore previous operational states
}
}, [isAdminPage]);
<Sidebar
collapsed={sidebarCollapsed}
onCollapseToggle={() => setSidebarCollapsed(v => !v)}
searchValue={filterQuery}
onSearchChange={setFilterQuery}
>
<Sidebar.Header logo={<CameleerLogo />} title="cameleer" version="v3.2.1" />
{isAdminPage && (
<Sidebar.Section label="ADMIN" icon={<Settings size={14} />}
collapsed={adminCollapsed} onToggle={() => setAdminCollapsed(v => !v)}>
<SidebarTree nodes={adminNodes} ... filterQuery={filterQuery} />
</Sidebar.Section>
)}
<Sidebar.Section label="APPLICATIONS" icon={<Box size={14} />}
collapsed={appsCollapsed} onToggle={() => { setAppsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
<SidebarTree nodes={appNodes} ... filterQuery={filterQuery} />
</Sidebar.Section>
<Sidebar.Section label="AGENTS" icon={<Cpu size={14} />}
collapsed={agentsCollapsed} onToggle={() => { setAgentsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
<SidebarTree nodes={agentNodes} ... filterQuery={filterQuery} />
</Sidebar.Section>
<Sidebar.Section label="ROUTES" icon={<GitBranch size={14} />}
collapsed={routesCollapsed} onToggle={() => { setRoutesCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
<SidebarTree nodes={routeNodes} ... filterQuery={filterQuery} />
</Sidebar.Section>
<Sidebar.Footer>
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => nav('/api-docs')} />
</Sidebar.Footer>
</Sidebar>
```
## Mock App Migration — LayoutShell
The 11 page files currently duplicating `<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>` will be consolidated into a single `LayoutShell` component.
### `src/layout/LayoutShell.tsx`
Composes the sidebar once using the new compound API. All page-specific content is rendered via `<Outlet />`.
```tsx
// src/layout/LayoutShell.tsx
export function LayoutShell() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [filterQuery, setFilterQuery] = useState('')
const [appsCollapsed, setAppsCollapsed] = useState(false)
const [agentsCollapsed, setAgentsCollapsed] = useState(false)
const [routesCollapsed, setRoutesCollapsed] = useState(false)
const { starredIds, isStarred, toggleStar } = useStarred()
const location = useLocation()
// ... build tree nodes from SIDEBAR_APPS, starred section, etc.
return (
<AppShell
sidebar={
<Sidebar
collapsed={sidebarCollapsed}
onCollapseToggle={() => setSidebarCollapsed(v => !v)}
searchValue={filterQuery}
onSearchChange={setFilterQuery}
>
<Sidebar.Header logo={...} title="cameleer" version="v3.2.1" />
<Sidebar.Section label="Applications" icon={...}
collapsed={appsCollapsed} onToggle={() => setAppsCollapsed(v => !v)}>
<SidebarTree nodes={appNodes} filterQuery={filterQuery} ... />
</Sidebar.Section>
<Sidebar.Section label="Agents" icon={...}
collapsed={agentsCollapsed} onToggle={() => setAgentsCollapsed(v => !v)}>
<SidebarTree nodes={agentNodes} filterQuery={filterQuery} ... />
</Sidebar.Section>
<Sidebar.Section label="Routes" icon={...}
collapsed={routesCollapsed} onToggle={() => setRoutesCollapsed(v => !v)}>
<SidebarTree nodes={routeNodes} filterQuery={filterQuery} ... />
</Sidebar.Section>
{/* Starred section built from useStarred + SIDEBAR_APPS */}
<Sidebar.Footer>
<Sidebar.FooterLink icon={...} label="Admin" ... />
<Sidebar.FooterLink icon={...} label="API Docs" ... />
</Sidebar.Footer>
</Sidebar>
}
>
<Outlet />
</AppShell>
)
}
```
### Route structure change
`App.tsx` switches from per-page `<Route element={<Page />}>` to a layout route:
```tsx
<Route element={<LayoutShell />}>
<Route path="/apps" element={<Dashboard />} />
<Route path="/apps/:id" element={<Dashboard />} />
...all existing routes...
</Route>
```
All tree-building helpers (`buildAppTreeNodes`, `buildRouteTreeNodes`, `buildAgentTreeNodes`), starred section logic (`collectStarredItems`, `StarredGroup`), `formatCount`, and `sidebarRevealPath` handling move from `Sidebar.tsx` into `LayoutShell.tsx`. Each page file loses its `<AppShell sidebar={...}>` wrapper and becomes just the page content.
The Inventory page's `LayoutSection` keeps its own inline `<Sidebar>` demo with `SAMPLE_APPS` data — it's a showcase, not a navigation shell.
## Breaking Change
This is a **breaking change** to the `Sidebar` API. The old `<Sidebar apps={[...]} onNavigate={...} />` signature is removed entirely. The consuming application must migrate to the compound component API in the same release cycle.
Coordinate: bump DS version, update server UI, deploy together.

165
e2e/admin.spec.ts Normal file
View File

@@ -0,0 +1,165 @@
import { test, expect } from '@playwright/test'
test.describe('Admin - User Management (/admin/rbac)', () => {
test('renders admin tabs and user table', async ({ page }) => {
await page.goto('/admin/rbac')
// Admin navigation tabs
await expect(page.getByRole('tab', { name: 'User Management' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Audit Log' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'OIDC' })).toBeVisible()
// User Management sub-tabs
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Groups' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Roles' })).toBeVisible()
// TopBar breadcrumb
await expect(page.getByLabel('Breadcrumb').getByText('User Management')).toBeVisible()
})
test('switching between Users, Groups, and Roles tabs', async ({ page }) => {
await page.goto('/admin/rbac')
// Default tab is Users
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
// Switch to Groups tab
await page.getByRole('tab', { name: 'Groups' }).click()
await page.waitForTimeout(300)
// Switch to Roles tab
await page.getByRole('tab', { name: 'Roles' }).click()
await page.waitForTimeout(300)
// Switch back to Users
await page.getByRole('tab', { name: 'Users' }).click()
})
test('navigating between admin sections via tabs', async ({ page }) => {
await page.goto('/admin/rbac')
// Click Audit Log tab
await page.getByRole('tab', { name: 'Audit Log' }).click()
await expect(page).toHaveURL(/\/admin\/audit/)
// Click OIDC tab
await page.getByRole('tab', { name: 'OIDC' }).click()
await expect(page).toHaveURL(/\/admin\/oidc/)
// Back to User Management
await page.getByRole('tab', { name: 'User Management' }).click()
await expect(page).toHaveURL(/\/admin\/rbac/)
})
})
test.describe('Admin - Audit Log (/admin/audit)', () => {
test('renders audit table with filters', async ({ page }) => {
await page.goto('/admin/audit')
// Table headers
await expect(page.getByRole('columnheader', { name: 'Timestamp' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'User' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Category' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Action' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Result' })).toBeVisible()
// Table has data
const rows = page.locator('table tbody tr')
expect(await rows.count()).toBeGreaterThan(0)
// Filter inputs exist
await expect(page.getByPlaceholder('Filter by user...')).toBeVisible()
await expect(page.getByPlaceholder('Search action or target...')).toBeVisible()
})
test('filtering audit events by search', async ({ page }) => {
await page.goto('/admin/audit')
const searchInput = page.getByPlaceholder('Search action or target...')
await searchInput.fill('deploy')
// Table should update
await page.waitForTimeout(300)
const rows = page.locator('table tbody tr')
const count = await rows.count()
expect(count).toBeGreaterThanOrEqual(0)
})
})
test.describe('Admin - OIDC Config (/admin/oidc)', () => {
test('renders OIDC form with all fields', async ({ page }) => {
await page.goto('/admin/oidc')
// Section headers
await expect(page.getByText('Behavior')).toBeVisible()
await expect(page.getByText('Provider Settings')).toBeVisible()
await expect(page.getByText('Claim Mapping')).toBeVisible()
await expect(page.getByText('Default Roles')).toBeVisible()
await expect(page.getByText('Danger Zone')).toBeVisible()
// Form fields by id
await expect(page.locator('#issuer')).toBeVisible()
await expect(page.locator('#client-id')).toBeVisible()
await expect(page.locator('#client-secret')).toBeVisible()
await expect(page.locator('#roles-claim')).toBeVisible()
await expect(page.locator('#name-claim')).toBeVisible()
// Buttons
await expect(page.getByRole('button', { name: 'Test Connection' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
await expect(page.getByRole('button', { name: /Delete OIDC/i })).toBeVisible()
// Default roles tags
await expect(page.getByText('USER').first()).toBeVisible()
await expect(page.getByText('VIEWER').first()).toBeVisible()
})
test('toggling Enabled switch', async ({ page }) => {
await page.goto('/admin/oidc')
// The Toggle's checkbox is visually hidden — click the label wrapper instead
const enabledLabel = page.locator('label').filter({ hasText: 'Enabled' })
await enabledLabel.click()
// Should not crash; label still visible
await expect(enabledLabel).toBeVisible()
})
test('adding and removing a role tag', async ({ page }) => {
await page.goto('/admin/oidc')
// Add a new role
const roleInput = page.getByPlaceholder('Add role...')
await roleInput.fill('EDITOR')
// Use the Add button next to the input (scoped to same row)
await roleInput.press('Enter')
// New role tag should appear
await expect(page.getByText('EDITOR')).toBeVisible()
// Remove it via aria-label on the tag's remove button
await page.getByRole('button', { name: 'Remove EDITOR' }).click()
// EDITOR tag should be gone
await expect(page.getByText('EDITOR')).not.toBeVisible()
})
test('Save button shows success toast', async ({ page }) => {
await page.goto('/admin/oidc')
await page.getByRole('button', { name: 'Save' }).click()
// Toast notification
await expect(page.getByText('Settings saved')).toBeVisible()
})
test('Delete button shows confirmation dialog', async ({ page }) => {
await page.goto('/admin/oidc')
await page.getByRole('button', { name: /Delete OIDC/i }).click()
// Confirmation dialog should appear
await expect(page.getByText('Delete OIDC configuration?')).toBeVisible()
})
})

80
e2e/agents.spec.ts Normal file
View File

@@ -0,0 +1,80 @@
import { test, expect } from '@playwright/test'
test.describe('Agent Health (/agents)', () => {
test('renders stat cards and group cards', async ({ page }) => {
await page.goto('/agents')
// Stat strip
await expect(page.getByText('Total Agents')).toBeVisible()
await expect(page.getByText('Total TPS')).toBeVisible()
// Group cards for each application
await expect(page.getByText('order-service').first()).toBeVisible()
await expect(page.getByText('payment-svc').first()).toBeVisible()
await expect(page.getByText('notification-hub').first()).toBeVisible()
// Instance tables have data
const instanceRows = page.locator('table tbody tr')
expect(await instanceRows.count()).toBeGreaterThan(0)
// Instance table headers
await expect(page.getByRole('columnheader', { name: 'Instance' }).first()).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'State' }).first()).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Uptime' }).first()).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'TPS' }).first()).toBeVisible()
// Timeline section
await expect(page.getByText('Timeline').first()).toBeVisible()
})
test('clicking an instance row opens the detail panel', async ({ page }) => {
await page.goto('/agents')
// Click first instance row
const instanceRow = page.locator('table tbody tr').first()
await instanceRow.click()
// Detail panel opens — look for detail-specific labels
await expect(page.getByText('Version').first()).toBeVisible()
await expect(page.getByText('Throughput').first()).toBeVisible()
})
test('detail panel has Performance tab with charts', async ({ page }) => {
await page.goto('/agents')
// Click an instance to open detail panel
const instanceRow = page.locator('table tbody tr').first()
await instanceRow.click()
// Wait for panel to open
await expect(page.getByText('Version').first()).toBeVisible()
// DetailPanel tabs are plain buttons (not role="tab")
// Switch to Performance tab
const perfTab = page.getByRole('button', { name: 'Performance' })
await perfTab.click()
// Performance charts should render
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
await expect(page.getByText('Error Rate (err/h)').first()).toBeVisible()
})
test('app-scoped agents view', async ({ page }) => {
await page.goto('/agents/order-service')
// Breadcrumb/scope shows app
await expect(page.getByLabel('Breadcrumb').getByText('Agents')).toBeVisible()
// Only order-service agents should show
await expect(page.getByText('ord-1').first()).toBeVisible()
await expect(page.getByText('ord-2').first()).toBeVisible()
await expect(page.getByText('ord-3').first()).toBeVisible()
})
test('dead agent shows alert banner', async ({ page }) => {
await page.goto('/agents')
// notification-hub has a dead instance, should show alert
await expect(page.getByText('Single point of failure')).toBeVisible()
})
})

90
e2e/dashboard.spec.ts Normal file
View File

@@ -0,0 +1,90 @@
import { test, expect } from '@playwright/test'
/** Click the 7d time range preset so hardcoded mock data (March 18) is visible. */
async function widenTimeRange(page: import('@playwright/test').Page) {
await page.getByRole('tab', { name: '7d' }).click()
}
test.describe('Dashboard (/apps)', () => {
test('renders KPI stat cards and exchange table', async ({ page }) => {
await page.goto('/apps')
await widenTimeRange(page)
// KPI health strip renders
await expect(page.getByText('Recent Exchanges')).toBeVisible()
// Table headers
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Application' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Exchange ID' })).toBeVisible()
// Table has data rows
const rows = page.locator('table tbody tr')
await expect(rows.first()).toBeVisible()
expect(await rows.count()).toBeGreaterThan(0)
// Sidebar renders with app names
await expect(page.getByText('order-service').first()).toBeVisible()
await expect(page.getByText('payment-svc').first()).toBeVisible()
// TopBar renders
await expect(page.getByLabel('Breadcrumb').getByText('Applications')).toBeVisible()
await expect(page.getByText('PRODUCTION')).toBeVisible()
// Shortcuts bar
await expect(page.getByText('Ctrl+K').first()).toBeVisible()
})
test('clicking a table row opens the detail panel', async ({ page }) => {
await page.goto('/apps')
await widenTimeRange(page)
// Click the first data row
const firstRow = page.locator('table tbody tr').first()
await expect(firstRow).toBeVisible()
await firstRow.click()
// Detail panel should open — look for "Open full details" link
await expect(page.getByText('Open full details')).toBeVisible()
// Overview section
await expect(page.getByText('Correlation').first()).toBeVisible()
})
test('navigating to app-scoped dashboard filters exchanges', async ({ page }) => {
await page.goto('/apps/order-service')
// Breadcrumb shows app scope
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
// Table should still render
await expect(page.getByText('Recent Exchanges')).toBeVisible()
})
test('sidebar navigation works', async ({ page }) => {
await page.goto('/apps')
// Click on an app in the sidebar
const sidebarApp = page.getByText('order-service').first()
await sidebarApp.click()
// URL should change to the app scope
await expect(page).toHaveURL(/\/apps\/order-service/)
})
test('inspect button navigates to exchange detail', async ({ page }) => {
await page.goto('/apps')
await widenTimeRange(page)
// Wait for table rows to appear
const firstRow = page.locator('table tbody tr').first()
await expect(firstRow).toBeVisible()
// Click the inspect button (↗) on first row
const inspectBtn = firstRow.locator('button[title="Inspect exchange"]')
await inspectBtn.click()
// Should navigate to exchange detail page
await expect(page).toHaveURL(/\/exchanges\//)
})
})

60
e2e/exchanges.spec.ts Normal file
View File

@@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test'
test.describe('Exchange Detail (/exchanges/:id)', () => {
test('renders exchange header, timeline, and message panels', async ({ page }) => {
await page.goto('/exchanges/E-2026-03-18-00201')
// Exchange header — use the one NOT in the breadcrumb
await expect(page.getByText('E-2026-03-18-00201').nth(1)).toBeVisible()
// Header stats
await expect(page.getByText('Duration').first()).toBeVisible()
await expect(page.getByText('Processors').first()).toBeVisible()
// Processor Timeline section
await expect(page.getByText('Processor Timeline').first()).toBeVisible()
// Timeline/Flow toggle buttons
await expect(page.getByRole('button', { name: 'Timeline' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Flow' })).toBeVisible()
// Message IN panel
await expect(page.getByText('Message IN')).toBeVisible()
await expect(page.getByText('Headers').first()).toBeVisible()
await expect(page.getByText('Body').first()).toBeVisible()
})
test('switching between Timeline and Flow view', async ({ page }) => {
await page.goto('/exchanges/E-2026-03-18-00201')
// Default view is timeline (gantt)
const timelineBtn = page.getByRole('button', { name: 'Timeline' })
const flowBtn = page.getByRole('button', { name: 'Flow' })
// Switch to Flow view
await flowBtn.click()
// Flow view should render (RouteFlow component)
await expect(flowBtn).toHaveClass(/active|Active/)
// Switch back to Timeline
await timelineBtn.click()
await expect(timelineBtn).toHaveClass(/active|Active/)
})
test('not-found exchange shows warning', async ({ page }) => {
await page.goto('/exchanges/nonexistent-id')
await expect(page.getByText('not found', { exact: false })).toBeVisible()
})
test('breadcrumb navigation works', async ({ page }) => {
await page.goto('/exchanges/E-2026-03-18-00201')
// Click Applications breadcrumb to go back
const appsBreadcrumb = page.getByRole('link', { name: 'Applications' })
await appsBreadcrumb.click()
await expect(page).toHaveURL(/\/apps/)
})
})

63
e2e/routes.spec.ts Normal file
View File

@@ -0,0 +1,63 @@
import { test, expect } from '@playwright/test'
test.describe('Routes (/routes)', () => {
test('renders KPI cards, route table, and charts', async ({ page }) => {
await page.goto('/routes')
// KPI cards
await expect(page.getByText('Total Throughput')).toBeVisible()
await expect(page.getByText('System Error Rate')).toBeVisible()
await expect(page.getByText('Latency Percentiles')).toBeVisible()
await expect(page.getByText('Active Routes')).toBeVisible()
await expect(page.getByText('In-Flight Exchanges')).toBeVisible()
// Route performance table
await expect(page.getByText('Per-Route Performance')).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Exchanges' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Success %' })).toBeVisible()
const rows = page.locator('table tbody tr')
expect(await rows.count()).toBeGreaterThan(0)
// Charts render
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
await expect(page.getByText('Latency (ms)')).toBeVisible()
await expect(page.getByText('Errors by Route')).toBeVisible()
await expect(page.getByText('Message Volume (msg/min)')).toBeVisible()
// Auto-refresh indicator
await expect(page.getByText('Auto-refresh: 30s')).toBeVisible()
})
test('clicking a route row navigates to route detail', async ({ page }) => {
await page.goto('/routes')
// Click first route row
const firstRow = page.locator('table tbody tr').first()
await firstRow.click()
// Should navigate to route detail
await expect(page).toHaveURL(/\/routes\/[^/]+\/[^/]+/)
// Route detail view: processor performance table
await expect(page.getByText('Processor Performance')).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Processor' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Type' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Invocations' })).toBeVisible()
// Route Flow diagram
await expect(page.getByText('Route Flow')).toBeVisible()
})
test('app-scoped routes view filters data', async ({ page }) => {
await page.goto('/routes/order-service')
// Breadcrumb shows scope
await expect(page.getByRole('link', { name: 'Routes' })).toBeVisible()
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
// Table still renders
await expect(page.getByText('Per-Route Performance')).toBeVisible()
})
})

814
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@cameleer/design-system",
"version": "0.1.0",
"version": "0.1.6",
"type": "module",
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",
@@ -10,10 +10,16 @@
"types": "./dist/index.es.d.ts",
"import": "./dist/index.es.js"
},
"./style.css": "./dist/style.css"
"./style.css": "./dist/style.css",
"./assets/*": "./assets/*"
},
"files": ["dist"],
"sideEffects": ["*.css"],
"files": [
"dist",
"assets"
],
"sideEffects": [
"*.css"
],
"publishConfig": {
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
},
@@ -27,9 +33,11 @@
"build:lib": "vite build --config vite.lib.config.ts",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest"
"test": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"lucide-react": "^1.7.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
@@ -40,12 +48,14 @@
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^3.2.4",
"happy-dom": "^20.8.4",
"typescript": "^5.6.0",
"vite": "^6.0.0",

21
playwright.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
retries: 0,
use: {
baseURL: 'http://localhost:5173',
headless: true,
viewport: { width: 1440, height: 900 },
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
],
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: true,
timeout: 15_000,
},
})

View File

@@ -19,7 +19,8 @@ import { buildSearchData } from './mocks/searchData'
import { exchanges } from './mocks/exchanges'
import { routes } from './mocks/routes'
import { agents } from './mocks/agents'
import { SIDEBAR_APPS, buildRouteToAppMap } from './mocks/sidebar'
import { buildRouteToAppMap } from './mocks/sidebar'
import { LayoutShell } from './layout/LayoutShell'
const routeToApp = buildRouteToAppMap()
@@ -78,21 +79,23 @@ export default function App() {
return (
<>
<Routes>
<Route path="/" element={<Navigate to="/apps" replace />} />
<Route path="/apps" element={<Dashboard />} />
<Route path="/apps/:id" element={<Dashboard />} />
<Route path="/apps/:id/:routeId" element={<Dashboard />} />
<Route path="/routes" element={<RoutesPage />} />
<Route path="/routes/:appId" element={<RoutesPage />} />
<Route path="/routes/:appId/:routeId" element={<RoutesPage />} />
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
<Route path="/agents/:appId/:instanceId" element={<AgentInstance />} />
<Route path="/agents/*" element={<AgentHealth />} />
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
<Route path="/admin/audit" element={<AuditLog />} />
<Route path="/admin/oidc" element={<OidcConfig />} />
<Route path="/admin/rbac" element={<UserManagement />} />
<Route path="/api-docs" element={<ApiDocs />} />
<Route element={<LayoutShell />}>
<Route path="/" element={<Navigate to="/apps" replace />} />
<Route path="/apps" element={<Dashboard />} />
<Route path="/apps/:id" element={<Dashboard />} />
<Route path="/apps/:id/:routeId" element={<Dashboard />} />
<Route path="/routes" element={<RoutesPage />} />
<Route path="/routes/:appId" element={<RoutesPage />} />
<Route path="/routes/:appId/:routeId" element={<RoutesPage />} />
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
<Route path="/agents/:appId/:instanceId" element={<AgentInstance />} />
<Route path="/agents/*" element={<AgentHealth />} />
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
<Route path="/admin/audit" element={<AuditLog />} />
<Route path="/admin/oidc" element={<OidcConfig />} />
<Route path="/admin/rbac" element={<UserManagement />} />
<Route path="/api-docs" element={<ApiDocs />} />
</Route>
<Route path="/inventory" element={<Inventory />} />
</Routes>
<CommandPalette

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@@ -78,17 +78,16 @@ describe('AlertDialog', () => {
it('renders danger variant icon', () => {
render(<AlertDialog {...defaultProps} variant="danger" />)
// Icon area should be present (aria-hidden)
expect(screen.getByText('✕')).toBeInTheDocument()
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('renders warning variant icon', () => {
render(<AlertDialog {...defaultProps} variant="warning" />)
expect(screen.getByText('⚠')).toBeInTheDocument()
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('renders info variant icon', () => {
render(<AlertDialog {...defaultProps} variant="info" />)
expect(screen.getByText('')).toBeInTheDocument()
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
})

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import React, { useEffect, useRef } from 'react'
import { XCircle, AlertTriangle, Info } from 'lucide-react'
import { Modal } from '../Modal/Modal'
import { Button } from '../../primitives/Button/Button'
import styles from './AlertDialog.module.css'
@@ -16,10 +17,10 @@ interface AlertDialogProps {
className?: string
}
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, string> = {
danger: '✕',
warning: '⚠',
info: '',
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, React.ReactNode> = {
danger: <XCircle size={20} />,
warning: <AlertTriangle size={20} />,
info: <Info size={20} />,
}
export function AlertDialog({

View File

@@ -83,6 +83,20 @@ export function BarChart({
setTooltip({ x: mx, y: my, label: catLabel, values })
}
function showBarTooltip(e: React.MouseEvent<SVGRectElement>, cat: string) {
const rect = e.currentTarget.closest('svg')!.getBoundingClientRect()
handleMouseEnter(
cat,
e.clientX - rect.left,
e.clientY - rect.top,
series.map((ss, ssi) => ({
series: ss.label,
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
})),
)
}
return (
<div className={`${styles.wrapper} ${className ?? ''}`}>
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
@@ -138,19 +152,7 @@ export function BarChart({
height={barH}
fill={color}
className={styles.bar}
onMouseEnter={(e) => {
const rect = e.currentTarget.closest('svg')!.getBoundingClientRect()
handleMouseEnter(
cat,
e.clientX - rect.left,
e.clientY - rect.top,
series.map((ss, ssi) => ({
series: ss.label,
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
})),
)
}}
onMouseEnter={(e) => showBarTooltip(e, cat)}
/>
)
})}
@@ -184,19 +186,7 @@ export function BarChart({
height={barH}
fill={color}
className={styles.bar}
onMouseEnter={(e) => {
const svgEl = e.currentTarget.closest('svg')!.getBoundingClientRect()
handleMouseEnter(
cat,
e.clientX - svgEl.left,
e.clientY - svgEl.top,
series.map((ss, ssi) => ({
series: ss.label,
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
})),
)
}}
onMouseEnter={(e) => showBarTooltip(e, cat)}
/>
)
})}

View File

@@ -277,6 +277,23 @@
overflow-y: auto;
}
/* Match context snippet */
.matchContext {
font-size: 11px;
color: var(--text-faint);
font-family: var(--font-mono);
margin-top: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.matchContext em {
font-style: normal;
color: var(--amber);
font-weight: 600;
}
/* Match highlight */
.mark {
background: none;

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Search, X, ChevronUp, ChevronDown } from 'lucide-react'
import styles from './CommandPalette.module.css'
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
@@ -12,24 +13,36 @@ interface CommandPaletteProps {
onSelect: (result: SearchResult) => void
data: SearchResult[]
onOpen?: () => void
onQueryChange?: (query: string) => void
/** Called when Enter is pressed without the user explicitly selecting a result (arrow keys/click).
* Useful for applying the query as a full-text search filter. */
onSubmit?: (query: string) => void
}
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
all: 'All',
const KNOWN_CATEGORY_LABELS: Record<string, string> = {
application: 'Applications',
exchange: 'Exchanges',
attribute: 'Attributes',
route: 'Routes',
agent: 'Agents',
}
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
'all',
/** Preferred display order for known categories */
const KNOWN_CATEGORY_ORDER: string[] = [
'application',
'exchange',
'attribute',
'route',
'agent',
]
function categoryLabel(cat: string): string {
if (cat === 'all') return 'All'
if (KNOWN_CATEGORY_LABELS[cat]) return KNOWN_CATEGORY_LABELS[cat]
// Title-case unknown categories: "my-thing" → "My Thing", "foo_bar" → "Foo Bar"
return cat.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
function highlightText(text: string, query: string, matchRanges?: [number, number][]): ReactNode {
if (!query && (!matchRanges || matchRanges.length === 0)) return text
@@ -60,12 +73,13 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
return <>{parts}</>
}
export function CommandPalette({ open, onClose, onSelect, data, onOpen }: CommandPaletteProps) {
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) {
const [query, setQuery] = useState('')
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
const [activeCategory, setActiveCategory] = useState<string>('all')
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
const [focusedIdx, setFocusedIdx] = useState(0)
const [expandedId, setExpandedId] = useState<string | null>(null)
const userNavigated = useRef(false)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
@@ -88,25 +102,21 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
setQuery('')
setFocusedIdx(0)
setExpandedId(null)
userNavigated.current = false
}
}, [open])
// Filter results
const filtered = useMemo(() => {
// Stage 1: apply text query + scope filters (used for counts)
const queryFiltered = useMemo(() => {
let results = data
if (activeCategory !== 'all') {
results = results.filter((r) => r.category === activeCategory)
}
if (query.trim()) {
const q = query.toLowerCase()
results = results.filter(
(r) => r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
(r) => r.serverFiltered || r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
)
}
// Apply scope filters
for (const sf of scopeFilters) {
results = results.filter((r) =>
r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()),
@@ -114,11 +124,17 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
}
return results
}, [data, query, activeCategory, scopeFilters])
}, [data, query, scopeFilters])
// Stage 2: apply category filter (used for display)
const filtered = useMemo(() => {
if (activeCategory === 'all') return queryFiltered
return queryFiltered.filter((r) => r.category === activeCategory)
}, [queryFiltered, activeCategory])
// Group results by category
const grouped = useMemo(() => {
const map = new Map<SearchCategory, SearchResult[]>()
const map = new Map<string, SearchResult[]>()
for (const r of filtered) {
if (!map.has(r.category)) map.set(r.category, [])
map.get(r.category)!.push(r)
@@ -129,13 +145,26 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
// Flatten for keyboard nav
const flatResults = useMemo(() => filtered, [filtered])
// Counts per category
// Counts per category (from query-filtered, before category filter)
const categoryCounts = useMemo(() => {
const counts: Record<string, number> = { all: data.length }
for (const r of data) {
const counts: Record<string, number> = { all: queryFiltered.length }
for (const r of queryFiltered) {
counts[r.category] = (counts[r.category] ?? 0) + 1
}
return counts
}, [queryFiltered])
// Build tab list dynamically: 'all' + known categories (in order) + any unknown categories found in data
const visibleCategories = useMemo(() => {
const dataCategories = new Set(data.map((r) => r.category))
const tabs: string[] = ['all']
for (const cat of KNOWN_CATEGORY_ORDER) {
if (dataCategories.has(cat)) tabs.push(cat)
}
for (const cat of dataCategories) {
if (!tabs.includes(cat)) tabs.push(cat)
}
return tabs
}, [data])
function handleKeyDown(e: React.KeyboardEvent) {
@@ -145,15 +174,20 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
break
case 'ArrowDown':
e.preventDefault()
userNavigated.current = true
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
break
case 'ArrowUp':
e.preventDefault()
userNavigated.current = true
setFocusedIdx((i) => Math.max(i - 1, 0))
break
case 'Enter':
e.preventDefault()
if (flatResults[focusedIdx]) {
if (!userNavigated.current && onSubmit && query.trim()) {
onSubmit(query.trim())
onClose()
} else if (flatResults[focusedIdx]) {
onSelect(flatResults[focusedIdx])
onClose()
}
@@ -171,10 +205,23 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
setScopeFilters((prev) => prev.filter((_, i) => i !== idx))
}
function toggleExpanded(e: React.MouseEvent, id: string) {
e.stopPropagation()
setExpandedId((prev) => (prev === id ? null : id))
}
if (!open) return null
return createPortal(
<div className={styles.overlay} onClick={onClose} data-testid="command-palette-overlay">
<div
className={styles.overlay}
onClick={onClose}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClose() }}
role="button"
tabIndex={0}
aria-label="Close command palette"
data-testid="command-palette-overlay"
>
<div
className={styles.panel}
onClick={(e) => e.stopPropagation()}
@@ -185,7 +232,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
>
{/* Search input area */}
<div className={styles.searchArea}>
<span className={styles.searchIcon} aria-hidden="true"></span>
<span className={styles.searchIcon} aria-hidden="true"><Search size={14} /></span>
{scopeFilters.map((sf, i) => (
<span key={i} className={styles.scopeTag}>
<span className={styles.scopeField}>{sf.field}:</span>
@@ -195,7 +242,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
onClick={() => removeScopeFilter(i)}
aria-label={`Remove filter ${sf.field}:${sf.value}`}
>
×
<X size={10} />
</button>
</span>
))}
@@ -208,6 +255,8 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
onChange={(e) => {
setQuery(e.target.value)
setFocusedIdx(0)
userNavigated.current = false
onQueryChange?.(e.target.value)
}}
aria-label="Search"
/>
@@ -216,7 +265,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
{/* Category tabs */}
<div className={styles.tabs} role="tablist">
{ALL_CATEGORIES.map((cat) => (
{visibleCategories.map((cat) => (
<button
key={cat}
role="tab"
@@ -232,7 +281,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
setFocusedIdx(0)
}}
>
{CATEGORY_LABELS[cat]}
{categoryLabel(cat)}
{categoryCounts[cat] != null && (
<span className={styles.tabCount}>{categoryCounts[cat]}</span>
)}
@@ -253,7 +302,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
Array.from(grouped.entries()).map(([category, items]) => (
<div key={category} className={styles.group}>
<div className={styles.groupHeader}>
<SectionHeader>{CATEGORY_LABELS[category]}</SectionHeader>
<SectionHeader>{categoryLabel(category)}</SectionHeader>
</div>
{items.map((result) => {
const flatIdx = flatResults.indexOf(result)
@@ -276,7 +325,13 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
onSelect(result)
onClose()
}}
onMouseEnter={() => setFocusedIdx(flatIdx)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onSelect(result)
onClose()
}
}}
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
>
<div className={styles.itemMain}>
{result.icon && (
@@ -301,18 +356,21 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
<div className={styles.itemMeta}>
{highlightText(result.meta, query)}
</div>
{result.matchContext && (
<div
className={styles.matchContext}
dangerouslySetInnerHTML={{ __html: result.matchContext }}
/>
)}
</div>
{result.expandedContent && (
<button
className={styles.expandBtn}
onClick={(e) => {
e.stopPropagation()
setExpandedId((prev) => (prev === result.id ? null : result.id))
}}
onClick={(e) => toggleExpanded(e, result.id)}
aria-expanded={isExpanded}
aria-label="Toggle detail"
>
{isExpanded ? '▲' : '▼'}
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
)}
</div>
@@ -341,7 +399,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
</div>
<div className={styles.shortcut}>
<KeyboardHint keys="Enter" />
<span>Open</span>
<span>Search</span>
</div>
<div className={styles.shortcut}>
<KeyboardHint keys="Esc" />

View File

@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent'
/** Known categories: 'application' | 'exchange' | 'attribute' | 'route' | 'agent'. Custom categories are rendered with title-cased labels and a default icon. */
export type SearchCategory = string
export interface SearchResult {
id: string
@@ -13,6 +14,10 @@ export interface SearchResult {
path?: string
expandedContent?: string
matchRanges?: [number, number][]
/** Skip client-side query filtering (result already matched server-side) */
serverFiltered?: boolean
/** Server-side match snippet with <em> tags around matched text */
matchContext?: string
}
export interface ScopeFilter {

View File

@@ -12,6 +12,23 @@
box-shadow: none;
}
.fillHeight {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.fillHeight .scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.fillHeight .footer {
flex-shrink: 0;
}
.scroll {
overflow-x: auto;
}
@@ -35,6 +52,9 @@
background: var(--bg-raised);
border-bottom: 1px solid var(--border);
transition: color 0.12s;
position: sticky;
top: 0;
z-index: 1;
}
.th.sortable {

View File

@@ -24,6 +24,8 @@ export function DataTable<T extends { id: string }>({
rowAccent,
expandedContent,
flush = false,
fillHeight = false,
onSortChange,
}: DataTableProps<T>) {
const [sortKey, setSortKey] = useState<string | null>(null)
const [sortDir, setSortDir] = useState<SortDir>('asc')
@@ -31,14 +33,16 @@ export function DataTable<T extends { id: string }>({
const [pageSize, setPageSize] = useState(initialPageSize)
const [expandedId, setExpandedId] = useState<string | null>(null)
// When onSortChange is provided (controlled mode), skip client-side sorting
const sorted = useMemo(() => {
if (onSortChange) return data
if (!sortKey) return data
return [...data].sort((a, b) => {
const av = (a as Record<string, unknown>)[sortKey]
const bv = (b as Record<string, unknown>)[sortKey]
return compareValues(av, bv, sortDir)
})
}, [data, sortKey, sortDir])
}, [data, sortKey, sortDir, onSortChange])
const totalRows = sorted.length
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize))
@@ -52,13 +56,17 @@ export function DataTable<T extends { id: string }>({
function handleHeaderClick(col: Column<T>) {
if (!sortable && !col.sortable) return
let newDir: SortDir
if (sortKey === col.key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
newDir = sortDir === 'asc' ? 'desc' : 'asc'
setSortDir(newDir)
} else {
newDir = 'asc'
setSortKey(col.key)
setSortDir('asc')
setSortDir(newDir)
}
setPage(1)
onSortChange?.(col.key, newDir)
}
function handleRowClick(row: T) {
@@ -74,7 +82,7 @@ export function DataTable<T extends { id: string }>({
}))
return (
<div className={`${styles.wrapper} ${flush ? styles.flush : ''}`}>
<div className={`${styles.wrapper} ${flush ? styles.flush : ''} ${fillHeight ? styles.fillHeight : ''}`}>
<div className={styles.scroll}>
<table className={styles.table}>
<thead>

View File

@@ -20,4 +20,12 @@ export interface DataTableProps<T extends { id: string }> {
expandedContent?: (row: T) => ReactNode | null
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
flush?: boolean
/** Make the table fill remaining vertical space in a flex parent.
* The table body scrolls while the header stays sticky and the
* pagination footer stays pinned at the bottom. */
fillHeight?: boolean
/** Controlled sort: called when the user clicks a sortable column header.
* When provided, the component skips client-side sorting — the caller is
* responsible for providing `data` in the desired order. */
onSortChange?: (key: string, dir: 'asc' | 'desc') => void
}

View File

@@ -1,4 +1,21 @@
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.15);
z-index: 99;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.panel {
position: fixed;
top: 0;
right: 0;
height: 100vh;
width: 0;
overflow: hidden;
transition: width 0.25s ease, opacity 0.2s ease;
@@ -7,13 +24,15 @@
display: flex;
flex-direction: column;
background: var(--bg-surface);
flex-shrink: 0;
z-index: 100;
box-shadow: none;
}
.panel.open {
width: 400px;
opacity: 1;
border-left-color: var(--border);
box-shadow: var(--shadow-lg);
animation: slideInRight 0.25s ease-out both;
}

View File

@@ -1,4 +1,5 @@
import { useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import styles from './DetailPanel.module.css'
interface Tab {
@@ -22,47 +23,54 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
return (
<aside
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
aria-hidden={!open}
>
<div className={styles.header}>
<span className={styles.title}>{title}</span>
<button
className={styles.closeBtn}
onClick={onClose}
aria-label="Close panel"
type="button"
>
&times;
</button>
</div>
{tabs && tabs.length > 0 && (
<div className={styles.tabs}>
{tabs.map((tab) => (
<button
key={tab.value}
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
onClick={() => setActiveTab(tab.value)}
type="button"
>
{tab.label}
</button>
))}
const content = (
<>
{open && <div className={styles.backdrop} onClick={onClose} aria-hidden="true" />}
<aside
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
aria-hidden={!open}
>
<div className={styles.header}>
<span className={styles.title}>{title}</span>
<button
className={styles.closeBtn}
onClick={onClose}
aria-label="Close panel"
type="button"
>
&times;
</button>
</div>
)}
<div className={styles.body}>
{children ?? activeContent}
</div>
{tabs && tabs.length > 0 && (
<div className={styles.tabs}>
{tabs.map((tab) => (
<button
key={tab.value}
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
onClick={() => setActiveTab(tab.value)}
type="button"
>
{tab.label}
</button>
))}
</div>
)}
{actions && (
<div className={styles.actions}>
{actions}
<div className={styles.body}>
{children ?? activeContent}
</div>
)}
</aside>
{actions && (
<div className={styles.actions}>
{actions}
</div>
)}
</aside>
</>
)
// Portal to AppShell level if target exists, otherwise render in place
const portalTarget = document.getElementById('cameleer-detail-panel-root')
return portalTarget ? createPortal(content, portalTarget) : content
}

View File

@@ -0,0 +1,49 @@
.entityListRoot {
display: flex;
flex-direction: column;
height: 100%;
}
.listHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.listHeaderSearch {
flex: 1;
}
.list {
flex: 1;
overflow-y: auto;
}
.entityItem {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
transition: background 0.1s;
border-bottom: 1px solid var(--border-subtle);
}
.entityItem:hover {
background: var(--bg-hover);
}
.entityItemSelected {
background: var(--amber-bg);
border-left: 3px solid var(--amber);
}
.emptyMessage {
padding: 32px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}

View File

@@ -0,0 +1,167 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { EntityList } from './EntityList'
interface TestItem {
id: string
name: string
}
const items: TestItem[] = [
{ id: '1', name: 'Alpha' },
{ id: '2', name: 'Beta' },
{ id: '3', name: 'Gamma' },
]
describe('EntityList', () => {
it('renders all items', () => {
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
/>
)
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Beta')).toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
})
it('calls onSelect when item clicked', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
onSelect={onSelect}
/>
)
await user.click(screen.getByText('Beta'))
expect(onSelect).toHaveBeenCalledWith('2')
})
it('highlights selected item (aria-selected="true" and has selected class)', () => {
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
selectedId="2"
/>
)
const selectedOption = screen.getByText('Beta').closest('[role="option"]')
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
const unselectedOption = screen.getByText('Alpha').closest('[role="option"]')
expect(unselectedOption).toHaveAttribute('aria-selected', 'false')
})
it('renders search input when onSearch provided', () => {
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
onSearch={() => {}}
searchPlaceholder="Filter items..."
/>
)
expect(screen.getByPlaceholderText('Filter items...')).toBeInTheDocument()
})
it('calls onSearch when typing in search', async () => {
const onSearch = vi.fn()
const user = userEvent.setup()
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
onSearch={onSearch}
/>
)
const input = screen.getByPlaceholderText('Search...')
await user.type(input, 'test')
expect(onSearch).toHaveBeenLastCalledWith('test')
})
it('renders add button when onAdd provided', () => {
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
onAdd={() => {}}
addLabel="Add Item"
/>
)
expect(screen.getByText('Add Item')).toBeInTheDocument()
})
it('calls onAdd when add button clicked', async () => {
const onAdd = vi.fn()
const user = userEvent.setup()
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
onAdd={onAdd}
addLabel="Add Item"
/>
)
await user.click(screen.getByText('Add Item'))
expect(onAdd).toHaveBeenCalledOnce()
})
it('hides header when no search or add', () => {
const { container } = render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
/>
)
// No input or button should be present in the header area
expect(container.querySelector('input')).toBeNull()
expect(container.querySelector('button')).toBeNull()
})
it('shows empty message when items is empty', () => {
render(
<EntityList
items={[]}
renderItem={(item: TestItem) => <span>{item.name}</span>}
getItemId={(item: TestItem) => item.id}
/>
)
expect(screen.getByText('No items found')).toBeInTheDocument()
})
it('shows custom empty message', () => {
render(
<EntityList
items={[]}
renderItem={(item: TestItem) => <span>{item.name}</span>}
getItemId={(item: TestItem) => item.id}
emptyMessage="Nothing here"
/>
)
expect(screen.getByText('Nothing here')).toBeInTheDocument()
})
it('accepts className', () => {
const { container } = render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
className="custom-class"
/>
)
expect(container.firstChild).toHaveClass('custom-class')
})
})

View File

@@ -0,0 +1,97 @@
import { useState, type ReactNode } from 'react'
import { Input } from '../../primitives/Input/Input'
import { Button } from '../../primitives/Button/Button'
import styles from './EntityList.module.css'
interface EntityListProps<T> {
items: T[]
renderItem: (item: T, isSelected: boolean) => ReactNode
getItemId: (item: T) => string
selectedId?: string
onSelect?: (id: string) => void
searchPlaceholder?: string
onSearch?: (query: string) => void
addLabel?: string
onAdd?: () => void
emptyMessage?: string
className?: string
}
export function EntityList<T>({
items,
renderItem,
getItemId,
selectedId,
onSelect,
searchPlaceholder = 'Search...',
onSearch,
addLabel,
onAdd,
emptyMessage = 'No items found',
className,
}: EntityListProps<T>) {
const [searchValue, setSearchValue] = useState('')
const showHeader = !!onSearch || !!onAdd
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value
setSearchValue(value)
onSearch?.(value)
}
function handleSearchClear() {
setSearchValue('')
onSearch?.('')
}
return (
<div className={`${styles.entityListRoot} ${className ?? ''}`}>
{showHeader && (
<div className={styles.listHeader}>
{onSearch && (
<Input
placeholder={searchPlaceholder}
value={searchValue}
onChange={handleSearchChange}
onClear={handleSearchClear}
className={styles.listHeaderSearch}
/>
)}
{onAdd && addLabel && (
<Button size="sm" variant="secondary" onClick={onAdd}>
{addLabel}
</Button>
)}
</div>
)}
<div className={styles.list} role="listbox">
{items.map((item) => {
const id = getItemId(item)
const isSelected = id === selectedId
return (
<div
key={id}
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
onClick={() => onSelect?.(id)}
role="option"
tabIndex={0}
aria-selected={isSelected}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect?.(id)
}
}}
>
{renderItem(item, isSelected)}
</div>
)
})}
{items.length === 0 && (
<div className={styles.emptyMessage}>{emptyMessage}</div>
)}
</div>
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
import { X as XIcon, AlertTriangle, Play, Loader } from 'lucide-react'
import styles from './EventFeed.module.css'
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
@@ -47,11 +48,11 @@ function getSearchableText(event: FeedEvent): string {
return ''
}
const DEFAULT_ICONS: Record<SeverityFilter, string> = {
error: '\u2715', // ✕
warning: '\u26A0', // ⚠
success: '\u25B6', // ▶
running: '\u2699', // ⚙
const DEFAULT_ICONS: Record<SeverityFilter, ReactNode> = {
error: <XIcon size={14} />,
warning: <AlertTriangle size={14} />,
success: <Play size={14} />,
running: <Loader size={14} />,
}
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
@@ -81,25 +82,25 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
// Auto-scroll to bottom
const scrollToBottom = useCallback(() => {
// Auto-scroll to top (newest entries are at top in desc sort)
const scrollToTop = useCallback(() => {
const el = scrollRef.current
if (el) {
el.scrollTop = el.scrollHeight
el.scrollTop = 0
}
}, [])
useEffect(() => {
if (!isPaused) {
scrollToBottom()
scrollToTop()
}
}, [events, isPaused, scrollToBottom])
}, [events, isPaused, scrollToTop])
function handleScroll() {
const el = scrollRef.current
if (!el) return
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8
setIsPaused(!atBottom)
const atTop = el.scrollTop < 8
setIsPaused(!atTop)
}
function toggleFilter(severity: SeverityFilter) {
@@ -136,7 +137,7 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
onClick={() => setSearch('')}
aria-label="Clear search"
>
×
<XIcon size={12} />
</button>
)}
</div>
@@ -196,10 +197,10 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
className={styles.resumeBtn}
onClick={() => {
setIsPaused(false)
scrollToBottom()
scrollToTop()
}}
>
Resume auto-scroll
&uarr; Scroll to latest
</button>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { useState, type ChangeEvent } from 'react'
import { Search } from 'lucide-react'
import styles from './FilterBar.module.css'
import { Input } from '../../primitives/Input/Input'
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
@@ -77,12 +78,7 @@ export function FilterBar({
if (onSearchChange) onSearchChange('')
else setInternalSearch('')
} : undefined}
icon={
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
}
icon={<Search size={13} />}
/>
</div>

View File

@@ -0,0 +1,79 @@
.kpiStrip {
display: grid;
gap: 12px;
margin-bottom: 20px;
}
.kpiCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 16px 18px 12px;
box-shadow: var(--shadow-card);
position: relative;
overflow: hidden;
transition: box-shadow 0.15s;
}
.kpiCard:hover {
box-shadow: var(--shadow-md);
}
.kpiCard::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--kpi-border-color), transparent);
}
.label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 6px;
}
.valueRow {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 4px;
}
.value {
font-family: var(--font-mono);
font-size: 26px;
font-weight: 600;
line-height: 1.2;
color: var(--text-primary);
}
.trend {
font-family: var(--font-mono);
font-size: 11px;
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: auto;
}
.trendSuccess { color: var(--success); }
.trendWarning { color: var(--warning); }
.trendError { color: var(--error); }
.trendMuted { color: var(--text-muted); }
.subtitle {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.sparkline {
margin-top: 8px;
height: 32px;
}

View File

@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { KpiStrip } from './KpiStrip'
import type { KpiItem } from './KpiStrip'
const sampleItems: KpiItem[] = [
{ label: 'Total', value: 42 },
{ label: 'Active', value: '18', trend: { label: '+3', variant: 'success' } },
{ label: 'Errors', value: 5, subtitle: 'last 24h', sparkline: [1, 3, 2, 5, 4] },
]
describe('KpiStrip', () => {
it('renders all items', () => {
const { container } = render(<KpiStrip items={sampleItems} />)
const cards = container.querySelectorAll('[class*="kpiCard"]')
expect(cards).toHaveLength(3)
})
it('renders labels and values', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('Total')).toBeInTheDocument()
expect(screen.getByText('42')).toBeInTheDocument()
expect(screen.getByText('Active')).toBeInTheDocument()
expect(screen.getByText('18')).toBeInTheDocument()
})
it('renders trend with correct text', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('+3')).toBeInTheDocument()
})
it('applies variant class to trend (trendSuccess)', () => {
render(<KpiStrip items={sampleItems} />)
const trend = screen.getByText('+3')
expect(trend.className).toContain('trendSuccess')
})
it('hides trend when omitted', () => {
render(<KpiStrip items={[{ label: 'No Trend', value: 10 }]} />)
const { container } = render(<KpiStrip items={[{ label: 'No Trend2', value: 10 }]} />)
const trends = container.querySelectorAll('[class*="trend"]')
expect(trends).toHaveLength(0)
})
it('renders subtitle', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('last 24h')).toBeInTheDocument()
})
it('renders sparkline when data provided', () => {
const { container } = render(<KpiStrip items={sampleItems} />)
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThanOrEqual(1)
})
it('accepts className prop', () => {
const { container } = render(<KpiStrip items={sampleItems} className="custom" />)
expect(container.firstChild).toHaveClass('custom')
})
it('handles empty items array', () => {
const { container } = render(<KpiStrip items={[]} />)
const cards = container.querySelectorAll('[class*="kpiCard"]')
expect(cards).toHaveLength(0)
})
it('uses default border color (--amber) when borderColor omitted', () => {
const { container } = render(<KpiStrip items={[{ label: 'Default', value: 1 }]} />)
const card = container.querySelector('[class*="kpiCard"]') as HTMLElement
expect(card.style.getPropertyValue('--kpi-border-color')).toBe('var(--amber)')
})
it('applies custom borderColor', () => {
const items: KpiItem[] = [{ label: 'Custom', value: 1, borderColor: 'var(--teal)' }]
const { container } = render(<KpiStrip items={items} />)
const card = container.querySelector('[class*="kpiCard"]') as HTMLElement
expect(card.style.getPropertyValue('--kpi-border-color')).toBe('var(--teal)')
})
it('renders trend with muted variant by default', () => {
const items: KpiItem[] = [{ label: 'Muted', value: 1, trend: { label: '0%' } }]
render(<KpiStrip items={items} />)
const trend = screen.getByText('0%')
expect(trend.className).toContain('trendMuted')
})
})

View File

@@ -0,0 +1,71 @@
import styles from './KpiStrip.module.css'
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
import type { CSSProperties, ReactNode } from 'react'
export interface KpiItem {
label: string
value: string | number
trend?: { label: ReactNode; variant?: 'success' | 'warning' | 'error' | 'muted' }
subtitle?: string
sparkline?: number[]
borderColor?: string
}
export interface KpiStripProps {
items: KpiItem[]
className?: string
}
const trendClassMap: Record<string, string> = {
success: styles.trendSuccess,
warning: styles.trendWarning,
error: styles.trendError,
muted: styles.trendMuted,
}
export function KpiStrip({ items, className }: KpiStripProps) {
const stripClasses = [styles.kpiStrip, className ?? ''].filter(Boolean).join(' ')
const gridStyle: CSSProperties = {
gridTemplateColumns: items.length > 0 ? `repeat(${items.length}, 1fr)` : undefined,
}
return (
<div className={stripClasses} style={gridStyle}>
{items.map((item) => {
const borderColor = item.borderColor ?? 'var(--amber)'
const cardStyle: CSSProperties & Record<string, string> = {
'--kpi-border-color': borderColor,
}
const trendVariant = item.trend?.variant ?? 'muted'
const trendClass = trendClassMap[trendVariant] ?? styles.trendMuted
return (
<div key={item.label} className={styles.kpiCard} style={cardStyle}>
<div className={styles.label}>{item.label}</div>
<div className={styles.valueRow}>
<span className={styles.value}>{item.value}</span>
{item.trend && (
<span className={`${styles.trend} ${trendClass}`}>
{item.trend.label}
</span>
)}
</div>
{item.subtitle && (
<div className={styles.subtitle}>{item.subtitle}</div>
)}
{item.sparkline && item.sparkline.length >= 2 && (
<div className={styles.sparkline}>
<Sparkline
data={item.sparkline}
color={borderColor}
width={200}
height={32}
/>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,78 @@
.container {
overflow-y: auto;
padding: 8px 0;
font-family: var(--font-mono);
}
.line {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 3px 12px;
line-height: 1.5;
}
.line:hover {
background: var(--bg-hover);
}
.timestamp {
flex-shrink: 0;
font-size: 11px;
color: var(--text-muted);
min-width: 56px;
}
.levelBadge {
flex-shrink: 0;
font-size: 9px;
font-weight: 600;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.3px;
padding: 1px 6px;
border-radius: 9999px;
line-height: 1.5;
white-space: nowrap;
}
.levelInfo {
color: var(--running);
background: color-mix(in srgb, var(--running) 12%, transparent);
}
.levelWarn {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
}
.levelError {
color: var(--error);
background: color-mix(in srgb, var(--error) 12%, transparent);
}
.levelDebug {
color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
}
.levelTrace {
color: var(--text-faint);
background: color-mix(in srgb, var(--text-faint) 8%, transparent);
}
.message {
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-primary);
word-break: break-word;
line-height: 1.5;
}
.empty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { LogViewer, type LogEntry } from './LogViewer'
const entries: LogEntry[] = [
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'Server started' },
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
{ timestamp: '2024-01-15T10:30:20Z', level: 'trace', message: 'Entering handleRequest()' },
]
describe('LogViewer', () => {
it('renders entries with timestamps and messages', () => {
render(<LogViewer entries={entries} />)
expect(screen.getByText('Server started')).toBeInTheDocument()
expect(screen.getByText('High memory usage')).toBeInTheDocument()
expect(screen.getByText('Connection failed')).toBeInTheDocument()
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
expect(screen.getByText('Entering handleRequest()')).toBeInTheDocument()
})
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG, TRACE)', () => {
render(<LogViewer entries={entries} />)
expect(screen.getByText('INFO')).toBeInTheDocument()
expect(screen.getByText('WARN')).toBeInTheDocument()
expect(screen.getByText('ERROR')).toBeInTheDocument()
expect(screen.getByText('DEBUG')).toBeInTheDocument()
expect(screen.getByText('TRACE')).toBeInTheDocument()
})
it('renders with custom maxHeight (number)', () => {
const { container } = render(<LogViewer entries={entries} maxHeight={300} />)
const el = container.firstElementChild as HTMLElement
expect(el.style.maxHeight).toBe('300px')
})
it('renders with string maxHeight', () => {
const { container } = render(<LogViewer entries={entries} maxHeight="50vh" />)
const el = container.firstElementChild as HTMLElement
expect(el.style.maxHeight).toBe('50vh')
})
it('handles empty entries', () => {
render(<LogViewer entries={[]} />)
expect(screen.getByText('No log entries.')).toBeInTheDocument()
})
it('accepts className prop', () => {
const { container } = render(<LogViewer entries={entries} className="custom-class" />)
const el = container.firstElementChild as HTMLElement
expect(el.classList.contains('custom-class')).toBe(true)
})
it('has role="log" for accessibility', () => {
render(<LogViewer entries={entries} />)
expect(screen.getByRole('log')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,78 @@
import { useRef, useEffect, useCallback } from 'react'
import styles from './LogViewer.module.css'
export interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error' | 'debug' | 'trace'
message: string
}
export interface LogViewerProps {
entries: LogEntry[]
maxHeight?: number | string
className?: string
}
const LEVEL_CLASS: Record<LogEntry['level'], string> = {
info: styles.levelInfo,
warn: styles.levelWarn,
error: styles.levelError,
debug: styles.levelDebug,
trace: styles.levelTrace,
}
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
} catch {
return iso
}
}
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const isAtTopRef = useRef(true)
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
isAtTopRef.current = el.scrollTop < 20
}, [])
useEffect(() => {
const el = scrollRef.current
if (el && isAtTopRef.current) {
el.scrollTop = 0
}
}, [entries])
const heightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
return (
<div
ref={scrollRef}
className={[styles.container, className].filter(Boolean).join(' ')}
style={{ maxHeight: heightStyle }}
onScroll={handleScroll}
role="log"
>
{entries.map((entry, i) => (
<div key={i} className={styles.line}>
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
{entry.level.toUpperCase()}
</span>
<span className={styles.message}>{entry.message}</span>
</div>
))}
{entries.length === 0 && (
<div className={styles.empty}>No log entries.</div>
)}
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginDialog } from './LoginDialog'
const defaultProps = {
open: true,
onClose: vi.fn(),
onSubmit: vi.fn(),
}
describe('LoginDialog', () => {
it('renders Modal with LoginForm when open', () => {
render(<LoginDialog {...defaultProps} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
})
it('does not render when closed', () => {
render(<LoginDialog {...defaultProps} open={false} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('calls onClose on Esc', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<LoginDialog {...defaultProps} onClose={onClose} />)
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
it('calls onClose on backdrop click', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<LoginDialog {...defaultProps} onClose={onClose} />)
await user.click(screen.getByTestId('modal-backdrop'))
expect(onClose).toHaveBeenCalled()
})
it('passes LoginForm props through', () => {
render(
<LoginDialog
{...defaultProps}
title="Welcome"
socialProviders={[{ label: 'Continue with Google', onClick: vi.fn() }]}
error="Bad credentials"
/>,
)
expect(screen.getByText('Welcome')).toBeInTheDocument()
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
expect(screen.getByText('Bad credentials')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,15 @@
import { Modal } from '../Modal/Modal'
import { LoginForm, type LoginFormProps } from './LoginForm'
export interface LoginDialogProps extends LoginFormProps {
open: boolean
onClose: () => void
}
export function LoginDialog({ open, onClose, className, ...formProps }: LoginDialogProps) {
return (
<Modal open={open} onClose={onClose} size="sm" className={className}>
<LoginForm {...formProps} />
</Modal>
)
}

View File

@@ -0,0 +1,111 @@
.loginForm {
display: flex;
flex-direction: column;
align-items: center;
font-family: var(--font-body);
width: 100%;
}
.logo {
margin-bottom: 8px;
}
.title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 20px;
}
.error {
width: 100%;
margin-bottom: 16px;
}
.socialSection {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-bottom: 20px;
}
.socialButton {
width: 100%;
justify-content: center;
}
.divider {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin-bottom: 20px;
}
.dividerLine {
flex: 1;
height: 1px;
background: var(--border);
}
.dividerText {
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.fields {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
}
.rememberRow {
display: flex;
justify-content: space-between;
align-items: center;
}
.forgotLink {
font-size: 11px;
color: var(--amber);
font-weight: 500;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-family: var(--font-body);
}
.forgotLink:hover {
text-decoration: underline;
}
.submitButton {
width: 100%;
}
.signUpText {
text-align: center;
font-size: 12px;
color: var(--text-secondary);
}
.signUpLink {
color: var(--amber);
font-weight: 500;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-family: var(--font-body);
font-size: 12px;
}
.signUpLink:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,193 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'
const socialProviders = [
{ label: 'Continue with Google', onClick: vi.fn() },
{ label: 'Continue with GitHub', onClick: vi.fn() },
]
const allProps = {
logo: <div data-testid="logo">Logo</div>,
title: 'Welcome back',
socialProviders,
onSubmit: vi.fn(),
onForgotPassword: vi.fn(),
onSignUp: vi.fn(),
}
describe('LoginForm', () => {
describe('rendering', () => {
it('renders all elements when all props provided', () => {
render(<LoginForm {...allProps} />)
expect(screen.getByTestId('logo')).toBeInTheDocument()
expect(screen.getByText('Welcome back')).toBeInTheDocument()
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
expect(screen.getByText('Continue with GitHub')).toBeInTheDocument()
expect(screen.getByText('or')).toBeInTheDocument()
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument()
expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument()
expect(screen.getByText(/sign up/i)).toBeInTheDocument()
})
it('renders default title when title prop omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
})
it('hides social section when socialProviders is empty', () => {
render(<LoginForm onSubmit={vi.fn()} socialProviders={[]} />)
expect(screen.queryByText('or')).not.toBeInTheDocument()
})
it('hides social section when socialProviders is omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.queryByText('or')).not.toBeInTheDocument()
})
it('hides forgot password link when onForgotPassword omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.queryByText(/forgot password/i)).not.toBeInTheDocument()
})
it('hides sign up link when onSignUp omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.queryByText(/sign up/i)).not.toBeInTheDocument()
})
it('hides credentials section when onSubmit omitted (social only)', () => {
render(<LoginForm socialProviders={socialProviders} />)
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument()
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument()
expect(screen.queryByText('or')).not.toBeInTheDocument()
// Social buttons should still render
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
})
it('shows server error Alert when error prop set', () => {
render(<LoginForm onSubmit={vi.fn()} error="Invalid credentials" />)
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
})
})
describe('validation', () => {
it('validates required email', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Email is required')).toBeInTheDocument()
})
it('validates email format', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'notanemail')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument()
})
it('validates required password', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Password is required')).toBeInTheDocument()
})
it('validates password minimum length', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'short')
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument()
})
it('clears field errors on typing', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Email is required')).toBeInTheDocument()
await user.type(screen.getByLabelText(/email/i), 't')
expect(screen.queryByText('Email is required')).not.toBeInTheDocument()
})
it('calls onSubmit with credentials when valid', async () => {
const onSubmit = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByLabelText(/remember me/i))
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
remember: true,
})
})
it('does not call onSubmit when validation fails', async () => {
const onSubmit = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={onSubmit} />)
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe('loading state', () => {
it('disables form inputs when loading', () => {
render(<LoginForm {...allProps} loading />)
expect(screen.getByLabelText(/email/i)).toBeDisabled()
expect(screen.getByLabelText(/password/i)).toBeDisabled()
expect(screen.getByLabelText(/remember me/i)).toBeDisabled()
})
it('shows spinner on submit button when loading', () => {
render(<LoginForm {...allProps} loading />)
const submitBtn = screen.getByRole('button', { name: /sign in/i })
expect(submitBtn).toBeDisabled()
// Button component renders Spinner when loading=true
expect(submitBtn.querySelector('[class*="spinner"]')).toBeInTheDocument()
})
it('disables social buttons when loading', () => {
render(<LoginForm {...allProps} loading />)
expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Continue with GitHub' })).toBeDisabled()
})
})
describe('callbacks', () => {
it('calls social provider onClick when clicked', async () => {
const onClick = vi.fn()
const user = userEvent.setup()
render(<LoginForm socialProviders={[{ label: 'Continue with Google', onClick }]} onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Continue with Google' }))
expect(onClick).toHaveBeenCalledOnce()
})
it('calls onForgotPassword when link clicked', async () => {
const onForgotPassword = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} onForgotPassword={onForgotPassword} />)
await user.click(screen.getByText(/forgot password/i))
expect(onForgotPassword).toHaveBeenCalledOnce()
})
it('calls onSignUp when link clicked', async () => {
const onSignUp = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} onSignUp={onSignUp} />)
await user.click(screen.getByText(/sign up/i))
expect(onSignUp).toHaveBeenCalledOnce()
})
})
})

View File

@@ -0,0 +1,223 @@
import { useEffect, useRef, useState, type ReactNode, type FormEvent } from 'react'
import { Button } from '../../primitives/Button/Button'
import { Input } from '../../primitives/Input/Input'
import { Checkbox } from '../../primitives/Checkbox/Checkbox'
import { FormField } from '../../primitives/FormField/FormField'
import { Alert } from '../../primitives/Alert/Alert'
import styles from './LoginForm.module.css'
export interface SocialProvider {
label: string
icon?: ReactNode
onClick: () => void
}
export interface LoginFormProps {
logo?: ReactNode
title?: string
socialProviders?: SocialProvider[]
onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void
onForgotPassword?: () => void
onSignUp?: () => void
error?: string
loading?: boolean
className?: string
}
interface FieldErrors {
email?: string
password?: string
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function validate(email: string, password: string): FieldErrors {
const errors: FieldErrors = {}
if (!email) {
errors.email = 'Email is required'
} else if (!EMAIL_REGEX.test(email)) {
errors.email = 'Please enter a valid email address'
}
if (!password) {
errors.password = 'Password is required'
} else if (password.length < 8) {
errors.password = 'Password must be at least 8 characters'
}
return errors
}
export function LoginForm({
logo,
title = 'Sign in',
socialProviders,
onSubmit,
onForgotPassword,
onSignUp,
error,
loading = false,
className,
}: LoginFormProps) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const [submitted, setSubmitted] = useState(false)
const emailRef = useRef<HTMLInputElement>(null)
// Auto-focus first input on mount
useEffect(() => {
emailRef.current?.focus()
}, [])
// Reset submitted flag when error prop changes (new server error from re-attempt)
useEffect(() => {
if (error) setSubmitted(false)
}, [error])
// Server error is shown from prop, hidden after next submit attempt
const showServerError = error && !submitted
const hasSocial = socialProviders && socialProviders.length > 0
const hasCredentials = !!onSubmit
const showDivider = hasSocial && hasCredentials
function handleSubmit(e: FormEvent) {
e.preventDefault()
setSubmitted(true)
const errors = validate(email, password)
setFieldErrors(errors)
if (Object.keys(errors).length === 0) {
onSubmit?.({ email, password, remember })
}
}
return (
<div className={`${styles.loginForm} ${className ?? ''}`}>
{logo && <div className={styles.logo}>{logo}</div>}
<h2 className={styles.title}>{title}</h2>
{showServerError && (
<div className={styles.error}>
<Alert variant="error">{error}</Alert>
</div>
)}
{hasSocial && (
<div className={styles.socialSection}>
{socialProviders.map((provider) => (
<Button
key={provider.label}
variant="secondary"
className={styles.socialButton}
onClick={provider.onClick}
disabled={loading}
type="button"
>
{provider.icon}
{provider.label}
</Button>
))}
</div>
)}
{showDivider && (
<div className={styles.divider}>
<div className={styles.dividerLine} />
<span className={styles.dividerText}>or</span>
<div className={styles.dividerLine} />
</div>
)}
{hasCredentials && (
<form
className={styles.fields}
onSubmit={handleSubmit}
aria-label="Sign in"
noValidate
>
<FormField label="Email" htmlFor="login-email" required error={fieldErrors.email}>
<Input
ref={emailRef}
id="login-email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => {
setEmail(e.target.value)
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }))
}}
disabled={loading}
/>
</FormField>
<FormField label="Password" htmlFor="login-password" required error={fieldErrors.password}>
<Input
id="login-password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }))
}}
disabled={loading}
/>
</FormField>
<div className={styles.rememberRow}>
<Checkbox
label="Remember me"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
disabled={loading}
/>
{onForgotPassword && (
<button
type="button"
className={styles.forgotLink}
onClick={onForgotPassword}
>
Forgot password?
</button>
)}
</div>
<Button
variant="primary"
type="submit"
loading={loading}
className={styles.submitButton}
>
Sign in
</Button>
{onSignUp && (
<div className={styles.signUpText}>
Don&apos;t have an account?{' '}
<button
type="button"
className={styles.signUpLink}
onClick={onSignUp}
>
Sign up
</button>
</div>
)}
</form>
)}
{!hasCredentials && onSignUp && (
<div className={styles.signUpText}>
Don&apos;t have an account?{' '}
<button
type="button"
className={styles.signUpLink}
onClick={onSignUp}
>
Sign up
</button>
</div>
)}
</div>
)
}

View File

@@ -96,6 +96,58 @@
padding: 2px 0 2px 4px;
}
/* Action trigger — hidden by default, shown on hover/selected */
.actionsTrigger {
opacity: 0;
transition: opacity 0.1s;
flex-shrink: 0;
display: flex;
align-items: center;
}
.row:hover .actionsTrigger,
.actionsVisible {
opacity: 1;
}
.actionsBtn {
background: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
padding: 0 4px;
font-size: 14px;
line-height: 1;
color: var(--text-muted);
transition: all 0.1s;
font-family: var(--font-body);
}
.actionsBtn:hover {
background: var(--bg-hover);
border-color: var(--border-subtle);
color: var(--text-primary);
}
/* Badges */
.badge {
font-family: var(--font-mono);
font-size: 7px;
font-weight: 600;
color: #fff;
border-radius: 6px;
padding: 0 4px;
text-transform: uppercase;
white-space: nowrap;
margin-left: 4px;
vertical-align: middle;
}
.badgeInfo { background: var(--running); }
.badgeSuccess { background: var(--success); }
.badgeWarning { background: var(--amber); }
.badgeError { background: var(--error); }
.empty {
color: var(--text-muted);
font-size: 12px;

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ProcessorTimeline } from './ProcessorTimeline'
const processors = [
{ name: 'Validate', type: 'validator', durationMs: 12, status: 'ok' as const, startMs: 0 },
{ name: 'Enrich', type: 'enricher', durationMs: 35, status: 'slow' as const, startMs: 12 },
{ name: 'Route', type: 'router', durationMs: 8, status: 'fail' as const, startMs: 47 },
]
describe('ProcessorTimeline', () => {
it('renders processor names', () => {
render(<ProcessorTimeline processors={processors} totalMs={55} />)
expect(screen.getByText('Validate')).toBeInTheDocument()
expect(screen.getByText('Enrich')).toBeInTheDocument()
expect(screen.getByText('Route')).toBeInTheDocument()
})
it('does not render action trigger when no actions provided', () => {
const { container } = render(
<ProcessorTimeline processors={processors} totalMs={55} />,
)
expect(container.querySelector('[aria-label*="Actions for"]')).not.toBeInTheDocument()
})
it('renders action trigger when actions provided', () => {
const { container } = render(
<ProcessorTimeline
processors={processors}
totalMs={55}
actions={[{ label: 'Change Log Level', onClick: () => {} }]}
/>,
)
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
expect(triggers.length).toBe(3)
})
it('clicking action trigger does not fire onProcessorClick', async () => {
const onProcessorClick = vi.fn()
const user = userEvent.setup()
const { container } = render(
<ProcessorTimeline
processors={processors}
totalMs={55}
onProcessorClick={onProcessorClick}
actions={[{ label: 'Test Action', onClick: () => {} }]}
/>,
)
const trigger = container.querySelector('[aria-label="Actions for Validate"]')!
await user.click(trigger)
expect(onProcessorClick).not.toHaveBeenCalled()
})
it('calls action onClick when menu item clicked', async () => {
const actionClick = vi.fn()
const user = userEvent.setup()
const { container } = render(
<ProcessorTimeline
processors={processors}
totalMs={55}
actions={[{ label: 'Change Log Level', onClick: actionClick }]}
/>,
)
const trigger = container.querySelector('[aria-label="Actions for Validate"]')!
await user.click(trigger)
await user.click(screen.getByText('Change Log Level'))
expect(actionClick).toHaveBeenCalledOnce()
})
it('supports dynamic getActions per processor', () => {
const { container } = render(
<ProcessorTimeline
processors={processors}
totalMs={55}
getActions={(proc) =>
proc.status === 'fail'
? [{ label: 'View Error', onClick: () => {} }]
: []
}
/>,
)
// Only the failing processor should have an action trigger
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
expect(triggers.length).toBe(1)
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for Route')
})
})

View File

@@ -1,4 +1,8 @@
import type { ReactNode } from 'react'
import { EllipsisVertical } from 'lucide-react'
import styles from './ProcessorTimeline.module.css'
import { Dropdown } from '../Dropdown/Dropdown'
import type { NodeBadge } from '../RouteFlow/RouteFlow'
export interface ProcessorStep {
name: string
@@ -6,6 +10,15 @@ export interface ProcessorStep {
durationMs: number
status: 'ok' | 'slow' | 'fail'
startMs: number
badges?: NodeBadge[]
}
export interface ProcessorAction {
label: string
icon?: ReactNode
onClick: () => void
disabled?: boolean
divider?: boolean
}
interface ProcessorTimelineProps {
@@ -13,6 +26,8 @@ interface ProcessorTimelineProps {
totalMs: number
onProcessorClick?: (processor: ProcessorStep, index: number) => void
selectedIndex?: number
actions?: ProcessorAction[]
getActions?: (processor: ProcessorStep, index: number) => ProcessorAction[]
className?: string
}
@@ -26,6 +41,8 @@ export function ProcessorTimeline({
totalMs,
onProcessorClick,
selectedIndex,
actions,
getActions,
className,
}: ProcessorTimelineProps) {
const safeTotal = totalMs || 1
@@ -70,6 +87,16 @@ export function ProcessorTimeline({
>
<div className={styles.name} title={proc.name}>
{proc.name}
{proc.badges?.map((badge, bi) => (
<span
key={bi}
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
style={badge.onClick ? { cursor: 'pointer' } : undefined}
>
{badge.label}
</span>
))}
</div>
<div className={styles.barBg}>
<div
@@ -82,6 +109,30 @@ export function ProcessorTimeline({
</div>
</div>
<div className={styles.dur}>{formatDuration(proc.durationMs)}</div>
{(() => {
const resolvedActions = getActions ? getActions(proc, i) : (actions ?? [])
if (resolvedActions.length === 0) return null
return (
<div
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Dropdown
trigger={
<button
className={styles.actionsBtn}
aria-label={`Actions for ${proc.name}`}
type="button"
>
<EllipsisVertical size={14} />
</button>
}
items={resolvedActions}
/>
</div>
)
})()}
</div>
)
})}

View File

@@ -188,17 +188,100 @@
outline-offset: 2px;
}
/* Bottleneck badge */
.bottleneckBadge {
/* Action trigger — hidden by default, shown on hover/selected */
.actionsTrigger {
opacity: 0;
transition: opacity 0.1s;
flex-shrink: 0;
display: flex;
align-items: center;
margin-left: 4px;
}
.node:hover .actionsTrigger,
.actionsVisible {
opacity: 1;
}
.actionsBtn {
background: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
padding: 0 4px;
font-size: 14px;
line-height: 1;
color: var(--text-muted);
transition: all 0.1s;
font-family: var(--font-body);
}
.actionsBtn:hover {
background: var(--bg-hover);
border-color: var(--border-subtle);
color: var(--text-primary);
}
/* Badges */
.badgeRow {
position: absolute;
top: -7px;
right: 8px;
display: flex;
gap: 3px;
pointer-events: auto;
}
.badge {
font-family: var(--font-mono);
font-size: 8px;
font-weight: 600;
padding: 1px 6px;
border-radius: 8px;
background: var(--error);
color: #fff;
border-radius: 8px;
padding: 1px 6px;
text-transform: uppercase;
white-space: nowrap;
letter-spacing: 0.3px;
}
.badgeInfo { background: var(--running); }
.badgeSuccess { background: var(--success); }
.badgeWarning { background: var(--amber); }
.badgeError { background: var(--error); }
/* Node wrapper (replaces inline style) */
.nodeWrapper {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
/* Multi-flow sections */
.flowSection {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.flowSectionSeparated {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--border);
}
.flowLabel {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
padding-left: 2px;
width: 100%;
}
.flowLabelDefault { color: var(--text-muted); }
.flowLabelError { color: var(--error); }
.flowLabelWarning { color: var(--warning); }
.flowLabelInfo { color: var(--running); }

View File

@@ -0,0 +1,160 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { RouteFlow } from './RouteFlow'
const nodes = [
{ name: 'jms:orders', type: 'from' as const, durationMs: 4, status: 'ok' as const },
{ name: 'OrderValidator', type: 'process' as const, durationMs: 8, status: 'ok' as const },
{ name: 'http:payment-api', type: 'to' as const, durationMs: 187, status: 'slow' as const },
{ name: 'dead-letter:failed', type: 'error-handler' as const, durationMs: 14, status: 'fail' as const },
]
describe('RouteFlow', () => {
it('renders node names', () => {
render(<RouteFlow nodes={nodes} />)
expect(screen.getByText('jms:orders')).toBeInTheDocument()
expect(screen.getByText('OrderValidator')).toBeInTheDocument()
expect(screen.getByText('http:payment-api')).toBeInTheDocument()
expect(screen.getByText('dead-letter:failed')).toBeInTheDocument()
})
it('does not render action trigger when no actions provided', () => {
const { container } = render(<RouteFlow nodes={nodes} />)
expect(container.querySelector('[aria-label*="Actions for"]')).not.toBeInTheDocument()
})
it('renders action trigger on all nodes including error handlers when actions provided', () => {
const { container } = render(
<RouteFlow
nodes={nodes}
actions={[{ label: 'View Config', onClick: () => {} }]}
/>,
)
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
expect(triggers.length).toBe(4) // 3 main + 1 error handler
})
it('clicking action trigger does not fire onNodeClick', async () => {
const onNodeClick = vi.fn()
const user = userEvent.setup()
const { container } = render(
<RouteFlow
nodes={nodes}
onNodeClick={onNodeClick}
actions={[{ label: 'Test Action', onClick: () => {} }]}
/>,
)
const trigger = container.querySelector('[aria-label="Actions for jms:orders"]')!
await user.click(trigger)
expect(onNodeClick).not.toHaveBeenCalled()
})
it('calls action onClick when menu item clicked', async () => {
const actionClick = vi.fn()
const user = userEvent.setup()
const { container } = render(
<RouteFlow
nodes={nodes}
actions={[{ label: 'Change Log Level', onClick: actionClick }]}
/>,
)
const trigger = container.querySelector('[aria-label="Actions for jms:orders"]')!
await user.click(trigger)
await user.click(screen.getByText('Change Log Level'))
expect(actionClick).toHaveBeenCalledOnce()
})
it('supports dynamic getActions per node', () => {
const { container } = render(
<RouteFlow
nodes={nodes}
getActions={(node) =>
node.type === 'process'
? [{ label: 'Edit Processor', onClick: () => {} }]
: []
}
/>,
)
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
expect(triggers.length).toBe(1)
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for OrderValidator')
})
})
const multiFlows = [
{
label: 'Main Route',
nodes: [
{ name: 'timer:tick', type: 'from' as const, durationMs: 0, status: 'ok' as const },
{ name: 'Processor1', type: 'process' as const, durationMs: 8, status: 'ok' as const },
],
},
{
label: 'onException',
variant: 'error' as const,
nodes: [
{ name: 'LogHandler', type: 'process' as const, durationMs: 3, status: 'ok' as const },
{ name: 'dead-letter:errors', type: 'to' as const, durationMs: 8, status: 'fail' as const },
],
},
]
describe('RouteFlow (multi-flow)', () => {
it('renders all segment labels', () => {
render(<RouteFlow flows={multiFlows} />)
expect(screen.getByText('Main Route')).toBeInTheDocument()
expect(screen.getByText('onException')).toBeInTheDocument()
})
it('renders all nodes across segments', () => {
render(<RouteFlow flows={multiFlows} />)
expect(screen.getByText('timer:tick')).toBeInTheDocument()
expect(screen.getByText('Processor1')).toBeInTheDocument()
expect(screen.getByText('LogHandler')).toBeInTheDocument()
expect(screen.getByText('dead-letter:errors')).toBeInTheDocument()
})
it('uses global flat indexing for onNodeClick', async () => {
const onNodeClick = vi.fn()
const user = userEvent.setup()
render(<RouteFlow flows={multiFlows} onNodeClick={onNodeClick} />)
// Click the first node of the second flow (global index = 2)
await user.click(screen.getByText('LogHandler'))
expect(onNodeClick).toHaveBeenCalledWith(
expect.objectContaining({ name: 'LogHandler' }),
2,
)
})
it('selectedIndex highlights correct node across flows', () => {
const { container } = render(<RouteFlow flows={multiFlows} selectedIndex={3} />)
// Index 3 = dead-letter:errors (2nd node of 2nd flow)
const selectedNodes = container.querySelectorAll('[class*="nodeSelected"]')
expect(selectedNodes.length).toBe(1)
expect(selectedNodes[0]).toHaveTextContent('dead-letter:errors')
})
it('actions work in multi-flow mode', () => {
const { container } = render(
<RouteFlow
flows={multiFlows}
actions={[{ label: 'Test Action', onClick: () => {} }]}
/>,
)
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
expect(triggers.length).toBe(4)
})
it('flows takes precedence over nodes', () => {
render(
<RouteFlow
nodes={nodes}
flows={multiFlows}
/>,
)
// Should render flow content, not nodes content
expect(screen.getByText('Main Route')).toBeInTheDocument()
expect(screen.queryByText('jms:orders')).not.toBeInTheDocument()
})
})

View File

@@ -1,4 +1,13 @@
import type { ReactNode } from 'react'
import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-react'
import styles from './RouteFlow.module.css'
import { Dropdown } from '../Dropdown/Dropdown'
export interface NodeBadge {
label: string
variant?: 'info' | 'success' | 'warning' | 'error'
onClick?: () => void
}
export interface RouteNode {
name: string
@@ -6,12 +15,30 @@ export interface RouteNode {
durationMs: number
status: 'ok' | 'slow' | 'fail'
isBottleneck?: boolean
badges?: NodeBadge[]
}
export interface NodeAction {
label: string
icon?: ReactNode
onClick: () => void
disabled?: boolean
divider?: boolean
}
export interface FlowSegment {
label: string
nodes: RouteNode[]
variant?: 'default' | 'error' | 'warning' | 'info'
}
interface RouteFlowProps {
nodes: RouteNode[]
nodes?: RouteNode[]
flows?: FlowSegment[]
onNodeClick?: (node: RouteNode, index: number) => void
selectedIndex?: number
actions?: NodeAction[]
getActions?: (node: RouteNode, index: number) => NodeAction[]
className?: string
}
@@ -29,12 +56,12 @@ function durationClass(ms: number, status: string): string {
return styles.durBreach
}
const TYPE_ICONS: Record<string, string> = {
'from': '\u25B6',
'process': '\u2699',
'to': '\u25A2',
'choice': '\u25C6',
'error-handler': '\u26A0',
const TYPE_ICONS: Record<string, ReactNode> = {
'from': <Play size={14} />,
'process': <Cog size={14} />,
'to': <Square size={14} />,
'choice': <Diamond size={14} />,
'error-handler': <AlertTriangle size={14} />,
}
const ICON_CLASSES: Record<string, string> = {
@@ -52,12 +79,141 @@ function nodeStatusClass(node: RouteNode): string {
return styles.nodeHealthy
}
export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: RouteFlowProps) {
const mainNodes = nodes.filter((n) => n.type !== 'error-handler')
const errorHandlers = nodes.filter((n) => n.type === 'error-handler')
function renderActionTrigger(
node: RouteNode,
index: number,
isSelected: boolean,
actions?: NodeAction[],
getActions?: (node: RouteNode, index: number) => NodeAction[],
) {
const resolvedActions = getActions ? getActions(node, index) : (actions ?? [])
if (resolvedActions.length === 0) return null
return (
<div
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Dropdown
trigger={
<button
className={styles.actionsBtn}
aria-label={`Actions for ${node.name}`}
type="button"
>
<EllipsisVertical size={14} />
</button>
}
items={resolvedActions}
/>
</div>
)
}
const FLOW_LABEL_CLASSES: Record<string, string> = {
'default': styles.flowLabelDefault,
'error': styles.flowLabelError,
'warning': styles.flowLabelWarning,
'info': styles.flowLabelInfo,
}
function renderNodeChain(
nodes: RouteNode[],
globalIndexOffset: number,
onNodeClick?: RouteFlowProps['onNodeClick'],
selectedIndex?: number,
actions?: NodeAction[],
getActions?: (node: RouteNode, index: number) => NodeAction[],
) {
const isClickable = !!onNodeClick
return nodes.map((node, i) => {
const globalIndex = globalIndexOffset + i
const isSelected = selectedIndex === globalIndex
return (
<div key={i} className={styles.nodeWrapper}>
{i > 0 && (
<div className={styles.connector}>
<div className={styles.connectorLine} />
<div className={styles.connectorArrow} />
</div>
)}
<div
className={`${styles.node} ${nodeStatusClass(node)} ${isSelected ? styles.nodeSelected : ''} ${isClickable ? styles.nodeClickable : ''}`}
onClick={() => onNodeClick?.(node, globalIndex)}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
onKeyDown={(e) => {
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
onNodeClick?.(node, globalIndex)
}
}}
>
{(node.isBottleneck || node.badges?.length) ? (
<span className={styles.badgeRow}>
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
{node.badges?.map((badge, bi) => (
<span
key={bi}
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
style={badge.onClick ? { cursor: 'pointer' } : undefined}
>
{badge.label}
</span>
))}
</span>
) : null}
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
{TYPE_ICONS[node.type] ?? <Square size={14} />}
</div>
<div className={styles.info}>
<div className={styles.type}>{node.type}</div>
<div className={styles.label} title={node.name}>{node.name}</div>
</div>
<div className={styles.stats}>
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
{formatDuration(node.durationMs)}
</div>
</div>
{renderActionTrigger(node, globalIndex, isSelected, actions, getActions)}
</div>
</div>
)
})
}
export function RouteFlow({ nodes, flows, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) {
// Multi-flow mode
if (flows && flows.length > 0) {
let globalOffset = 0
return (
<div className={`${styles.wrapper} ${className ?? ''}`}>
{flows.map((flow, fi) => {
const sectionOffset = globalOffset
globalOffset += flow.nodes.length
const variant = flow.variant ?? 'default'
const labelClass = FLOW_LABEL_CLASSES[variant] ?? styles.flowLabelDefault
return (
<div key={fi} className={`${styles.flowSection} ${fi > 0 ? styles.flowSectionSeparated : ''}`}>
<div className={`${styles.flowLabel} ${labelClass}`}>{flow.label}</div>
{renderNodeChain(flow.nodes, sectionOffset, onNodeClick, selectedIndex, actions, getActions)}
</div>
)
})}
</div>
)
}
// Legacy mode (single nodes array with automatic error-handler separation)
const allNodes = nodes ?? []
const mainNodes = allNodes.filter((n) => n.type !== 'error-handler')
const errorHandlers = allNodes.filter((n) => n.type === 'error-handler')
// Map from mainNodes index back to original nodes index
const mainNodeOriginalIndices = nodes.reduce<number[]>((acc, n, idx) => {
const mainNodeOriginalIndices = allNodes.reduce<number[]>((acc, n, idx) => {
if (n.type !== 'error-handler') acc.push(idx)
return acc
}, [])
@@ -70,7 +226,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
const isClickable = !!onNodeClick
return (
<div key={i} style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div key={i} className={styles.nodeWrapper}>
{i > 0 && (
<div className={styles.connector}>
<div className={styles.connectorLine} />
@@ -89,9 +245,23 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
}
}}
>
{node.isBottleneck && <span className={styles.bottleneckBadge}>BOTTLENECK</span>}
{(node.isBottleneck || node.badges?.length) ? (
<span className={styles.badgeRow}>
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
{node.badges?.map((badge, bi) => (
<span
key={bi}
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
style={badge.onClick ? { cursor: 'pointer' } : undefined}
>
{badge.label}
</span>
))}
</span>
) : null}
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
{TYPE_ICONS[node.type] ?? '\u25A2'}
{TYPE_ICONS[node.type] ?? <Square size={14} />}
</div>
<div className={styles.info}>
<div className={styles.type}>{node.type}</div>
@@ -102,6 +272,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
{formatDuration(node.durationMs)}
</div>
</div>
{renderActionTrigger(node, originalIndex, isSelected, actions, getActions)}
</div>
</div>
)
@@ -110,22 +281,26 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
{errorHandlers.length > 0 && (
<div className={styles.errorSection}>
<div className={styles.errorLabel}>Error Handler</div>
{errorHandlers.map((node, i) => (
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
{TYPE_ICONS['error-handler']}
</div>
<div className={styles.info}>
<div className={styles.type}>{node.type}</div>
<div className={styles.label} title={node.name}>{node.name}</div>
</div>
<div className={styles.stats}>
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
{formatDuration(node.durationMs)}
{errorHandlers.map((node, i) => {
const errOriginalIndex = allNodes.indexOf(node)
return (
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
{TYPE_ICONS['error-handler']}
</div>
<div className={styles.info}>
<div className={styles.type}>{node.type}</div>
<div className={styles.label} title={node.name}>{node.name}</div>
</div>
<div className={styles.stats}>
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
{formatDuration(node.durationMs)}
</div>
</div>
{renderActionTrigger(node, errOriginalIndex, false, actions, getActions)}
</div>
</div>
))}
)
})}
</div>
)}
</div>

View File

@@ -0,0 +1,37 @@
.splitPane {
display: grid;
grid-template-columns: var(--split-columns, 1fr 2fr);
gap: 1px;
background: var(--border-subtle);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
min-height: 0;
height: 100%;
box-shadow: var(--shadow-card);
}
.listPane {
background: var(--bg-surface);
display: flex;
flex-direction: column;
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
overflow-y: auto;
}
.detailPane {
background: var(--bg-raised);
overflow-y: auto;
padding: 20px;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
}
.emptyDetail {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-faint);
font-size: 13px;
font-family: var(--font-body);
font-style: italic;
}

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { SplitPane } from './SplitPane'
describe('SplitPane', () => {
it('renders list and detail content', () => {
render(
<SplitPane
list={<div>List items</div>}
detail={<div>Detail content</div>}
/>
)
expect(screen.getByText('List items')).toBeInTheDocument()
expect(screen.getByText('Detail content')).toBeInTheDocument()
})
it('shows default empty message when detail is null', () => {
render(
<SplitPane
list={<div>List items</div>}
detail={null}
/>
)
expect(screen.getByText('Select an item to view details')).toBeInTheDocument()
})
it('shows custom empty message', () => {
render(
<SplitPane
list={<div>List items</div>}
detail={null}
emptyMessage="Pick something"
/>
)
expect(screen.getByText('Pick something')).toBeInTheDocument()
})
it('renders with different ratios (checks --split-columns CSS property)', () => {
const { container, rerender } = render(
<SplitPane
list={<div>List</div>}
detail={<div>Detail</div>}
ratio="1:1"
/>
)
const root = container.firstChild as HTMLElement
expect(root.style.getPropertyValue('--split-columns')).toBe('1fr 1fr')
rerender(
<SplitPane
list={<div>List</div>}
detail={<div>Detail</div>}
ratio="2:3"
/>
)
expect(root.style.getPropertyValue('--split-columns')).toBe('2fr 3fr')
})
it('accepts className', () => {
const { container } = render(
<SplitPane
list={<div>List</div>}
detail={<div>Detail</div>}
className="custom-class"
/>
)
expect(container.firstChild).toHaveClass('custom-class')
})
})

View File

@@ -0,0 +1,38 @@
import type { ReactNode } from 'react'
import styles from './SplitPane.module.css'
interface SplitPaneProps {
list: ReactNode
detail: ReactNode | null
emptyMessage?: string
ratio?: '1:1' | '1:2' | '2:3'
className?: string
}
const ratioMap: Record<string, string> = {
'1:1': '1fr 1fr',
'1:2': '1fr 2fr',
'2:3': '2fr 3fr',
}
export function SplitPane({
list,
detail,
emptyMessage = 'Select an item to view details',
ratio = '1:2',
className,
}: SplitPaneProps) {
return (
<div
className={`${styles.splitPane} ${className ?? ''}`}
style={{ '--split-columns': ratioMap[ratio] } as React.CSSProperties}
>
<div className={styles.listPane}>{list}</div>
<div className={styles.detailPane}>
{detail !== null ? detail : (
<div className={styles.emptyDetail}>{emptyMessage}</div>
)}
</div>
</div>
)
}

View File

@@ -89,7 +89,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'Info', variant: 'info' }) })
expect(screen.getByText('')).toBeInTheDocument()
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('shows correct icon for success variant', () => {
@@ -97,7 +97,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'OK', variant: 'success' }) })
expect(screen.getByText('')).toBeInTheDocument()
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('shows correct icon for warning variant', () => {
@@ -105,7 +105,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) })
expect(screen.getByText('')).toBeInTheDocument()
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('shows correct icon for error variant', () => {
@@ -113,7 +113,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'Err', variant: 'error' }) })
expect(screen.getByText('')).toBeInTheDocument()
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('dismisses toast when close button is clicked', () => {

View File

@@ -8,6 +8,7 @@ import {
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
import styles from './Toast.module.css'
// ── Types ──────────────────────────────────────────────────────────────────
@@ -39,11 +40,11 @@ const MAX_TOASTS = 5
const DEFAULT_DURATION = 5000
const EXIT_ANIMATION_MS = 300
const ICONS: Record<ToastVariant, string> = {
info: '',
success: '✓',
warning: '⚠',
error: '✕',
const ICONS: Record<ToastVariant, ReactNode> = {
info: <Info size={16} />,
success: <CheckCircle size={16} />,
warning: <AlertTriangle size={16} />,
error: <XCircle size={16} />,
}
// ── Context ────────────────────────────────────────────────────────────────
@@ -56,6 +57,10 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const dismiss = useCallback((id: string) => {
// Clear auto-dismiss timer if running
const timer = timersRef.current.get(id)
@@ -70,10 +75,8 @@ export function ToastProvider({ children }: { children: ReactNode }) {
)
// Remove after animation completes
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, EXIT_ANIMATION_MS)
}, [])
setTimeout(() => removeToast(id), EXIT_ANIMATION_MS)
}, [removeToast])
const toast = useCallback(
(options: ToastOptions): string => {
@@ -183,7 +186,7 @@ function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) {
aria-label="Dismiss notification"
type="button"
>
&times;
<X size={14} />
</button>
</div>
)

View File

@@ -31,6 +31,52 @@ function flattenVisibleNodes(
return result
}
// ── Keyboard nav helpers ─────────────────────────────────────────────────────
function handleArrowDown(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
}
function handleArrowUp(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
const prev = visibleNodes[currentIndex - 1]
if (prev) focusNode(prev.node.id)
}
function handleArrowRight(
current: FlatNode | undefined,
currentIndex: number,
expandedSet: Set<string>,
visibleNodes: FlatNode[],
handleToggle: (id: string) => void,
focusNode: (id: string) => void,
) {
if (!current) return
const hasChildren = current.node.children && current.node.children.length > 0
if (!hasChildren) return
if (!expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else {
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
}
}
function handleArrowLeft(
current: FlatNode | undefined,
expandedSet: Set<string>,
handleToggle: (id: string) => void,
focusNode: (id: string) => void,
) {
if (!current) return
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren && expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else if (current.parentId !== null) {
focusNode(current.parentId)
}
}
interface TreeViewProps {
nodes: TreeNode[]
onSelect?: (id: string) => void
@@ -105,68 +151,13 @@ export function TreeView({
const current = visibleNodes[currentIndex]
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
break
}
case 'ArrowUp': {
e.preventDefault()
const prev = visibleNodes[currentIndex - 1]
if (prev) focusNode(prev.node.id)
break
}
case 'ArrowRight': {
e.preventDefault()
if (!current) break
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren) {
if (!expandedSet.has(current.node.id)) {
// Expand it
handleToggle(current.node.id)
} else {
// Move to first child (it will be the next visible node)
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
}
}
break
}
case 'ArrowLeft': {
e.preventDefault()
if (!current) break
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren && expandedSet.has(current.node.id)) {
// Collapse
handleToggle(current.node.id)
} else if (current.parentId !== null) {
// Move to parent
focusNode(current.parentId)
}
break
}
case 'Enter': {
e.preventDefault()
if (current) {
onSelect?.(current.node.id)
}
break
}
case 'Home': {
e.preventDefault()
if (visibleNodes.length > 0) {
focusNode(visibleNodes[0].node.id)
}
break
}
case 'End': {
e.preventDefault()
if (visibleNodes.length > 0) {
focusNode(visibleNodes[visibleNodes.length - 1].node.id)
}
break
}
case 'ArrowDown': { e.preventDefault(); handleArrowDown(visibleNodes, currentIndex, focusNode); break }
case 'ArrowUp': { e.preventDefault(); handleArrowUp(visibleNodes, currentIndex, focusNode); break }
case 'ArrowRight': { e.preventDefault(); handleArrowRight(current, currentIndex, expandedSet, visibleNodes, handleToggle, focusNode); break }
case 'ArrowLeft': { e.preventDefault(); handleArrowLeft(current, expandedSet, handleToggle, focusNode); break }
case 'Enter': { e.preventDefault(); if (current) onSelect?.(current.node.id); break }
case 'Home': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[0].node.id); break }
case 'End': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[visibleNodes.length - 1].node.id); break }
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -239,6 +230,10 @@ function TreeNodeRow({
return (
<li role="none">
{/* S1082: No onKeyDown here by design — the parent <ul role="tree"> carries
onKeyDown={handleKeyDown} which handles Enter (select) and all arrow keys
per the WAI-ARIA tree widget pattern. Adding a duplicate handler here would
fire the action twice. */}
<div
role="treeitem"
aria-expanded={hasChildren ? isExpanded : undefined}

View File

@@ -11,23 +11,37 @@ export type { ConfirmDialogProps } from './ConfirmDialog/ConfirmDialog'
export { DataTable } from './DataTable/DataTable'
export type { Column, DataTableProps } from './DataTable/types'
export { DetailPanel } from './DetailPanel/DetailPanel'
export { EntityList } from './EntityList/EntityList'
export { Dropdown } from './Dropdown/Dropdown'
export { EventFeed } from './EventFeed/EventFeed'
export { GroupCard } from './GroupCard/GroupCard'
export { KpiStrip } from './KpiStrip/KpiStrip'
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
export type { FeedEvent } from './EventFeed/EventFeed'
export { FilterBar } from './FilterBar/FilterBar'
export { LineChart } from './LineChart/LineChart'
export { LogViewer } from './LogViewer/LogViewer'
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
export { LoginDialog } from './LoginForm/LoginDialog'
export type { LoginDialogProps } from './LoginForm/LoginDialog'
export { LoginForm } from './LoginForm/LoginForm'
export type { LoginFormProps, SocialProvider } from './LoginForm/LoginForm'
export { MenuItem } from './MenuItem/MenuItem'
export { Modal } from './Modal/Modal'
export { MultiSelect } from './MultiSelect/MultiSelect'
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
export { Popover } from './Popover/Popover'
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline'
export { RouteFlow } from './RouteFlow/RouteFlow'
export type { RouteNode } from './RouteFlow/RouteFlow'
export type { RouteNode, NodeAction, NodeBadge, FlowSegment } from './RouteFlow/RouteFlow'
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
export { SplitPane } from './SplitPane/SplitPane'
export { Tabs } from './Tabs/Tabs'
export { ToastProvider, useToast } from './Toast/Toast'
export { TreeView } from './TreeView/TreeView'
// Chart utilities for consumers using Recharts or custom charts
export { CHART_COLORS } from './_chart-utils'
export type { ChartSeries, DataPoint } from './_chart-utils'

View File

@@ -7,5 +7,8 @@ export * from './layout'
export * from './providers/ThemeProvider'
export * from './providers/CommandPaletteProvider'
export * from './providers/GlobalFilterProvider'
export { BreadcrumbProvider, useBreadcrumb } from './providers/BreadcrumbProvider'
export type { BreadcrumbItem } from './providers/BreadcrumbProvider'
export * from './utils/hashColor'
export * from './utils/timePresets'
export * from './utils/rechartsTheme'

View File

@@ -2,6 +2,7 @@
display: flex;
height: 100vh;
overflow: hidden;
position: relative;
}
.main {

View File

@@ -4,17 +4,18 @@ import type { ReactNode } from 'react'
interface AppShellProps {
sidebar: ReactNode
children: ReactNode
/** @deprecated DetailPanel now portals itself automatically. This prop is ignored. */
detail?: ReactNode
}
export function AppShell({ sidebar, children, detail }: AppShellProps) {
export function AppShell({ sidebar, children }: AppShellProps) {
return (
<div className={styles.app}>
{sidebar}
<div className={styles.main}>
{children}
</div>
{detail}
<div id="cameleer-detail-panel-root" />
</div>
)
}

View File

@@ -5,6 +5,36 @@
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
transition: width 200ms ease;
}
.sidebarCollapsed {
width: 48px;
}
/* Collapse toggle */
.collapseToggle {
position: absolute;
top: 8px;
right: 4px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--sidebar-muted);
cursor: pointer;
border-radius: var(--radius-sm);
padding: 0;
z-index: 1;
transition: color 0.12s;
}
.collapseToggle:hover {
color: var(--sidebar-text);
}
/* Logo */
@@ -15,6 +45,12 @@
gap: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
overflow: hidden;
}
.sidebarCollapsed .logo {
padding: 16px 0;
justify-content: center;
}
.logoImg {
@@ -106,71 +142,40 @@
background: rgba(255, 255, 255, 0.08);
}
/* Scrollable nav area */
.navArea {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* Section headers */
.section {
padding: 14px 12px 5px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.2px;
/* Section icon (collapsed rail) */
.sectionIcon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
color: var(--sidebar-muted);
}
/* Items container */
.items {
padding: 0 6px;
}
/* Nav item (flat links like Dashboards) */
.item {
/* Rail item (collapsed sidebar section) */
.sectionRailItem {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 12px;
border-radius: var(--radius-sm);
color: var(--sidebar-text);
font-size: 13px;
justify-content: center;
padding: 10px 0;
cursor: pointer;
transition: all 0.12s;
border-left: 3px solid transparent;
margin-bottom: 1px;
user-select: none;
transition: background 0.12s;
}
.item:hover {
.sectionRailItem:hover {
background: var(--sidebar-hover);
color: #e8dfd4;
}
.item.active {
background: var(--sidebar-active);
color: var(--amber);
.sectionRailItemActive {
border-left-color: var(--amber);
}
.navIcon {
font-size: 14px;
width: 18px;
text-align: center;
color: var(--sidebar-muted);
flex-shrink: 0;
}
.item.active .navIcon {
.sectionRailItemActive .sectionIcon {
color: var(--amber);
}
.routeArrow {
color: var(--sidebar-muted);
font-size: 10px;
flex-shrink: 0;
.treeSectionActive {
border-left-color: var(--amber);
}
/* Item sub-elements */
@@ -200,15 +205,7 @@
padding: 0 6px 6px;
margin-bottom: 2px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.treeSectionLabel {
padding: 10px 12px 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--sidebar-muted);
border-left: 3px solid transparent;
}
/* Collapsible section toggle */
@@ -218,6 +215,8 @@
gap: 2px;
width: 100%;
padding: 8px 0 4px;
cursor: pointer;
user-select: none;
}
.treeSectionChevronBtn {
@@ -383,100 +382,13 @@
color: var(--amber);
}
/* ── Starred section ─────────────────────────────────────────────────────── */
.starredSection {
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 4px;
}
.starredHeader {
color: var(--amber);
}
.starredList {
padding: 0 6px 6px;
}
.starredGroup {
margin-bottom: 4px;
}
.starredGroupLabel {
padding: 4px 12px 2px;
font-size: 10px;
color: var(--sidebar-muted);
font-weight: 500;
}
.starredItem {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: var(--radius-sm);
color: var(--sidebar-text);
font-size: 12px;
cursor: pointer;
transition: background 0.12s;
user-select: none;
}
.starredItem:hover {
background: var(--sidebar-hover);
}
.starredItemInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.starredItemName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.starredItemContext {
font-size: 10px;
color: var(--sidebar-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Remove button */
.starredRemove {
background: none;
border: none;
padding: 2px;
margin: 0;
color: var(--sidebar-muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
display: flex;
align-items: center;
flex-shrink: 0;
}
.starredItem:hover .starredRemove {
opacity: 1;
}
.starredRemove:hover {
color: var(--error);
}
/* ── Bottom links ────────────────────────────────────────────────────────── */
.bottom {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 6px;
flex-shrink: 0;
margin-top: auto;
}
.bottomItem {

View File

@@ -1,172 +1,327 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { Sidebar, type SidebarApp } from './Sidebar'
import { Sidebar } from './Sidebar'
import { ThemeProvider } from '../../providers/ThemeProvider'
const TEST_APPS: SidebarApp[] = [
{
id: 'order-service',
name: 'order-service',
health: 'live',
exchangeCount: 1433,
routes: [
{ id: 'order-intake', name: 'order-intake', exchangeCount: 892 },
{ id: 'order-enrichment', name: 'order-enrichment', exchangeCount: 541 },
],
agents: [
{ id: 'prod-1', name: 'prod-1', status: 'live', tps: 14.2 },
{ id: 'prod-2', name: 'prod-2', status: 'live', tps: 11.8 },
],
},
{
id: 'payment-svc',
name: 'payment-svc',
health: 'live',
exchangeCount: 912,
routes: [
{ id: 'payment-process', name: 'payment-process', exchangeCount: 414 },
],
agents: [],
},
]
// ── Helpers ─────────────────────────────────────────────────────────────────
function renderSidebar(props: Partial<Parameters<typeof Sidebar>[0]> = {}) {
return render(
const LogoIcon = () => <svg data-testid="logo-icon" />
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<MemoryRouter>
<Sidebar apps={TEST_APPS} {...props} />
</MemoryRouter>
</ThemeProvider>,
<MemoryRouter>{children}</MemoryRouter>
</ThemeProvider>
)
}
describe('Sidebar', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
// ── Tests ────────────────────────────────────────────────────────────────────
describe('Sidebar compound component', () => {
// 1. renders Header with logo, title, version
it('renders Header with logo, title, and version', () => {
render(
<Wrapper>
<Sidebar>
<Sidebar.Header logo={<LogoIcon />} title="MyApp" version="v1.2.3" />
</Sidebar>
</Wrapper>,
)
expect(screen.getByTestId('logo-icon')).toBeInTheDocument()
expect(screen.getByText('MyApp')).toBeInTheDocument()
expect(screen.getByText('v1.2.3')).toBeInTheDocument()
})
it('renders the logo and brand name', () => {
renderSidebar()
expect(screen.getByText('cameleer')).toBeInTheDocument()
expect(screen.getByText('v3.2.1')).toBeInTheDocument()
// 2. hides Header title and version when collapsed
it('hides Header title and version when sidebar is collapsed', () => {
render(
<Wrapper>
<Sidebar collapsed>
<Sidebar.Header logo={<LogoIcon />} title="MyApp" version="v1.2.3" />
</Sidebar>
</Wrapper>,
)
expect(screen.queryByText('MyApp')).not.toBeInTheDocument()
expect(screen.queryByText('v1.2.3')).not.toBeInTheDocument()
// Logo should still be visible
expect(screen.getByTestId('logo-icon')).toBeInTheDocument()
})
it('renders the search input', () => {
renderSidebar()
expect(screen.getByPlaceholderText('Filter...')).toBeInTheDocument()
// 3. renders Section with label and children
it('renders Section with label and children when open', () => {
render(
<Wrapper>
<Sidebar>
<Sidebar.Section
icon={<span>icon</span>}
label="Settings"
open
onToggle={vi.fn()}
>
<div>Section Child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
expect(screen.getByText('Settings')).toBeInTheDocument()
expect(screen.getByText('Section Child')).toBeInTheDocument()
})
it('renders Navigation section header', () => {
renderSidebar()
expect(screen.getByText('Navigation')).toBeInTheDocument()
// 4. hides Section children when section collapsed (open=false)
it('hides Section children when section is not open', () => {
render(
<Wrapper>
<Sidebar>
<Sidebar.Section
icon={<span>icon</span>}
label="Settings"
open={false}
onToggle={vi.fn()}
>
<div>Section Child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
expect(screen.getByText('Settings')).toBeInTheDocument()
expect(screen.queryByText('Section Child')).not.toBeInTheDocument()
})
it('renders Applications tree section', () => {
renderSidebar()
expect(screen.getByText('Applications')).toBeInTheDocument()
// 5. calls onToggle when Section header clicked
it('calls onToggle when Section chevron button is clicked', async () => {
const user = userEvent.setup()
const onToggle = vi.fn()
render(
<Wrapper>
<Sidebar>
<Sidebar.Section
icon={<span>icon</span>}
label="Settings"
open
onToggle={onToggle}
>
<div>child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const btn = screen.getByRole('button', { name: /collapse settings/i })
await user.click(btn)
expect(onToggle).toHaveBeenCalledTimes(1)
})
it('renders Agents tree section', () => {
renderSidebar()
expect(screen.getByText('Agents')).toBeInTheDocument()
// 6. renders collapse toggle and calls onCollapseToggle
it('renders collapse toggle button and calls onCollapseToggle when clicked', async () => {
const user = userEvent.setup()
const onCollapseToggle = vi.fn()
render(
<Wrapper>
<Sidebar onCollapseToggle={onCollapseToggle}>
<Sidebar.Header logo={<LogoIcon />} title="App" />
</Sidebar>
</Wrapper>,
)
const toggleBtn = screen.getByRole('button', { name: /collapse sidebar/i })
await user.click(toggleBtn)
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
})
it('renders Routes nav link', () => {
renderSidebar()
expect(screen.getByText('Routes')).toBeInTheDocument()
// 7. renders expand toggle label when collapsed
it('renders expand toggle when sidebar is collapsed', () => {
render(
<Wrapper>
<Sidebar collapsed onCollapseToggle={vi.fn()}>
<Sidebar.Header logo={<LogoIcon />} title="App" />
</Sidebar>
</Wrapper>,
)
expect(screen.getByRole('button', { name: /expand sidebar/i })).toBeInTheDocument()
})
it('renders bottom links', () => {
renderSidebar()
// 8. renders search input and calls onSearchChange
it('renders search input and calls onSearchChange on input', async () => {
const user = userEvent.setup()
const onSearchChange = vi.fn()
render(
<Wrapper>
<Sidebar searchValue="" onSearchChange={onSearchChange}>
<Sidebar.Header logo={<LogoIcon />} title="App" />
</Sidebar>
</Wrapper>,
)
const input = screen.getByPlaceholderText('Filter...')
expect(input).toBeInTheDocument()
await user.type(input, 'hello')
expect(onSearchChange).toHaveBeenCalled()
// Each keystroke fires once
expect(onSearchChange.mock.calls[0][0]).toBe('h')
})
// 9. hides search when collapsed
it('hides search input when sidebar is collapsed', () => {
render(
<Wrapper>
<Sidebar collapsed searchValue="" onSearchChange={vi.fn()}>
<Sidebar.Header logo={<LogoIcon />} title="App" />
</Sidebar>
</Wrapper>,
)
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
})
// 10. hides search when onSearchChange not provided
it('hides search input when onSearchChange is not provided', () => {
render(
<Wrapper>
<Sidebar searchValue="">
<Sidebar.Header logo={<LogoIcon />} title="App" />
</Sidebar>
</Wrapper>,
)
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
})
// 11. renders FooterLinks with icons and labels
it('renders FooterLinks with icon and label', () => {
render(
<Wrapper>
<Sidebar>
<Sidebar.Footer>
<Sidebar.FooterLink
icon={<span data-testid="footer-icon">ic</span>}
label="Admin"
/>
</Sidebar.Footer>
</Sidebar>
</Wrapper>,
)
expect(screen.getByTestId('footer-icon')).toBeInTheDocument()
expect(screen.getByText('Admin')).toBeInTheDocument()
expect(screen.getByText('API Docs')).toBeInTheDocument()
})
it('renders app names in the Applications tree', () => {
renderSidebar()
// order-service appears in Applications, Routes, and Agents trees
expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
// 12. hides FooterLink labels when collapsed and sets title tooltip
it('hides FooterLink label when collapsed and exposes title tooltip', () => {
render(
<Wrapper>
<Sidebar collapsed>
<Sidebar.Footer>
<Sidebar.FooterLink
icon={<span>ic</span>}
label="Admin"
/>
</Sidebar.Footer>
</Sidebar>
</Wrapper>,
)
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
// The clickable element should carry a title attribute for tooltip
// (accessible name comes from icon content when label is hidden)
const item = screen.getByTitle('Admin')
expect(item).toHaveAttribute('title', 'Admin')
})
it('renders exchange count badges', () => {
renderSidebar()
expect(screen.getByText('1.4k')).toBeInTheDocument()
})
it('renders agent live count badge in Agents tree', () => {
renderSidebar()
expect(screen.getByText('2/2 live')).toBeInTheDocument()
})
it('does not show starred section when nothing is starred', () => {
renderSidebar()
expect(screen.queryByText('★ Starred')).not.toBeInTheDocument()
})
it('shows starred section after starring an item', async () => {
// 13. calls FooterLink onClick
it('calls FooterLink onClick when clicked', async () => {
const user = userEvent.setup()
renderSidebar()
const onClick = vi.fn()
// Find the first app row (order-service in Applications tree) and hover to reveal star
const appRows = screen.getAllByText('order-service')
const appRow = appRows[0].closest('[role="treeitem"]')!
await user.hover(appRow)
render(
<Wrapper>
<Sidebar>
<Sidebar.Footer>
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" onClick={onClick} />
</Sidebar.Footer>
</Sidebar>
</Wrapper>,
)
// Click the star button
const starBtn = appRow.querySelector('button[aria-label="Add to starred"]')!
await user.click(starBtn)
expect(screen.getByText('★ Starred')).toBeInTheDocument()
await user.click(screen.getByText('Admin'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('filters tree items by search', async () => {
// 14. renders Section as icon-rail item when sidebar collapsed
it('renders Section as icon-rail item when sidebar is collapsed', () => {
render(
<Wrapper>
<Sidebar collapsed>
<Sidebar.Section
icon={<span data-testid="section-icon">ic</span>}
label="Settings"
open={false}
onToggle={vi.fn()}
>
<div>child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
// Label text should not be visible (only as tooltip via title attr)
expect(screen.queryByText('Settings')).not.toBeInTheDocument()
// Rail item carries title attribute for tooltip
// (accessible name comes from icon content when label is hidden)
const railItem = screen.getByTitle('Settings')
expect(railItem).toHaveAttribute('title', 'Settings')
// Icon should still render
expect(screen.getByTestId('section-icon')).toBeInTheDocument()
// Section children should not be rendered
expect(screen.queryByText('child')).not.toBeInTheDocument()
})
// 15. fires both onCollapseToggle and onToggle when icon-rail section clicked
it('fires both onCollapseToggle and onToggle when icon-rail section is clicked', async () => {
const user = userEvent.setup()
renderSidebar()
const onCollapseToggle = vi.fn()
const onToggle = vi.fn()
const searchInput = screen.getByPlaceholderText('Filter...')
await user.type(searchInput, 'payment')
render(
<Wrapper>
<Sidebar collapsed onCollapseToggle={onCollapseToggle}>
<Sidebar.Section
icon={<span>ic</span>}
label="Settings"
open={false}
onToggle={onToggle}
>
<div>child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
// payment-svc should still be visible (may appear in multiple trees)
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
const railItem = screen.getByTitle('Settings')
await user.click(railItem)
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
expect(onToggle).toHaveBeenCalledTimes(1)
})
it('expands tree to show children when chevron is clicked', async () => {
const user = userEvent.setup()
renderSidebar()
// 16. applies active highlight to FooterLink
it('applies active highlight class to FooterLink when active', () => {
render(
<Wrapper>
<Sidebar>
<Sidebar.Footer>
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" active />
</Sidebar.Footer>
</Sidebar>
</Wrapper>,
)
// Find the expand button for order-service in Applications tree
const expandBtns = screen.getAllByLabelText('Expand')
await user.click(expandBtns[0])
// Routes should now be visible
expect(screen.getByText('order-intake')).toBeInTheDocument()
expect(screen.getByText('order-enrichment')).toBeInTheDocument()
})
it('collapses expanded tree when chevron is clicked again', async () => {
const user = userEvent.setup()
renderSidebar()
const expandBtns = screen.getAllByLabelText('Expand')
await user.click(expandBtns[0])
expect(screen.getByText('order-intake')).toBeInTheDocument()
const collapseBtn = screen.getByLabelText('Collapse')
await user.click(collapseBtn)
expect(screen.queryByText('order-intake')).not.toBeInTheDocument()
})
it('does not render apps with no agents in the Agents tree', () => {
renderSidebar()
// payment-svc has no agents, so it shouldn't appear under the Agents section header
// But it still appears under Applications. Let's check the agent tree specifically.
const agentBadges = screen.queryAllByText(/\/.*live/)
// Only order-service should have an agent badge
expect(agentBadges).toHaveLength(1)
expect(agentBadges[0].textContent).toBe('2/2 live')
const item = screen.getByText('Admin').closest('[role="button"]')!
expect(item.className).toMatch(/bottomItemActive/)
})
})

View File

@@ -1,563 +1,253 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { type ReactNode, Children, isValidElement } from 'react'
import {
Search,
X,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react'
import styles from './Sidebar.module.css'
import camelLogoUrl from '../../../assets/camel-logo.svg'
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
import { useStarred } from './useStarred'
import { StatusDot } from '../../primitives/StatusDot/StatusDot'
import { SidebarContext, useSidebarContext } from './SidebarContext'
// ── Types ────────────────────────────────────────────────────────────────────
// ── Sub-component props ─────────────────────────────────────────────────────
export interface SidebarApp {
id: string
name: string
health: 'live' | 'stale' | 'dead'
exchangeCount: number
routes: SidebarRoute[]
agents: SidebarAgent[]
}
export interface SidebarRoute {
id: string
name: string
exchangeCount: number
}
export interface SidebarAgent {
id: string
name: string
status: 'live' | 'stale' | 'dead'
tps: number
}
interface SidebarProps {
apps: SidebarApp[]
interface SidebarHeaderProps {
logo: ReactNode
title: string
version?: string
onClick?: () => void
className?: string
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function formatCount(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
return String(n)
}
function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps.map((app) => ({
id: `app:${app.id}`,
label: app.name,
icon: <StatusDot variant={app.health} />,
badge: formatCount(app.exchangeCount),
path: `/apps/${app.id}`,
starrable: true,
starKey: app.id,
children: app.routes.map((route) => ({
id: `route:${app.id}:${route.id}`,
starKey: `${app.id}:${route.id}`,
label: route.name,
icon: <span className={styles.routeArrow}>&#9656;</span>,
badge: formatCount(route.exchangeCount),
path: `/apps/${app.id}/${route.id}`,
starrable: true,
})),
}))
}
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps
.filter((app) => app.routes.length > 0)
.map((app) => ({
id: `routes:${app.id}`,
label: app.name,
icon: <StatusDot variant={app.health} />,
badge: `${app.routes.length} routes`,
path: `/routes/${app.id}`,
starrable: true,
starKey: `routes:${app.id}`,
children: app.routes.map((route) => ({
id: `routestat:${app.id}:${route.id}`,
starKey: `routes:${app.id}:${route.id}`,
label: route.name,
icon: <span className={styles.routeArrow}>&#9656;</span>,
badge: formatCount(route.exchangeCount),
path: `/routes/${app.id}/${route.id}`,
starrable: true,
})),
}))
}
function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps
.filter((app) => app.agents.length > 0)
.map((app) => {
const liveCount = app.agents.filter((a) => a.status === 'live').length
return {
id: `agents:${app.id}`,
label: app.name,
icon: <StatusDot variant={app.health} />,
badge: `${liveCount}/${app.agents.length} live`,
path: `/agents/${app.id}`,
starrable: true,
starKey: `agents:${app.id}`,
children: app.agents.map((agent) => ({
id: `agent:${app.id}:${agent.id}`,
starKey: `${app.id}:${agent.id}`,
label: agent.name,
badge: `${agent.tps.toFixed(1)}/s`,
path: `/agents/${app.id}/${agent.id}`,
starrable: true,
})),
}
})
}
// ── Starred section helpers ──────────────────────────────────────────────────
interface StarredItem {
starKey: string
interface SidebarSectionProps {
icon: ReactNode
label: string
icon?: React.ReactNode
path: string
type: 'application' | 'route' | 'agent' | 'routestat'
parentApp?: string
open: boolean
onToggle: () => void
active?: boolean
children: ReactNode
className?: string
}
function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): StarredItem[] {
const items: StarredItem[] = []
for (const app of apps) {
if (starredIds.has(app.id)) {
items.push({
starKey: app.id,
label: app.name,
icon: <StatusDot variant={app.health} />,
path: `/apps/${app.id}`,
type: 'application',
})
}
for (const route of app.routes) {
const key = `${app.id}:${route.id}`
if (starredIds.has(key)) {
items.push({
starKey: key,
label: route.name,
path: `/apps/${app.id}/${route.id}`,
type: 'route',
parentApp: app.name,
})
}
}
const agentsAppKey = `agents:${app.id}`
if (starredIds.has(agentsAppKey)) {
items.push({
starKey: agentsAppKey,
label: app.name,
icon: <StatusDot variant={app.health} />,
path: `/agents/${app.id}`,
type: 'agent',
})
}
for (const agent of app.agents) {
const key = `${app.id}:${agent.id}`
if (starredIds.has(key)) {
items.push({
starKey: key,
label: agent.name,
path: `/agents/${app.id}/${agent.id}`,
type: 'agent',
parentApp: app.name,
})
}
}
// Routes tree starred items
const routesAppKey = `routes:${app.id}`
if (starredIds.has(routesAppKey)) {
items.push({
starKey: routesAppKey,
label: app.name,
icon: <StatusDot variant={app.health} />,
path: `/routes/${app.id}`,
type: 'routestat',
})
}
for (const route of app.routes) {
const routeKey = `routes:${app.id}:${route.id}`
if (starredIds.has(routeKey)) {
items.push({
starKey: routeKey,
label: route.name,
path: `/routes/${app.id}/${route.id}`,
type: 'routestat',
parentApp: app.name,
})
}
}
}
return items
interface SidebarFooterProps {
children: ReactNode
className?: string
}
// ── StarredGroup ─────────────────────────────────────────────────────────────
function StarredGroup({
label,
items,
onNavigate,
onRemove,
}: {
interface SidebarFooterLinkProps {
icon: ReactNode
label: string
items: StarredItem[]
onNavigate: (path: string) => void
onRemove: (starKey: string) => void
}) {
active?: boolean
onClick?: () => void
className?: string
}
interface SidebarRootProps {
collapsed?: boolean
onCollapseToggle?: () => void
searchValue?: string
onSearchChange?: (query: string) => void
children: ReactNode
className?: string
}
// ── Sub-components ──────────────────────────────────────────────────────────
function SidebarHeader({ logo, title, version, onClick, className }: SidebarHeaderProps) {
const { collapsed } = useSidebarContext()
return (
<div className={styles.starredGroup}>
<div className={styles.starredGroupLabel}>{label}</div>
{items.map((item) => (
<div
key={item.starKey}
className={styles.starredItem}
onClick={() => onNavigate(item.path)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path) }}
>
{item.icon}
<div className={styles.starredItemInfo}>
<span className={styles.starredItemName}>{item.label}</span>
{item.parentApp && (
<span className={styles.starredItemContext}>{item.parentApp}</span>
)}
</div>
<button
className={styles.starredRemove}
onClick={(e) => { e.stopPropagation(); onRemove(item.starKey) }}
tabIndex={-1}
aria-label={`Remove ${item.label} from starred`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<div
className={`${styles.logo} ${className ?? ''}`}
onClick={onClick}
style={onClick ? { cursor: 'pointer' } : undefined}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
>
{logo}
{!collapsed && (
<div>
<span className={styles.brand}>{title}</span>
{version && <span className={styles.version}>{version}</span>}
</div>
))}
)}
</div>
)
}
// ── Sidebar ──────────────────────────────────────────────────────────────────
function SidebarSection({
icon,
label,
open,
onToggle,
active,
children,
className,
}: SidebarSectionProps) {
const { collapsed, onCollapseToggle } = useSidebarContext()
export function Sidebar({ apps, className }: SidebarProps) {
const [search, setSearch] = useState('')
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true')
const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
_setAppsCollapsed((prev) => {
const next = updater(prev)
localStorage.setItem('cameleer:sidebar:apps-collapsed', String(next))
return next
})
}
const setAgentsCollapsed = (updater: (v: boolean) => boolean) => {
_setAgentsCollapsed((prev) => {
const next = updater(prev)
localStorage.setItem('cameleer:sidebar:agents-collapsed', String(next))
return next
})
}
const setRoutesCollapsed = (updater: (v: boolean) => boolean) => {
_setRoutesCollapsed((prev) => {
const next = updater(prev)
localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next))
return next
})
}
const navigate = useNavigate()
const location = useLocation()
const { starredIds, isStarred, toggleStar } = useStarred()
// Build tree data
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
const routeNodes = useMemo(() => buildRouteTreeNodes(apps), [apps])
// Sidebar reveal from Cmd-K navigation (passed via location state)
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
useEffect(() => {
if (!sidebarRevealPath) return
// Uncollapse Applications section if reveal path matches an apps tree node
const matchesAppTree = appNodes.some((node) =>
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
// In icon-rail (collapsed) mode, render a centered icon with tooltip
if (collapsed) {
return (
<div
className={`${styles.sectionRailItem} ${active ? styles.sectionRailItemActive : ''} ${className ?? ''}`}
title={label}
onClick={() => {
// Expand sidebar and open the section
onCollapseToggle?.()
onToggle()
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onCollapseToggle?.()
onToggle()
}
}}
>
<span className={styles.sectionIcon}>{icon}</span>
</div>
)
if (matchesAppTree && appsCollapsed) {
_setAppsCollapsed(false)
localStorage.setItem('cameleer:sidebar:apps-collapsed', 'false')
}
// Uncollapse Agents section if reveal path matches an agents tree node
const matchesAgentTree = agentNodes.some((node) =>
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
)
if (matchesAgentTree && agentsCollapsed) {
_setAgentsCollapsed(false)
localStorage.setItem('cameleer:sidebar:agents-collapsed', 'false')
}
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
// Build starred items
const starredItems = useMemo(
() => collectStarredItems(apps, starredIds),
[apps, starredIds],
)
const starredApps = starredItems.filter((i) => i.type === 'application')
const starredRoutes = starredItems.filter((i) => i.type === 'route')
const starredAgents = starredItems.filter((i) => i.type === 'agent')
const starredRouteStats = starredItems.filter((i) => i.type === 'routestat')
const hasStarred = starredItems.length > 0
// For exchange detail pages, use the reveal path for sidebar selection so
// the parent route is highlighted (exchanges have no sidebar entry of their own)
const effectiveSelectedPath = location.pathname.startsWith('/exchanges/') && sidebarRevealPath
? sidebarRevealPath
: location.pathname
}
return (
<aside className={`${styles.sidebar} ${className ?? ''}`}>
{/* Logo */}
<div className={styles.logo} onClick={() => navigate('/apps')} style={{ cursor: 'pointer' }}>
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
<div>
<span className={styles.brand}>cameleer</span>
<span className={styles.version}>v3.2.1</span>
</div>
<div className={`${styles.treeSection} ${active ? styles.treeSectionActive : ''} ${className ?? ''}`}>
<div
className={styles.treeSectionToggle}
onClick={onToggle}
role="button"
tabIndex={0}
aria-expanded={open}
aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() } }}
>
{icon && <span className={styles.sectionIcon}>{icon}</span>}
<span className={styles.treeSectionLabel}>{label}</span>
</div>
{/* Search */}
<div className={styles.searchWrap}>
<div className={styles.searchInner}>
<span className={styles.searchIcon} aria-hidden="true">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span>
<input
className={styles.searchInput}
type="text"
placeholder="Filter..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearch('')}
aria-label="Clear search"
>
×
</button>
)}
</div>
</div>
{/* Navigation (scrollable) — includes starred section */}
<div className={styles.navArea}>
<div className={styles.section}>Navigation</div>
{/* Applications tree (collapsible, label navigates to /apps) */}
<div className={styles.treeSection}>
<div className={styles.treeSectionToggle}>
<button
className={styles.treeSectionChevronBtn}
onClick={() => setAppsCollapsed((v) => !v)}
aria-expanded={!appsCollapsed}
aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'}
>
{appsCollapsed ? '▸' : '▾'}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/apps')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/apps') }}
>
Applications
</span>
</div>
{!appsCollapsed && (
<SidebarTree
nodes={appNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:apps"
autoRevealPath={sidebarRevealPath}
/>
)}
</div>
{/* Agents tree (collapsible, label navigates to /agents) */}
<div className={styles.treeSection}>
<div className={styles.treeSectionToggle}>
<button
className={styles.treeSectionChevronBtn}
onClick={() => setAgentsCollapsed((v) => !v)}
aria-expanded={!agentsCollapsed}
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
>
{agentsCollapsed ? '▸' : '▾'}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/agents')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/agents') }}
>
Agents
</span>
</div>
{!agentsCollapsed && (
<SidebarTree
nodes={agentNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:agents"
autoRevealPath={sidebarRevealPath}
/>
)}
</div>
{/* Routes tree (collapsible, label navigates to /routes) */}
<div className={styles.treeSection}>
<div className={styles.treeSectionToggle}>
<button
className={styles.treeSectionChevronBtn}
onClick={() => setRoutesCollapsed((v) => !v)}
aria-expanded={!routesCollapsed}
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
>
{routesCollapsed ? '▸' : '▾'}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/routes')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
>
Routes
</span>
</div>
{!routesCollapsed && (
<SidebarTree
nodes={routeNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:routes"
autoRevealPath={sidebarRevealPath}
/>
)}
</div>
{/* No results message */}
{search && appNodes.length === 0 && agentNodes.length === 0 && (
<div className={styles.noResults}>No results</div>
)}
{/* Starred section (inside scrollable area, hidden when empty) */}
{hasStarred && (
<div className={styles.starredSection}>
<div className={styles.section}>
<span className={styles.starredHeader}> Starred</span>
</div>
<div className={styles.starredList}>
{starredApps.length > 0 && (
<StarredGroup
label="Applications"
items={starredApps}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
{starredRoutes.length > 0 && (
<StarredGroup
label="Routes"
items={starredRoutes}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
{starredAgents.length > 0 && (
<StarredGroup
label="Agents"
items={starredAgents}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
{starredRouteStats.length > 0 && (
<StarredGroup
label="Routes"
items={starredRouteStats}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
</div>
</div>
)}
</div>
{/* Bottom links */}
<div className={styles.bottom}>
<div
className={[
styles.bottomItem,
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
].filter(Boolean).join(' ')}
onClick={() => navigate('/admin')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/admin') }}
>
<span className={styles.bottomIcon}>&#9881;</span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>Admin</div>
</div>
</div>
<div
className={[
styles.bottomItem,
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
].filter(Boolean).join(' ')}
onClick={() => navigate('/api-docs')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/api-docs') }}
>
<span className={styles.bottomIcon}>&#9776;</span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>API Docs</div>
</div>
</div>
</div>
</aside>
{open && children}
</div>
)
}
function SidebarFooter({ children, className }: SidebarFooterProps) {
return (
<div className={`${styles.bottom} ${className ?? ''}`}>
{children}
</div>
)
}
function SidebarFooterLink({ icon, label, active, onClick, className }: SidebarFooterLinkProps) {
const { collapsed } = useSidebarContext()
return (
<div
className={[
styles.bottomItem,
active ? styles.bottomItemActive : '',
className ?? '',
].filter(Boolean).join(' ')}
onClick={onClick}
role="button"
tabIndex={0}
title={collapsed ? label : undefined}
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
>
<span className={styles.bottomIcon}>{icon}</span>
{!collapsed && (
<div className={styles.itemInfo}>
<div className={styles.itemName}>{label}</div>
</div>
)}
</div>
)
}
// ── Root component ──────────────────────────────────────────────────────────
function SidebarRoot({
collapsed = false,
onCollapseToggle,
searchValue,
onSearchChange,
children,
className,
}: SidebarRootProps) {
return (
<SidebarContext.Provider value={{ collapsed, onCollapseToggle }}>
<aside
className={[
styles.sidebar,
collapsed ? styles.sidebarCollapsed : '',
className ?? '',
].filter(Boolean).join(' ')}
>
{/* Collapse toggle */}
{onCollapseToggle && (
<button
className={styles.collapseToggle}
onClick={onCollapseToggle}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? <ChevronsRight size={14} /> : <ChevronsLeft size={14} />}
</button>
)}
{/* Render Header first, then search, then remaining children */}
{(() => {
const childArray = Children.toArray(children)
const headerIdx = childArray.findIndex(
(child) => isValidElement(child) && child.type === SidebarHeader,
)
const header = headerIdx >= 0 ? childArray[headerIdx] : null
const rest = headerIdx >= 0
? [...childArray.slice(0, headerIdx), ...childArray.slice(headerIdx + 1)]
: childArray
return (
<>
{header}
{onSearchChange && !collapsed && (
<div className={styles.searchWrap}>
<div className={styles.searchInner}>
<span className={styles.searchIcon} aria-hidden="true">
<Search size={12} />
</span>
<input
className={styles.searchInput}
type="text"
placeholder="Filter..."
value={searchValue ?? ''}
onChange={(e) => onSearchChange(e.target.value)}
/>
{searchValue && (
<button
type="button"
className={styles.searchClear}
onClick={() => onSearchChange('')}
aria-label="Clear search"
>
<X size={12} />
</button>
)}
</div>
</div>
)}
{rest}
</>
)
})()}
</aside>
</SidebarContext.Provider>
)
}
// ── Compound export ─────────────────────────────────────────────────────────
export const Sidebar = Object.assign(SidebarRoot, {
Header: SidebarHeader,
Section: SidebarSection,
Footer: SidebarFooter,
FooterLink: SidebarFooterLink,
})

View File

@@ -0,0 +1,14 @@
import { createContext, useContext } from 'react'
export interface SidebarContextValue {
collapsed: boolean
onCollapseToggle?: () => void
}
export const SidebarContext = createContext<SidebarContextValue>({
collapsed: false,
})
export function useSidebarContext(): SidebarContextValue {
return useContext(SidebarContext)
}

View File

@@ -9,6 +9,7 @@ import {
type MouseEvent,
} from 'react'
import { useNavigate } from 'react-router-dom'
import { Star, ChevronRight, ChevronDown } from 'lucide-react'
import styles from './Sidebar.module.css'
// ── Types ────────────────────────────────────────────────────────────────────
@@ -33,24 +34,17 @@ export interface SidebarTreeProps {
filterQuery?: string
persistKey?: string // sessionStorage key to persist expand state across remounts
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
onNavigate?: (path: string) => void
}
// ── Star icon SVGs ───────────────────────────────────────────────────────────
// ── Star icons ───────────────────────────────────────────────────────────────
function StarOutline() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
return <Star size={14} />
}
function StarFilled() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
return <Star size={14} fill="currentColor" />
}
// ── Persistent expand state ──────────────────────────────────────────────────
@@ -130,6 +124,52 @@ function filterNodes(
return { filtered: walk(nodes), matchedParentIds }
}
// ── Keyboard nav helpers ─────────────────────────────────────────────────────
function handleArrowDown(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
}
function handleArrowUp(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
const prev = visibleNodes[currentIndex - 1]
if (prev) focusNode(prev.node.id)
}
function handleArrowRight(
current: FlatNode | undefined,
currentIndex: number,
expandedSet: Set<string>,
visibleNodes: FlatNode[],
handleToggle: (id: string) => void,
focusNode: (id: string) => void,
) {
if (!current) return
const hasChildren = current.node.children && current.node.children.length > 0
if (!hasChildren) return
if (!expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else {
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
}
}
function handleArrowLeft(
current: FlatNode | undefined,
expandedSet: Set<string>,
handleToggle: (id: string) => void,
focusNode: (id: string) => void,
) {
if (!current) return
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren && expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else if (current.parentId !== null) {
focusNode(current.parentId)
}
}
// ── SidebarTree ──────────────────────────────────────────────────────────────
export function SidebarTree({
@@ -141,8 +181,10 @@ export function SidebarTree({
filterQuery,
persistKey,
autoRevealPath,
onNavigate,
}: SidebarTreeProps) {
const navigate = useNavigate()
const routerNavigate = useNavigate()
const navigate = onNavigate ?? routerNavigate
// Expand/collapse state — optionally persisted to sessionStorage
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
@@ -226,64 +268,13 @@ export function SidebarTree({
const current = visibleNodes[currentIndex]
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
break
}
case 'ArrowUp': {
e.preventDefault()
const prev = visibleNodes[currentIndex - 1]
if (prev) focusNode(prev.node.id)
break
}
case 'ArrowRight': {
e.preventDefault()
if (!current) break
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren) {
if (!expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else {
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
}
}
break
}
case 'ArrowLeft': {
e.preventDefault()
if (!current) break
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren && expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else if (current.parentId !== null) {
focusNode(current.parentId)
}
break
}
case 'Enter': {
e.preventDefault()
if (current?.node.path) {
navigate(current.node.path)
}
break
}
case 'Home': {
e.preventDefault()
if (visibleNodes.length > 0) {
focusNode(visibleNodes[0].node.id)
}
break
}
case 'End': {
e.preventDefault()
if (visibleNodes.length > 0) {
focusNode(visibleNodes[visibleNodes.length - 1].node.id)
}
break
}
case 'ArrowDown': { e.preventDefault(); handleArrowDown(visibleNodes, currentIndex, focusNode); break }
case 'ArrowUp': { e.preventDefault(); handleArrowUp(visibleNodes, currentIndex, focusNode); break }
case 'ArrowRight': { e.preventDefault(); handleArrowRight(current, currentIndex, expandedSet, visibleNodes, handleToggle, focusNode); break }
case 'ArrowLeft': { e.preventDefault(); handleArrowLeft(current, expandedSet, handleToggle, focusNode); break }
case 'Enter': { e.preventDefault(); if (current?.node.path) navigate(current.node.path); break }
case 'Home': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[0].node.id); break }
case 'End': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[visibleNodes.length - 1].node.id); break }
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -375,6 +366,10 @@ function SidebarTreeRow({
return (
<li role="none">
{/* S1082: No onKeyDown here by design — the parent <ul role="tree"> carries
onKeyDown={handleKeyDown} which handles Enter (navigate) and all arrow keys
per the WAI-ARIA tree widget pattern. Adding a duplicate handler here would
fire the action twice. */}
<div
role="treeitem"
aria-expanded={hasChildren ? isExpanded : undefined}
@@ -395,7 +390,7 @@ function SidebarTreeRow({
tabIndex={-1}
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? '▾' : '▸'}
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
) : null}
</span>

View File

@@ -81,6 +81,56 @@
flex-shrink: 0;
}
.liveToggle {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
height: 30px;
}
.liveToggle:hover {
border-color: var(--text-faint);
}
.liveToggleActive {
color: var(--success);
border-color: var(--success-border);
background: var(--success-bg);
}
.liveToggleActive:hover {
border-color: var(--success);
}
.liveDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.liveToggleActive .liveDot {
background: var(--success);
animation: livePulse 2s ease-in-out infinite;
}
@keyframes livePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.themeToggle {
background: none;
border: 1px solid var(--border);
@@ -103,14 +153,17 @@
}
.env {
display: flex;
align-items: center;
height: 30px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
padding: 3px 10px;
border-radius: 10px;
background: var(--success-bg);
color: var(--success);
border: 1px solid var(--success-border);
text-transform: uppercase;
letter-spacing: 0.5px;
}
@@ -121,6 +174,7 @@
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
}
.userName {

View File

@@ -1,5 +1,8 @@
import { type ReactNode } from 'react'
import { Search, Moon, Sun, Power } from 'lucide-react'
import styles from './TopBar.module.css'
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
import { Dropdown } from '../../composites/Dropdown/Dropdown'
import { Avatar } from '../../primitives/Avatar/Avatar'
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
@@ -7,16 +10,14 @@ import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeD
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
import { useTheme } from '../../providers/ThemeProvider'
interface BreadcrumbItem {
label: string
href?: string
}
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
interface TopBarProps {
breadcrumb: BreadcrumbItem[]
environment?: string
environment?: ReactNode
user?: { name: string }
onLogout?: () => void
className?: string
}
@@ -31,16 +32,18 @@ export function TopBar({
breadcrumb,
environment,
user,
onLogout,
className,
}: TopBarProps) {
const globalFilters = useGlobalFilters()
const commandPalette = useCommandPalette()
const { theme, toggleTheme } = useTheme()
const breadcrumbOverride = useBreadcrumbOverride()
return (
<header className={`${styles.topbar} ${className ?? ''}`}>
{/* Left: Breadcrumb */}
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
{/* Search trigger */}
<button
@@ -50,10 +53,7 @@ export function TopBar({
aria-label="Open search"
>
<span className={styles.searchIcon} aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<Search size={13} />
</span>
<span className={styles.searchPlaceholder}>Search... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span>
@@ -81,8 +81,18 @@ export function TopBar({
onChange={globalFilters.setTimeRange}
/>
{/* Right: theme toggle, env badge, user */}
{/* Right: auto-refresh toggle, theme toggle, env badge, user */}
<div className={styles.right}>
<button
className={`${styles.liveToggle} ${globalFilters.autoRefresh ? styles.liveToggleActive : ''}`}
onClick={() => globalFilters.setAutoRefresh(!globalFilters.autoRefresh)}
type="button"
aria-label={globalFilters.autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh'}
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
>
<span className={styles.liveDot} />
{globalFilters.autoRefresh ? 'AUTO' : 'MANUAL'}
</button>
<button
className={styles.themeToggle}
onClick={toggleTheme}
@@ -90,16 +100,23 @@ export function TopBar({
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? '\u263E' : '\u2600'}
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
</button>
{environment && (
<span className={styles.env}>{environment}</span>
<div className={styles.env}>{environment}</div>
)}
{user && (
<div className={styles.user}>
<span className={styles.userName}>{user.name}</span>
<Avatar name={user.name} size="md" />
</div>
<Dropdown
trigger={
<div className={styles.user}>
<span className={styles.userName}>{user.name}</span>
<Avatar name={user.name} size="md" />
</div>
}
items={[
{ label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
]}
/>
)}
</div>
</header>

View File

@@ -1,4 +1,6 @@
export { AppShell } from './AppShell/AppShell'
export { Sidebar } from './Sidebar/Sidebar'
export type { SidebarApp, SidebarRoute, SidebarAgent } from './Sidebar/Sidebar'
export { SidebarTree } from './Sidebar/SidebarTree'
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
export { useStarred } from './Sidebar/useStarred'
export { TopBar } from './TopBar/TopBar'

View File

@@ -46,24 +46,23 @@ describe('Alert', () => {
})
it('shows default icon for each variant', () => {
const { rerender } = render(<Alert variant="info">msg</Alert>)
expect(screen.getByText('')).toBeInTheDocument()
const { container, rerender } = render(<Alert variant="info">msg</Alert>)
// Each variant should render an SVG icon in the icon slot
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
rerender(<Alert variant="success">msg</Alert>)
expect(screen.getByText('✓')).toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
rerender(<Alert variant="warning">msg</Alert>)
expect(screen.getByText('⚠')).toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
rerender(<Alert variant="error">msg</Alert>)
expect(screen.getByText('✕')).toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('renders a custom icon when provided', () => {
render(<Alert icon={<span></span>}>Custom icon alert</Alert>)
expect(screen.getByText('')).toBeInTheDocument()
// Default icon should not appear
expect(screen.queryByText('')).not.toBeInTheDocument()
render(<Alert icon={<span data-testid="custom-icon"></span>}>Custom icon alert</Alert>)
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('does not show dismiss button when dismissible is false', () => {

View File

@@ -1,4 +1,5 @@
import { ReactNode } from 'react'
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
import styles from './Alert.module.css'
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
@@ -13,11 +14,11 @@ interface AlertProps {
className?: string
}
const DEFAULT_ICONS: Record<AlertVariant, string> = {
info: '',
success: '✓',
warning: '⚠',
error: '✕',
const DEFAULT_ICONS: Record<AlertVariant, ReactNode> = {
info: <Info size={16} />,
success: <CheckCircle size={16} />,
warning: <AlertTriangle size={16} />,
error: <XCircle size={16} />,
}
const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = {
@@ -61,7 +62,7 @@ export function Alert({
aria-label="Dismiss alert"
type="button"
>
&times;
<X size={14} />
</button>
)}
</div>

View File

@@ -11,3 +11,22 @@
.accent-warning { border-top: 3px solid var(--warning); }
.accent-error { border-top: 3px solid var(--error); }
.accent-running { border-top: 3px solid var(--running); }
.titleHeader {
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.titleText {
font-size: 11px;
text-transform: uppercase;
font-family: var(--font-mono);
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.5px;
margin: 0;
}
.body {
padding: 16px;
}

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Card } from './Card'
describe('Card', () => {
it('renders children', () => {
render(<Card>Hello world</Card>)
expect(screen.getByText('Hello world')).toBeInTheDocument()
})
it('renders title when provided', () => {
render(<Card title="Status">Content</Card>)
expect(screen.getByText('Status')).toBeInTheDocument()
})
it('does not render title header when title is omitted', () => {
const { container } = render(<Card>Content</Card>)
expect(container.querySelector('h3')).toBeNull()
})
it('wraps children in body div when title is provided', () => {
render(<Card title="Status"><span>Content</span></Card>)
const content = screen.getByText('Content')
expect(content.parentElement).toHaveClass('body')
})
it('renders with accent and title together', () => {
const { container } = render(
<Card accent="success" title="Health">Content</Card>,
)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('accent-success')
expect(screen.getByText('Health')).toBeInTheDocument()
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('accepts className prop', () => {
const { container } = render(<Card className="custom">Content</Card>)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('custom')
})
it('renders children directly when no title (no wrapper div)', () => {
const { container } = render(<Card><span>Direct child</span></Card>)
const card = container.firstChild as HTMLElement
const span = screen.getByText('Direct child')
expect(span.parentElement).toBe(card)
})
})

View File

@@ -4,15 +4,25 @@ import type { ReactNode } from 'react'
interface CardProps {
children: ReactNode
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' | 'none'
title?: string
className?: string
}
export function Card({ children, accent = 'none', className }: CardProps) {
export function Card({ children, accent = 'none', title, className }: CardProps) {
const classes = [
styles.card,
accent !== 'none' ? styles[`accent-${accent}`] : '',
className ?? '',
].filter(Boolean).join(' ')
return <div className={classes}>{children}</div>
return (
<div className={classes}>
{title && (
<div className={styles.titleHeader}>
<h3 className={styles.titleText}>{title}</h3>
</div>
)}
{title ? <div className={styles.body}>{children}</div> : children}
</div>
)
}

View File

@@ -59,7 +59,15 @@ export function InlineEdit({ value, onSave, placeholder, disabled, className }:
<span className={`${styles.display} ${disabled ? styles.disabled : ''} ${className ?? ''}`}>
<span
className={isEmpty ? styles.placeholder : styles.value}
role="button"
tabIndex={disabled ? undefined : 0}
onClick={startEdit}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
startEdit()
}
}}
>
{isEmpty ? placeholder : value}
</span>

View File

@@ -1,5 +1,6 @@
import styles from './Input.module.css'
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
import { X } from 'lucide-react'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
icon?: ReactNode
@@ -25,7 +26,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
onClick={onClear}
aria-label="Clear search"
>
×
<X size={12} />
</button>
)}
</div>

View File

@@ -0,0 +1,7 @@
.statusText {}
.success { color: var(--success); }
.warning { color: var(--warning); }
.error { color: var(--error); }
.running { color: var(--running); }
.muted { color: var(--text-muted); }
.bold { font-weight: 600; }

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { StatusText } from './StatusText'
describe('StatusText', () => {
it('renders children text', () => {
render(<StatusText variant="success">Online</StatusText>)
expect(screen.getByText('Online')).toBeInTheDocument()
})
it('renders as a span element', () => {
render(<StatusText variant="success">Status</StatusText>)
const el = screen.getByText('Status')
expect(el.tagName).toBe('SPAN')
})
it('applies variant class', () => {
render(<StatusText variant="error">Failed</StatusText>)
expect(screen.getByText('Failed')).toHaveClass('error')
})
it('applies bold class when bold=true', () => {
render(<StatusText variant="success" bold>OK</StatusText>)
expect(screen.getByText('OK')).toHaveClass('bold')
})
it('does not apply bold class by default', () => {
render(<StatusText variant="success">OK</StatusText>)
expect(screen.getByText('OK')).not.toHaveClass('bold')
})
it('accepts custom className', () => {
render(<StatusText variant="muted" className="custom">Text</StatusText>)
expect(screen.getByText('Text')).toHaveClass('custom')
})
it('renders all 5 variant classes correctly', () => {
const variants = ['success', 'warning', 'error', 'running', 'muted'] as const
for (const variant of variants) {
const { unmount } = render(
<StatusText variant={variant}>{variant}</StatusText>
)
expect(screen.getByText(variant)).toHaveClass(variant)
unmount()
}
})
})

View File

@@ -0,0 +1,20 @@
import styles from './StatusText.module.css'
import type { ReactNode } from 'react'
interface StatusTextProps {
variant: 'success' | 'warning' | 'error' | 'running' | 'muted'
bold?: boolean
children: ReactNode
className?: string
}
export function StatusText({ variant, bold = false, children, className }: StatusTextProps) {
const classes = [
styles.statusText,
styles[variant],
bold ? styles.bold : '',
className ?? '',
].filter(Boolean).join(' ')
return <span className={classes}>{children}</span>
}

View File

@@ -30,6 +30,7 @@ export { Sparkline } from './Sparkline/Sparkline'
export { Spinner } from './Spinner/Spinner'
export { StatCard } from './StatCard/StatCard'
export { StatusDot } from './StatusDot/StatusDot'
export { StatusText } from './StatusText/StatusText'
export { Tag } from './Tag/Tag'
export { Textarea } from './Textarea/Textarea'
export { TimeRangeDropdown } from './TimeRangeDropdown/TimeRangeDropdown'

View File

@@ -0,0 +1,44 @@
import { createContext, useContext, useState, useEffect } from 'react'
import type { ReactNode } from 'react'
export interface BreadcrumbItem {
label: string
href?: string
}
interface BreadcrumbContextValue {
override: BreadcrumbItem[] | null
setOverride: (items: BreadcrumbItem[] | null) => void
}
const BreadcrumbContext = createContext<BreadcrumbContextValue>({
override: null,
setOverride: () => {},
})
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
const [override, setOverride] = useState<BreadcrumbItem[] | null>(null)
return (
<BreadcrumbContext.Provider value={{ override, setOverride }}>
{children}
</BreadcrumbContext.Provider>
)
}
/**
* Override the TopBar breadcrumb with page-specific semantic items.
* Pass `null` to clear (or let unmount handle it).
* Callers should `useMemo` the items array to avoid unnecessary re-renders.
*/
export function useBreadcrumb(items: BreadcrumbItem[] | null) {
const { setOverride } = useContext(BreadcrumbContext)
useEffect(() => {
setOverride(items)
return () => setOverride(null)
}, [items, setOverride])
}
/** Internal — used by TopBar to read the current override. */
export function useBreadcrumbOverride(): BreadcrumbItem[] | null {
return useContext(BreadcrumbContext).override
}

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
import { computePresetRange } from '../utils/timePresets'
export interface TimeRange {
@@ -12,10 +12,13 @@ export type ExchangeStatus = 'completed' | 'failed' | 'running' | 'warning'
interface GlobalFilterContextValue {
timeRange: TimeRange
setTimeRange: (range: TimeRange) => void
refreshTimeRange: () => void
statusFilters: Set<ExchangeStatus>
toggleStatus: (status: ExchangeStatus) => void
clearStatusFilters: () => void
isInTimeRange: (timestamp: Date) => boolean
autoRefresh: boolean
setAutoRefresh: (enabled: boolean) => void
}
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
@@ -27,9 +30,17 @@ function getDefaultTimeRange(): TimeRange {
return { start, end, preset: DEFAULT_PRESET }
}
function getInitialAutoRefresh(): boolean {
try {
const stored = localStorage.getItem('cameleer:auto-refresh')
return stored === null ? true : stored === 'true'
} catch { return true }
}
export function GlobalFilterProvider({ children }: { children: ReactNode }) {
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
const [autoRefresh, setAutoRefreshState] = useState<boolean>(getInitialAutoRefresh)
const setTimeRange = useCallback((range: TimeRange) => {
setTimeRangeState(range)
@@ -51,6 +62,29 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
setStatusFilters(new Set())
}, [])
const setAutoRefresh = useCallback((enabled: boolean) => {
setAutoRefreshState(enabled)
try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {}
}, [])
// Keep the time range sliding forward when a preset is active and live
useEffect(() => {
if (!autoRefresh || !timeRange.preset) return
const id = setInterval(() => {
const { start, end } = computePresetRange(timeRange.preset!)
setTimeRangeState({ start, end, preset: timeRange.preset })
}, 10_000)
return () => clearInterval(id)
}, [autoRefresh, timeRange.preset])
// Recompute time range from preset on demand (for manual refresh in PAUSED mode)
const refreshTimeRange = useCallback(() => {
if (timeRange.preset) {
const { start, end } = computePresetRange(timeRange.preset)
setTimeRangeState({ start, end, preset: timeRange.preset })
}
}, [timeRange.preset])
const isInTimeRange = useCallback(
(timestamp: Date) => {
if (timeRange.preset) {
@@ -65,7 +99,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
return (
<GlobalFilterContext.Provider
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange }}
value={{ timeRange, setTimeRange, refreshTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
>
{children}
</GlobalFilterContext.Provider>

View File

@@ -0,0 +1,71 @@
import { CHART_COLORS } from '../composites/_chart-utils'
/**
* Pre-configured Recharts prop objects that match the design system's
* chart styling. Spread these onto Recharts sub-components:
*
* ```tsx
* import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
* import { LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip, Legend } from 'recharts'
*
* <LineChart data={data}>
* <CartesianGrid {...rechartsTheme.cartesianGrid} />
* <XAxis dataKey="name" {...rechartsTheme.xAxis} />
* <YAxis {...rechartsTheme.yAxis} />
* <Tooltip {...rechartsTheme.tooltip} />
* <Legend {...rechartsTheme.legend} />
* <Line stroke={CHART_COLORS[0]} strokeWidth={2} dot={false} />
* </LineChart>
* ```
*/
export const rechartsTheme = {
colors: CHART_COLORS,
cartesianGrid: {
stroke: 'var(--border-subtle)',
strokeDasharray: '3 3',
vertical: false,
},
xAxis: {
tick: { fontSize: 9, fontFamily: 'var(--font-mono)', fill: 'var(--text-faint)' },
axisLine: { stroke: 'var(--border-subtle)' },
tickLine: false as const,
},
yAxis: {
tick: { fontSize: 9, fontFamily: 'var(--font-mono)', fill: 'var(--text-faint)' },
axisLine: false as const,
tickLine: false as const,
},
tooltip: {
contentStyle: {
background: 'var(--bg-surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
boxShadow: 'var(--shadow-md)',
fontSize: 11,
padding: '6px 10px',
},
labelStyle: {
color: 'var(--text-muted)',
fontSize: 11,
marginBottom: 4,
},
itemStyle: {
color: 'var(--text-primary)',
fontFamily: 'var(--font-mono)',
fontSize: 11,
padding: 0,
},
cursor: { stroke: 'var(--text-faint)' },
},
legend: {
wrapperStyle: {
fontSize: 11,
color: 'var(--text-secondary)',
},
},
} as const

Some files were not shown because too many files have changed in this diff Show More