Compare commits

...

60 Commits

Author SHA1 Message Date
hsiegeln
a62ff5b064 feat: add source badge to LogViewer entries
All checks were successful
Build & Publish / publish (push) Successful in 1m58s
SonarQube Analysis / sonarqube (push) Successful in 2m47s
LogEntry now accepts an optional `source` field. When present, a small
badge (container/app/agent) renders between the level and message columns.
Backward compatible — entries without source render as before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:30:29 +02:00
hsiegeln
53a9ed015a fix: limit x-axis tick labels to prevent overlap
All checks were successful
Build & Publish / publish (push) Successful in 1m23s
SonarQube Analysis / sonarqube (push) Successful in 2m31s
Auto-calculates tick interval to show ~6 labels max, with minTickGap
as safety net. Fixes unreadable x-axis on short time ranges (1h).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:08:03 +02:00
hsiegeln
38e42d10bb chore: bump version to 0.1.47
All checks were successful
Build & Publish / publish (push) Successful in 1m58s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:38:17 +02:00
hsiegeln
ae420246c8 docs: update COMPONENT_GUIDE for ThemedChart migration 2026-04-12 19:37:27 +02:00
hsiegeln
7d9643bd1b feat!: replace custom chart components with ThemedChart + Recharts
BREAKING: LineChart, AreaChart, BarChart, ChartSeries, DataPoint removed.
Use ThemedChart with Recharts children (Line, Area, Bar, etc.) instead.
2026-04-12 19:36:19 +02:00
hsiegeln
99ff461d4d feat: add ThemedChart wrapper component 2026-04-12 19:34:21 +02:00
hsiegeln
ce93ba456c feat: add ChartTooltip component for ThemedChart 2026-04-12 19:33:45 +02:00
hsiegeln
3fc5fb8267 chore: add recharts dependency, move CHART_COLORS to rechartsTheme
Also fixes duplicate CHART_COLORS export in index.ts by switching from
export * to a named export of rechartsTheme only.
2026-04-12 19:33:28 +02:00
hsiegeln
e07afe37f2 docs: add Recharts migration implementation plan
All checks were successful
Build & Publish / publish (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:30:33 +02:00
hsiegeln
89453121a0 docs: add Recharts migration design spec
All checks were successful
Build & Publish / publish (push) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:24:32 +02:00
hsiegeln
232868b9e7 feat: responsive charts with timestamp tooltips
All checks were successful
Build & Publish / publish (push) Successful in 1m43s
Charts (LineChart, AreaChart, BarChart) now fill their container
width instead of using a fixed 400px default. SVGs use width=100%
with preserveAspectRatio=none.

LineChart and AreaChart tooltips now show the data point timestamp
when x-values are Date objects, displayed as a header row above
the series values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:50:50 +02:00
hsiegeln
8b32fe3994 chore: bump version to 0.1.45
All checks were successful
Build & Publish / publish (push) Successful in 1m40s
2026-04-12 17:24:58 +02:00
hsiegeln
250cbec7b7 fix: reduce version text size and increase separation from brand name
Version in sidebar header now 10px (was 12px), 8px margin (was 4px),
with reduced opacity for visual hierarchy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:24:42 +02:00
hsiegeln
dfac0db564 chore: bump version to 0.1.44
All checks were successful
Build & Publish / publish (push) Successful in 1m32s
SonarQube Analysis / sonarqube (push) Successful in 2m33s
2026-04-11 10:59:10 +02:00
hsiegeln
bb8e6d9d65 fix: error toasts persist until manually dismissed
All checks were successful
Build & Publish / publish (push) Successful in 1m11s
Error variant toasts no longer auto-close after 5 seconds. Duration 0
means "no auto-dismiss". Other variants unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:56:39 +02:00
hsiegeln
fd08d7a552 feat: add FileInput primitive — drag-and-drop file picker
All checks were successful
Build & Publish / publish (push) Successful in 1m24s
Dashed-border dropzone with icon, filename display, clear button,
and imperative handle (file/clear). Replaces native file inputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:00:21 +02:00
hsiegeln
6ea2a29a7c fix: remove double-border on TopBar environment slot
All checks were successful
Build & Publish / publish (push) Successful in 1m38s
SonarQube Analysis / sonarqube (push) Successful in 2m24s
The .env wrapper had its own border/background/padding which caused a
nested border appearance when wrapping a Select component. Simplified
to a plain flex container — the child component handles its own styling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:59:24 +02:00
hsiegeln
51d5d9337a fix: add @keyframes spin to Spinner CSS module
All checks were successful
Build & Publish / publish (push) Successful in 1m35s
CSS Modules scopes animation names, so the global @keyframes spin in
reset.css was not visible. The spinner rendered but never animated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:36:34 +02:00
hsiegeln
cd00a2e0fa chore: bump version to 0.1.7
All checks were successful
Build & Publish / publish (push) Successful in 1m42s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:03:01 +02:00
hsiegeln
b443fc787e refactor: decompose TopBar into composable shell with children slot
TopBar was a monolith that hardcoded server-specific controls (status
filters, time range, auto-refresh, search trigger). Extract these into
standalone SearchTrigger and AutoRefreshToggle components. TopBar now
accepts children for the center slot, letting consumers compose their
own controls. Fixes cameleer/cameleer-saas#53.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:00:57 +02:00
hsiegeln
4a6e6dea96 chore: add GitNexus config to .gitignore and CLAUDE.md
All checks were successful
Build & Publish / publish (push) Successful in 1m10s
SonarQube Analysis / sonarqube (push) Successful in 2m13s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:31:04 +02:00
hsiegeln
638b868649 refactor: extract duplicated utility functions into shared format-utils
All checks were successful
Build & Publish / publish (push) Successful in 1m12s
Consolidate formatDuration, statusToVariant, statusLabel, formatTimestamp,
toRouteNodeType, and durationClass from 5 page/component files into one
shared utils module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:28:32 +02:00
hsiegeln
433d0926e6 style: enforce 12px minimum font size across all components
All checks were successful
Build & Publish / publish (push) Successful in 1m2s
SonarQube Analysis / sonarqube (push) Successful in 2m25s
Add --font-size-min: 12px token to tokens.css. Replace all 10px and 11px
font-size declarations with 12px across 54 CSS module files for improved
readability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:13:17 +02:00
hsiegeln
7e545140a2 feat: add userMenuItems prop to TopBar and export DropdownItem type
All checks were successful
Build & Publish / publish (push) Successful in 1m0s
Allows consumers to inject custom menu items above the Logout action
in the user dropdown. Items are separated from Logout by a divider.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:07:26 +02:00
hsiegeln
e572df8558 fix(assets): remove semi-white background artifacts from logo SVG and PNGs
All checks were successful
Build & Publish / publish (push) Successful in 1m4s
SonarQube Analysis / sonarqube (push) Successful in 2m27s
Stripped 8 near-white path layers (brightness > 200) from the traced SVG,
leaving only amber/gold tones. Regenerated all PNG variants from the
cleaned SVG. Validated on dark and red backgrounds with zero artifacts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:19:39 +02:00
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
122 changed files with 6155 additions and 2889 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ dist/
test-results/ test-results/
screenshots/ screenshots/
.playwright-mcp/ .playwright-mcp/
.gitnexus

119
CLAUDE.md
View File

@@ -40,6 +40,10 @@ import { Button, Input } from '../design-system/primitives'
import { Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer } 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 type { Column, KpiItem, LogEntry } from '../design-system/composites'
import { AppShell } from '../design-system/layout/AppShell' 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' import { ThemeProvider } from '../design-system/providers/ThemeProvider'
``` ```
@@ -93,6 +97,10 @@ import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
// All components from single entry // All components from single entry
import { Button, Input, Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, StatusText, 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 // Types
import type { Column, DataTableProps, SearchResult, KpiItem, LogEntry } from '@cameleer/design-system' import type { Column, DataTableProps, SearchResult, KpiItem, LogEntry } from '@cameleer/design-system'
@@ -110,4 +118,115 @@ import type { ChartSeries, DataPoint } from '@cameleer/design-system'
// Styles (once, at app root) // Styles (once, at app root)
import '@cameleer/design-system/style.css' 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
``` ```
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **design-system** (1461 symbols, 2336 relationships, 23 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/design-system/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/design-system/context` | Codebase overview, check index freshness |
| `gitnexus://repo/design-system/clusters` | All functional areas |
| `gitnexus://repo/design-system/processes` | All execution flows |
| `gitnexus://repo/design-system/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->

View File

@@ -38,10 +38,12 @@
- Removable label → **Tag** - Removable label → **Tag**
### "I need navigation" ### "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** - Breadcrumb trail → **Breadcrumb**
- Paginated data → **Pagination** (standalone) or **DataTable** (built-in pagination) - 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" ### "I need floating content"
- Tooltip on hover → **Tooltip** - Tooltip on hover → **Tooltip**
@@ -52,8 +54,8 @@
### "I need to display data" ### "I need to display data"
- Key metrics → **StatCard** (with optional sparkline/trend) - Key metrics → **StatCard** (with optional sparkline/trend)
- Tabular data → **DataTable** (sortable, paginated) - Tabular data → **DataTable** (sortable, paginated)
- Time series → **LineChart**, **AreaChart** - Time series → **ThemedChart** with `<Line>` or `<Area>`
- Categorical comparison → **BarChart** - Categorical comparison → **ThemedChart** with `<Bar>`
- Inline trend → **Sparkline** - Inline trend → **Sparkline**
- Advanced charts (treemap, radar, heatmap, pie, etc.) → **Recharts** with `rechartsTheme` (see [Charting Strategy](#charting-strategy)) - Advanced charts (treemap, radar, heatmap, pie, etc.) → **Recharts** with `rechartsTheme` (see [Charting Strategy](#charting-strategy))
- Event log → **EventFeed** - Event log → **EventFeed**
@@ -99,7 +101,24 @@
### Standard page layout ### 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 ### Data page pattern
@@ -119,7 +138,7 @@ FormField wraps any input (Input, Textarea, Select, RadioGroup, etc.)
### KPI dashboard ### KPI dashboard
``` ```
Row of StatCard components (each with optional Sparkline and trend) Row of StatCard components (each with optional Sparkline and trend)
Below: charts (AreaChart, LineChart, BarChart) Below: charts (ThemedChart with Line, Area, or Bar)
``` ```
### Master/detail management pattern ### Master/detail management pattern
@@ -156,57 +175,54 @@ StatCard strip (top, recalculates per scope)
→ GroupCard grid (2-col for all, full-width for single app) → GroupCard grid (2-col for all, full-width for single app)
Each GroupCard: header (app name + count) + meta (TPS, routes) + instance rows Each GroupCard: header (app name + count) + meta (TPS, routes) + instance rows
Instance rows: StatusDot + name + Badge + metrics Instance rows: StatusDot + name + Badge + metrics
Single instance: expanded with LineChart panels Single instance: expanded with ThemedChart panels
→ EventFeed (bottom, filtered by scope) → EventFeed (bottom, filtered by scope)
URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/:instanceId URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/:instanceId
``` ```
## Charting Strategy ## 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. The design system provides a **ThemedChart** wrapper component that applies consistent styling to Recharts charts. Recharts is bundled as a dependency — consumers do not need to install it separately.
**Recharts is the app's dependency, not the design system's.** The design system only exports a theme config object. ### Usage
### Setup in consuming app
```bash
npm install recharts
```
### Usage with theme preset
```tsx ```tsx
import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system' import { ThemedChart, Line, Area, ReferenceLine, CHART_COLORS } from '@cameleer/design-system'
import {
ResponsiveContainer, LineChart, Line,
CartesianGrid, XAxis, YAxis, Tooltip, Legend,
} from 'recharts'
<ResponsiveContainer width="100%" height={300}> const data = metrics.map(m => ({ time: m.timestamp, cpu: m.value * 100 }))
<LineChart data={data}>
<CartesianGrid {...rechartsTheme.cartesianGrid} /> <ThemedChart data={data} height={160} xDataKey="time" yLabel="%">
<XAxis dataKey="name" {...rechartsTheme.xAxis} /> <Area dataKey="cpu" stroke={CHART_COLORS[0]} fill={CHART_COLORS[0]} fillOpacity={0.1} />
<YAxis {...rechartsTheme.yAxis} /> <ReferenceLine y={85} stroke="var(--error)" strokeDasharray="5 3" label="Alert" />
<Tooltip {...rechartsTheme.tooltip} /> </ThemedChart>
<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 ### ThemedChart Props
| Export | Description | | Prop | Type | Default | Description |
|--------|-------------| |------|------|---------|-------------|
| `rechartsTheme.cartesianGrid` | Dashed gridlines, subtle stroke | | `data` | `Record<string, any>[]` | required | Flat array of data objects |
| `rechartsTheme.xAxis` | Mono font axis ticks, subtle color | | `height` | `number` | `200` | Chart height in pixels |
| `rechartsTheme.yAxis` | Mono font axis ticks, no axis line | | `xDataKey` | `string` | `"time"` | Key for x-axis values |
| `rechartsTheme.tooltip` | Surface bg, border, shadow, monospace values | | `xType` | `'number' \| 'category'` | `"category"` | X-axis scale type |
| `rechartsTheme.legend` | Matching text size and color | | `xTickFormatter` | `(value: any) => string` | — | Custom x-axis label formatter |
| `rechartsTheme.colors` | The 8 `CHART_COLORS` (CSS variables with light/dark support) | | `yTickFormatter` | `(value: any) => string` | — | Custom y-axis label formatter |
| `yLabel` | `string` | — | Y-axis label text |
| `children` | `ReactNode` | required | Recharts elements (Line, Area, Bar, etc.) |
| `className` | `string` | — | Container CSS class |
### Available Recharts Re-exports
`Line`, `Area`, `Bar`, `ReferenceLine`, `ReferenceArea`, `Legend`, `Brush`
For chart types not covered (treemap, radar, pie, sankey), import from `recharts` directly and use `rechartsTheme` for consistent styling.
### Theme Utilities
| Export | Purpose |
|--------|---------|
| `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` | | `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` |
| `ChartSeries` / `DataPoint` | Type interfaces for chart data | | `rechartsTheme` | Pre-configured prop objects for Recharts sub-components |
## Component Index ## Component Index
@@ -215,11 +231,9 @@ import {
| Accordion | composite | Multiple collapsible sections, single or multi-open mode | | Accordion | composite | Multiple collapsible sections, single or multi-open mode |
| Alert | primitive | Page-level attention banner with variant colors | | Alert | primitive | Page-level attention banner with variant colors |
| AlertDialog | composite | Confirmation dialog for destructive/important actions | | AlertDialog | composite | Confirmation dialog for destructive/important actions |
| AreaChart | composite | Time series visualization with filled area |
| Avatar | primitive | User representation with initials and color | | Avatar | primitive | User representation with initials and color |
| AvatarGroup | composite | Stacked overlapping avatars with overflow count | | AvatarGroup | composite | Stacked overlapping avatars with overflow count |
| Badge | primitive | Labeled status indicator with semantic colors | | Badge | primitive | Labeled status indicator with semantic colors |
| BarChart | composite | Categorical data comparison, optional stacking |
| Breadcrumb | composite | Navigation path showing current location | | Breadcrumb | composite | Navigation path showing current location |
| Button | primitive | Action trigger (primary, secondary, danger, ghost) | | Button | primitive | Action trigger (primary, secondary, danger, ghost) |
| ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange | | ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange |
@@ -227,7 +241,7 @@ import {
| Checkbox | primitive | Boolean input with label | | Checkbox | primitive | Boolean input with label |
| CodeBlock | primitive | Syntax-highlighted code/JSON display | | CodeBlock | primitive | Syntax-highlighted code/JSON display |
| Collapsible | primitive | Single expand/collapse section | | 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 | | 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 | | DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
| DateRangePicker | primitive | Date range selection with presets | | DateRangePicker | primitive | Date range selection with presets |
@@ -247,7 +261,7 @@ import {
| KeyboardHint | primitive | Keyboard shortcut display | | KeyboardHint | primitive | Keyboard shortcut display |
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline | | KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
| Label | primitive | Form label with optional required asterisk | | Label | primitive | Form label with optional required asterisk |
| LineChart | composite | Time series line visualization | | ThemedChart | composite | Recharts wrapper with themed axes, grid, and tooltip |
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries | | LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
| MenuItem | composite | Sidebar navigation item with health/count | | MenuItem | composite | Sidebar navigation item with health/count |
| Modal | composite | Generic dialog overlay with backdrop | | Modal | composite | Generic dialog overlay with backdrop |
@@ -284,8 +298,10 @@ import {
| Component | Purpose | | Component | Purpose |
|-----------|---------| |-----------|---------|
| AppShell | Page shell: sidebar + topbar + main + optional detail panel | | AppShell | Page shell: sidebar + topbar + main + optional detail panel |
| Sidebar | Hierarchical navigation with Applications/Agents/Routes trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) | | 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. |
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar | | 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 ## Import Paths
@@ -296,6 +312,10 @@ import { Button, Input, Badge } from './design-system/primitives'
import { DataTable, Modal, Toast } from './design-system/composites' import { DataTable, Modal, Toast } from './design-system/composites'
import type { Column, SearchResult, FeedEvent } from './design-system/composites' import type { Column, SearchResult, FeedEvent } from './design-system/composites'
import { AppShell } from './design-system/layout/AppShell' 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' import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
``` ```
@@ -309,6 +329,35 @@ import type { Column, DataTableProps, SearchResult } from '@cameleer/design-syst
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions. 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 ## Styling Rules
- **CSS Modules only** — no inline styles except dynamic values (width, color from props) - **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: 941 B

BIN
assets/cameleer3-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
assets/cameleer3-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
assets/cameleer3-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
assets/cameleer3-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
assets/cameleer3-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

BIN
assets/cameleer3-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

112
assets/cameleer3-logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,845 @@
# Recharts Migration 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:** Replace the design system's hand-rolled SVG chart components with a single `ThemedChart` wrapper around Recharts, using Recharts-native data format.
**Architecture:** Add Recharts as a DS dependency. Create `ThemedChart` component that renders `ResponsiveContainer` + `ComposedChart` with pre-themed grid/axes/tooltip. Consumers compose Recharts elements (`<Line>`, `<Area>`, `<Bar>`, `<ReferenceLine>`) as children. Delete old `LineChart/`, `AreaChart/`, `BarChart/`, `_chart-utils.ts`. Migrate the server UI's `AgentInstance.tsx` to the new API.
**Tech Stack:** React 19, Recharts, CSS Modules, Vitest
---
## File Structure
**Design System (`C:\Users\Hendrik\Documents\projects\design-system`):**
| Action | Path | Purpose |
|--------|------|---------|
| Create | `src/design-system/composites/ThemedChart/ThemedChart.tsx` | Wrapper component: ResponsiveContainer + ComposedChart + themed axes/grid/tooltip |
| Create | `src/design-system/composites/ThemedChart/ChartTooltip.tsx` | Custom tooltip with timestamp header + series values |
| Create | `src/design-system/composites/ThemedChart/ChartTooltip.module.css` | Tooltip styles using DS tokens |
| Create | `src/design-system/composites/ThemedChart/ThemedChart.test.tsx` | Render tests for ThemedChart |
| Modify | `src/design-system/utils/rechartsTheme.ts` | Move `CHART_COLORS` definition here (was in `_chart-utils.ts`) |
| Modify | `src/design-system/composites/index.ts` | Remove old chart exports, add ThemedChart + Recharts re-exports |
| Modify | `COMPONENT_GUIDE.md` | Update charting strategy section |
| Modify | `package.json` | Add `recharts` dependency, bump version |
| Delete | `src/design-system/composites/LineChart/` | Old hand-rolled SVG line chart |
| Delete | `src/design-system/composites/AreaChart/` | Old hand-rolled SVG area chart |
| Delete | `src/design-system/composites/BarChart/` | Old hand-rolled SVG bar chart |
| Delete | `src/design-system/composites/_chart-utils.ts` | Old chart utilities |
**Server UI (`C:\Users\Hendrik\Documents\projects\cameleer3-server`):**
| Action | Path | Purpose |
|--------|------|---------|
| Modify | `ui/src/pages/AgentInstance/AgentInstance.tsx` | Migrate 6 charts to ThemedChart + Recharts children |
| Modify | `ui/package.json` | Update `@cameleer/design-system` to new version |
---
### Task 1: Add Recharts Dependency and Move CHART_COLORS
**Files:**
- Modify: `package.json`
- Modify: `src/design-system/utils/rechartsTheme.ts`
- [ ] **Step 1: Install recharts**
```bash
cd C:\Users\Hendrik\Documents\projects\design-system
npm install recharts
```
- [ ] **Step 2: Move CHART_COLORS into rechartsTheme.ts**
Replace the entire file `src/design-system/utils/rechartsTheme.ts` with:
```tsx
export const CHART_COLORS = [
'var(--chart-1)',
'var(--chart-2)',
'var(--chart-3)',
'var(--chart-4)',
'var(--chart-5)',
'var(--chart-6)',
'var(--chart-7)',
'var(--chart-8)',
]
/**
* Pre-configured Recharts prop objects that match the design system's
* chart styling. Used internally by ThemedChart and available for
* consumers composing Recharts directly.
*/
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
```
- [ ] **Step 3: Verify build compiles**
```bash
npm run build:lib
```
Expected: Build succeeds. The old chart components still import `CHART_COLORS` from `_chart-utils.ts` which still exists — they'll be deleted in Task 4.
- [ ] **Step 4: Commit**
```bash
git add package.json package-lock.json src/design-system/utils/rechartsTheme.ts
git commit -m "chore: add recharts dependency, move CHART_COLORS to rechartsTheme"
```
---
### Task 2: Create ChartTooltip Component
**Files:**
- Create: `src/design-system/composites/ThemedChart/ChartTooltip.tsx`
- Create: `src/design-system/composites/ThemedChart/ChartTooltip.module.css`
- [ ] **Step 1: Create tooltip CSS**
Create `src/design-system/composites/ThemedChart/ChartTooltip.module.css`:
```css
.tooltip {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
padding: 6px 10px;
font-size: 12px;
}
.time {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
margin-bottom: 4px;
padding-bottom: 3px;
border-bottom: 1px solid var(--border-subtle);
}
.row {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 2px;
}
.row:last-child {
margin-bottom: 0;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.label {
color: var(--text-muted);
font-size: 11px;
}
.value {
font-family: var(--font-mono);
font-weight: 600;
font-size: 11px;
color: var(--text-primary);
}
```
- [ ] **Step 2: Create ChartTooltip component**
Create `src/design-system/composites/ThemedChart/ChartTooltip.tsx`:
```tsx
import type { TooltipProps } from 'recharts'
import styles from './ChartTooltip.module.css'
function formatValue(val: number): string {
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
if (val >= 1000) return `${(val / 1000).toFixed(1)}k`
if (Number.isInteger(val)) return String(val)
return val.toFixed(1)
}
function formatTimestamp(val: unknown): string | null {
if (val == null) return null
const str = String(val)
const ms = typeof val === 'number' && val > 1e12 ? val
: typeof val === 'number' && val > 1e9 ? val * 1000
: Date.parse(str)
if (isNaN(ms)) return str
return new Date(ms).toLocaleString([], {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
}
export function ChartTooltip({ active, payload, label }: TooltipProps<number, string>) {
if (!active || !payload?.length) return null
const timeLabel = formatTimestamp(label)
return (
<div className={styles.tooltip}>
{timeLabel && <div className={styles.time}>{timeLabel}</div>}
{payload.map((entry) => (
<div key={entry.dataKey as string} className={styles.row}>
<span className={styles.dot} style={{ background: entry.color }} />
<span className={styles.label}>{entry.name}:</span>
<span className={styles.value}>{formatValue(entry.value as number)}</span>
</div>
))}
</div>
)
}
```
- [ ] **Step 3: Commit**
```bash
git add src/design-system/composites/ThemedChart/
git commit -m "feat: add ChartTooltip component for ThemedChart"
```
---
### Task 3: Create ThemedChart Component
**Files:**
- Create: `src/design-system/composites/ThemedChart/ThemedChart.tsx`
- Create: `src/design-system/composites/ThemedChart/ThemedChart.test.tsx`
- [ ] **Step 1: Create ThemedChart component**
Create `src/design-system/composites/ThemedChart/ThemedChart.tsx`:
```tsx
import {
ResponsiveContainer,
ComposedChart,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
} from 'recharts'
import { rechartsTheme } from '../../utils/rechartsTheme'
import { ChartTooltip } from './ChartTooltip'
interface ThemedChartProps {
data: Record<string, any>[]
height?: number
xDataKey?: string
xType?: 'number' | 'category'
xTickFormatter?: (value: any) => string
yTickFormatter?: (value: any) => string
yLabel?: string
children: React.ReactNode
className?: string
}
export function ThemedChart({
data,
height = 200,
xDataKey = 'time',
xType = 'category',
xTickFormatter,
yTickFormatter,
yLabel,
children,
className,
}: ThemedChartProps) {
if (!data.length) {
return null
}
return (
<div className={className} style={{ width: '100%', height }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid {...rechartsTheme.cartesianGrid} />
<XAxis
dataKey={xDataKey}
type={xType}
{...rechartsTheme.xAxis}
tickFormatter={xTickFormatter}
/>
<YAxis
{...rechartsTheme.yAxis}
tickFormatter={yTickFormatter}
label={yLabel ? {
value: yLabel,
angle: -90,
position: 'insideLeft',
style: { fontSize: 11, fill: 'var(--text-muted)' },
} : undefined}
/>
<Tooltip content={<ChartTooltip />} cursor={rechartsTheme.tooltip.cursor} />
{children}
</ComposedChart>
</ResponsiveContainer>
</div>
)
}
```
- [ ] **Step 2: Write test**
Create `src/design-system/composites/ThemedChart/ThemedChart.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ThemedChart } from './ThemedChart'
import { Line } from 'recharts'
// Recharts uses ResizeObserver internally
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverMock as any
describe('ThemedChart', () => {
it('renders nothing when data is empty', () => {
const { container } = render(
<ThemedChart data={[]}>
<Line dataKey="value" />
</ThemedChart>,
)
expect(container.innerHTML).toBe('')
})
it('renders a chart container when data is provided', () => {
const data = [
{ time: '10:00', value: 10 },
{ time: '10:01', value: 20 },
]
const { container } = render(
<ThemedChart data={data} height={160}>
<Line dataKey="value" />
</ThemedChart>,
)
expect(container.querySelector('.recharts-responsive-container')).toBeTruthy()
})
it('applies custom className', () => {
const data = [{ time: '10:00', value: 5 }]
const { container } = render(
<ThemedChart data={data} className="my-chart">
<Line dataKey="value" />
</ThemedChart>,
)
expect(container.querySelector('.my-chart')).toBeTruthy()
})
})
```
- [ ] **Step 3: Run tests**
```bash
npx vitest run src/design-system/composites/ThemedChart/ThemedChart.test.tsx
```
Expected: 3 tests pass.
- [ ] **Step 4: Verify lib build**
```bash
npm run build:lib
```
Expected: Build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/design-system/composites/ThemedChart/
git commit -m "feat: add ThemedChart wrapper component"
```
---
### Task 4: Update Barrel Exports and Delete Old Charts
**Files:**
- Modify: `src/design-system/composites/index.ts`
- Delete: `src/design-system/composites/LineChart/`
- Delete: `src/design-system/composites/AreaChart/`
- Delete: `src/design-system/composites/BarChart/`
- Delete: `src/design-system/composites/_chart-utils.ts`
- [ ] **Step 1: Update composites/index.ts**
Remove these lines:
```
export { AreaChart } from './AreaChart/AreaChart'
export { BarChart } from './BarChart/BarChart'
export { LineChart } from './LineChart/LineChart'
export { CHART_COLORS } from './_chart-utils'
export type { ChartSeries, DataPoint } from './_chart-utils'
```
Add in their place:
```tsx
// Charts — ThemedChart wrapper + Recharts re-exports
export { ThemedChart } from './ThemedChart/ThemedChart'
export { CHART_COLORS, rechartsTheme } from '../utils/rechartsTheme'
export {
Line, Area, Bar,
ReferenceLine, ReferenceArea,
Legend, Brush,
} from 'recharts'
```
- [ ] **Step 2: Remove the rechartsTheme re-export from main index.ts**
In `src/design-system/index.ts`, remove this line (it's now re-exported via composites):
```
export * from './utils/rechartsTheme'
```
Replace with a targeted export that avoids double-exporting `CHART_COLORS`:
```tsx
export { rechartsTheme } from './utils/rechartsTheme'
```
Wait — actually `composites/index.ts` already re-exports both `CHART_COLORS` and `rechartsTheme`. And `index.ts` does `export * from './composites'`. So the main `index.ts` line `export * from './utils/rechartsTheme'` would cause a duplicate export of both symbols. Remove it entirely:
Delete this line from `src/design-system/index.ts`:
```
export * from './utils/rechartsTheme'
```
- [ ] **Step 3: Delete old chart directories and utilities**
```bash
rm -rf src/design-system/composites/LineChart
rm -rf src/design-system/composites/AreaChart
rm -rf src/design-system/composites/BarChart
rm src/design-system/composites/_chart-utils.ts
```
- [ ] **Step 4: Verify lib build**
```bash
npm run build:lib
```
Expected: Build succeeds. The old components are gone, ThemedChart and Recharts re-exports are the new public API.
- [ ] **Step 5: Run all tests**
```bash
npx vitest run
```
Expected: All tests pass. No test files existed for the deleted components.
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat!: replace custom chart components with ThemedChart + Recharts
BREAKING: LineChart, AreaChart, BarChart, ChartSeries, DataPoint removed.
Use ThemedChart with Recharts children (Line, Area, Bar, etc.) instead."
```
---
### Task 5: Update COMPONENT_GUIDE.md
**Files:**
- Modify: `COMPONENT_GUIDE.md`
- [ ] **Step 1: Update the charting strategy section**
Find the section starting with `## Charting Strategy` (around line 183) and replace through line 228 with:
```markdown
## Charting Strategy
The design system provides a **ThemedChart** wrapper component that applies consistent styling to Recharts charts. Recharts is bundled as a dependency — consumers do not need to install it separately.
### Usage
```tsx
import { ThemedChart, Line, Area, ReferenceLine, CHART_COLORS } from '@cameleer/design-system'
const data = metrics.map(m => ({ time: m.timestamp, cpu: m.value * 100 }))
<ThemedChart data={data} height={160} xDataKey="time" yLabel="%">
<Area dataKey="cpu" stroke={CHART_COLORS[0]} fill={CHART_COLORS[0]} fillOpacity={0.1} />
<ReferenceLine y={85} stroke="var(--error)" strokeDasharray="5 3" label="Alert" />
</ThemedChart>
```
### ThemedChart Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `data` | `Record<string, any>[]` | required | Flat array of data objects |
| `height` | `number` | `200` | Chart height in pixels |
| `xDataKey` | `string` | `"time"` | Key for x-axis values |
| `xType` | `'number' \| 'category'` | `"category"` | X-axis scale type |
| `xTickFormatter` | `(value: any) => string` | — | Custom x-axis label formatter |
| `yTickFormatter` | `(value: any) => string` | — | Custom y-axis label formatter |
| `yLabel` | `string` | — | Y-axis label text |
| `children` | `ReactNode` | required | Recharts elements (Line, Area, Bar, etc.) |
| `className` | `string` | — | Container CSS class |
### Available Recharts Re-exports
`Line`, `Area`, `Bar`, `ReferenceLine`, `ReferenceArea`, `Legend`, `Brush`
For chart types not covered (treemap, radar, pie, sankey), import from `recharts` directly and use `rechartsTheme` for consistent styling.
### Theme Utilities
| Export | Purpose |
|--------|---------|
| `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` |
| `rechartsTheme` | Pre-configured prop objects for Recharts sub-components |
```
- [ ] **Step 2: Update the component index table**
Find the rows for `AreaChart`, `BarChart`, `LineChart` in the component index table and replace all three with:
```
| ThemedChart | composite | Recharts wrapper with themed axes, grid, and tooltip |
```
- [ ] **Step 3: Update the decision tree**
Find lines 57-60 (the chart decision tree entries):
```
- Time series → **LineChart**, **AreaChart**
- Categorical comparison → **BarChart**
```
Replace with:
```
- Time series → **ThemedChart** with `<Line>` or `<Area>`
- Categorical comparison → **ThemedChart** with `<Bar>`
```
- [ ] **Step 4: Commit**
```bash
git add COMPONENT_GUIDE.md
git commit -m "docs: update COMPONENT_GUIDE for ThemedChart migration"
```
---
### Task 6: Publish Design System
**Files:**
- Modify: `package.json` (version bump)
- [ ] **Step 1: Bump version**
In `package.json`, change `"version"` to `"0.1.47"`.
- [ ] **Step 2: Build and verify**
```bash
npm run build:lib
```
- [ ] **Step 3: Commit and tag**
```bash
git add package.json
git commit -m "chore: bump version to 0.1.47"
git tag v0.1.47
git push && git push --tags
```
- [ ] **Step 4: Wait for CI to publish**
Wait for the Gitea CI pipeline to build and publish `@cameleer/design-system@0.1.47` to the npm registry. Verify with:
```bash
npm view @cameleer/design-system@0.1.47 version
```
---
### Task 7: Migrate Server UI AgentInstance Charts
**Files:**
- Modify: `C:\Users\Hendrik\Documents\projects\cameleer3-server\ui\src\pages\AgentInstance\AgentInstance.tsx`
- Modify: `C:\Users\Hendrik\Documents\projects\cameleer3-server\ui\package.json`
- [ ] **Step 1: Update design system dependency**
```bash
cd C:\Users\Hendrik\Documents\projects\cameleer3-server\ui
npm install @cameleer/design-system@0.1.47
```
- [ ] **Step 2: Update imports in AgentInstance.tsx**
Replace the chart-related imports:
Old:
```tsx
import {
StatCard, StatusDot, Badge, LineChart, AreaChart, BarChart,
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
LogViewer, ButtonGroup, useGlobalFilters,
} from '@cameleer/design-system'
```
New:
```tsx
import {
StatCard, StatusDot, Badge,
ThemedChart, Line, Area, ReferenceLine, CHART_COLORS,
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
LogViewer, ButtonGroup, useGlobalFilters,
} from '@cameleer/design-system'
```
- [ ] **Step 3: Replace data prep — JVM metrics**
Replace the 4 JVM series useMemo blocks (cpuSeries, heapSeries, threadSeries, gcSeries) with flat data builders:
```tsx
const formatTime = (t: string) =>
new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
// JVM chart data — merge all metrics into flat objects by time bucket
const cpuData = useMemo(() => {
const pts = jvmMetrics?.metrics?.['process.cpu.usage.value'];
if (!pts?.length) return [];
return pts.map((p: any) => ({ time: p.time, cpu: p.value * 100 }));
}, [jvmMetrics]);
const heapData = useMemo(() => {
const pts = jvmMetrics?.metrics?.['jvm.memory.used.value'];
if (!pts?.length) return [];
return pts.map((p: any) => ({ time: p.time, heap: p.value / (1024 * 1024) }));
}, [jvmMetrics]);
const threadData = useMemo(() => {
const pts = jvmMetrics?.metrics?.['jvm.threads.live.value'];
if (!pts?.length) return [];
return pts.map((p: any) => ({ time: p.time, threads: p.value }));
}, [jvmMetrics]);
const gcData = useMemo(() => {
const pts = jvmMetrics?.metrics?.['jvm.gc.pause.total_time'];
if (!pts?.length) return [];
return pts.map((p: any) => ({ time: p.time, gc: p.value }));
}, [jvmMetrics]);
```
- [ ] **Step 4: Replace data prep — throughput and error**
Replace the throughputSeries and errorSeries useMemo blocks:
```tsx
const throughputData = useMemo(() => {
if (!chartData.length) return [];
return chartData.map((d: any) => ({ time: d.date.toISOString(), throughput: d.throughput }));
}, [chartData]);
const errorData = useMemo(() => {
if (!chartData.length) return [];
return chartData.map((d: any) => ({ time: d.date.toISOString(), errorPct: d.errorPct }));
}, [chartData]);
```
- [ ] **Step 5: Replace chart JSX — CPU Usage**
Replace the CPU chart card content:
```tsx
{cpuData.length ? (
<ThemedChart data={cpuData} height={160} xDataKey="time"
xTickFormatter={formatTime} yLabel="%">
<Area dataKey="cpu" name="CPU %" stroke={CHART_COLORS[0]}
fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
<ReferenceLine y={85} stroke="var(--error)" strokeDasharray="5 3"
label={{ value: 'Alert', position: 'right', fill: 'var(--error)', fontSize: 9 }} />
</ThemedChart>
) : (
<EmptyState title="No data" description="No CPU metrics available" />
)}
```
- [ ] **Step 6: Replace chart JSX — Memory (Heap)**
Replace the heap chart card content:
```tsx
{heapData.length ? (
<ThemedChart data={heapData} height={160} xDataKey="time"
xTickFormatter={formatTime} yLabel="MB">
<Area dataKey="heap" name="Heap MB" stroke={CHART_COLORS[0]}
fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
{heapMax != null && (
<ReferenceLine y={heapMax / (1024 * 1024)} stroke="var(--error)" strokeDasharray="5 3"
label={{ value: 'Max Heap', position: 'right', fill: 'var(--error)', fontSize: 9 }} />
)}
</ThemedChart>
) : (
<EmptyState title="No data" description="No heap metrics available" />
)}
```
- [ ] **Step 7: Replace chart JSX — Throughput**
```tsx
{throughputData.length ? (
<ThemedChart data={throughputData} height={160} xDataKey="time"
xTickFormatter={formatTime} yLabel="msg/s">
<Line dataKey="throughput" name="msg/s" stroke={CHART_COLORS[0]}
strokeWidth={2} dot={false} />
</ThemedChart>
) : (
<EmptyState title="No data" description="No throughput data in range" />
)}
```
- [ ] **Step 8: Replace chart JSX — Error Rate**
```tsx
{errorData.length ? (
<ThemedChart data={errorData} height={160} xDataKey="time"
xTickFormatter={formatTime} yLabel="%">
<Line dataKey="errorPct" name="Error %" stroke={CHART_COLORS[0]}
strokeWidth={2} dot={false} />
</ThemedChart>
) : (
<EmptyState title="No data" description="No error data in range" />
)}
```
- [ ] **Step 9: Replace chart JSX — Thread Count**
```tsx
{threadData.length ? (
<ThemedChart data={threadData} height={160} xDataKey="time"
xTickFormatter={formatTime} yLabel="threads">
<Line dataKey="threads" name="Threads" stroke={CHART_COLORS[0]}
strokeWidth={2} dot={false} />
</ThemedChart>
) : (
<EmptyState title="No data" description="No thread metrics available" />
)}
```
- [ ] **Step 10: Replace chart JSX — GC Pauses**
```tsx
{gcData.length ? (
<ThemedChart data={gcData} height={160} xDataKey="time"
xTickFormatter={formatTime} yLabel="ms">
<Area dataKey="gc" name="GC ms" stroke={CHART_COLORS[1]}
fill={CHART_COLORS[1]} fillOpacity={0.1} strokeWidth={2} dot={false} />
</ThemedChart>
) : (
<EmptyState title="No data" description="No GC metrics available" />
)}
```
- [ ] **Step 11: Update the Thread Count meta display**
The thread count meta currently reads from `threadSeries`. Update to read from `threadData`:
Old:
```tsx
{threadSeries
? `${threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active`
: ''}
```
New:
```tsx
{threadData.length
? `${threadData[threadData.length - 1].threads.toFixed(0)} active`
: ''}
```
- [ ] **Step 12: Build server UI**
```bash
cd C:\Users\Hendrik\Documents\projects\cameleer3-server\ui
npm run build
```
Expected: Build succeeds.
- [ ] **Step 13: Commit and push**
```bash
cd C:\Users\Hendrik\Documents\projects\cameleer3-server
git add ui/
git commit -m "feat: migrate agent charts to ThemedChart + Recharts
Replace custom LineChart/AreaChart/BarChart usage with ThemedChart
wrapper. Data format changed from ChartSeries[] to Recharts-native
flat objects. Uses DS v0.1.47."
git push
```

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.

View File

@@ -0,0 +1,121 @@
# Recharts Migration Design
## Goal
Replace the design system's hand-rolled SVG chart components (LineChart, AreaChart, BarChart) with Recharts-based implementations. The current custom charts have responsiveness issues (preserveAspectRatio distorting text), limited tooltip/axis formatting, and growing maintenance burden.
## Architecture
Add Recharts as a DS dependency. Replace the three custom chart components and `_chart-utils.ts` with a single `ThemedChart` wrapper component. Consumers compose Recharts elements (`<Line>`, `<Area>`, `<Bar>`, etc.) as children inside `<ThemedChart>`. The DS re-exports Recharts components so consumers don't need to add Recharts as a separate dependency.
Sparkline stays hand-rolled SVG (no axes, no tooltips — Recharts is overkill).
### What gets removed
- `composites/LineChart/` directory (LineChart.tsx, LineChart.module.css)
- `composites/AreaChart/` directory (AreaChart.tsx, AreaChart.module.css)
- `composites/BarChart/` directory (BarChart.tsx, BarChart.module.css)
- `composites/_chart-utils.ts`
- `ChartSeries` and `DataPoint` type exports
### What gets added
- `composites/ThemedChart/ThemedChart.tsx` — wrapper component
- `composites/ThemedChart/ChartTooltip.tsx` — internal themed tooltip
- `composites/ThemedChart/ThemedChart.module.css`
- Recharts re-exports from the DS barrel
### What stays unchanged
- `utils/rechartsTheme.ts` (now also used internally by ThemedChart)
- `CHART_COLORS`
- `primitives/Sparkline/`
## Data Format
Consumers use Recharts-native flat data format instead of the current `ChartSeries[]`:
```tsx
// Before (custom)
const series = [
{ label: 'CPU %', data: pts.map(p => ({ x: new Date(p.time), y: p.value * 100 })) }
]
<AreaChart series={series} height={160} yLabel="%" />
// After (Recharts-native)
const data = pts.map(p => ({ time: p.time, cpu: p.value * 100 }))
<ThemedChart data={data} height={160} xDataKey="time" yLabel="%">
<Area dataKey="cpu" stroke={CHART_COLORS[0]} fill={CHART_COLORS[0]} fillOpacity={0.1} />
</ThemedChart>
```
This is a breaking change. The migration cost is bounded — only `AgentInstance.tsx` in the server repo uses these components.
## ThemedChart API
```tsx
interface ThemedChartProps {
data: Record<string, any>[]
height?: number // default 200
xDataKey?: string // default "time"
xType?: 'number' | 'category' // default "category"
xTickFormatter?: (value: any) => string
yTickFormatter?: (value: any) => string
yLabel?: string
children: React.ReactNode // Recharts elements
className?: string
}
```
ThemedChart renders internally:
- `ResponsiveContainer` (width 100%, height from prop)
- `ComposedChart` (supports mixing Line + Bar + Area in one chart)
- `CartesianGrid` with `rechartsTheme.cartesianGrid`
- `XAxis` with `rechartsTheme.xAxis` + formatter
- `YAxis` with `rechartsTheme.yAxis` + formatter + label
- `Tooltip` with custom `ChartTooltip` component
## Tooltip
ThemedChart provides a custom `ChartTooltip` component (internal, not exported) that:
- Shows the x-value formatted as date/time in a header row (mono font, subtle border separator)
- Shows each series with colored dot + label + formatted value
- Uses DS tokens for styling (surface bg, border, shadow, mono font)
Consumers can override by passing their own `<Tooltip content={...} />` as a child. Recharts uses the last Tooltip it finds, so a consumer-provided one replaces the default.
## DS Re-exports
Selected Recharts components re-exported so consumers don't need `recharts` in their own package.json:
```tsx
export { ThemedChart } from './ThemedChart/ThemedChart'
export {
Line, Area, Bar, ReferenceLine, ReferenceArea,
Legend, Brush, ComposedChart,
} from 'recharts'
```
More can be added on demand.
## Consumer Migration — Server UI
`AgentInstance.tsx` has 6 charts to migrate:
| Chart | Before | After |
|-------|--------|-------|
| CPU Usage | `<AreaChart series={cpuSeries} threshold={...}>` | `<ThemedChart><Area dataKey="cpu" /><ReferenceLine y={85} /></ThemedChart>` |
| Memory (Heap) | `<AreaChart series={heapSeries} threshold={...}>` | `<ThemedChart><Area dataKey="heap" /><ReferenceLine y={max} /></ThemedChart>` |
| Throughput | `<LineChart series={throughputSeries}>` | `<ThemedChart><Line dataKey="throughput" /></ThemedChart>` |
| Error Rate | `<LineChart series={errorSeries}>` | `<ThemedChart><Line dataKey="errorPct" /></ThemedChart>` |
| Thread Count | `<LineChart series={threadSeries}>` | `<ThemedChart><Line dataKey="threads" /></ThemedChart>` |
| GC Pauses | `<AreaChart series={gcSeries}>` | `<ThemedChart><Area dataKey="gc" /></ThemedChart>` |
Data prep changes from building `ChartSeries[]` arrays to flat objects with named keys.
## Not in Scope
- Sparkline migration (no responsiveness issues, no axes/tooltips)
- SaaS UI (no chart component usage)
- Dashboard tab (already uses Recharts directly with `rechartsTheme`)
- New chart types (treemap, radar, etc. — consumers compose these directly)

411
package-lock.json generated
View File

@@ -1,17 +1,18 @@
{ {
"name": "@cameleer/design-system", "name": "@cameleer/design-system",
"version": "0.1.6", "version": "0.1.46",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@cameleer/design-system", "name": "@cameleer/design-system",
"version": "0.1.6", "version": "0.1.46",
"dependencies": { "dependencies": {
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.0.0" "react-router-dom": "^7.0.0",
"recharts": "^3.8.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
@@ -1012,6 +1013,42 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1519,6 +1556,18 @@
"string-argv": "~0.3.1" "string-argv": "~0.3.1"
} }
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -1680,6 +1729,69 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/deep-eql": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -1708,7 +1820,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -1724,6 +1836,12 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/whatwg-mimetype": { "node_modules/@types/whatwg-mimetype": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
@@ -2319,6 +2437,15 @@
"node": ">= 16" "node": ">= 16"
} }
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2399,9 +2526,130 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/de-indent": { "node_modules/de-indent": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@@ -2427,6 +2675,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-eql": { "node_modules/deep-eql": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -2506,6 +2760,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -2568,6 +2832,12 @@
"@types/estree": "^1.0.0" "@types/estree": "^1.0.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/expect-type": { "node_modules/expect-type": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -2814,6 +3084,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-lazy": { "node_modules/import-lazy": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz",
@@ -2834,6 +3114,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-core-module": { "node_modules/is-core-module": {
"version": "2.16.1", "version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@@ -3476,10 +3765,32 @@
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3528,6 +3839,36 @@
"react-dom": ">=18" "react-dom": ">=18"
} }
}, },
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redent": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -3542,6 +3883,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -3552,6 +3908,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -3921,6 +4283,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -4051,6 +4419,37 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.1", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@cameleer/design-system", "name": "@cameleer/design-system",
"version": "0.1.6", "version": "0.1.49",
"type": "module", "type": "module",
"main": "./dist/index.es.js", "main": "./dist/index.es.js",
"module": "./dist/index.es.js", "module": "./dist/index.es.js",
@@ -10,10 +10,12 @@
"types": "./dist/index.es.d.ts", "types": "./dist/index.es.d.ts",
"import": "./dist/index.es.js" "import": "./dist/index.es.js"
}, },
"./style.css": "./dist/style.css" "./style.css": "./dist/style.css",
"./assets/*": "./assets/*"
}, },
"files": [ "files": [
"dist" "dist",
"assets"
], ],
"sideEffects": [ "sideEffects": [
"*.css" "*.css"
@@ -38,7 +40,8 @@
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.0.0" "react-router-dom": "^7.0.0",
"recharts": "^3.8.1"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^19.0.0", "react": "^19.0.0",

View File

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

View File

@@ -1,157 +0,0 @@
.wrapper {
position: relative;
display: inline-flex;
flex-direction: column;
gap: 4px;
}
.svg {
display: block;
overflow: visible;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 12px;
height: 120px;
}
/* Grid */
.gridLine {
stroke: var(--border-subtle);
stroke-width: 1;
stroke-dasharray: 3 3;
}
.axisLabel {
font-family: var(--font-mono);
font-size: 9px;
fill: var(--text-faint);
}
/* Threshold line */
.thresholdLine {
stroke: var(--error);
stroke-width: 1.5;
stroke-dasharray: 5 3;
}
.thresholdLabel {
font-family: var(--font-mono);
font-size: 9px;
fill: var(--error);
}
/* Area + line */
.area {
opacity: 0.1;
}
.line {
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Crosshair */
.crosshair {
stroke: var(--text-faint);
stroke-width: 1;
stroke-dasharray: 3 3;
pointer-events: none;
}
/* Tooltip */
.tooltip {
position: absolute;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
padding: 6px 10px;
font-size: 11px;
pointer-events: none;
z-index: 10;
white-space: nowrap;
}
.tooltipRow {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 2px;
}
.tooltipRow:last-child {
margin-bottom: 0;
}
.tooltipDot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.tooltipLabel {
color: var(--text-muted);
}
.tooltipValue {
font-family: var(--font-mono);
font-weight: 600;
color: var(--text-primary);
}
/* Legend */
.legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding-left: 48px;
}
.legendItem {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-secondary);
}
.legendDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.legendLabel {
font-size: 11px;
}
/* Axis labels */
.yLabel {
font-size: 10px;
color: var(--text-muted);
writing-mode: vertical-lr;
transform: rotate(180deg);
text-align: center;
position: absolute;
left: 4px;
top: 0;
bottom: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.xLabel {
text-align: center;
font-size: 10px;
color: var(--text-muted);
padding-left: 48px;
}

View File

@@ -1,254 +0,0 @@
import { useState } from 'react'
import styles from './AreaChart.module.css'
import {
computeYScale,
computeXScale,
seriesPoints,
seriesPath,
formatAxisLabel,
CHART_COLORS,
type ChartSeries,
} from '../_chart-utils'
interface Threshold {
value: number
label: string
}
interface AreaChartProps {
series: ChartSeries[]
xLabel?: string
yLabel?: string
threshold?: Threshold
height?: number
width?: number
className?: string
}
const Y_TICK_COUNT = 4
const DIMS = {
paddingTop: 12,
paddingRight: 16,
paddingBottom: 28,
paddingLeft: 48,
}
export function AreaChart({
series,
xLabel,
yLabel,
threshold,
height = 200,
width = 400,
className,
}: AreaChartProps) {
const [tooltip, setTooltip] = useState<{
x: number
y: number
values: { label: string; value: number; color: string }[]
} | null>(null)
const dims = { ...DIMS, width, height }
const allData = series.flatMap((s) => s.data)
if (allData.length === 0) {
return <div className={`${styles.empty} ${className ?? ''}`}>No data</div>
}
const { max, toY } = computeYScale(series, dims, threshold?.value)
const { toX } = computeXScale(series, dims)
const plotH = height - dims.paddingTop - dims.paddingBottom
const plotW = width - dims.paddingLeft - dims.paddingRight
const bottomY = dims.paddingTop + plotH
// Y-axis ticks
const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) =>
Math.round((max / Y_TICK_COUNT) * i),
)
// X-axis ticks (first, middle, last)
const firstSeries = series[0]
const xSamples =
firstSeries && firstSeries.data.length > 0
? [
firstSeries.data[0].x,
firstSeries.data[Math.floor(firstSeries.data.length / 2)]?.x,
firstSeries.data[firstSeries.data.length - 1].x,
].filter(Boolean)
: []
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
const rect = e.currentTarget.getBoundingClientRect()
const mx = e.clientX - rect.left
const my = e.clientY - rect.top
// Find closest x value
const pctX = (mx - dims.paddingLeft) / plotW
const values = series.map((s, i) => {
const idx = Math.round(pctX * (s.data.length - 1))
const clamped = Math.max(0, Math.min(s.data.length - 1, idx))
const pt = s.data[clamped]
return {
label: s.label,
value: pt?.y ?? 0,
color: s.color ?? CHART_COLORS[i % CHART_COLORS.length],
}
})
setTooltip({ x: mx, y: my, values })
}
return (
<div className={`${styles.wrapper} ${className ?? ''}`}>
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={styles.svg}
onMouseMove={handleMouseMove}
onMouseLeave={() => setTooltip(null)}
aria-label="Area chart"
role="img"
>
{/* Grid lines */}
{yTicks.map((val) => {
const y = toY(val)
return (
<g key={val}>
<line
x1={dims.paddingLeft}
y1={y}
x2={width - dims.paddingRight}
y2={y}
className={styles.gridLine}
/>
<text x={dims.paddingLeft - 6} y={y + 4} className={styles.axisLabel} textAnchor="end">
{formatAxisLabel(val)}
</text>
</g>
)
})}
{/* X-axis labels */}
{xSamples.map((xVal, i) => {
const xPos = toX(xVal)
const xv = xVal instanceof Date ? xVal : new Date(xVal as number)
const label =
xVal instanceof Date || (typeof xVal === 'number' && xVal > 1e10)
? xv.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: formatAxisLabel(xVal as number)
const anchor = i === 0 ? 'start' : i === xSamples.length - 1 ? 'end' : 'middle'
return (
<text
key={i}
x={xPos}
y={height - dims.paddingBottom + 16}
className={styles.axisLabel}
textAnchor={anchor}
>
{label}
</text>
)
})}
{/* SLA threshold line */}
{threshold && (
<g>
<line
x1={dims.paddingLeft}
y1={toY(threshold.value)}
x2={width - dims.paddingRight}
y2={toY(threshold.value)}
className={styles.thresholdLine}
/>
<text
x={width - dims.paddingRight - 4}
y={toY(threshold.value) - 4}
className={styles.thresholdLabel}
textAnchor="end"
>
{threshold.label}
</text>
</g>
)}
{/* Area fills */}
{series.map((s, i) => {
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
const areaD = seriesPath(s, toX, toY, bottomY)
return (
<path
key={`area-${i}`}
d={areaD}
fill={color}
className={styles.area}
/>
)
})}
{/* Lines */}
{series.map((s, i) => {
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
const pts = seriesPoints(s, toX, toY)
return (
<polyline
key={`line-${i}`}
points={pts}
fill="none"
stroke={color}
className={styles.line}
/>
)
})}
{/* Crosshair */}
{tooltip && (
<line
x1={tooltip.x}
y1={dims.paddingTop}
x2={tooltip.x}
y2={bottomY}
className={styles.crosshair}
/>
)}
</svg>
{/* Tooltip */}
{tooltip && (
<div
className={styles.tooltip}
style={{
left: tooltip.x + 12,
top: tooltip.y,
}}
>
{tooltip.values.map((v) => (
<div key={v.label} className={styles.tooltipRow}>
<span className={styles.tooltipDot} style={{ background: v.color }} />
<span className={styles.tooltipLabel}>{v.label}:</span>
<span className={styles.tooltipValue}>{formatAxisLabel(v.value)}</span>
</div>
))}
</div>
)}
{/* Legend */}
{series.length > 1 && (
<div className={styles.legend}>
{series.map((s, i) => (
<div key={s.label} className={styles.legendItem}>
<span
className={styles.legendDot}
style={{ background: s.color ?? CHART_COLORS[i % CHART_COLORS.length] }}
/>
<span className={styles.legendLabel}>{s.label}</span>
</div>
))}
</div>
)}
{xLabel && <div className={styles.xLabel}>{xLabel}</div>}
</div>
)
}

View File

@@ -32,7 +32,7 @@
background-color: var(--bg-inset); background-color: var(--bg-inset);
color: var(--text-secondary); color: var(--text-secondary);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
flex-shrink: 0; flex-shrink: 0;
margin-left: -8px; margin-left: -8px;

View File

@@ -1,143 +0,0 @@
.wrapper {
position: relative;
display: inline-flex;
flex-direction: column;
gap: 4px;
}
.svg {
display: block;
overflow: visible;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 12px;
height: 120px;
}
.gridLine {
stroke: var(--border-subtle);
stroke-width: 1;
stroke-dasharray: 3 3;
}
.axisLabel {
font-family: var(--font-mono);
font-size: 9px;
fill: var(--text-faint);
}
.catLabel {
font-family: var(--font-mono);
font-size: 9px;
fill: var(--text-faint);
}
.bar {
cursor: pointer;
transition: opacity 0.1s;
}
.bar:hover {
opacity: 0.8;
}
.tooltip {
position: absolute;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
padding: 6px 10px;
font-size: 11px;
pointer-events: none;
z-index: 10;
white-space: nowrap;
}
.tooltipTitle {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
font-size: 11px;
}
.tooltipRow {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 2px;
}
.tooltipRow:last-child {
margin-bottom: 0;
}
.tooltipDot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.tooltipLabel {
color: var(--text-muted);
}
.tooltipValue {
font-family: var(--font-mono);
font-weight: 600;
color: var(--text-primary);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding-left: 48px;
}
.legendItem {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-secondary);
}
.legendDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.legendLabel {
font-size: 11px;
}
.yLabel {
font-size: 10px;
color: var(--text-muted);
writing-mode: vertical-lr;
transform: rotate(180deg);
text-align: center;
position: absolute;
left: 4px;
top: 0;
bottom: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.xLabel {
text-align: center;
font-size: 10px;
color: var(--text-muted);
padding-left: 48px;
}

View File

@@ -1,251 +0,0 @@
import { useState } from 'react'
import styles from './BarChart.module.css'
import { formatAxisLabel, CHART_COLORS } from '../_chart-utils'
interface BarSeries {
label: string
data: { x: string; y: number }[]
color?: string
}
interface BarChartProps {
series: BarSeries[]
stacked?: boolean
height?: number
width?: number
xLabel?: string
yLabel?: string
className?: string
}
const PADDING = { top: 12, right: 16, bottom: 40, left: 48 }
const Y_TICK_COUNT = 4
const BAR_GAP = 0.2 // fraction of bar group width reserved for gaps
export function BarChart({
series,
stacked = false,
height = 200,
width = 400,
xLabel,
yLabel,
className,
}: BarChartProps) {
const [tooltip, setTooltip] = useState<{
x: number
y: number
label: string
values: { series: string; value: number; color: string }[]
} | null>(null)
if (series.length === 0 || series[0].data.length === 0) {
return <div className={`${styles.empty} ${className ?? ''}`}>No data</div>
}
// Collect all x categories (union across all series)
const categories = Array.from(new Set(series.flatMap((s) => s.data.map((d) => d.x))))
const numCats = categories.length
const plotW = width - PADDING.left - PADDING.right
const plotH = height - PADDING.top - PADDING.bottom
// Compute max Y
let maxY = 0
if (stacked) {
for (const cat of categories) {
const sum = series.reduce((acc, s) => {
const pt = s.data.find((d) => d.x === cat)
return acc + (pt?.y ?? 0)
}, 0)
maxY = Math.max(maxY, sum)
}
} else {
maxY = Math.max(...series.flatMap((s) => s.data.map((d) => d.y)))
}
maxY = maxY || 1
const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) =>
Math.round((maxY / Y_TICK_COUNT) * i),
)
const toY = (val: number) => PADDING.top + plotH - (val / maxY) * plotH
const bottomY = PADDING.top + plotH
const catWidth = plotW / numCats
const groupGap = catWidth * BAR_GAP
const groupW = catWidth - groupGap
function handleMouseEnter(
catLabel: string,
mx: number,
my: number,
values: { series: string; value: number; color: string }[],
) {
setTooltip({ x: mx, y: my, label: catLabel, values })
}
return (
<div className={`${styles.wrapper} ${className ?? ''}`}>
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={styles.svg}
onMouseLeave={() => setTooltip(null)}
aria-label="Bar chart"
role="img"
>
{/* Grid lines */}
{yTicks.map((val) => {
const y = toY(val)
return (
<g key={val}>
<line
x1={PADDING.left}
y1={y}
x2={width - PADDING.right}
y2={y}
className={styles.gridLine}
/>
<text x={PADDING.left - 6} y={y + 4} className={styles.axisLabel} textAnchor="end">
{formatAxisLabel(val)}
</text>
</g>
)
})}
{/* Bars */}
{categories.map((cat, ci) => {
const groupX = PADDING.left + ci * catWidth + groupGap / 2
if (stacked) {
let stackY = bottomY
return (
<g key={cat}>
{series.map((s, si) => {
const pt = s.data.find((d) => d.x === cat)
const val = pt?.y ?? 0
const barH = (val / maxY) * plotH
const color = s.color ?? CHART_COLORS[si % CHART_COLORS.length]
const y = stackY - barH
stackY -= barH
return (
<rect
key={si}
x={groupX}
y={y}
width={groupW}
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],
})),
)
}}
/>
)
})}
<text
x={groupX + groupW / 2}
y={bottomY + 14}
className={styles.catLabel}
textAnchor="middle"
>
{cat}
</text>
</g>
)
}
// Grouped
const barW = groupW / series.length
return (
<g key={cat}>
{series.map((s, si) => {
const pt = s.data.find((d) => d.x === cat)
const val = pt?.y ?? 0
const barH = (val / maxY) * plotH
const color = s.color ?? CHART_COLORS[si % CHART_COLORS.length]
return (
<rect
key={si}
x={groupX + si * barW}
y={toY(val)}
width={barW - 1}
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],
})),
)
}}
/>
)
})}
<text
x={groupX + groupW / 2}
y={bottomY + 14}
className={styles.catLabel}
textAnchor="middle"
>
{cat}
</text>
</g>
)
})}
</svg>
{/* Tooltip */}
{tooltip && (
<div
className={styles.tooltip}
style={{ left: tooltip.x + 12, top: tooltip.y }}
>
<div className={styles.tooltipTitle}>{tooltip.label}</div>
{tooltip.values.map((v) => (
<div key={v.series} className={styles.tooltipRow}>
<span className={styles.tooltipDot} style={{ background: v.color }} />
<span className={styles.tooltipLabel}>{v.series}:</span>
<span className={styles.tooltipValue}>{formatAxisLabel(v.value)}</span>
</div>
))}
</div>
)}
{/* Legend */}
{series.length > 1 && (
<div className={styles.legend}>
{series.map((s, i) => (
<div key={s.label} className={styles.legendItem}>
<span
className={styles.legendDot}
style={{ background: s.color ?? CHART_COLORS[i % CHART_COLORS.length] }}
/>
<span className={styles.legendLabel}>{s.label}</span>
</div>
))}
</div>
)}
{xLabel && <div className={styles.xLabel}>{xLabel}</div>}
</div>
)
}

View File

@@ -16,7 +16,7 @@
.sep { .sep {
color: var(--text-faint); color: var(--text-faint);
font-size: 11px; font-size: 12px;
} }
.link { .link {

View File

@@ -63,7 +63,7 @@
border: 1px solid var(--amber-light); border: 1px solid var(--amber-light);
border-radius: 4px; border-radius: 4px;
padding: 1px 6px; padding: 1px 6px;
font-size: 11px; font-size: 12px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -133,7 +133,7 @@
background: var(--bg-inset); background: var(--bg-inset);
border-radius: 10px; border-radius: 10px;
padding: 0 6px; padding: 0 6px;
font-size: 10px; font-size: 12px;
font-family: var(--font-mono); font-family: var(--font-mono);
color: var(--text-muted); color: var(--text-muted);
} }
@@ -227,7 +227,7 @@
display: inline-flex; display: inline-flex;
padding: 1px 7px; padding: 1px 7px;
border-radius: 10px; border-radius: 10px;
font-size: 10px; font-size: 12px;
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: 600; font-weight: 600;
background: var(--bg-inset); background: var(--bg-inset);
@@ -239,13 +239,13 @@
.itemTime { .itemTime {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
color: var(--text-faint); color: var(--text-faint);
margin-left: auto; margin-left: auto;
} }
.itemMeta { .itemMeta {
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font-mono); font-family: var(--font-mono);
margin-top: 2px; margin-top: 2px;
@@ -259,7 +259,7 @@
border: none; border: none;
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
font-size: 10px; font-size: 12px;
padding: 2px 6px; padding: 2px 6px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
flex-shrink: 0; flex-shrink: 0;
@@ -279,7 +279,7 @@
/* Match context snippet */ /* Match context snippet */
.matchContext { .matchContext {
font-size: 11px; font-size: 12px;
color: var(--text-faint); color: var(--text-faint);
font-family: var(--font-mono); font-family: var(--font-mono);
margin-top: 3px; margin-top: 3px;
@@ -316,6 +316,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
} }

View File

@@ -19,8 +19,7 @@ interface CommandPaletteProps {
onSubmit?: (query: string) => void onSubmit?: (query: string) => void
} }
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = { const KNOWN_CATEGORY_LABELS: Record<string, string> = {
all: 'All',
application: 'Applications', application: 'Applications',
exchange: 'Exchanges', exchange: 'Exchanges',
attribute: 'Attributes', attribute: 'Attributes',
@@ -28,8 +27,8 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
agent: 'Agents', agent: 'Agents',
} }
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [ /** Preferred display order for known categories */
'all', const KNOWN_CATEGORY_ORDER: string[] = [
'application', 'application',
'exchange', 'exchange',
'attribute', 'attribute',
@@ -37,6 +36,13 @@ const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
'agent', '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 { function highlightText(text: string, query: string, matchRanges?: [number, number][]): ReactNode {
if (!query && (!matchRanges || matchRanges.length === 0)) return text if (!query && (!matchRanges || matchRanges.length === 0)) return text
@@ -69,7 +75,7 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) { export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all') const [activeCategory, setActiveCategory] = useState<string>('all')
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([]) const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
const [focusedIdx, setFocusedIdx] = useState(0) const [focusedIdx, setFocusedIdx] = useState(0)
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
@@ -128,7 +134,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
// Group results by category // Group results by category
const grouped = useMemo(() => { const grouped = useMemo(() => {
const map = new Map<SearchCategory, SearchResult[]>() const map = new Map<string, SearchResult[]>()
for (const r of filtered) { for (const r of filtered) {
if (!map.has(r.category)) map.set(r.category, []) if (!map.has(r.category)) map.set(r.category, [])
map.get(r.category)!.push(r) map.get(r.category)!.push(r)
@@ -148,6 +154,19 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
return counts return counts
}, [queryFiltered]) }, [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) { function handleKeyDown(e: React.KeyboardEvent) {
switch (e.key) { switch (e.key) {
case 'Escape': case 'Escape':
@@ -186,10 +205,23 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
setScopeFilters((prev) => prev.filter((_, i) => i !== idx)) 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 if (!open) return null
return createPortal( 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 <div
className={styles.panel} className={styles.panel}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -233,7 +265,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
{/* Category tabs */} {/* Category tabs */}
<div className={styles.tabs} role="tablist"> <div className={styles.tabs} role="tablist">
{ALL_CATEGORIES.map((cat) => ( {visibleCategories.map((cat) => (
<button <button
key={cat} key={cat}
role="tab" role="tab"
@@ -249,7 +281,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
setFocusedIdx(0) setFocusedIdx(0)
}} }}
> >
{CATEGORY_LABELS[cat]} {categoryLabel(cat)}
{categoryCounts[cat] != null && ( {categoryCounts[cat] != null && (
<span className={styles.tabCount}>{categoryCounts[cat]}</span> <span className={styles.tabCount}>{categoryCounts[cat]}</span>
)} )}
@@ -270,7 +302,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
Array.from(grouped.entries()).map(([category, items]) => ( Array.from(grouped.entries()).map(([category, items]) => (
<div key={category} className={styles.group}> <div key={category} className={styles.group}>
<div className={styles.groupHeader}> <div className={styles.groupHeader}>
<SectionHeader>{CATEGORY_LABELS[category]}</SectionHeader> <SectionHeader>{categoryLabel(category)}</SectionHeader>
</div> </div>
{items.map((result) => { {items.map((result) => {
const flatIdx = flatResults.indexOf(result) const flatIdx = flatResults.indexOf(result)
@@ -293,6 +325,12 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
onSelect(result) onSelect(result)
onClose() onClose()
}} }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onSelect(result)
onClose()
}
}}
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }} onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
> >
<div className={styles.itemMain}> <div className={styles.itemMain}>
@@ -328,10 +366,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
{result.expandedContent && ( {result.expandedContent && (
<button <button
className={styles.expandBtn} className={styles.expandBtn}
onClick={(e) => { onClick={(e) => toggleExpanded(e, result.id)}
e.stopPropagation()
setExpandedId((prev) => (prev === result.id ? null : result.id))
}}
aria-expanded={isExpanded} aria-expanded={isExpanded}
aria-label="Toggle detail" aria-label="Toggle detail"
> >

View File

@@ -1,6 +1,7 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
export type SearchCategory = 'application' | 'exchange' | 'attribute' | '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 { export interface SearchResult {
id: string id: string

View File

@@ -42,7 +42,7 @@
.th { .th {
padding: 9px 14px; padding: 9px 14px;
text-align: left; text-align: left;
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.8px; letter-spacing: 0.8px;
@@ -147,7 +147,7 @@
.rangeInfo { .rangeInfo {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 12px;
} }
.paginationRight { .paginationRight {
@@ -157,7 +157,7 @@
} }
.pageSizeLabel { .pageSizeLabel {
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
} }
@@ -173,7 +173,7 @@
.pageNum { .pageNum {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
min-width: 40px; min-width: 40px;
text-align: center; text-align: center;

View File

@@ -29,7 +29,7 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--bg-surface); background: var(--bg-surface);
font-size: 11px; font-size: 12px;
font-family: var(--font-mono); font-family: var(--font-mono);
color: var(--text-primary); color: var(--text-primary);
outline: none; outline: none;
@@ -81,7 +81,7 @@
background: none; background: none;
border: none; border: none;
color: var(--text-muted); color: var(--text-muted);
font-size: 11px; font-size: 12px;
cursor: pointer; cursor: pointer;
padding: 2px 6px; padding: 2px 6px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -165,7 +165,7 @@
} }
.time { .time {
font-size: 10px; font-size: 12px;
font-family: var(--font-mono); font-family: var(--font-mono);
color: var(--text-muted); color: var(--text-muted);
margin-top: 1px; margin-top: 1px;
@@ -215,7 +215,7 @@
border: 1px solid var(--amber-light); border: 1px solid var(--amber-light);
border-radius: 0; border-radius: 0;
color: var(--amber-deep); color: var(--amber-deep);
font-size: 11px; font-size: 12px;
font-family: var(--font-mono); font-family: var(--font-mono);
cursor: pointer; cursor: pointer;
text-align: center; text-align: center;

View File

@@ -43,7 +43,7 @@
} }
.clearAll { .clearAll {
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
padding: 3px 6px; padding: 3px 6px;

View File

@@ -63,7 +63,7 @@
gap: 16px; gap: 16px;
padding: 8px 16px; padding: 8px 16px;
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
background: var(--bg-surface); background: var(--bg-surface);
} }

View File

@@ -30,7 +30,7 @@
} }
.label { .label {
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.6px; letter-spacing: 0.6px;
@@ -55,7 +55,7 @@
.trend { .trend {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 12px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
@@ -68,7 +68,7 @@
.trendMuted { color: var(--text-muted); } .trendMuted { color: var(--text-muted); }
.subtitle { .subtitle {
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
margin-top: 2px; margin-top: 2px;
} }

View File

@@ -1,146 +0,0 @@
.wrapper {
position: relative;
display: inline-flex;
flex-direction: column;
gap: 4px;
}
.svg {
display: block;
overflow: visible;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 12px;
height: 120px;
}
.gridLine {
stroke: var(--border-subtle);
stroke-width: 1;
stroke-dasharray: 3 3;
}
.axisLabel {
font-family: var(--font-mono);
font-size: 9px;
fill: var(--text-faint);
}
.thresholdLine {
stroke: var(--error);
stroke-width: 1.5;
stroke-dasharray: 5 3;
}
.thresholdLabel {
font-family: var(--font-mono);
font-size: 9px;
fill: var(--error);
}
.line {
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.crosshair {
stroke: var(--text-faint);
stroke-width: 1;
stroke-dasharray: 3 3;
pointer-events: none;
}
.tooltip {
position: absolute;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
padding: 6px 10px;
font-size: 11px;
pointer-events: none;
z-index: 10;
white-space: nowrap;
}
.tooltipRow {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 2px;
}
.tooltipRow:last-child {
margin-bottom: 0;
}
.tooltipDot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.tooltipLabel {
color: var(--text-muted);
}
.tooltipValue {
font-family: var(--font-mono);
font-weight: 600;
color: var(--text-primary);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding-left: 48px;
}
.legendItem {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-secondary);
}
.legendDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.legendLabel {
font-size: 11px;
}
.yLabel {
font-size: 10px;
color: var(--text-muted);
writing-mode: vertical-lr;
transform: rotate(180deg);
text-align: center;
position: absolute;
left: 4px;
top: 0;
bottom: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.xLabel {
text-align: center;
font-size: 10px;
color: var(--text-muted);
padding-left: 48px;
}

View File

@@ -1,233 +0,0 @@
import { useState } from 'react'
import styles from './LineChart.module.css'
import {
computeYScale,
computeXScale,
seriesPoints,
formatAxisLabel,
CHART_COLORS,
type ChartSeries,
} from '../_chart-utils'
interface Threshold {
value: number
label: string
}
interface LineChartProps {
series: ChartSeries[]
xLabel?: string
yLabel?: string
threshold?: Threshold
height?: number
width?: number
className?: string
}
const Y_TICK_COUNT = 4
const DIMS = {
paddingTop: 12,
paddingRight: 16,
paddingBottom: 28,
paddingLeft: 48,
}
export function LineChart({
series,
xLabel,
yLabel,
threshold,
height = 200,
width = 400,
className,
}: LineChartProps) {
const [tooltip, setTooltip] = useState<{
x: number
y: number
values: { label: string; value: number; color: string }[]
} | null>(null)
const dims = { ...DIMS, width, height }
const allData = series.flatMap((s) => s.data)
if (allData.length === 0) {
return <div className={`${styles.empty} ${className ?? ''}`}>No data</div>
}
const { max, toY } = computeYScale(series, dims, threshold?.value)
const { toX } = computeXScale(series, dims)
const plotH = height - dims.paddingTop - dims.paddingBottom
const plotW = width - dims.paddingLeft - dims.paddingRight
const bottomY = dims.paddingTop + plotH
const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) =>
Math.round((max / Y_TICK_COUNT) * i),
)
const firstSeries = series[0]
const xSamples =
firstSeries && firstSeries.data.length > 0
? [
firstSeries.data[0].x,
firstSeries.data[Math.floor(firstSeries.data.length / 2)]?.x,
firstSeries.data[firstSeries.data.length - 1].x,
].filter(Boolean)
: []
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
const rect = e.currentTarget.getBoundingClientRect()
const mx = e.clientX - rect.left
const my = e.clientY - rect.top
const pctX = (mx - dims.paddingLeft) / plotW
const values = series.map((s, i) => {
const idx = Math.round(pctX * (s.data.length - 1))
const clamped = Math.max(0, Math.min(s.data.length - 1, idx))
const pt = s.data[clamped]
return {
label: s.label,
value: pt?.y ?? 0,
color: s.color ?? CHART_COLORS[i % CHART_COLORS.length],
}
})
setTooltip({ x: mx, y: my, values })
}
return (
<div className={`${styles.wrapper} ${className ?? ''}`}>
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={styles.svg}
onMouseMove={handleMouseMove}
onMouseLeave={() => setTooltip(null)}
aria-label="Line chart"
role="img"
>
{/* Grid lines */}
{yTicks.map((val) => {
const y = toY(val)
return (
<g key={val}>
<line
x1={dims.paddingLeft}
y1={y}
x2={width - dims.paddingRight}
y2={y}
className={styles.gridLine}
/>
<text x={dims.paddingLeft - 6} y={y + 4} className={styles.axisLabel} textAnchor="end">
{formatAxisLabel(val)}
</text>
</g>
)
})}
{/* X-axis labels */}
{xSamples.map((xVal, i) => {
const xPos = toX(xVal)
const xv = xVal instanceof Date ? xVal : new Date(xVal as number)
const label =
xVal instanceof Date || (typeof xVal === 'number' && xVal > 1e10)
? xv.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: formatAxisLabel(xVal as number)
const anchor = i === 0 ? 'start' : i === xSamples.length - 1 ? 'end' : 'middle'
return (
<text
key={i}
x={xPos}
y={height - dims.paddingBottom + 16}
className={styles.axisLabel}
textAnchor={anchor}
>
{label}
</text>
)
})}
{/* Threshold line */}
{threshold && (
<g>
<line
x1={dims.paddingLeft}
y1={toY(threshold.value)}
x2={width - dims.paddingRight}
y2={toY(threshold.value)}
className={styles.thresholdLine}
/>
<text
x={width - dims.paddingRight - 4}
y={toY(threshold.value) - 4}
className={styles.thresholdLabel}
textAnchor="end"
>
{threshold.label}
</text>
</g>
)}
{/* Lines only (no area fill) */}
{series.map((s, i) => {
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
const pts = seriesPoints(s, toX, toY)
return (
<polyline
key={`line-${i}`}
points={pts}
fill="none"
stroke={color}
className={styles.line}
/>
)
})}
{/* Crosshair */}
{tooltip && (
<line
x1={tooltip.x}
y1={dims.paddingTop}
x2={tooltip.x}
y2={bottomY}
className={styles.crosshair}
/>
)}
</svg>
{/* Tooltip */}
{tooltip && (
<div
className={styles.tooltip}
style={{ left: tooltip.x + 12, top: tooltip.y }}
>
{tooltip.values.map((v) => (
<div key={v.label} className={styles.tooltipRow}>
<span className={styles.tooltipDot} style={{ background: v.color }} />
<span className={styles.tooltipLabel}>{v.label}:</span>
<span className={styles.tooltipValue}>{formatAxisLabel(v.value)}</span>
</div>
))}
</div>
)}
{/* Legend */}
{series.length > 1 && (
<div className={styles.legend}>
{series.map((s, i) => (
<div key={s.label} className={styles.legendItem}>
<span
className={styles.legendDot}
style={{ background: s.color ?? CHART_COLORS[i % CHART_COLORS.length] }}
/>
<span className={styles.legendLabel}>{s.label}</span>
</div>
))}
</div>
)}
{xLabel && <div className={styles.xLabel}>{xLabel}</div>}
</div>
)
}

View File

@@ -18,7 +18,7 @@
.timestamp { .timestamp {
flex-shrink: 0; flex-shrink: 0;
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
min-width: 56px; min-width: 56px;
} }
@@ -61,6 +61,38 @@
background: color-mix(in srgb, var(--text-faint) 8%, transparent); background: color-mix(in srgb, var(--text-faint) 8%, transparent);
} }
.sourceBadge {
flex-shrink: 0;
font-size: 9px;
font-family: var(--font-mono);
padding: 1px 6px;
border-radius: 3px;
line-height: 1.5;
white-space: nowrap;
min-width: 48px;
text-align: center;
}
.sourceContainer {
color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
}
.sourceApp {
color: var(--running);
background: color-mix(in srgb, var(--running) 10%, transparent);
}
.sourceAgent {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 10%, transparent);
}
.sourceDefault {
color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted) 8%, transparent);
}
.message { .message {
font-size: 12px; font-size: 12px;
font-family: var(--font-mono); font-family: var(--font-mono);

View File

@@ -3,9 +3,9 @@ import { render, screen } from '@testing-library/react'
import { LogViewer, type LogEntry } from './LogViewer' import { LogViewer, type LogEntry } from './LogViewer'
const entries: LogEntry[] = [ const entries: LogEntry[] = [
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'Server started' }, { timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'Server started', source: 'app' },
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' }, { timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage', source: 'container' },
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' }, { timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed', source: 'agent' },
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' }, { timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
{ timestamp: '2024-01-15T10:30:20Z', level: 'trace', message: 'Entering handleRequest()' }, { timestamp: '2024-01-15T10:30:20Z', level: 'trace', message: 'Entering handleRequest()' },
] ]
@@ -52,6 +52,23 @@ describe('LogViewer', () => {
expect(el.classList.contains('custom-class')).toBe(true) expect(el.classList.contains('custom-class')).toBe(true)
}) })
it('renders source badges when source is provided', () => {
render(<LogViewer entries={entries} />)
expect(screen.getByText('app')).toBeInTheDocument()
expect(screen.getByText('container')).toBeInTheDocument()
expect(screen.getByText('agent')).toBeInTheDocument()
})
it('omits source badge when source is not provided', () => {
const noSourceEntries: LogEntry[] = [
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'No source here' },
]
render(<LogViewer entries={noSourceEntries} />)
expect(screen.getByText('No source here')).toBeInTheDocument()
expect(screen.queryByText('app')).not.toBeInTheDocument()
expect(screen.queryByText('container')).not.toBeInTheDocument()
})
it('has role="log" for accessibility', () => { it('has role="log" for accessibility', () => {
render(<LogViewer entries={entries} />) render(<LogViewer entries={entries} />)
expect(screen.getByRole('log')).toBeInTheDocument() expect(screen.getByRole('log')).toBeInTheDocument()

View File

@@ -5,6 +5,7 @@ export interface LogEntry {
timestamp: string timestamp: string
level: 'info' | 'warn' | 'error' | 'debug' | 'trace' level: 'info' | 'warn' | 'error' | 'debug' | 'trace'
message: string message: string
source?: string
} }
export interface LogViewerProps { export interface LogViewerProps {
@@ -21,6 +22,12 @@ const LEVEL_CLASS: Record<LogEntry['level'], string> = {
trace: styles.levelTrace, trace: styles.levelTrace,
} }
const SOURCE_CLASS: Record<string, string> = {
container: styles.sourceContainer,
app: styles.sourceApp,
agent: styles.sourceAgent,
}
function formatTime(iso: string): string { function formatTime(iso: string): string {
try { try {
return new Date(iso).toLocaleTimeString('en-GB', { return new Date(iso).toLocaleTimeString('en-GB', {
@@ -67,6 +74,11 @@ export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProp
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}> <span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
{entry.level.toUpperCase()} {entry.level.toUpperCase()}
</span> </span>
{entry.source && (
<span className={[styles.sourceBadge, SOURCE_CLASS[entry.source] ?? styles.sourceDefault].join(' ')}>
{entry.source}
</span>
)}
<span className={styles.message}>{entry.message}</span> <span className={styles.message}>{entry.message}</span>
</div> </div>
))} ))}

View File

@@ -51,7 +51,7 @@
.dividerText { .dividerText {
color: var(--text-muted); color: var(--text-muted);
font-size: 11px; font-size: 12px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
font-weight: 500; font-weight: 500;
@@ -71,7 +71,7 @@
} }
.forgotLink { .forgotLink {
font-size: 11px; font-size: 12px;
color: var(--amber); color: var(--amber);
font-weight: 500; font-weight: 500;
background: none; background: none;

View File

@@ -45,7 +45,7 @@
} }
.meta { .meta {
font-size: 11px; font-size: 12px;
color: var(--sidebar-muted); color: var(--sidebar-muted);
font-family: var(--font-mono); font-family: var(--font-mono);
white-space: nowrap; white-space: nowrap;
@@ -59,7 +59,7 @@
.count { .count {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 12px;
color: var(--sidebar-muted); color: var(--sidebar-muted);
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
padding: 1px 6px; padding: 1px 6px;

View File

@@ -46,7 +46,7 @@
.chevron { .chevron {
color: var(--text-faint); color: var(--text-faint);
font-size: 11px; font-size: 12px;
flex-shrink: 0; flex-shrink: 0;
} }

View File

@@ -8,7 +8,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-size: 11px; font-size: 12px;
} }
.row.clickable { .row.clickable {
@@ -32,7 +32,7 @@
text-align: right; text-align: right;
color: var(--text-secondary); color: var(--text-secondary);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -83,7 +83,7 @@
.dur { .dur {
width: 42px; width: 42px;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
flex-shrink: 0; flex-shrink: 0;
text-align: right; text-align: right;

View File

@@ -52,7 +52,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 10px; font-size: 12px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -97,7 +97,7 @@
.label { .label {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
color: var(--text-primary); color: var(--text-primary);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -112,7 +112,7 @@
.duration { .duration {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
} }

View File

@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-react' import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-react'
import styles from './RouteFlow.module.css' import styles from './RouteFlow.module.css'
import { Dropdown } from '../Dropdown/Dropdown' import { Dropdown } from '../Dropdown/Dropdown'
import { formatDuration } from '../../../utils/format-utils'
export interface NodeBadge { export interface NodeBadge {
label: string label: string
@@ -42,12 +43,6 @@ interface RouteFlowProps {
className?: string className?: string
} }
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
return `${ms}ms`
}
function durationClass(ms: number, status: string): string { function durationClass(ms: number, status: string): string {
if (status === 'fail') return styles.durBreach if (status === 'fail') return styles.durBreach
if (ms < 50) return styles.durFast if (ms < 50) return styles.durFast

View File

@@ -57,7 +57,7 @@
.count { .count {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-muted); color: var(--text-muted);

View File

@@ -17,7 +17,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
font-size: 10px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);

View File

@@ -38,7 +38,7 @@
.count { .count {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
background: var(--bg-inset); background: var(--bg-inset);
color: var(--text-muted); color: var(--text-muted);
padding: 1px 5px; padding: 1px 5px;

View File

@@ -0,0 +1,47 @@
.tooltip {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
padding: 6px 10px;
font-size: 12px;
}
.time {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
margin-bottom: 4px;
padding-bottom: 3px;
border-bottom: 1px solid var(--border-subtle);
}
.row {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 2px;
}
.row:last-child {
margin-bottom: 0;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.label {
color: var(--text-muted);
font-size: 11px;
}
.value {
font-family: var(--font-mono);
font-weight: 600;
font-size: 11px;
color: var(--text-primary);
}

View File

@@ -0,0 +1,41 @@
import type { TooltipProps } from 'recharts'
import styles from './ChartTooltip.module.css'
function formatValue(val: number): string {
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
if (val >= 1000) return `${(val / 1000).toFixed(1)}k`
if (Number.isInteger(val)) return String(val)
return val.toFixed(1)
}
function formatTimestamp(val: unknown): string | null {
if (val == null) return null
const str = String(val)
const ms = typeof val === 'number' && val > 1e12 ? val
: typeof val === 'number' && val > 1e9 ? val * 1000
: Date.parse(str)
if (isNaN(ms)) return str
return new Date(ms).toLocaleString([], {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
}
export function ChartTooltip({ active, payload, label }: TooltipProps<number, string>) {
if (!active || !payload?.length) return null
const timeLabel = formatTimestamp(label)
return (
<div className={styles.tooltip}>
{timeLabel && <div className={styles.time}>{timeLabel}</div>}
{payload.map((entry) => (
<div key={entry.dataKey as string} className={styles.row}>
<span className={styles.dot} style={{ background: entry.color }} />
<span className={styles.label}>{entry.name}:</span>
<span className={styles.value}>{formatValue(entry.value as number)}</span>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { ThemedChart } from './ThemedChart'
import { Line } from 'recharts'
// Recharts uses ResizeObserver internally
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverMock as any
describe('ThemedChart', () => {
it('renders nothing when data is empty', () => {
const { container } = render(
<ThemedChart data={[]}>
<Line dataKey="value" />
</ThemedChart>,
)
expect(container.innerHTML).toBe('')
})
it('renders a chart container when data is provided', () => {
const data = [
{ time: '10:00', value: 10 },
{ time: '10:01', value: 20 },
]
const { container } = render(
<ThemedChart data={data} height={160}>
<Line dataKey="value" />
</ThemedChart>,
)
expect(container.querySelector('.recharts-responsive-container')).toBeTruthy()
})
it('applies custom className', () => {
const data = [{ time: '10:00', value: 5 }]
const { container } = render(
<ThemedChart data={data} className="my-chart">
<Line dataKey="value" />
</ThemedChart>,
)
expect(container.querySelector('.my-chart')).toBeTruthy()
})
})

View File

@@ -0,0 +1,74 @@
import {
ResponsiveContainer,
ComposedChart,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
} from 'recharts'
import { rechartsTheme } from '../../utils/rechartsTheme'
import { ChartTooltip } from './ChartTooltip'
interface ThemedChartProps {
data: Record<string, any>[]
height?: number
xDataKey?: string
xType?: 'number' | 'category'
xTickFormatter?: (value: any) => string
yTickFormatter?: (value: any) => string
yLabel?: string
children: React.ReactNode
className?: string
}
export function ThemedChart({
data,
height = 200,
xDataKey = 'time',
xType = 'category',
xTickFormatter,
yTickFormatter,
yLabel,
children,
className,
}: ThemedChartProps) {
if (!data.length) {
return null
}
// Show ~5-6 ticks max to avoid label overlap
const maxTicks = 6
const tickInterval = data.length > maxTicks
? Math.ceil(data.length / maxTicks) - 1
: 0
return (
<div className={className} style={{ width: '100%', height }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid {...rechartsTheme.cartesianGrid} />
<XAxis
dataKey={xDataKey}
type={xType}
{...rechartsTheme.xAxis}
tickFormatter={xTickFormatter}
interval={tickInterval}
minTickGap={40}
/>
<YAxis
{...rechartsTheme.yAxis}
tickFormatter={yTickFormatter}
label={yLabel ? {
value: yLabel,
angle: -90,
position: 'insideLeft',
style: { fontSize: 11, fill: 'var(--text-muted)' },
} : undefined}
/>
<Tooltip content={<ChartTooltip />} cursor={rechartsTheme.tooltip.cursor} />
{children}
</ComposedChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -57,6 +57,10 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]) const [toasts, setToasts] = useState<ToastItem[]>([])
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map()) 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) => { const dismiss = useCallback((id: string) => {
// Clear auto-dismiss timer if running // Clear auto-dismiss timer if running
const timer = timersRef.current.get(id) const timer = timersRef.current.get(id)
@@ -71,16 +75,15 @@ export function ToastProvider({ children }: { children: ReactNode }) {
) )
// Remove after animation completes // Remove after animation completes
setTimeout(() => { setTimeout(() => removeToast(id), EXIT_ANIMATION_MS)
setToasts((prev) => prev.filter((t) => t.id !== id)) }, [removeToast])
}, EXIT_ANIMATION_MS)
}, [])
const toast = useCallback( const toast = useCallback(
(options: ToastOptions): string => { (options: ToastOptions): string => {
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
const duration = options.duration ?? DEFAULT_DURATION
const variant = options.variant ?? 'info' const variant = options.variant ?? 'info'
// Error toasts persist until manually dismissed; others auto-close after DEFAULT_DURATION
const duration = options.duration ?? (variant === 'error' ? 0 : DEFAULT_DURATION)
const newToast: ToastItem = { const newToast: ToastItem = {
id, id,
@@ -97,11 +100,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return next.slice(-MAX_TOASTS) return next.slice(-MAX_TOASTS)
}) })
// Schedule auto-dismiss // Schedule auto-dismiss (duration 0 = persist until manual dismiss)
const timer = setTimeout(() => { if (duration > 0) {
dismiss(id) const timer = setTimeout(() => {
}, duration) dismiss(id)
timersRef.current.set(id, timer) }, duration)
timersRef.current.set(id, timer)
}
return id return id
}, },

View File

@@ -59,7 +59,7 @@
} }
.chevron { .chevron {
font-size: 10px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
line-height: 1; line-height: 1;
} }
@@ -85,7 +85,7 @@
.meta { .meta {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
margin-left: auto; margin-left: auto;
padding-left: 8px; padding-left: 8px;

View File

@@ -31,6 +31,52 @@ function flattenVisibleNodes(
return result 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 { interface TreeViewProps {
nodes: TreeNode[] nodes: TreeNode[]
onSelect?: (id: string) => void onSelect?: (id: string) => void
@@ -105,68 +151,13 @@ export function TreeView({
const current = visibleNodes[currentIndex] const current = visibleNodes[currentIndex]
switch (e.key) { switch (e.key) {
case 'ArrowDown': { case 'ArrowDown': { e.preventDefault(); handleArrowDown(visibleNodes, currentIndex, focusNode); break }
e.preventDefault() case 'ArrowUp': { e.preventDefault(); handleArrowUp(visibleNodes, currentIndex, focusNode); break }
const next = visibleNodes[currentIndex + 1] case 'ArrowRight': { e.preventDefault(); handleArrowRight(current, currentIndex, expandedSet, visibleNodes, handleToggle, focusNode); break }
if (next) focusNode(next.node.id) case 'ArrowLeft': { e.preventDefault(); handleArrowLeft(current, expandedSet, handleToggle, focusNode); break }
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 'ArrowUp': { case 'End': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[visibleNodes.length - 1].node.id); break }
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
}
} }
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -239,6 +230,10 @@ function TreeNodeRow({
return ( return (
<li role="none"> <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 <div
role="treeitem" role="treeitem"
aria-expanded={hasChildren ? isExpanded : undefined} aria-expanded={hasChildren ? isExpanded : undefined}

View File

@@ -1,101 +0,0 @@
export interface DataPoint {
x: number | Date
y: number
}
export interface ChartSeries {
label: string
data: DataPoint[]
color?: string
}
export interface ChartDimensions {
width: number
height: number
paddingTop: number
paddingRight: number
paddingBottom: number
paddingLeft: number
}
export function computeYScale(
series: ChartSeries[],
dims: ChartDimensions,
threshold?: number,
) {
const allY = series.flatMap((s) => s.data.map((d) => d.y))
if (threshold != null) allY.push(threshold)
const min = 0
const max = Math.max(...allY, 1)
const range = max - min
const plotH = dims.height - dims.paddingTop - dims.paddingBottom
const toY = (val: number) => dims.paddingTop + plotH - ((val - min) / range) * plotH
return { min, max, range, toY }
}
export function computeXScale(series: ChartSeries[], dims: ChartDimensions) {
const allX = series.flatMap((s) =>
s.data.map((d) => (d.x instanceof Date ? d.x.getTime() : d.x)),
)
const minX = Math.min(...allX)
const maxX = Math.max(...allX)
const rangeX = maxX - minX || 1
const plotW = dims.width - dims.paddingLeft - dims.paddingRight
const toX = (val: number | Date) => {
const v = val instanceof Date ? val.getTime() : val
return dims.paddingLeft + ((v - minX) / rangeX) * plotW
}
return { minX, maxX, rangeX, toX }
}
export function seriesPoints(
series: ChartSeries,
toX: (v: number | Date) => number,
toY: (v: number) => number,
): string {
return series.data.map((d) => `${toX(d.x).toFixed(1)},${toY(d.y).toFixed(1)}`).join(' ')
}
export function seriesPath(
series: ChartSeries,
toX: (v: number | Date) => number,
toY: (v: number) => number,
bottomY: number,
): string {
if (series.data.length === 0) return ''
const pts = series.data.map((d) => `${toX(d.x).toFixed(1)},${toY(d.y).toFixed(1)}`)
const firstX = toX(series.data[0].x).toFixed(1)
const lastX = toX(series.data[series.data.length - 1].x).toFixed(1)
return `M${pts.join(' L')} L${lastX},${bottomY} L${firstX},${bottomY} Z`
}
export function formatAxisLabel(val: number): string {
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
if (val >= 1000) return `${(val / 1000).toFixed(1)}k`
if (Number.isInteger(val)) return String(val)
return val.toFixed(1)
}
export function formatXLabel(val: number | Date, _totalPoints: number): string {
if (val instanceof Date || (typeof val === 'number' && val > 1e10)) {
const d = val instanceof Date ? val : new Date(val)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
return formatAxisLabel(val as number)
}
// Default color tokens for series (fallback when no color given)
export const CHART_COLORS = [
'var(--chart-1)',
'var(--chart-2)',
'var(--chart-3)',
'var(--chart-4)',
'var(--chart-5)',
'var(--chart-6)',
'var(--chart-7)',
'var(--chart-8)',
]

View File

@@ -1,8 +1,6 @@
export { Accordion } from './Accordion/Accordion' export { Accordion } from './Accordion/Accordion'
export { AlertDialog } from './AlertDialog/AlertDialog' export { AlertDialog } from './AlertDialog/AlertDialog'
export { AreaChart } from './AreaChart/AreaChart'
export { AvatarGroup } from './AvatarGroup/AvatarGroup' export { AvatarGroup } from './AvatarGroup/AvatarGroup'
export { BarChart } from './BarChart/BarChart'
export { Breadcrumb } from './Breadcrumb/Breadcrumb' export { Breadcrumb } from './Breadcrumb/Breadcrumb'
export { CommandPalette } from './CommandPalette/CommandPalette' export { CommandPalette } from './CommandPalette/CommandPalette'
export type { SearchResult, SearchCategory, ScopeFilter } from './CommandPalette/types' export type { SearchResult, SearchCategory, ScopeFilter } from './CommandPalette/types'
@@ -13,13 +11,13 @@ export type { Column, DataTableProps } from './DataTable/types'
export { DetailPanel } from './DetailPanel/DetailPanel' export { DetailPanel } from './DetailPanel/DetailPanel'
export { EntityList } from './EntityList/EntityList' export { EntityList } from './EntityList/EntityList'
export { Dropdown } from './Dropdown/Dropdown' export { Dropdown } from './Dropdown/Dropdown'
export type { DropdownItem } from './Dropdown/Dropdown'
export { EventFeed } from './EventFeed/EventFeed' export { EventFeed } from './EventFeed/EventFeed'
export { GroupCard } from './GroupCard/GroupCard' export { GroupCard } from './GroupCard/GroupCard'
export { KpiStrip } from './KpiStrip/KpiStrip' export { KpiStrip } from './KpiStrip/KpiStrip'
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip' export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
export type { FeedEvent } from './EventFeed/EventFeed' export type { FeedEvent } from './EventFeed/EventFeed'
export { FilterBar } from './FilterBar/FilterBar' export { FilterBar } from './FilterBar/FilterBar'
export { LineChart } from './LineChart/LineChart'
export { LogViewer } from './LogViewer/LogViewer' export { LogViewer } from './LogViewer/LogViewer'
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer' export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
export { LoginDialog } from './LoginForm/LoginDialog' export { LoginDialog } from './LoginForm/LoginDialog'
@@ -42,6 +40,11 @@ export { Tabs } from './Tabs/Tabs'
export { ToastProvider, useToast } from './Toast/Toast' export { ToastProvider, useToast } from './Toast/Toast'
export { TreeView } from './TreeView/TreeView' export { TreeView } from './TreeView/TreeView'
// Chart utilities for consumers using Recharts or custom charts // Charts — ThemedChart wrapper + Recharts re-exports
export { CHART_COLORS } from './_chart-utils' export { ThemedChart } from './ThemedChart/ThemedChart'
export type { ChartSeries, DataPoint } from './_chart-utils' export { CHART_COLORS, rechartsTheme } from '../utils/rechartsTheme'
export {
Line, Area, Bar,
ReferenceLine, ReferenceArea,
Legend, Brush,
} from 'recharts'

View File

@@ -11,4 +11,4 @@ export { BreadcrumbProvider, useBreadcrumb } from './providers/BreadcrumbProvide
export type { BreadcrumbItem } from './providers/BreadcrumbProvider' export type { BreadcrumbItem } from './providers/BreadcrumbProvider'
export * from './utils/hashColor' export * from './utils/hashColor'
export * from './utils/timePresets' export * from './utils/timePresets'
export * from './utils/rechartsTheme'

View File

@@ -5,6 +5,36 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; 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 */ /* Logo */
@@ -15,6 +45,12 @@
gap: 10px; gap: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0; flex-shrink: 0;
overflow: hidden;
}
.sidebarCollapsed .logo {
padding: 16px 0;
justify-content: center;
} }
.logoImg { .logoImg {
@@ -36,7 +72,8 @@
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
color: var(--sidebar-muted); color: var(--sidebar-muted);
margin-left: 4px; margin-left: 8px;
opacity: 0.6;
} }
/* Search */ /* Search */
@@ -106,71 +143,40 @@
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
} }
/* Scrollable nav area */ /* Section icon (collapsed rail) */
.navArea { .sectionIcon {
flex: 1; display: flex;
overflow-y: auto; align-items: center;
min-height: 0; justify-content: center;
} width: 16px;
/* Section headers */
.section {
padding: 14px 12px 5px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--sidebar-muted); color: var(--sidebar-muted);
} }
/* Items container */ /* Rail item (collapsed sidebar section) */
.items { .sectionRailItem {
padding: 0 6px;
}
/* Nav item (flat links like Dashboards) */
.item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; justify-content: center;
padding: 7px 12px; padding: 10px 0;
border-radius: var(--radius-sm);
color: var(--sidebar-text);
font-size: 13px;
cursor: pointer; cursor: pointer;
transition: all 0.12s;
border-left: 3px solid transparent; border-left: 3px solid transparent;
margin-bottom: 1px; transition: background 0.12s;
user-select: none;
} }
.item:hover { .sectionRailItem:hover {
background: var(--sidebar-hover); background: var(--sidebar-hover);
color: #e8dfd4;
} }
.item.active { .sectionRailItemActive {
background: var(--sidebar-active);
color: var(--amber);
border-left-color: var(--amber); border-left-color: var(--amber);
} }
.navIcon { .sectionRailItemActive .sectionIcon {
font-size: 14px;
width: 18px;
text-align: center;
color: var(--sidebar-muted);
flex-shrink: 0;
}
.item.active .navIcon {
color: var(--amber); color: var(--amber);
} }
.routeArrow { .treeSectionActive {
color: var(--sidebar-muted); border-left-color: var(--amber);
font-size: 10px;
flex-shrink: 0;
} }
/* Item sub-elements */ /* Item sub-elements */
@@ -200,15 +206,7 @@
padding: 0 6px 6px; padding: 0 6px 6px;
margin-bottom: 2px; margin-bottom: 2px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12); border-bottom: 1px solid rgba(255, 255, 255, 0.12);
} border-left: 3px solid transparent;
.treeSectionLabel {
padding: 10px 12px 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--sidebar-muted);
} }
/* Collapsible section toggle */ /* Collapsible section toggle */
@@ -218,6 +216,8 @@
gap: 2px; gap: 2px;
width: 100%; width: 100%;
padding: 8px 0 4px; padding: 8px 0 4px;
cursor: pointer;
user-select: none;
} }
.treeSectionChevronBtn { .treeSectionChevronBtn {
@@ -239,7 +239,7 @@
} }
.treeSectionLabel { .treeSectionLabel {
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
@@ -314,7 +314,7 @@
padding: 0; padding: 0;
margin: 0; margin: 0;
color: var(--sidebar-muted); color: var(--sidebar-muted);
font-size: 11px; font-size: 12px;
cursor: pointer; cursor: pointer;
line-height: 1; line-height: 1;
display: flex; display: flex;
@@ -346,7 +346,7 @@
/* Badge */ /* Badge */
.treeBadge { .treeBadge {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
color: var(--sidebar-muted); color: var(--sidebar-muted);
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
padding: 1px 6px; padding: 1px 6px;
@@ -383,100 +383,13 @@
color: var(--amber); 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 links ────────────────────────────────────────────────────────── */
.bottom { .bottom {
border-top: 1px solid rgba(255, 255, 255, 0.06); border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 6px; padding: 6px;
flex-shrink: 0; flex-shrink: 0;
margin-top: auto;
} }
.bottomItem { .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 { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import { Sidebar, type SidebarApp } from './Sidebar' import { Sidebar } from './Sidebar'
import { ThemeProvider } from '../../providers/ThemeProvider' import { ThemeProvider } from '../../providers/ThemeProvider'
const TEST_APPS: SidebarApp[] = [ // ── Helpers ─────────────────────────────────────────────────────────────────
{
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: [],
},
]
function renderSidebar(props: Partial<Parameters<typeof Sidebar>[0]> = {}) { const LogoIcon = () => <svg data-testid="logo-icon" />
return render(
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider> <ThemeProvider>
<MemoryRouter> <MemoryRouter>{children}</MemoryRouter>
<Sidebar apps={TEST_APPS} {...props} /> </ThemeProvider>
</MemoryRouter>
</ThemeProvider>,
) )
} }
describe('Sidebar', () => { // ── Tests ────────────────────────────────────────────────────────────────────
beforeEach(() => {
localStorage.clear() describe('Sidebar compound component', () => {
sessionStorage.clear() // 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', () => { // 2. hides Header title and version when collapsed
renderSidebar() it('hides Header title and version when sidebar is collapsed', () => {
expect(screen.getByText('cameleer')).toBeInTheDocument() render(
expect(screen.getByText('v3.2.1')).toBeInTheDocument() <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', () => { // 3. renders Section with label and children
renderSidebar() it('renders Section with label and children when open', () => {
expect(screen.getByPlaceholderText('Filter...')).toBeInTheDocument() 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', () => { // 4. hides Section children when section collapsed (open=false)
renderSidebar() it('hides Section children when section is not open', () => {
expect(screen.getByText('Navigation')).toBeInTheDocument() 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', () => { // 5. calls onToggle when Section header clicked
renderSidebar() it('calls onToggle when Section chevron button is clicked', async () => {
expect(screen.getByText('Applications')).toBeInTheDocument() 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', () => { // 6. renders collapse toggle and calls onCollapseToggle
renderSidebar() it('renders collapse toggle button and calls onCollapseToggle when clicked', async () => {
expect(screen.getByText('Agents')).toBeInTheDocument() 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', () => { // 7. renders expand toggle label when collapsed
renderSidebar() it('renders expand toggle when sidebar is collapsed', () => {
expect(screen.getByText('Routes')).toBeInTheDocument() 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', () => { // 8. renders search input and calls onSearchChange
renderSidebar() 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('Admin')).toBeInTheDocument()
expect(screen.getByText('API Docs')).toBeInTheDocument()
}) })
it('renders app names in the Applications tree', () => { // 12. hides FooterLink labels when collapsed and sets title tooltip
renderSidebar() it('hides FooterLink label when collapsed and exposes title tooltip', () => {
// order-service appears in Applications, Routes, and Agents trees render(
expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1) <Wrapper>
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1) <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', () => { // 13. calls FooterLink onClick
renderSidebar() it('calls FooterLink onClick when clicked', async () => {
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 () => {
const user = userEvent.setup() const user = userEvent.setup()
renderSidebar() const onClick = vi.fn()
// Find the first app row (order-service in Applications tree) and hover to reveal star render(
const appRows = screen.getAllByText('order-service') <Wrapper>
const appRow = appRows[0].closest('[role="treeitem"]')! <Sidebar>
await user.hover(appRow) <Sidebar.Footer>
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" onClick={onClick} />
</Sidebar.Footer>
</Sidebar>
</Wrapper>,
)
// Click the star button await user.click(screen.getByText('Admin'))
const starBtn = appRow.querySelector('button[aria-label="Add to starred"]')! expect(onClick).toHaveBeenCalledTimes(1)
await user.click(starBtn)
expect(screen.getByText('★ Starred')).toBeInTheDocument()
}) })
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() const user = userEvent.setup()
renderSidebar() const onCollapseToggle = vi.fn()
const onToggle = vi.fn()
const searchInput = screen.getByPlaceholderText('Filter...') render(
await user.type(searchInput, 'payment') <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) const railItem = screen.getByTitle('Settings')
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1) await user.click(railItem)
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
expect(onToggle).toHaveBeenCalledTimes(1)
}) })
it('expands tree to show children when chevron is clicked', async () => { // 16. applies active highlight to FooterLink
const user = userEvent.setup() it('applies active highlight class to FooterLink when active', () => {
renderSidebar() 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 item = screen.getByText('Admin').closest('[role="button"]')!
const expandBtns = screen.getAllByLabelText('Expand') expect(item.className).toMatch(/bottomItemActive/)
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')
}) })
}) })

View File

@@ -1,563 +1,253 @@
import { useState, useEffect, useMemo } from 'react' import { type ReactNode, Children, isValidElement } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import {
import { Search, X, ChevronRight, ChevronDown, Settings, FileText } from 'lucide-react' Search,
X,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react'
import styles from './Sidebar.module.css' import styles from './Sidebar.module.css'
import camelLogoUrl from '../../../assets/camel-logo.svg' import { SidebarContext, useSidebarContext } from './SidebarContext'
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
import { useStarred } from './useStarred'
import { StatusDot } from '../../primitives/StatusDot/StatusDot'
// ── Types ──────────────────────────────────────────────────────────────────── // ── Sub-component props ─────────────────────────────────────────────────────
export interface SidebarApp { interface SidebarHeaderProps {
id: string logo: ReactNode
name: string title: string
health: 'live' | 'stale' | 'dead' version?: string
exchangeCount: number onClick?: () => void
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[]
className?: string className?: string
onNavigate?: (path: string) => void
} }
// ── Helpers ────────────────────────────────────────────────────────────────── interface SidebarSectionProps {
icon: ReactNode
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: <ChevronRight size={12} />,
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: <ChevronRight size={12} />,
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
label: string label: string
icon?: React.ReactNode open: boolean
path: string onToggle: () => void
type: 'application' | 'route' | 'agent' | 'routestat' active?: boolean
parentApp?: string children: ReactNode
className?: string
} }
function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): StarredItem[] { interface SidebarFooterProps {
const items: StarredItem[] = [] children: ReactNode
className?: string
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
} }
// ── StarredGroup ───────────────────────────────────────────────────────────── interface SidebarFooterLinkProps {
icon: ReactNode
function StarredGroup({
label,
items,
onNavigate,
onRemove,
}: {
label: string label: string
items: StarredItem[] active?: boolean
onNavigate: (path: string) => void onClick?: () => void
onRemove: (starKey: string) => 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 ( return (
<div className={styles.starredGroup}> <div
<div className={styles.starredGroupLabel}>{label}</div> className={`${styles.logo} ${className ?? ''}`}
{items.map((item) => ( onClick={onClick}
<div style={onClick ? { cursor: 'pointer' } : undefined}
key={item.starKey} role={onClick ? 'button' : undefined}
className={styles.starredItem} tabIndex={onClick ? 0 : undefined}
onClick={() => onNavigate(item.path)} onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
role="button" >
tabIndex={0} {logo}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path) }} {!collapsed && (
> <div>
{item.icon} <span className={styles.brand}>{title}</span>
<div className={styles.starredItemInfo}> {version && <span className={styles.version}>{version}</span>}
<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`}
>
<X size={12} />
</button>
</div> </div>
))} )}
</div> </div>
) )
} }
// ── Sidebar ────────────────────────────────────────────────────────────────── function SidebarSection({
icon,
label,
open,
onToggle,
active,
children,
className,
}: SidebarSectionProps) {
const { collapsed, onCollapseToggle } = useSidebarContext()
export function Sidebar({ apps, className, onNavigate }: SidebarProps) { // In icon-rail (collapsed) mode, render a centered icon with tooltip
const [search, setSearch] = useState('') if (collapsed) {
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true') return (
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true') <div
const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true') className={`${styles.sectionRailItem} ${active ? styles.sectionRailItemActive : ''} ${className ?? ''}`}
title={label}
const setAppsCollapsed = (updater: (v: boolean) => boolean) => { onClick={() => {
_setAppsCollapsed((prev) => { // Expand sidebar and open the section
const next = updater(prev) onCollapseToggle?.()
localStorage.setItem('cameleer:sidebar:apps-collapsed', String(next)) onToggle()
return next }}
}) role="button"
} tabIndex={0}
onKeyDown={(e) => {
const setAgentsCollapsed = (updater: (v: boolean) => boolean) => { if (e.key === 'Enter' || e.key === ' ') {
_setAgentsCollapsed((prev) => { onCollapseToggle?.()
const next = updater(prev) onToggle()
localStorage.setItem('cameleer:sidebar:agents-collapsed', String(next)) }
return next }}
}) >
} <span className={styles.sectionIcon}>{icon}</span>
</div>
const setRoutesCollapsed = (updater: (v: boolean) => boolean) => {
_setRoutesCollapsed((prev) => {
const next = updater(prev)
localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next))
return next
})
}
const routerNavigate = useNavigate()
const nav = onNavigate ?? routerNavigate
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),
) )
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 ( return (
<aside className={`${styles.sidebar} ${className ?? ''}`}> <div className={`${styles.treeSection} ${active ? styles.treeSectionActive : ''} ${className ?? ''}`}>
{/* Logo */} <div
<div className={styles.logo} onClick={() => nav('/apps')} style={{ cursor: 'pointer' }}> className={styles.treeSectionToggle}
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} /> onClick={onToggle}
<div> role="button"
<span className={styles.brand}>cameleer</span> tabIndex={0}
<span className={styles.version}>v3.2.1</span> aria-expanded={open}
</div> 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> </div>
{open && children}
{/* Search */} </div>
<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={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearch('')}
aria-label="Clear search"
>
<X size={12} />
</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 ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
onClick={() => nav('/apps')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/apps') }}
>
Applications
</span>
</div>
{!appsCollapsed && (
<SidebarTree
nodes={appNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:apps"
autoRevealPath={sidebarRevealPath}
onNavigate={onNavigate}
/>
)}
</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 ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
onClick={() => nav('/agents')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/agents') }}
>
Agents
</span>
</div>
{!agentsCollapsed && (
<SidebarTree
nodes={agentNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:agents"
autoRevealPath={sidebarRevealPath}
onNavigate={onNavigate}
/>
)}
</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 ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
onClick={() => nav('/routes')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/routes') }}
>
Routes
</span>
</div>
{!routesCollapsed && (
<SidebarTree
nodes={routeNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:routes"
autoRevealPath={sidebarRevealPath}
onNavigate={onNavigate}
/>
)}
</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={nav}
onRemove={toggleStar}
/>
)}
{starredRoutes.length > 0 && (
<StarredGroup
label="Routes"
items={starredRoutes}
onNavigate={nav}
onRemove={toggleStar}
/>
)}
{starredAgents.length > 0 && (
<StarredGroup
label="Agents"
items={starredAgents}
onNavigate={nav}
onRemove={toggleStar}
/>
)}
{starredRouteStats.length > 0 && (
<StarredGroup
label="Routes"
items={starredRouteStats}
onNavigate={nav}
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={() => nav('/admin')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/admin') }}
>
<span className={styles.bottomIcon}><Settings size={14} /></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={() => nav('/api-docs')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/api-docs') }}
>
<span className={styles.bottomIcon}><FileText size={14} /></span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>API Docs</div>
</div>
</div>
</div>
</aside>
) )
} }
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

@@ -124,6 +124,52 @@ function filterNodes(
return { filtered: walk(nodes), matchedParentIds } 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 ────────────────────────────────────────────────────────────── // ── SidebarTree ──────────────────────────────────────────────────────────────
export function SidebarTree({ export function SidebarTree({
@@ -222,64 +268,13 @@ export function SidebarTree({
const current = visibleNodes[currentIndex] const current = visibleNodes[currentIndex]
switch (e.key) { switch (e.key) {
case 'ArrowDown': { case 'ArrowDown': { e.preventDefault(); handleArrowDown(visibleNodes, currentIndex, focusNode); break }
e.preventDefault() case 'ArrowUp': { e.preventDefault(); handleArrowUp(visibleNodes, currentIndex, focusNode); break }
const next = visibleNodes[currentIndex + 1] case 'ArrowRight': { e.preventDefault(); handleArrowRight(current, currentIndex, expandedSet, visibleNodes, handleToggle, focusNode); break }
if (next) focusNode(next.node.id) case 'ArrowLeft': { e.preventDefault(); handleArrowLeft(current, expandedSet, handleToggle, focusNode); break }
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 'ArrowUp': { case 'End': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[visibleNodes.length - 1].node.id); break }
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
}
} }
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -371,6 +366,10 @@ function SidebarTreeRow({
return ( return (
<li role="none"> <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 <div
role="treeitem" role="treeitem"
aria-expanded={hasChildren ? isExpanded : undefined} aria-expanded={hasChildren ? isExpanded : undefined}

View File

@@ -0,0 +1,22 @@
import styles from './TopBar.module.css'
interface AutoRefreshToggleProps {
active: boolean
onChange: (active: boolean) => void
className?: string
}
export function AutoRefreshToggle({ active, onChange, className }: AutoRefreshToggleProps) {
return (
<button
className={`${styles.liveToggle} ${active ? styles.liveToggleActive : ''} ${className ?? ''}`}
onClick={() => onChange(!active)}
type="button"
aria-label={active ? 'Disable auto-refresh' : 'Enable auto-refresh'}
title={active ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
>
<span className={styles.liveDot} />
{active ? 'AUTO' : 'MANUAL'}
</button>
)
}

View File

@@ -0,0 +1,24 @@
import { Search } from 'lucide-react'
import styles from './TopBar.module.css'
interface SearchTriggerProps {
onClick: () => void
className?: string
}
export function SearchTrigger({ onClick, className }: SearchTriggerProps) {
return (
<button
className={`${styles.search} ${className ?? ''}`}
onClick={onClick}
type="button"
aria-label="Open search"
>
<span className={styles.searchIcon} aria-hidden="true">
<Search size={13} />
</span>
<span className={styles.searchPlaceholder}>Search... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span>
</button>
)
}

View File

@@ -62,7 +62,7 @@
.kbd { .kbd {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 0 4px; padding: 0 4px;
@@ -91,7 +91,7 @@
background: var(--bg-raised); background: var(--bg-raised);
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.5px; letter-spacing: 0.5px;
cursor: pointer; cursor: pointer;
@@ -153,16 +153,8 @@
} }
.env { .env {
font-family: var(--font-mono); display: flex;
font-size: 10px; align-items: center;
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;
} }
.user { .user {

View File

@@ -1,41 +1,32 @@
import { Search, Moon, Sun, Power } from 'lucide-react' import { type ReactNode } from 'react'
import { Moon, Sun, Power } from 'lucide-react'
import styles from './TopBar.module.css' import styles from './TopBar.module.css'
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb' import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
import { Dropdown } from '../../composites/Dropdown/Dropdown' import { Dropdown } from '../../composites/Dropdown/Dropdown'
import { Avatar } from '../../primitives/Avatar/Avatar' import { Avatar } from '../../primitives/Avatar/Avatar'
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
import { useTheme } from '../../providers/ThemeProvider' import { useTheme } from '../../providers/ThemeProvider'
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider' import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider' import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
interface TopBarProps { interface TopBarProps {
breadcrumb: BreadcrumbItem[] breadcrumb: BreadcrumbItem[]
environment?: string environment?: ReactNode
user?: { name: string } user?: { name: string }
userMenuItems?: import('../../composites/Dropdown/Dropdown').DropdownItem[]
onLogout?: () => void onLogout?: () => void
className?: string className?: string
children?: ReactNode
} }
const STATUS_ITEMS: ButtonGroupItem[] = [
{ value: 'completed', label: 'OK', color: 'var(--success)' },
{ value: 'warning', label: 'Warn', color: 'var(--warning)' },
{ value: 'failed', label: 'Error', color: 'var(--error)' },
{ value: 'running', label: 'Running', color: 'var(--running)' },
]
export function TopBar({ export function TopBar({
breadcrumb, breadcrumb,
environment, environment,
user, user,
userMenuItems,
onLogout, onLogout,
className, className,
children,
}: TopBarProps) { }: TopBarProps) {
const globalFilters = useGlobalFilters()
const commandPalette = useCommandPalette()
const { theme, toggleTheme } = useTheme() const { theme, toggleTheme } = useTheme()
const breadcrumbOverride = useBreadcrumbOverride() const breadcrumbOverride = useBreadcrumbOverride()
@@ -44,54 +35,11 @@ export function TopBar({
{/* Left: Breadcrumb */} {/* Left: Breadcrumb */}
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} /> <Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
{/* Search trigger */} {/* Center: consumer-provided controls */}
<button {children}
className={styles.search}
onClick={() => commandPalette.setOpen(true)}
type="button"
aria-label="Open search"
>
<span className={styles.searchIcon} aria-hidden="true">
<Search size={13} />
</span>
<span className={styles.searchPlaceholder}>Search... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span>
</button>
{/* Status filter group */} {/* Right: theme toggle, env badge, user */}
<ButtonGroup
items={STATUS_ITEMS}
value={globalFilters.statusFilters}
onChange={(selected) => {
// Sync with global filter by toggling the diff
const current = globalFilters.statusFilters
for (const v of selected) {
if (!current.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
for (const v of current) {
if (!selected.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
}}
/>
{/* Time range pills */}
<TimeRangeDropdown
value={globalFilters.timeRange}
onChange={globalFilters.setTimeRange}
/>
{/* Right: auto-refresh toggle, theme toggle, env badge, user */}
<div className={styles.right}> <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 ? 'LIVE' : 'PAUSED'}
</button>
<button <button
className={styles.themeToggle} className={styles.themeToggle}
onClick={toggleTheme} onClick={toggleTheme}
@@ -102,7 +50,7 @@ export function TopBar({
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />} {theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
</button> </button>
{environment && ( {environment && (
<span className={styles.env}>{environment}</span> <div className={styles.env}>{environment}</div>
)} )}
{user && ( {user && (
<Dropdown <Dropdown
@@ -113,6 +61,8 @@ export function TopBar({
</div> </div>
} }
items={[ items={[
...(userMenuItems ?? []),
...(userMenuItems?.length ? [{ label: '', divider: true }] : []),
{ label: 'Logout', icon: <Power size={14} />, onClick: onLogout }, { label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
]} ]}
/> />

View File

@@ -1,4 +1,8 @@
export { AppShell } from './AppShell/AppShell' export { AppShell } from './AppShell/AppShell'
export { Sidebar } from './Sidebar/Sidebar' 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' export { TopBar } from './TopBar/TopBar'
export { SearchTrigger } from './TopBar/SearchTrigger'
export { AutoRefreshToggle } from './TopBar/AutoRefreshToggle'

View File

@@ -8,5 +8,5 @@
flex-shrink: 0; flex-shrink: 0;
} }
.sm { width: 24px; height: 24px; font-size: 9px; } .sm { width: 24px; height: 24px; font-size: 9px; }
.md { width: 28px; height: 28px; font-size: 11px; } .md { width: 28px; height: 28px; font-size: 12px; }
.lg { width: 40px; height: 40px; font-size: 14px; } .lg { width: 40px; height: 40px; font-size: 14px; }

View File

@@ -5,7 +5,7 @@
padding: 1px 8px; padding: 1px 8px;
border-radius: 10px; border-radius: 10px;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
border: 1px solid transparent; border: 1px solid transparent;

View File

@@ -13,7 +13,7 @@
.btn:disabled { opacity: 0.6; cursor: not-allowed; } .btn:disabled { opacity: 0.6; cursor: not-allowed; }
.sm { padding: 4px 10px; font-size: 11px; } .sm { padding: 4px 10px; font-size: 12px; }
.md { padding: 6px 14px; font-size: 12px; } .md { padding: 6px 14px; font-size: 12px; }
.primary { .primary {

View File

@@ -18,7 +18,7 @@
} }
.titleText { .titleText {
font-size: 11px; font-size: 12px;
text-transform: uppercase; text-transform: uppercase;
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: 600; font-weight: 600;

View File

@@ -26,7 +26,7 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
@@ -49,5 +49,5 @@
user-select: none; user-select: none;
text-align: right; text-align: right;
margin-right: 12px; margin-right: 12px;
font-size: 11px; font-size: 12px;
} }

View File

@@ -5,7 +5,7 @@
} }
.label { .label {
font-size: 11px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: var(--text-muted); color: var(--text-muted);
text-transform: uppercase; text-transform: uppercase;
@@ -18,7 +18,7 @@
background: transparent; background: transparent;
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 12px;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -72,7 +72,7 @@
border: none; border: none;
background: none; background: none;
color: var(--text-muted); color: var(--text-muted);
font-size: 10px; font-size: 12px;
cursor: pointer; cursor: pointer;
padding: 4px 8px; padding: 4px 8px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -93,7 +93,7 @@
} }
.dayHeader { .dayHeader {
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--text-faint); color: var(--text-faint);
text-align: center; text-align: center;
@@ -151,7 +151,7 @@
} }
.timeLabel { .timeLabel {
font-size: 11px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: var(--text-muted); color: var(--text-muted);
margin-right: auto; margin-right: auto;

View File

@@ -0,0 +1,64 @@
.wrap {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1.5px dashed var(--border);
border-radius: var(--radius-sm);
background: var(--bg-inset);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.wrap:hover {
border-color: var(--text-muted);
}
.dragOver {
border-color: var(--amber);
background: var(--amber-bg);
}
.icon {
color: var(--text-faint);
flex-shrink: 0;
line-height: 0;
}
.label {
font-family: var(--font-body);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.placeholder {
color: var(--text-faint);
}
.fileName {
color: var(--text-primary);
}
.clearBtn {
margin-left: auto;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
background: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-sm);
padding: 0;
transition: color 0.1s, background 0.1s;
}
.clearBtn:hover {
color: var(--text-primary);
background: var(--bg-hover);
}

View File

@@ -0,0 +1,99 @@
import styles from './FileInput.module.css'
import { forwardRef, useRef, useState, useImperativeHandle, type ReactNode } from 'react'
import { X } from 'lucide-react'
export interface FileInputProps {
/** File type filter, e.g. ".pem,.crt,.cer" */
accept?: string
/** Icon rendered before the label */
icon?: ReactNode
/** Placeholder text when no file is selected */
placeholder?: string
/** Additional CSS class */
className?: string
/** Called when a file is selected or cleared */
onChange?: (file: File | null) => void
}
export interface FileInputHandle {
/** The currently selected File, or null */
file: File | null
/** Programmatically clear the selection */
clear: () => void
}
export const FileInput = forwardRef<FileInputHandle, FileInputProps>(
({ accept, icon, placeholder = 'Drop file or click to browse', className, onChange }, ref) => {
const inputRef = useRef<HTMLInputElement>(null)
const [fileName, setFileName] = useState<string | null>(null)
const [dragOver, setDragOver] = useState(false)
const fileRef = useRef<File | null>(null)
function select(file: File | null) {
fileRef.current = file
setFileName(file?.name ?? null)
onChange?.(file)
}
function handleInputChange() {
select(inputRef.current?.files?.[0] ?? null)
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files[0]
if (file && inputRef.current) {
const dt = new DataTransfer()
dt.items.add(file)
inputRef.current.files = dt.files
select(file)
}
}
function handleClear(e: React.MouseEvent) {
e.stopPropagation()
if (inputRef.current) inputRef.current.value = ''
select(null)
}
useImperativeHandle(ref, () => ({
get file() { return fileRef.current },
clear() {
if (inputRef.current) inputRef.current.value = ''
select(null)
},
}))
const wrapClass = [styles.wrap, dragOver && styles.dragOver, className].filter(Boolean).join(' ')
return (
<div
className={wrapClass}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
{icon && <span className={styles.icon}>{icon}</span>}
<span className={`${styles.label} ${fileName ? styles.fileName : styles.placeholder}`}>
{fileName ?? placeholder}
</span>
{fileName && (
<button type="button" className={styles.clearBtn} onClick={handleClear} aria-label="Clear file">
<X size={12} />
</button>
)}
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleInputChange}
style={{ display: 'none' }}
/>
</div>
)
},
)
FileInput.displayName = 'FileInput'

View File

@@ -8,7 +8,7 @@
background: var(--bg-raised); background: var(--bg-raised);
color: var(--text-secondary); color: var(--text-secondary);
font-family: var(--font-body); font-family: var(--font-body);
font-size: 11px; font-size: 12px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
@@ -49,7 +49,7 @@
.count { .count {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
background: var(--bg-inset); background: var(--bg-inset);
border-radius: 8px; border-radius: 8px;

View File

@@ -6,13 +6,13 @@
} }
.hint { .hint {
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
margin-top: 4px; margin-top: 4px;
} }
.error { .error {
font-size: 11px; font-size: 12px;
color: var(--error); color: var(--error);
margin-top: 4px; margin-top: 4px;
} }

View File

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

View File

@@ -1,6 +1,6 @@
.kbd { .kbd {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
background: var(--bg-raised); background: var(--bg-raised);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 3px; border-radius: 3px;

View File

@@ -1,4 +1,4 @@
.mono { font-family: var(--font-mono); } .mono { font-family: var(--font-mono); }
.xs { font-size: 10px; } .xs { font-size: 12px; }
.sm { font-size: 11px; } .sm { font-size: 12px; }
.md { font-size: 13px; } .md { font-size: 13px; }

View File

@@ -7,7 +7,7 @@
.label { .label {
font-family: var(--font-body); font-family: var(--font-body);
font-size: 11px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1; line-height: 1;
} }

View File

@@ -5,7 +5,7 @@
} }
.label { .label {
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1.2px; letter-spacing: 1.2px;

View File

@@ -35,6 +35,6 @@
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
color: var(--text-faint); color: var(--text-faint);
font-size: 11px; font-size: 12px;
pointer-events: none; pointer-events: none;
} }

View File

@@ -5,3 +5,8 @@
border-radius: 50%; border-radius: 50%;
animation: spin 0.6s linear infinite; animation: spin 0.6s linear infinite;
} }
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@@ -21,7 +21,7 @@
} }
.label { .label {
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1.2px; letter-spacing: 1.2px;
@@ -47,7 +47,7 @@
} }
.trend { .trend {
font-size: 11px; font-size: 12px;
font-weight: 500; font-weight: 500;
} }
@@ -56,7 +56,7 @@
.trend.neutral { color: var(--text-muted); } .trend.neutral { color: var(--text-muted); }
.detail { .detail {
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
margin-top: 6px; margin-top: 6px;
} }

View File

@@ -5,7 +5,7 @@
padding: 3px 10px; padding: 3px 10px;
border: 1px solid; border: 1px solid;
border-radius: 20px; border-radius: 20px;
font-size: 11px; font-size: 12px;
font-family: var(--font-mono); font-family: var(--font-mono);
} }

View File

@@ -10,7 +10,7 @@
padding: 5px 9px; padding: 5px 9px;
background: var(--text-primary); background: var(--text-primary);
color: var(--bg-surface); color: var(--bg-surface);
font-size: 11px; font-size: 12px;
font-family: var(--font-body); font-family: var(--font-body);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
white-space: nowrap; white-space: nowrap;

View File

@@ -11,6 +11,8 @@ export { Collapsible } from './Collapsible/Collapsible'
export { DateRangePicker } from './DateRangePicker/DateRangePicker' export { DateRangePicker } from './DateRangePicker/DateRangePicker'
export { DateTimePicker } from './DateTimePicker/DateTimePicker' export { DateTimePicker } from './DateTimePicker/DateTimePicker'
export { EmptyState } from './EmptyState/EmptyState' export { EmptyState } from './EmptyState/EmptyState'
export { FileInput } from './FileInput/FileInput'
export type { FileInputProps, FileInputHandle } from './FileInput/FileInput'
export { FilterPill } from './FilterPill/FilterPill' export { FilterPill } from './FilterPill/FilterPill'
export { FormField } from './FormField/FormField' export { FormField } from './FormField/FormField'
export { InfoCallout } from './InfoCallout/InfoCallout' export { InfoCallout } from './InfoCallout/InfoCallout'

View File

@@ -12,6 +12,7 @@ export type ExchangeStatus = 'completed' | 'failed' | 'running' | 'warning'
interface GlobalFilterContextValue { interface GlobalFilterContextValue {
timeRange: TimeRange timeRange: TimeRange
setTimeRange: (range: TimeRange) => void setTimeRange: (range: TimeRange) => void
refreshTimeRange: () => void
statusFilters: Set<ExchangeStatus> statusFilters: Set<ExchangeStatus>
toggleStatus: (status: ExchangeStatus) => void toggleStatus: (status: ExchangeStatus) => void
clearStatusFilters: () => void clearStatusFilters: () => void
@@ -76,6 +77,14 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
return () => clearInterval(id) return () => clearInterval(id)
}, [autoRefresh, timeRange.preset]) }, [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( const isInTimeRange = useCallback(
(timestamp: Date) => { (timestamp: Date) => {
if (timeRange.preset) { if (timeRange.preset) {
@@ -90,7 +99,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
return ( return (
<GlobalFilterContext.Provider <GlobalFilterContext.Provider
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }} value={{ timeRange, setTimeRange, refreshTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
> >
{children} {children}
</GlobalFilterContext.Provider> </GlobalFilterContext.Provider>

View File

@@ -46,6 +46,7 @@
/* Typography */ /* Typography */
--font-body: 'DM Sans', system-ui, -apple-system, sans-serif; --font-body: 'DM Sans', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace; --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--font-size-min: 12px;
/* Spacing & Radii */ /* Spacing & Radii */
--radius-sm: 5px; --radius-sm: 5px;

View File

@@ -1,22 +1,18 @@
import { CHART_COLORS } from '../composites/_chart-utils' export const CHART_COLORS = [
'var(--chart-1)',
'var(--chart-2)',
'var(--chart-3)',
'var(--chart-4)',
'var(--chart-5)',
'var(--chart-6)',
'var(--chart-7)',
'var(--chart-8)',
]
/** /**
* Pre-configured Recharts prop objects that match the design system's * Pre-configured Recharts prop objects that match the design system's
* chart styling. Spread these onto Recharts sub-components: * chart styling. Used internally by ThemedChart and available for
* * consumers composing Recharts directly.
* ```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 = { export const rechartsTheme = {
colors: CHART_COLORS, colors: CHART_COLORS,

451
src/layout/LayoutShell.tsx Normal file
View File

@@ -0,0 +1,451 @@
import { useState, useEffect, useMemo, type ReactNode } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight, X } from 'lucide-react'
import { AppShell } from '../design-system/layout/AppShell/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 { StatusDot } from '../design-system/primitives/StatusDot/StatusDot'
import { SIDEBAR_APPS } from '../mocks/sidebar'
import type { SidebarApp } from '../mocks/sidebar'
import camelLogoUrl from '../assets/camel-logo.svg'
// ── Helpers ─────────────────────────────────────────────────────────────────
function formatCount(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
return String(n)
}
// ── Tree node builders ──────────────────────────────────────────────────────
function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps.map((app) => ({
id: app.id,
label: app.name,
icon: <StatusDot status={app.health} />,
badge: formatCount(app.exchangeCount),
path: `/apps/${app.id}`,
starrable: true,
starKey: `app:${app.id}`,
children: app.routes.map((route) => ({
id: `${app.id}/${route.id}`,
label: route.name,
icon: <ChevronRight size={12} />,
badge: formatCount(route.exchangeCount),
path: `/apps/${app.id}/${route.id}`,
})),
}))
}
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps
.filter((app) => app.routes.length > 0)
.map((app) => ({
id: `routes:${app.id}`,
label: app.name,
icon: <StatusDot status={app.health} />,
badge: `${app.routes.length} route${app.routes.length !== 1 ? 's' : ''}`,
path: `/routes/${app.id}`,
starrable: true,
starKey: `routestat:${app.id}`,
children: app.routes.map((route) => ({
id: `routes:${app.id}/${route.id}`,
label: route.name,
icon: <ChevronRight size={12} />,
badge: formatCount(route.exchangeCount),
path: `/routes/${app.id}/${route.id}`,
})),
}))
}
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 status={app.health} />,
badge: `${liveCount}/${app.agents.length} live`,
path: `/agents/${app.id}`,
starrable: true,
starKey: `agent:${app.id}`,
children: app.agents.map((agent) => ({
id: `agents:${app.id}/${agent.id}`,
label: agent.name,
icon: <StatusDot status={agent.status} />,
badge: `${agent.tps} tps`,
path: `/agents/${app.id}/${agent.id}`,
})),
}
})
}
// ── Starred items ───────────────────────────────────────────────────────────
interface StarredItem {
starKey: string
label: string
icon?: ReactNode
path: string
type: 'application' | 'route' | 'agent' | 'routestat'
parentApp?: string
}
function collectStarredItems(
apps: SidebarApp[],
starredIds: Set<string>,
): StarredItem[] {
const items: StarredItem[] = []
for (const app of apps) {
if (starredIds.has(`app:${app.id}`)) {
items.push({
starKey: `app:${app.id}`,
label: app.name,
icon: <Box size={12} />,
path: `/apps/${app.id}`,
type: 'application',
})
}
for (const route of app.routes) {
if (starredIds.has(`route:${app.id}/${route.id}`)) {
items.push({
starKey: `route:${app.id}/${route.id}`,
label: route.name,
icon: <ChevronRight size={12} />,
path: `/apps/${app.id}/${route.id}`,
type: 'route',
parentApp: app.name,
})
}
}
if (starredIds.has(`routestat:${app.id}`)) {
items.push({
starKey: `routestat:${app.id}`,
label: app.name,
icon: <GitBranch size={12} />,
path: `/routes/${app.id}`,
type: 'routestat',
})
}
if (starredIds.has(`agent:${app.id}`)) {
items.push({
starKey: `agent:${app.id}`,
label: app.name,
icon: <Cpu size={12} />,
path: `/agents/${app.id}`,
type: 'agent',
})
}
}
return items
}
// ── Starred group component ─────────────────────────────────────────────────
interface StarredGroupProps {
label: string
items: StarredItem[]
onRemove: (starKey: string) => void
onNavigate: (path: string) => void
}
function StarredGroup({ label, items, onRemove, onNavigate }: StarredGroupProps) {
if (items.length === 0) return null
return (
<div style={{ marginBottom: 8 }}>
<div
style={{
fontSize: 10,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--text-tertiary)',
padding: '4px 12px',
}}
>
{label}
</div>
{items.map((item) => (
<div
key={item.starKey}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '4px 12px',
cursor: 'pointer',
fontSize: 12,
color: 'var(--text-secondary)',
borderRadius: 4,
}}
onClick={() => onNavigate(item.path)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path)
}}
>
{item.icon && (
<span style={{ display: 'flex', alignItems: 'center', color: 'var(--text-tertiary)' }}>
{item.icon}
</span>
)}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.label}
{item.parentApp && (
<span style={{ color: 'var(--text-tertiary)', marginLeft: 4, fontSize: 10 }}>
{item.parentApp}
</span>
)}
</span>
<button
style={{
background: 'none',
border: 'none',
padding: 2,
cursor: 'pointer',
color: 'var(--text-tertiary)',
display: 'flex',
alignItems: 'center',
opacity: 0.6,
}}
onClick={(e) => {
e.stopPropagation()
onRemove(item.starKey)
}}
aria-label={`Remove ${item.label} from starred`}
>
<X size={10} />
</button>
</div>
))}
</div>
)
}
// ── localStorage-backed section collapse ────────────────────────────────────
function usePersistedCollapse(key: string, defaultValue: boolean): [boolean, () => void] {
const [value, setValue] = useState(() => {
try {
const raw = localStorage.getItem(key)
if (raw !== null) return raw === 'true'
} catch { /* ignore */ }
return defaultValue
})
const toggle = () => {
setValue((prev) => {
const next = !prev
try {
localStorage.setItem(key, String(next))
} catch { /* ignore */ }
return next
})
}
return [value, toggle]
}
// ── LayoutShell ─────────────────────────────────────────────────────────────
export function LayoutShell() {
const navigate = useNavigate()
const location = useLocation()
const { starredIds, isStarred, toggleStar } = useStarred()
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [filterQuery, setFilterQuery] = useState('')
// Section collapse state — persisted to localStorage
const [appsCollapsed, toggleAppsCollapsed] = usePersistedCollapse('cameleer:sidebar:apps-collapsed', false)
const [agentsCollapsed, toggleAgentsCollapsed] = usePersistedCollapse('cameleer:sidebar:agents-collapsed', false)
const [routesCollapsed, toggleRoutesCollapsed] = usePersistedCollapse('cameleer:sidebar:routes-collapsed', false)
// Tree data — static, so empty deps
const appNodes = useMemo(() => buildAppTreeNodes(SIDEBAR_APPS), [])
const agentNodes = useMemo(() => buildAgentTreeNodes(SIDEBAR_APPS), [])
const routeNodes = useMemo(() => buildRouteTreeNodes(SIDEBAR_APPS), [])
// Sidebar reveal from Cmd-K navigation
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
// Auto-uncollapse matching sections when sidebarRevealPath changes
useEffect(() => {
if (!sidebarRevealPath) return
if (sidebarRevealPath.startsWith('/apps') && appsCollapsed) {
toggleAppsCollapsed()
}
if (sidebarRevealPath.startsWith('/agents') && agentsCollapsed) {
toggleAgentsCollapsed()
}
if (sidebarRevealPath.startsWith('/routes') && routesCollapsed) {
toggleRoutesCollapsed()
}
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
const effectiveSelectedPath = sidebarRevealPath ?? location.pathname
// Starred items — collected and grouped
const allStarred = useMemo(
() => collectStarredItems(SIDEBAR_APPS, starredIds),
[starredIds],
)
const starredApps = allStarred.filter((s) => s.type === 'application')
const starredRoutes = allStarred.filter((s) => s.type === 'route')
const starredAgents = allStarred.filter((s) => s.type === 'agent')
const starredRouteStats = allStarred.filter((s) => s.type === 'routestat')
const hasStarred = allStarred.length > 0
const camelLogo = (
<img
src={camelLogoUrl}
alt=""
aria-hidden="true"
style={{
width: 28,
height: 24,
filter:
'brightness(0) saturate(100%) invert(76%) sepia(30%) saturate(400%) hue-rotate(5deg) brightness(95%)',
}}
/>
)
return (
<AppShell
sidebar={
<Sidebar
collapsed={sidebarCollapsed}
onCollapseToggle={() => setSidebarCollapsed((c) => !c)}
searchValue={filterQuery}
onSearchChange={setFilterQuery}
>
<Sidebar.Header
logo={camelLogo}
title="cameleer"
version="v3.2.1"
onClick={() => navigate('/apps')}
/>
<Sidebar.Section
label="Applications"
icon={<Box size={14} />}
open={!appsCollapsed}
onToggle={toggleAppsCollapsed}
active={location.pathname.startsWith('/apps')}
>
<SidebarTree
nodes={appNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="cameleer:expanded:apps"
autoRevealPath={sidebarRevealPath}
/>
</Sidebar.Section>
<Sidebar.Section
label="Agents"
icon={<Cpu size={14} />}
open={!agentsCollapsed}
onToggle={toggleAgentsCollapsed}
active={location.pathname.startsWith('/agents')}
>
<SidebarTree
nodes={agentNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="cameleer:expanded:agents"
autoRevealPath={sidebarRevealPath}
/>
</Sidebar.Section>
<Sidebar.Section
label="Routes"
icon={<GitBranch size={14} />}
open={!routesCollapsed}
onToggle={toggleRoutesCollapsed}
active={location.pathname.startsWith('/routes')}
>
<SidebarTree
nodes={routeNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="cameleer:expanded:routes"
autoRevealPath={sidebarRevealPath}
/>
</Sidebar.Section>
{hasStarred && (
<Sidebar.Section
label="\u2605 Starred"
icon={<span />}
open={true}
onToggle={() => {}}
active={false}
>
<StarredGroup
label="Applications"
items={starredApps}
onRemove={toggleStar}
onNavigate={navigate}
/>
<StarredGroup
label="Routes"
items={starredRoutes}
onRemove={toggleStar}
onNavigate={navigate}
/>
<StarredGroup
label="Route Stats"
items={starredRouteStats}
onRemove={toggleStar}
onNavigate={navigate}
/>
<StarredGroup
label="Agents"
items={starredAgents}
onRemove={toggleStar}
onNavigate={navigate}
/>
</Sidebar.Section>
)}
<Sidebar.Footer>
<Sidebar.FooterLink
icon={<Settings size={14} />}
label="Admin"
onClick={() => navigate('/admin')}
active={location.pathname.startsWith('/admin')}
/>
<Sidebar.FooterLink
icon={<FileText size={14} />}
label="API Docs"
onClick={() => navigate('/api-docs')}
active={location.pathname === '/api-docs'}
/>
</Sidebar.Footer>
</Sidebar>
}
>
<Outlet />
</AppShell>
)
}

View File

@@ -3,34 +3,7 @@ import { exchanges, type Exchange } from './exchanges'
import { routes } from './routes' import { routes } from './routes'
import { agents } from './agents' import { agents } from './agents'
import { SIDEBAR_APPS, buildRouteToAppMap, type SidebarApp } from './sidebar' import { SIDEBAR_APPS, buildRouteToAppMap, type SidebarApp } from './sidebar'
import { formatDuration, statusLabel, statusToVariant, formatTimestamp } from '../utils/format-utils'
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
return `${ms}ms`
}
function statusLabel(status: Exchange['status']): string {
switch (status) {
case 'completed': return 'OK'
case 'failed': return 'ERR'
case 'running': return 'RUN'
case 'warning': return 'WARN'
}
}
function statusToVariant(status: Exchange['status']): string {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'running': return 'running'
case 'warning': return 'warning'
}
}
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function healthToColor(health: SidebarApp['health']): string { function healthToColor(health: SidebarApp['health']): string {
switch (health) { switch (health) {

View File

@@ -1,9 +1,6 @@
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar' import { TopBar } from '../../design-system/layout/TopBar/TopBar'
import { Tabs } from '../../design-system/composites/Tabs/Tabs' import { Tabs } from '../../design-system/composites/Tabs/Tabs'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
import styles from './Admin.module.css' import styles from './Admin.module.css'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
@@ -23,7 +20,7 @@ export function AdminLayout({ title, children }: AdminLayoutProps) {
const location = useLocation() const location = useLocation()
return ( return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}> <>
<TopBar <TopBar
breadcrumb={[ breadcrumb={[
{ label: 'Admin', href: '/admin' }, { label: 'Admin', href: '/admin' },
@@ -40,6 +37,6 @@ export function AdminLayout({ title, children }: AdminLayoutProps) {
<div className={styles.adminContent}> <div className={styles.adminContent}>
{children} {children}
</div> </div>
</AppShell> </>
) )
} }

View File

@@ -42,7 +42,7 @@
} }
.tableMeta { .tableMeta {
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
} }
@@ -72,7 +72,7 @@
} }
.detailLabel { .detailLabel {
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.8px; letter-spacing: 0.8px;

View File

@@ -24,7 +24,7 @@
} }
.hint { .hint {
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font-body); font-family: var(--font-body);
} }

View File

@@ -11,7 +11,7 @@
} }
.entityMeta { .entityMeta {
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font-body); font-family: var(--font-body);
margin-top: 2px; margin-top: 2px;
@@ -101,7 +101,7 @@
} }
.inheritedNote { .inheritedNote {
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
font-style: italic; font-style: italic;
font-family: var(--font-body); font-family: var(--font-body);

View File

@@ -19,7 +19,7 @@
.breakdown { .breakdown {
display: flex; display: flex;
gap: 8px; gap: 8px;
font-size: 11px; font-size: 12px;
font-family: var(--font-mono); font-family: var(--font-mono);
} }
@@ -52,7 +52,7 @@
.scopeSep { .scopeSep {
color: var(--text-muted); color: var(--text-muted);
font-size: 10px; font-size: 12px;
} }
.scopeCurrent { .scopeCurrent {
@@ -76,7 +76,7 @@
} }
.sectionMeta { .sectionMeta {
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font-mono); font-family: var(--font-mono);
} }
@@ -101,7 +101,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
} }
@@ -118,7 +118,7 @@
gap: 8px; gap: 8px;
padding: 8px 16px; padding: 8px 16px;
background: var(--error-bg); background: var(--error-bg);
font-size: 11px; font-size: 12px;
color: var(--error); color: var(--error);
font-weight: 500; font-weight: 500;
} }
@@ -192,7 +192,7 @@
} }
.chartTitle { .chartTitle {
font-size: 11px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;

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