Compare commits

...

40 Commits

Author SHA1 Message Date
hsiegeln
3ef4c5686e fix(global-filter): keep time range sliding when auto-refresh is paused
All checks were successful
Build & Publish / publish (push) Successful in 1m6s
The preset time window now advances on a 10s interval regardless of
auto-refresh state. Pausing only stops query polling — the window
stays current so manual refreshes see up-to-date data.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:25:43 +01:00
hsiegeln
2ffc268b44 feat: add attribute search category to CommandPalette
All checks were successful
Build & Publish / publish (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:03:57 +01:00
54 changed files with 4644 additions and 1342 deletions

View File

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

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'
@@ -104,6 +112,10 @@ import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
// Utils // Utils
import { hashColor } from '@cameleer/design-system' import { hashColor } from '@cameleer/design-system'
// Recharts theme (for advanced charts — treemap, radar, heatmap, etc.)
import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
import type { ChartSeries, DataPoint } from '@cameleer/design-system'
// Styles (once, at app root) // Styles (once, at app root)
import '@cameleer/design-system/style.css' import '@cameleer/design-system/style.css'
``` ```

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**
@@ -55,6 +57,7 @@
- Time series → **LineChart**, **AreaChart** - Time series → **LineChart**, **AreaChart**
- Categorical comparison → **BarChart** - Categorical comparison → **BarChart**
- Inline trend → **Sparkline** - Inline trend → **Sparkline**
- Advanced charts (treemap, radar, heatmap, pie, etc.) → **Recharts** with `rechartsTheme` (see [Charting Strategy](#charting-strategy))
- Event log → **EventFeed** - Event log → **EventFeed**
- Processing pipeline (Gantt view) → **ProcessorTimeline** - Processing pipeline (Gantt view) → **ProcessorTimeline**
- Processing pipeline (flow diagram) → **RouteFlow** - Processing pipeline (flow diagram) → **RouteFlow**
@@ -98,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
@@ -160,6 +180,53 @@ StatCard strip (top, recalculates per scope)
URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/:instanceId URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/:instanceId
``` ```
## Charting Strategy
The design system includes built-in **AreaChart**, **BarChart**, **LineChart**, and **Sparkline** components for standard use cases. For advanced chart types (treemap, radar, heatmap, pie, sankey, etc.), consuming apps should use **Recharts** directly with the design system's theme preset for visual consistency.
**Recharts is the app's dependency, not the design system's.** The design system only exports a theme config object.
### Setup in consuming app
```bash
npm install recharts
```
### Usage with theme preset
```tsx
import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
import {
ResponsiveContainer, LineChart, Line,
CartesianGrid, XAxis, YAxis, Tooltip, Legend,
} from 'recharts'
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid {...rechartsTheme.cartesianGrid} />
<XAxis dataKey="name" {...rechartsTheme.xAxis} />
<YAxis {...rechartsTheme.yAxis} />
<Tooltip {...rechartsTheme.tooltip} />
<Legend {...rechartsTheme.legend} />
<Line dataKey="value" stroke={CHART_COLORS[0]} strokeWidth={2} dot={false} />
<Line dataKey="other" stroke={CHART_COLORS[1]} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
```
### Exports
| Export | Description |
|--------|-------------|
| `rechartsTheme.cartesianGrid` | Dashed gridlines, subtle stroke |
| `rechartsTheme.xAxis` | Mono font axis ticks, subtle color |
| `rechartsTheme.yAxis` | Mono font axis ticks, no axis line |
| `rechartsTheme.tooltip` | Surface bg, border, shadow, monospace values |
| `rechartsTheme.legend` | Matching text size and color |
| `rechartsTheme.colors` | The 8 `CHART_COLORS` (CSS variables with light/dark support) |
| `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` |
| `ChartSeries` / `DataPoint` | Type interfaces for chart data |
## Component Index ## Component Index
| Component | Layer | When to use | | Component | Layer | When to use |
@@ -179,7 +246,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| 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 |
@@ -236,7 +303,9 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| Component | Purpose | | Component | Purpose |
|-----------|---------| |-----------|---------|
| AppShell | Page shell: sidebar + topbar + main + optional detail panel | | AppShell | Page shell: sidebar + topbar + main + optional detail panel |
| Sidebar | Hierarchical navigation with Applications/Agents/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. |
| 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 badge, user avatar | | TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
## Import Paths ## Import Paths
@@ -248,6 +317,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'
``` ```

File diff suppressed because it is too large Load Diff

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.

737
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "@cameleer/design-system", "name": "@cameleer/design-system",
"version": "0.1.6", "version": "0.1.6",
"dependencies": { "dependencies": {
"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"
@@ -20,6 +21,7 @@
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^3.2.4",
"happy-dom": "^20.8.4", "happy-dom": "^20.8.4",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vite": "^6.0.0", "vite": "^6.0.0",
@@ -39,6 +41,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -331,6 +347,16 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -773,6 +799,34 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -931,6 +985,17 @@
"resolve": "~1.22.2" "resolve": "~1.22.2"
} }
}, },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.58.2", "version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
@@ -1697,6 +1762,40 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/coverage-v8": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
"integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"@bcoe/v8-coverage": "^1.0.2",
"ast-v8-to-istanbul": "^0.3.3",
"debug": "^4.4.1",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.6",
"istanbul-reports": "^3.1.7",
"magic-string": "^0.30.17",
"magicast": "^0.3.5",
"std-env": "^3.9.0",
"test-exclude": "^7.0.1",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "3.2.4",
"vitest": "3.2.4"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -1917,9 +2016,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vue/language-core/node_modules/brace-expansion": { "node_modules/@vue/language-core/node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2025,7 +2124,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -2074,6 +2172,25 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/ast-v8-to-istanbul": {
"version": "0.3.12",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
"integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -2098,9 +2215,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.4", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2202,6 +2319,26 @@
"node": ">= 16" "node": ">= 16"
} }
}, },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/compare-versions": { "node_modules/compare-versions": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
@@ -2236,6 +2373,21 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/css.escape": { "node_modules/css.escape": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -2313,6 +2465,13 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.313", "version": "1.5.313",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
@@ -2320,6 +2479,13 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": { "node_modules/entities": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -2461,6 +2627,23 @@
} }
} }
}, },
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "11.3.4", "version": "11.3.4",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
@@ -2511,6 +2694,61 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -2519,9 +2757,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/happy-dom": { "node_modules/happy-dom": {
"version": "20.8.4", "version": "20.8.9",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.4.tgz", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz",
"integrity": "sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==", "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2569,6 +2807,13 @@
"he": "bin/he" "he": "bin/he"
} }
}, },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"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",
@@ -2605,6 +2850,106 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-report/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.23",
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jju": { "node_modules/jju": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz",
@@ -2714,6 +3059,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -2735,6 +3089,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/magicast": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.4",
"@babel/types": "^7.25.4",
"source-map-js": "^1.2.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/min-indent": { "node_modules/min-indent": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -2761,6 +3156,16 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mlly": { "node_modules/mlly": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz",
@@ -2833,6 +3238,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/path-browserify": { "node_modules/path-browserify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -2840,6 +3252,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-parse": { "node_modules/path-parse": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -2847,6 +3269,30 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/pathe": { "node_modules/pathe": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -2872,9 +3318,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3194,6 +3640,29 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/siginfo": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -3201,6 +3670,19 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -3252,6 +3734,103 @@
"node": ">=0.6.19" "node": ">=0.6.19"
} }
}, },
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/strip-indent": { "node_modules/strip-indent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -3327,6 +3906,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/test-exclude": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
"integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^10.4.1",
"minimatch": "^10.2.2"
},
"engines": {
"node": ">=18"
}
},
"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",
@@ -3672,6 +4266,22 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -3689,6 +4299,107 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ws": { "node_modules/ws": {
"version": "8.19.0", "version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",

View File

@@ -35,6 +35,7 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"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"
@@ -52,6 +53,7 @@
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^3.2.4",
"happy-dom": "^20.8.4", "happy-dom": "^20.8.4",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vite": "^6.0.0", "vite": "^6.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,6 +79,7 @@ export default function App() {
return ( return (
<> <>
<Routes> <Routes>
<Route element={<LayoutShell />}>
<Route path="/" element={<Navigate to="/apps" replace />} /> <Route path="/" element={<Navigate to="/apps" replace />} />
<Route path="/apps" element={<Dashboard />} /> <Route path="/apps" element={<Dashboard />} />
<Route path="/apps/:id" element={<Dashboard />} /> <Route path="/apps/:id" element={<Dashboard />} />
@@ -93,6 +95,7 @@ export default function App() {
<Route path="/admin/oidc" element={<OidcConfig />} /> <Route path="/admin/oidc" element={<OidcConfig />} />
<Route path="/admin/rbac" element={<UserManagement />} /> <Route path="/admin/rbac" element={<UserManagement />} />
<Route path="/api-docs" element={<ApiDocs />} /> <Route path="/api-docs" element={<ApiDocs />} />
</Route>
<Route path="/inventory" element={<Inventory />} /> <Route path="/inventory" element={<Inventory />} />
</Routes> </Routes>
<CommandPalette <CommandPalette

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react' import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Search, X, ChevronUp, ChevronDown } from 'lucide-react'
import styles from './CommandPalette.module.css' import styles from './CommandPalette.module.css'
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader' import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock' import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
@@ -13,24 +14,35 @@ interface CommandPaletteProps {
data: SearchResult[] data: SearchResult[]
onOpen?: () => void onOpen?: () => void
onQueryChange?: (query: string) => void onQueryChange?: (query: string) => void
/** Called when Enter is pressed without the user explicitly selecting a result (arrow keys/click).
* Useful for applying the query as a full-text search filter. */
onSubmit?: (query: string) => void
} }
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = { const KNOWN_CATEGORY_LABELS: Record<string, string> = {
all: 'All',
application: 'Applications', application: 'Applications',
exchange: 'Exchanges', exchange: 'Exchanges',
attribute: 'Attributes',
route: 'Routes', route: 'Routes',
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',
'route', 'route',
'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
@@ -61,12 +73,13 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
return <>{parts}</> return <>{parts}</>
} }
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange }: 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)
const userNavigated = useRef(false)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null) const listRef = useRef<HTMLDivElement>(null)
@@ -89,6 +102,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
setQuery('') setQuery('')
setFocusedIdx(0) setFocusedIdx(0)
setExpandedId(null) setExpandedId(null)
userNavigated.current = false
} }
}, [open]) }, [open])
@@ -120,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)
@@ -140,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':
@@ -147,15 +174,20 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
break break
case 'ArrowDown': case 'ArrowDown':
e.preventDefault() e.preventDefault()
userNavigated.current = true
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1)) setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
break break
case 'ArrowUp': case 'ArrowUp':
e.preventDefault() e.preventDefault()
userNavigated.current = true
setFocusedIdx((i) => Math.max(i - 1, 0)) setFocusedIdx((i) => Math.max(i - 1, 0))
break break
case 'Enter': case 'Enter':
e.preventDefault() e.preventDefault()
if (flatResults[focusedIdx]) { if (!userNavigated.current && onSubmit && query.trim()) {
onSubmit(query.trim())
onClose()
} else if (flatResults[focusedIdx]) {
onSelect(flatResults[focusedIdx]) onSelect(flatResults[focusedIdx])
onClose() onClose()
} }
@@ -173,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()}
@@ -187,7 +232,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
> >
{/* Search input area */} {/* Search input area */}
<div className={styles.searchArea}> <div className={styles.searchArea}>
<span className={styles.searchIcon} aria-hidden="true"></span> <span className={styles.searchIcon} aria-hidden="true"><Search size={14} /></span>
{scopeFilters.map((sf, i) => ( {scopeFilters.map((sf, i) => (
<span key={i} className={styles.scopeTag}> <span key={i} className={styles.scopeTag}>
<span className={styles.scopeField}>{sf.field}:</span> <span className={styles.scopeField}>{sf.field}:</span>
@@ -197,7 +242,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
onClick={() => removeScopeFilter(i)} onClick={() => removeScopeFilter(i)}
aria-label={`Remove filter ${sf.field}:${sf.value}`} aria-label={`Remove filter ${sf.field}:${sf.value}`}
> >
× <X size={10} />
</button> </button>
</span> </span>
))} ))}
@@ -210,6 +255,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value) setQuery(e.target.value)
setFocusedIdx(0) setFocusedIdx(0)
userNavigated.current = false
onQueryChange?.(e.target.value) onQueryChange?.(e.target.value)
}} }}
aria-label="Search" aria-label="Search"
@@ -219,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"
@@ -235,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>
)} )}
@@ -256,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)
@@ -279,7 +325,13 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
onSelect(result) onSelect(result)
onClose() onClose()
}} }}
onMouseEnter={() => setFocusedIdx(flatIdx)} onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onSelect(result)
onClose()
}
}}
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
> >
<div className={styles.itemMain}> <div className={styles.itemMain}>
{result.icon && ( {result.icon && (
@@ -314,14 +366,11 @@ 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"
> >
{isExpanded ? '▲' : '▼'} {isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button> </button>
)} )}
</div> </div>
@@ -350,7 +399,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
</div> </div>
<div className={styles.shortcut}> <div className={styles.shortcut}>
<KeyboardHint keys="Enter" /> <KeyboardHint keys="Enter" />
<span>Open</span> <span>Search</span>
</div> </div>
<div className={styles.shortcut}> <div className={styles.shortcut}>
<KeyboardHint keys="Esc" /> <KeyboardHint keys="Esc" />

View File

@@ -1,6 +1,7 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent' /** Known categories: 'application' | 'exchange' | 'attribute' | 'route' | 'agent'. Custom categories are rendered with title-cased labels and a default icon. */
export type SearchCategory = string
export interface SearchResult { export interface SearchResult {
id: string id: string

View File

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

View File

@@ -24,6 +24,7 @@ export function DataTable<T extends { id: string }>({
rowAccent, rowAccent,
expandedContent, expandedContent,
flush = false, flush = false,
fillHeight = false,
onSortChange, onSortChange,
}: DataTableProps<T>) { }: DataTableProps<T>) {
const [sortKey, setSortKey] = useState<string | null>(null) const [sortKey, setSortKey] = useState<string | null>(null)
@@ -81,7 +82,7 @@ export function DataTable<T extends { id: string }>({
})) }))
return ( return (
<div className={`${styles.wrapper} ${flush ? styles.flush : ''}`}> <div className={`${styles.wrapper} ${flush ? styles.flush : ''} ${fillHeight ? styles.fillHeight : ''}`}>
<div className={styles.scroll}> <div className={styles.scroll}>
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>

View File

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

View File

@@ -1,4 +1,5 @@
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react' import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
import { X as XIcon, AlertTriangle, Play, Loader } from 'lucide-react'
import styles from './EventFeed.module.css' import styles from './EventFeed.module.css'
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup' import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup' import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
@@ -47,11 +48,11 @@ function getSearchableText(event: FeedEvent): string {
return '' return ''
} }
const DEFAULT_ICONS: Record<SeverityFilter, string> = { const DEFAULT_ICONS: Record<SeverityFilter, ReactNode> = {
error: '\u2715', // ✕ error: <XIcon size={14} />,
warning: '\u26A0', // ⚠ warning: <AlertTriangle size={14} />,
success: '\u25B6', // ▶ success: <Play size={14} />,
running: '\u2699', // ⚙ running: <Loader size={14} />,
} }
const SEVERITY_COLORS: Record<SeverityFilter, string> = { const SEVERITY_COLORS: Record<SeverityFilter, string> = {
@@ -136,7 +137,7 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
onClick={() => setSearch('')} onClick={() => setSearch('')}
aria-label="Clear search" aria-label="Clear search"
> >
× <XIcon size={12} />
</button> </button>
)} )}
</div> </div>

View File

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

View File

@@ -1,11 +1,11 @@
import styles from './KpiStrip.module.css' import styles from './KpiStrip.module.css'
import { Sparkline } from '../../primitives/Sparkline/Sparkline' import { Sparkline } from '../../primitives/Sparkline/Sparkline'
import type { CSSProperties } from 'react' import type { CSSProperties, ReactNode } from 'react'
export interface KpiItem { export interface KpiItem {
label: string label: string
value: string | number value: string | number
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' } trend?: { label: ReactNode; variant?: 'success' | 'warning' | 'error' | 'muted' }
subtitle?: string subtitle?: string
sparkline?: number[] sparkline?: number[]
borderColor?: string borderColor?: string

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { EllipsisVertical } from 'lucide-react'
import styles from './ProcessorTimeline.module.css' import styles from './ProcessorTimeline.module.css'
import { Dropdown } from '../Dropdown/Dropdown' import { Dropdown } from '../Dropdown/Dropdown'
import type { NodeBadge } from '../RouteFlow/RouteFlow' import type { NodeBadge } from '../RouteFlow/RouteFlow'
@@ -124,7 +125,7 @@ export function ProcessorTimeline({
aria-label={`Actions for ${proc.name}`} aria-label={`Actions for ${proc.name}`}
type="button" type="button"
> >
<EllipsisVertical size={14} />
</button> </button>
} }
items={resolvedActions} items={resolvedActions}

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from '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'
@@ -55,12 +56,12 @@ function durationClass(ms: number, status: string): string {
return styles.durBreach return styles.durBreach
} }
const TYPE_ICONS: Record<string, string> = { const TYPE_ICONS: Record<string, ReactNode> = {
'from': '\u25B6', 'from': <Play size={14} />,
'process': '\u2699', 'process': <Cog size={14} />,
'to': '\u25A2', 'to': <Square size={14} />,
'choice': '\u25C6', 'choice': <Diamond size={14} />,
'error-handler': '\u26A0', 'error-handler': <AlertTriangle size={14} />,
} }
const ICON_CLASSES: Record<string, string> = { const ICON_CLASSES: Record<string, string> = {
@@ -100,7 +101,7 @@ function renderActionTrigger(
aria-label={`Actions for ${node.name}`} aria-label={`Actions for ${node.name}`}
type="button" type="button"
> >
<EllipsisVertical size={14} />
</button> </button>
} }
items={resolvedActions} items={resolvedActions}
@@ -166,7 +167,7 @@ function renderNodeChain(
</span> </span>
) : null} ) : null}
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}> <div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
{TYPE_ICONS[node.type] ?? '\u25A2'} {TYPE_ICONS[node.type] ?? <Square size={14} />}
</div> </div>
<div className={styles.info}> <div className={styles.info}>
<div className={styles.type}>{node.type}</div> <div className={styles.type}>{node.type}</div>
@@ -260,7 +261,7 @@ export function RouteFlow({ nodes, flows, onNodeClick, selectedIndex, actions, g
</span> </span>
) : null} ) : null}
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}> <div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
{TYPE_ICONS[node.type] ?? '\u25A2'} {TYPE_ICONS[node.type] ?? <Square size={14} />}
</div> </div>
<div className={styles.info}> <div className={styles.info}>
<div className={styles.type}>{node.type}</div> <div className={styles.type}>{node.type}</div>

View File

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

View File

@@ -8,6 +8,7 @@ import {
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
import styles from './Toast.module.css' import styles from './Toast.module.css'
// ── Types ────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────
@@ -39,11 +40,11 @@ const MAX_TOASTS = 5
const DEFAULT_DURATION = 5000 const DEFAULT_DURATION = 5000
const EXIT_ANIMATION_MS = 300 const EXIT_ANIMATION_MS = 300
const ICONS: Record<ToastVariant, string> = { const ICONS: Record<ToastVariant, ReactNode> = {
info: '', info: <Info size={16} />,
success: '✓', success: <CheckCircle size={16} />,
warning: '⚠', warning: <AlertTriangle size={16} />,
error: '✕', error: <XCircle size={16} />,
} }
// ── Context ──────────────────────────────────────────────────────────────── // ── Context ────────────────────────────────────────────────────────────────
@@ -56,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)
@@ -70,10 +75,8 @@ 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 => {
@@ -183,7 +186,7 @@ function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) {
aria-label="Dismiss notification" aria-label="Dismiss notification"
type="button" type="button"
> >
&times; <X size={14} />
</button> </button>
</div> </div>
) )

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

@@ -41,3 +41,7 @@ export { SplitPane } from './SplitPane/SplitPane'
export { Tabs } from './Tabs/Tabs' 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
export { CHART_COLORS } from './_chart-utils'
export type { ChartSeries, DataPoint } from './_chart-utils'

View File

@@ -11,3 +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 {
@@ -106,71 +142,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 +205,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 +215,8 @@
gap: 2px; gap: 2px;
width: 100%; width: 100%;
padding: 8px 0 4px; padding: 8px 0 4px;
cursor: pointer;
user-select: none;
} }
.treeSectionChevronBtn { .treeSectionChevronBtn {
@@ -383,100 +382,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 {
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
} }
// ── 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: <span className={styles.routeArrow}>&#9656;</span>,
badge: formatCount(route.exchangeCount),
path: `/apps/${app.id}/${route.id}`,
starrable: true,
})),
}))
}
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps
.filter((app) => app.routes.length > 0)
.map((app) => ({
id: `routes:${app.id}`,
label: app.name,
icon: <StatusDot variant={app.health} />,
badge: `${app.routes.length} routes`,
path: `/routes/${app.id}`,
starrable: true,
starKey: `routes:${app.id}`,
children: app.routes.map((route) => ({
id: `routestat:${app.id}:${route.id}`,
starKey: `routes:${app.id}:${route.id}`,
label: route.name,
icon: <span className={styles.routeArrow}>&#9656;</span>,
badge: formatCount(route.exchangeCount),
path: `/routes/${app.id}/${route.id}`,
starrable: true,
})),
}))
}
function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps
.filter((app) => app.agents.length > 0)
.map((app) => {
const liveCount = app.agents.filter((a) => a.status === 'live').length
return {
id: `agents:${app.id}`,
label: app.name,
icon: <StatusDot variant={app.health} />,
badge: `${liveCount}/${app.agents.length} live`,
path: `/agents/${app.id}`,
starrable: true,
starKey: `agents:${app.id}`,
children: app.agents.map((agent) => ({
id: `agent:${app.id}:${agent.id}`,
starKey: `${app.id}:${agent.id}`,
label: agent.name,
badge: `${agent.tps.toFixed(1)}/s`,
path: `/agents/${app.id}/${agent.id}`,
starrable: true,
})),
}
})
}
// ── Starred section helpers ──────────────────────────────────────────────────
interface StarredItem {
starKey: string
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 interface SidebarFooterLinkProps {
} icon: ReactNode
// ── StarredGroup ─────────────────────────────────────────────────────────────
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 className={styles.starredGroupLabel}>{label}</div>
{items.map((item) => (
<div <div
key={item.starKey} className={`${styles.logo} ${className ?? ''}`}
className={styles.starredItem} onClick={onClick}
onClick={() => onNavigate(item.path)} style={onClick ? { cursor: 'pointer' } : undefined}
role="button" role={onClick ? 'button' : undefined}
tabIndex={0} tabIndex={onClick ? 0 : undefined}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path) }} onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
> >
{item.icon} {logo}
<div className={styles.starredItemInfo}> {!collapsed && (
<span className={styles.starredItemName}>{item.label}</span> <div>
{item.parentApp && ( <span className={styles.brand}>{title}</span>
<span className={styles.starredItemContext}>{item.parentApp}</span> {version && <span className={styles.version}>{version}</span>}
</div>
)} )}
</div> </div>
<button )
className={styles.starredRemove} }
onClick={(e) => { e.stopPropagation(); onRemove(item.starKey) }}
tabIndex={-1} function SidebarSection({
aria-label={`Remove ${item.label} from starred`} icon,
label,
open,
onToggle,
active,
children,
className,
}: SidebarSectionProps) {
const { collapsed, onCollapseToggle } = useSidebarContext()
// In icon-rail (collapsed) mode, render a centered icon with tooltip
if (collapsed) {
return (
<div
className={`${styles.sectionRailItem} ${active ? styles.sectionRailItemActive : ''} ${className ?? ''}`}
title={label}
onClick={() => {
// Expand sidebar and open the section
onCollapseToggle?.()
onToggle()
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onCollapseToggle?.()
onToggle()
}
}}
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <span className={styles.sectionIcon}>{icon}</span>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
))}
</div> </div>
) )
} }
// ── Sidebar ──────────────────────────────────────────────────────────────────
export function Sidebar({ apps, className }: SidebarProps) {
const [search, setSearch] = useState('')
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true')
const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
_setAppsCollapsed((prev) => {
const next = updater(prev)
localStorage.setItem('cameleer:sidebar:apps-collapsed', String(next))
return next
})
}
const setAgentsCollapsed = (updater: (v: boolean) => boolean) => {
_setAgentsCollapsed((prev) => {
const next = updater(prev)
localStorage.setItem('cameleer:sidebar:agents-collapsed', String(next))
return next
})
}
const setRoutesCollapsed = (updater: (v: boolean) => boolean) => {
_setRoutesCollapsed((prev) => {
const next = updater(prev)
localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next))
return next
})
}
const navigate = useNavigate()
const location = useLocation()
const { starredIds, isStarred, toggleStar } = useStarred()
// Build tree data
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
const routeNodes = useMemo(() => buildRouteTreeNodes(apps), [apps])
// Sidebar reveal from Cmd-K navigation (passed via location state)
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
useEffect(() => {
if (!sidebarRevealPath) return
// Uncollapse Applications section if reveal path matches an apps tree node
const matchesAppTree = appNodes.some((node) =>
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
)
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={() => navigate('/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}
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}
</div> </div>
)
}
{/* Search */} 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.searchWrap}>
<div className={styles.searchInner}> <div className={styles.searchInner}>
<span className={styles.searchIcon} aria-hidden="true"> <span className={styles.searchIcon} aria-hidden="true">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <Search size={12} />
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span> </span>
<input <input
className={styles.searchInput} className={styles.searchInput}
type="text" type="text"
placeholder="Filter..." placeholder="Filter..."
value={search} value={searchValue ?? ''}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
/> />
{search && ( {searchValue && (
<button <button
type="button" type="button"
className={styles.searchClear} className={styles.searchClear}
onClick={() => setSearch('')} onClick={() => onSearchChange('')}
aria-label="Clear search" aria-label="Clear search"
> >
× <X size={12} />
</button> </button>
)} )}
</div> </div>
</div> </div>
{/* Navigation (scrollable) — includes starred section */}
<div className={styles.navArea}>
<div className={styles.section}>Navigation</div>
{/* Applications tree (collapsible, label navigates to /apps) */}
<div className={styles.treeSection}>
<div className={styles.treeSectionToggle}>
<button
className={styles.treeSectionChevronBtn}
onClick={() => setAppsCollapsed((v) => !v)}
aria-expanded={!appsCollapsed}
aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'}
>
{appsCollapsed ? '▸' : '▾'}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/apps')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/apps') }}
>
Applications
</span>
</div>
{!appsCollapsed && (
<SidebarTree
nodes={appNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:apps"
autoRevealPath={sidebarRevealPath}
/>
)} )}
</div> {rest}
</>
{/* Agents tree (collapsible, label navigates to /agents) */} )
<div className={styles.treeSection}> })()}
<div className={styles.treeSectionToggle}>
<button
className={styles.treeSectionChevronBtn}
onClick={() => setAgentsCollapsed((v) => !v)}
aria-expanded={!agentsCollapsed}
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
>
{agentsCollapsed ? '▸' : '▾'}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/agents')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/agents') }}
>
Agents
</span>
</div>
{!agentsCollapsed && (
<SidebarTree
nodes={agentNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:agents"
autoRevealPath={sidebarRevealPath}
/>
)}
</div>
{/* Routes tree (collapsible, label navigates to /routes) */}
<div className={styles.treeSection}>
<div className={styles.treeSectionToggle}>
<button
className={styles.treeSectionChevronBtn}
onClick={() => setRoutesCollapsed((v) => !v)}
aria-expanded={!routesCollapsed}
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
>
{routesCollapsed ? '▸' : '▾'}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/routes')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
>
Routes
</span>
</div>
{!routesCollapsed && (
<SidebarTree
nodes={routeNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:routes"
autoRevealPath={sidebarRevealPath}
/>
)}
</div>
{/* No results message */}
{search && appNodes.length === 0 && agentNodes.length === 0 && (
<div className={styles.noResults}>No results</div>
)}
{/* Starred section (inside scrollable area, hidden when empty) */}
{hasStarred && (
<div className={styles.starredSection}>
<div className={styles.section}>
<span className={styles.starredHeader}> Starred</span>
</div>
<div className={styles.starredList}>
{starredApps.length > 0 && (
<StarredGroup
label="Applications"
items={starredApps}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
{starredRoutes.length > 0 && (
<StarredGroup
label="Routes"
items={starredRoutes}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
{starredAgents.length > 0 && (
<StarredGroup
label="Agents"
items={starredAgents}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
{starredRouteStats.length > 0 && (
<StarredGroup
label="Routes"
items={starredRouteStats}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
</div>
</div>
)}
</div>
{/* Bottom links */}
<div className={styles.bottom}>
<div
className={[
styles.bottomItem,
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
].filter(Boolean).join(' ')}
onClick={() => navigate('/admin')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/admin') }}
>
<span className={styles.bottomIcon}>&#9881;</span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>Admin</div>
</div>
</div>
<div
className={[
styles.bottomItem,
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
].filter(Boolean).join(' ')}
onClick={() => navigate('/api-docs')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/api-docs') }}
>
<span className={styles.bottomIcon}>&#9776;</span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>API Docs</div>
</div>
</div>
</div>
</aside> </aside>
</SidebarContext.Provider>
) )
} }
// ── Compound export ─────────────────────────────────────────────────────────
export const Sidebar = Object.assign(SidebarRoot, {
Header: SidebarHeader,
Section: SidebarSection,
Footer: SidebarFooter,
FooterLink: SidebarFooterLink,
})

View File

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

View File

@@ -9,6 +9,7 @@ import {
type MouseEvent, type MouseEvent,
} from 'react' } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Star, ChevronRight, ChevronDown } from 'lucide-react'
import styles from './Sidebar.module.css' import styles from './Sidebar.module.css'
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -33,24 +34,17 @@ export interface SidebarTreeProps {
filterQuery?: string filterQuery?: string
persistKey?: string // sessionStorage key to persist expand state across remounts persistKey?: string // sessionStorage key to persist expand state across remounts
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
onNavigate?: (path: string) => void
} }
// ── Star icon SVGs ─────────────────────────────────────────────────────────── // ── Star icons ───────────────────────────────────────────────────────────────
function StarOutline() { function StarOutline() {
return ( return <Star size={14} />
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
} }
function StarFilled() { function StarFilled() {
return ( return <Star size={14} fill="currentColor" />
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
} }
// ── Persistent expand state ────────────────────────────────────────────────── // ── Persistent expand state ──────────────────────────────────────────────────
@@ -130,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({
@@ -141,8 +181,10 @@ export function SidebarTree({
filterQuery, filterQuery,
persistKey, persistKey,
autoRevealPath, autoRevealPath,
onNavigate,
}: SidebarTreeProps) { }: SidebarTreeProps) {
const navigate = useNavigate() const routerNavigate = useNavigate()
const navigate = onNavigate ?? routerNavigate
// Expand/collapse state — optionally persisted to sessionStorage // Expand/collapse state — optionally persisted to sessionStorage
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>( const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
@@ -226,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
@@ -375,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}
@@ -395,7 +390,7 @@ function SidebarTreeRow({
tabIndex={-1} tabIndex={-1}
aria-label={isExpanded ? 'Collapse' : 'Expand'} aria-label={isExpanded ? 'Collapse' : 'Expand'}
> >
{isExpanded ? '▾' : '▸'} {isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button> </button>
) : null} ) : null}
</span> </span>

View File

@@ -1,3 +1,4 @@
import { Search, 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'
@@ -51,10 +52,7 @@ export function TopBar({
aria-label="Open search" aria-label="Open search"
> >
<span className={styles.searchIcon} aria-hidden="true"> <span className={styles.searchIcon} aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <Search size={13} />
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span> </span>
<span className={styles.searchPlaceholder}>Search... &#8984;K</span> <span className={styles.searchPlaceholder}>Search... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span> <span className={styles.kbd}>Ctrl+K</span>
@@ -101,7 +99,7 @@ export function TopBar({
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`} aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`} title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
> >
{theme === 'light' ? '\u263E' : '\u2600'} {theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
</button> </button>
{environment && ( {environment && (
<span className={styles.env}>{environment}</span> <span className={styles.env}>{environment}</span>
@@ -115,7 +113,7 @@ export function TopBar({
</div> </div>
} }
items={[ items={[
{ label: 'Logout', icon: '\u23FB', onClick: onLogout }, { label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
]} ]}
/> />
)} )}

View File

@@ -1,4 +1,6 @@
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'

View File

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

View File

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

View File

@@ -59,7 +59,15 @@ export function InlineEdit({ value, onSave, placeholder, disabled, className }:
<span className={`${styles.display} ${disabled ? styles.disabled : ''} ${className ?? ''}`}> <span className={`${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,5 +1,6 @@
import styles from './Input.module.css' import styles from './Input.module.css'
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react' import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
import { X } from 'lucide-react'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
icon?: ReactNode icon?: ReactNode
@@ -25,7 +26,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
onClick={onClear} onClick={onClear}
aria-label="Clear search" aria-label="Clear search"
> >
× <X size={12} />
</button> </button>
)} )}
</div> </div>

View File

@@ -66,15 +66,17 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {} try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {}
}, []) }, [])
// Keep the time range sliding forward when a preset is active and live // Keep the time range sliding forward whenever a preset is active.
// PAUSED mode only stops query polling — the time window still advances
// so that manual refreshes and sidebar-triggered queries see current data.
useEffect(() => { useEffect(() => {
if (!autoRefresh || !timeRange.preset) return if (!timeRange.preset) return
const id = setInterval(() => { const id = setInterval(() => {
const { start, end } = computePresetRange(timeRange.preset!) const { start, end } = computePresetRange(timeRange.preset!)
setTimeRangeState({ start, end, preset: timeRange.preset }) setTimeRangeState({ start, end, preset: timeRange.preset })
}, 10_000) }, 10_000)
return () => clearInterval(id) return () => clearInterval(id)
}, [autoRefresh, timeRange.preset]) }, [timeRange.preset])
const isInTimeRange = useCallback( const isInTimeRange = useCallback(
(timestamp: Date) => { (timestamp: Date) => {

View File

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

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

@@ -313,3 +313,74 @@ export const exchanges: Exchange[] = [
], ],
}, },
] ]
// ── Generate additional exchanges to reach ~200 total ────────────────────────
const GEN_ROUTES = [
'order-intake', 'payment-validate', 'order-enrichment', 'shipment-dispatch',
'payment-process', 'shipment-track', 'inventory-sync', 'notification-send',
'audit-log', 'customer-update', 'refund-process', 'invoice-generate',
]
const GEN_ROUTE_GROUPS = [
'order-flow', 'payment-flow', 'shipment-flow', 'inventory-flow',
'notification-flow', 'audit-flow', 'customer-flow', 'billing-flow',
]
const GEN_AGENTS = ['prod-1', 'prod-2', 'prod-3', 'prod-4']
const GEN_STATUSES: Exchange['status'][] = [
'completed', 'completed', 'completed', 'completed', 'completed',
'completed', 'failed', 'warning', 'running',
]
const GEN_PROCESSORS: ProcessorData[][] = [
[
{ name: 'from(jms:queue)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 4 },
{ name: 'validate(schema)', type: 'process', durationMs: 10, status: 'ok', startMs: 10 },
{ name: 'to(target)', type: 'to', durationMs: 15, status: 'ok', startMs: 20 },
],
[
{ name: 'from(jms:queue)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
{ name: 'enrich(external-api)', type: 'enrich', durationMs: 120, status: 'slow', startMs: 5 },
{ name: 'to(target)', type: 'to', durationMs: 20, status: 'ok', startMs: 125 },
],
[
{ name: 'from(jms:queue)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
{ name: 'to(external-gateway)', type: 'to', durationMs: 400, status: 'fail', startMs: 3 },
],
]
const GEN_ERRORS = [
{ msg: 'org.apache.camel.CamelExecutionException: Timeout after 5000ms', cls: 'org.apache.camel.CamelExecutionException' },
{ msg: 'java.sql.SQLTransientConnectionException: Connection pool exhausted', cls: 'java.sql.SQLTransientConnectionException' },
{ msg: 'org.apache.camel.component.http.HttpOperationFailedException: HTTP 502 Bad Gateway', cls: 'org.apache.camel.component.http.HttpOperationFailedException' },
]
const BASE_TIME = new Date('2026-03-18T08:00:00').getTime()
for (let i = 0; i < 185; i++) {
const idx = exchanges.length
const status = GEN_STATUSES[i % GEN_STATUSES.length]
const route = GEN_ROUTES[i % GEN_ROUTES.length]
const routeGroup = GEN_ROUTE_GROUPS[i % GEN_ROUTE_GROUPS.length]
const isFailed = status === 'failed'
const durationMs = isFailed
? 1000 + ((i * 37) % 4000)
: status === 'running'
? 10000 + ((i * 53) % 20000)
: 30 + ((i * 73) % 400)
const err = isFailed ? GEN_ERRORS[i % GEN_ERRORS.length] : undefined
exchanges.push({
id: `E-2026-03-18-${String(idx + 200).padStart(5, '0')}`,
orderId: `OP-${80000 + idx}`,
customer: `CUST-${10000 + ((i * 131) % 90000)}`,
route,
routeGroup,
status,
durationMs,
timestamp: new Date(BASE_TIME - i * 2 * 60 * 1000),
correlationId: `cmr-${i.toString(16).padStart(8, '0')}-gen`,
agent: GEN_AGENTS[i % GEN_AGENTS.length],
...(err ? { errorMessage: err.msg, errorClass: err.cls } : {}),
processors: GEN_PROCESSORS[i % GEN_PROCESSORS.length].map((p) => ({ ...p })),
...(i % 3 === 0 ? { correlationGroup: `${routeGroup}-${String(Math.floor(i / 3)).padStart(3, '0')}` } : {}),
})
}

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

@@ -1,10 +1,9 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import styles from './AgentHealth.module.css' import styles from './AgentHealth.module.css'
// Layout // Layout
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'
// Composites // Composites
@@ -27,7 +26,6 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv
// Mock data // Mock data
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents' import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
import { agentEvents } from '../../mocks/agentEvents' import { agentEvents } from '../../mocks/agentEvents'
// ── URL scope parsing ──────────────────────────────────────────────────────── // ── URL scope parsing ────────────────────────────────────────────────────────
@@ -316,19 +314,7 @@ export function AgentHealth() {
const isFullWidth = scope.level !== 'all' const isFullWidth = scope.level !== 'all'
return ( return (
<AppShell <>
sidebar={<Sidebar apps={SIDEBAR_APPS} />}
detail={
selectedInstance ? (
<DetailPanel
open={panelOpen}
onClose={() => setPanelOpen(false)}
title={selectedInstance.name}
tabs={detailTabs}
/>
) : undefined
}
>
<TopBar <TopBar
breadcrumb={buildBreadcrumb(scope)} breadcrumb={buildBreadcrumb(scope)}
environment="PRODUCTION" environment="PRODUCTION"
@@ -389,7 +375,7 @@ export function AgentHealth() {
{scope.level !== 'all' && ( {scope.level !== 'all' && (
<> <>
<Link to="/agents" className={styles.scopeLink}>All Agents</Link> <Link to="/agents" className={styles.scopeLink}>All Agents</Link>
<span className={styles.scopeSep}>&#9656;</span> <span className={styles.scopeSep}><ChevronRight size={12} /></span>
<span className={styles.scopeCurrent}>{scope.appId}</span> <span className={styles.scopeCurrent}>{scope.appId}</span>
</> </>
)} )}
@@ -453,6 +439,16 @@ export function AgentHealth() {
</div> </div>
)} )}
</div> </div>
</AppShell>
{/* Detail panel (portals itself) */}
{selectedInstance && (
<DetailPanel
open={panelOpen}
onClose={() => setPanelOpen(false)}
title={selectedInstance.name}
tabs={detailTabs}
/>
)}
</>
) )
} }

View File

@@ -1,10 +1,9 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import styles from './AgentInstance.module.css' import styles from './AgentInstance.module.css'
// Layout // Layout
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'
// Composites // Composites
@@ -27,7 +26,6 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv
// Data // Data
import { agents } from '../../mocks/agents' import { agents } from '../../mocks/agents'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
import { agentEvents } from '../../mocks/agentEvents' import { agentEvents } from '../../mocks/agentEvents'
import { useState } from 'react' import { useState } from 'react'
@@ -126,12 +124,12 @@ export function AgentInstance() {
if (!agent) { if (!agent) {
return ( return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}> <>
<TopBar breadcrumb={[{ label: 'Agents', href: '/agents' }, { label: 'Not Found' }]} environment="PRODUCTION" user={{ name: 'hendrik' }} /> <TopBar breadcrumb={[{ label: 'Agents', href: '/agents' }, { label: 'Not Found' }]} environment="PRODUCTION" user={{ name: 'hendrik' }} />
<div className={styles.content}> <div className={styles.content}>
<div className={styles.notFound}>Agent instance not found.</div> <div className={styles.notFound}>Agent instance not found.</div>
</div> </div>
</AppShell> </>
) )
} }
@@ -152,7 +150,7 @@ export function AgentInstance() {
const statusColor = agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error' const statusColor = agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error'
return ( return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}> <>
<TopBar <TopBar
breadcrumb={[ breadcrumb={[
{ label: 'Applications', href: '/apps' }, { label: 'Applications', href: '/apps' },
@@ -177,9 +175,9 @@ export function AgentInstance() {
{/* Scope trail + badges */} {/* Scope trail + badges */}
<div className={styles.scopeTrail}> <div className={styles.scopeTrail}>
<Link to="/agents" className={styles.scopeLink}>All Agents</Link> <Link to="/agents" className={styles.scopeLink}>All Agents</Link>
<span className={styles.scopeSep}>&#9656;</span> <span className={styles.scopeSep}><ChevronRight size={12} /></span>
<Link to={`/agents/${appId}`} className={styles.scopeLink}>{appId}</Link> <Link to={`/agents/${appId}`} className={styles.scopeLink}>{appId}</Link>
<span className={styles.scopeSep}>&#9656;</span> <span className={styles.scopeSep}><ChevronRight size={12} /></span>
<span className={styles.scopeCurrent}>{agent.name}</span> <span className={styles.scopeCurrent}>{agent.name}</span>
<Badge label={agent.status.toUpperCase()} color={statusColor} /> <Badge label={agent.status.toUpperCase()} color={statusColor} />
<Badge label={agent.version} color="auto" variant="outlined" /> <Badge label={agent.version} color="auto" variant="outlined" />
@@ -301,6 +299,6 @@ export function AgentInstance() {
</div> </div>
</div> </div>
</div> </div>
</AppShell> </>
) )
} }

View File

@@ -1,12 +1,9 @@
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 { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState' import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
export function ApiDocs() { export function ApiDocs() {
return ( return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}> <>
<TopBar <TopBar
breadcrumb={[{ label: 'API Documentation' }]} breadcrumb={[{ label: 'API Documentation' }]}
environment="PRODUCTION" environment="PRODUCTION"
@@ -17,6 +14,6 @@ export function ApiDocs() {
title="API Documentation" title="API Documentation"
description="API documentation coming soon." description="API documentation coming soon."
/> />
</AppShell> </>
) )
} }

View File

@@ -1,15 +1,12 @@
import { useParams } from 'react-router-dom' import { useParams } 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 { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState' import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
export function AppDetail() { export function AppDetail() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
return ( return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}> <>
<TopBar <TopBar
breadcrumb={[ breadcrumb={[
{ label: 'Applications', href: '/apps' }, { label: 'Applications', href: '/apps' },
@@ -23,6 +20,6 @@ export function AppDetail() {
title="Application Detail" title="Application Detail"
description="Application detail view coming soon." description="Application detail view coming soon."
/> />
</AppShell> </>
) )
} }

View File

@@ -4,7 +4,10 @@
overflow-y: auto; overflow-y: auto;
padding: 20px 24px 40px; padding: 20px 24px 40px;
min-width: 0; min-width: 0;
min-height: 0;
background: var(--bg-body); background: var(--bg-body);
display: flex;
flex-direction: column;
} }
/* Filter bar spacing */ /* Filter bar spacing */
@@ -19,6 +22,10 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
overflow: hidden; overflow: hidden;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
} }
.tableHeader { .tableHeader {

View File

@@ -1,10 +1,9 @@
import { useState, useMemo } from 'react' import React, { useState, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { TrendingUp, TrendingDown, ArrowRight, ExternalLink, AlertTriangle } from 'lucide-react'
import styles from './Dashboard.module.css' import styles from './Dashboard.module.css'
// Layout // Layout
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'
// Composites // Composites
@@ -43,10 +42,10 @@ const ACCENT_TO_COLOR: Record<KpiMetric['accent'], string> = {
warning: 'var(--warning)', warning: 'var(--warning)',
} }
const TREND_ICONS: Record<KpiMetric['trend'], string> = { const TREND_ICONS: Record<KpiMetric['trend'], React.ReactNode> = {
up: '\u2191', up: <TrendingUp size={12} />,
down: '\u2193', down: <TrendingDown size={12} />,
neutral: '\u2192', neutral: <ArrowRight size={12} />,
} }
function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' | 'error' | 'muted' { function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' | 'error' | 'muted' {
@@ -60,7 +59,7 @@ function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' |
const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({ const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
label: m.label, label: m.label,
value: m.unit ? `${m.value} ${m.unit}` : m.value, value: m.unit ? `${m.value} ${m.unit}` : m.value,
trend: { label: `${TREND_ICONS[m.trend]} ${m.trendValue}`, variant: sentimentToVariant(m.trendSentiment) }, trend: { label: <><span style={{ display: 'inline-flex', verticalAlign: 'middle' }}>{TREND_ICONS[m.trend]}</span> {m.trendValue}</>, variant: sentimentToVariant(m.trendSentiment) },
subtitle: m.detail, subtitle: m.detail,
sparkline: m.sparkline, sparkline: m.sparkline,
borderColor: ACCENT_TO_COLOR[m.accent], borderColor: ACCENT_TO_COLOR[m.accent],
@@ -206,7 +205,7 @@ export function Dashboard() {
navigate(`/exchanges/${row.id}`) navigate(`/exchanges/${row.id}`)
}} }}
> >
&#x2197; <ExternalLink size={14} />
</button> </button>
), ),
} }
@@ -286,12 +285,67 @@ export function Dashboard() {
const totalErrors = processorErrors.length + (hasExchangeError && processorErrors.length === 0 ? 1 : 0) const totalErrors = processorErrors.length + (hasExchangeError && processorErrors.length === 0 ? 1 : 0)
return ( return (
<AppShell <>
sidebar={ {/* Top bar */}
<Sidebar apps={SIDEBAR_APPS} /> <TopBar
breadcrumb={
routeId
? [{ label: 'Applications', href: '/apps' }, { label: appId!, href: `/apps/${appId}` }, { label: routeId }]
: appId
? [{ label: 'Applications', href: '/apps' }, { label: appId }]
: [{ label: 'Applications' }]
} }
detail={ environment="PRODUCTION"
selectedExchange ? ( user={{ name: 'hendrik' }}
/>
{/* Scrollable content */}
<div className={styles.content}>
{/* Health strip */}
<KpiStrip items={kpiItems} />
{/* Exchanges table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Recent Exchanges</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>
{filteredExchanges.length.toLocaleString()} of {scopedExchanges.length.toLocaleString()} exchanges
</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={COLUMNS}
data={filteredExchanges}
onRowClick={handleRowClick}
selectedId={selectedId}
sortable
flush
fillHeight
rowAccent={handleRowAccent}
expandedContent={(row) =>
row.errorMessage ? (
<div className={styles.inlineError}>
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
<div>
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
</div>
</div>
) : null
}
/>
</div>
</div>
{/* Shortcuts bar */}
<ShortcutsBar shortcuts={SHORTCUTS} />
{/* Detail panel (portals itself) */}
{selectedExchange && (
<DetailPanel <DetailPanel
open={panelOpen} open={panelOpen}
onClose={() => setPanelOpen(false)} onClose={() => setPanelOpen(false)}
@@ -303,7 +357,7 @@ export function Dashboard() {
className={styles.openDetailLink} className={styles.openDetailLink}
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)} onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
> >
Open full details &#x2192; Open full details <ArrowRight size={14} style={{ verticalAlign: 'middle' }} />
</button> </button>
</div> </div>
@@ -383,65 +437,7 @@ export function Dashboard() {
/> />
</div> </div>
</DetailPanel> </DetailPanel>
) : undefined )}
} </>
>
{/* Top bar */}
<TopBar
breadcrumb={
routeId
? [{ label: 'Applications', href: '/apps' }, { label: appId!, href: `/apps/${appId}` }, { label: routeId }]
: appId
? [{ label: 'Applications', href: '/apps' }, { label: appId }]
: [{ label: 'Applications' }]
}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
/>
{/* Scrollable content */}
<div className={styles.content}>
{/* Health strip */}
<KpiStrip items={kpiItems} />
{/* Exchanges table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Recent Exchanges</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>
{filteredExchanges.length.toLocaleString()} of {scopedExchanges.length.toLocaleString()} exchanges
</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={COLUMNS}
data={filteredExchanges}
onRowClick={handleRowClick}
selectedId={selectedId}
sortable
flush
rowAccent={handleRowAccent}
expandedContent={(row) =>
row.errorMessage ? (
<div className={styles.inlineError}>
<span className={styles.inlineErrorIcon}></span>
<div>
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
</div>
</div>
) : null
}
/>
</div>
</div>
{/* Shortcuts bar */}
<ShortcutsBar shortcuts={SHORTCUTS} />
</AppShell>
) )
} }

View File

@@ -3,8 +3,6 @@ import { useParams, useNavigate } from 'react-router-dom'
import styles from './ExchangeDetail.module.css' import styles from './ExchangeDetail.module.css'
// Layout // Layout
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'
// Composites // Composites
@@ -22,7 +20,7 @@ import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCall
// Mock data // Mock data
import { exchanges } from '../../mocks/exchanges' import { exchanges } from '../../mocks/exchanges'
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar' import { buildRouteToAppMap } from '../../mocks/sidebar'
const ROUTE_TO_APP = buildRouteToAppMap() const ROUTE_TO_APP = buildRouteToAppMap()
@@ -196,11 +194,7 @@ export function ExchangeDetail() {
// Not found state // Not found state
if (!exchange) { if (!exchange) {
return ( return (
<AppShell <>
sidebar={
<Sidebar apps={SIDEBAR_APPS} />
}
>
<TopBar <TopBar
breadcrumb={[ breadcrumb={[
{ label: 'Applications', href: '/apps' }, { label: 'Applications', href: '/apps' },
@@ -213,7 +207,7 @@ export function ExchangeDetail() {
<div className={styles.content}> <div className={styles.content}>
<InfoCallout variant="warning">Exchange "{id}" not found in mock data.</InfoCallout> <InfoCallout variant="warning">Exchange "{id}" not found in mock data.</InfoCallout>
</div> </div>
</AppShell> </>
) )
} }
@@ -229,11 +223,7 @@ export function ExchangeDetail() {
const isSelectedFailed = selectedProc?.status === 'fail' const isSelectedFailed = selectedProc?.status === 'fail'
return ( return (
<AppShell <>
sidebar={
<Sidebar apps={SIDEBAR_APPS} />
}
>
{/* Top bar */} {/* Top bar */}
<TopBar <TopBar
breadcrumb={[ breadcrumb={[
@@ -454,6 +444,6 @@ export function ExchangeDetail() {
)} )}
</div> </div>
</AppShell> </>
) )
} }

View File

@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Hexagon, ArrowRight, Diamond, Eye, Pencil, RotateCcw, Trash2, ChevronDown } from 'lucide-react'
import styles from './CompositesSection.module.css' import styles from './CompositesSection.module.css'
import { import {
Accordion, Accordion,
@@ -134,13 +135,27 @@ interface TableRow {
exchanges: number exchanges: number
} }
const TABLE_DATA: TableRow[] = [ const ROUTE_PREFIXES = [
{ id: '1', name: 'order-ingest', method: 'POST', status: 'live', exchanges: 1243 }, 'order', 'payment', 'inventory', 'customer', 'shipment', 'user', 'cart',
{ id: '2', name: 'payment-validate', method: 'POST', status: 'live', exchanges: 987 }, 'refund', 'stock', 'email', 'webhook', 'cache', 'log', 'rate', 'health',
{ id: '3', name: 'inventory-check', method: 'GET', status: 'stale', exchanges: 432 }, 'session', 'config', 'metrics', 'batch', 'audit', 'invoice', 'product',
{ id: '4', name: 'notify-customer', method: 'POST', status: 'live', exchanges: 876 }, 'catalog', 'search', 'report', 'export', 'import', 'sync', 'alert', 'ticket',
{ id: '5', name: 'archive-order', method: 'PUT', status: 'dead', exchanges: 54 },
] ]
const ROUTE_SUFFIXES = [
'ingest', 'validate', 'check', 'notify', 'archive', 'track', 'auth',
'update', 'process', 'sync', 'dispatch', 'relay', 'invalidate', 'aggregate',
'limit', 'refresh', 'reload', 'export', 'import', 'purge',
]
const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']
const STATUSES = ['live', 'live', 'live', 'live', 'stale', 'stale', 'dead']
const TABLE_DATA: TableRow[] = Array.from({ length: 500 }, (_, i) => ({
id: String(i + 1),
name: `${ROUTE_PREFIXES[i % ROUTE_PREFIXES.length]}-${ROUTE_SUFFIXES[(i * 7) % ROUTE_SUFFIXES.length]}`,
method: METHODS[i % METHODS.length],
status: STATUSES[i % STATUSES.length],
exchanges: ((i * 1337 + 421) % 9990) + 10,
}))
const NOW = new Date() const NOW = new Date()
const minsAgo = (n: number) => new Date(NOW.getTime() - n * 60 * 1000) const minsAgo = (n: number) => new Date(NOW.getTime() - n * 60 * 1000)
@@ -157,25 +172,25 @@ const TREE_NODES = [
{ {
id: 'app1', id: 'app1',
label: 'cameleer-prod', label: 'cameleer-prod',
icon: '⬡', icon: <Hexagon size={14} />,
children: [ children: [
{ {
id: 'route1', id: 'route1',
label: 'order-ingest', label: 'order-ingest',
icon: '→', icon: <ArrowRight size={14} />,
children: [ children: [
{ id: 'proc1', label: 'ValidateOrder', icon: '◈', meta: '12ms' }, { id: 'proc1', label: 'ValidateOrder', icon: <Diamond size={12} />, meta: '12ms' },
{ id: 'proc2', label: 'EnrichPayload', icon: '◈', meta: '8ms' }, { id: 'proc2', label: 'EnrichPayload', icon: <Diamond size={12} />, meta: '8ms' },
{ id: 'proc3', label: 'RouteToQueue', icon: '◈', meta: '3ms' }, { id: 'proc3', label: 'RouteToQueue', icon: <Diamond size={12} />, meta: '3ms' },
], ],
}, },
{ {
id: 'route2', id: 'route2',
label: 'payment-validate', label: 'payment-validate',
icon: '→', icon: <ArrowRight size={14} />,
children: [ children: [
{ id: 'proc4', label: 'TokenizeCard', icon: '◈', meta: '22ms' }, { id: 'proc4', label: 'TokenizeCard', icon: <Diamond size={12} />, meta: '22ms' },
{ id: 'proc5', label: 'AuthorizePayment', icon: '◈', meta: '45ms' }, { id: 'proc5', label: 'AuthorizePayment', icon: <Diamond size={12} />, meta: '45ms' },
], ],
}, },
], ],
@@ -467,12 +482,13 @@ export function CompositesSection() {
title="DataTable" title="DataTable"
description="Sortable, paginated table with row click, accent rows, and page size selector." description="Sortable, paginated table with row click, accent rows, and page size selector."
> >
<div style={{ width: '100%' }}> <div style={{ width: '100%', height: 500, display: 'flex', flexDirection: 'column' }}>
<DataTable <DataTable
columns={tableColumns} columns={tableColumns}
data={TABLE_DATA} data={TABLE_DATA}
sortable sortable
pageSize={5} pageSize={25}
fillHeight
/> />
</div> </div>
</DemoCard> </DemoCard>
@@ -505,13 +521,13 @@ export function CompositesSection() {
description="Click-triggered dropdown menu with icons, dividers, and disabled items." description="Click-triggered dropdown menu with icons, dividers, and disabled items."
> >
<Dropdown <Dropdown
trigger={<Button size="sm" variant="secondary">Actions </Button>} trigger={<Button size="sm" variant="secondary">Actions <ChevronDown size={12} /></Button>}
items={[ items={[
{ label: 'View details', icon: '👁', onClick: () => undefined }, { label: 'View details', icon: <Eye size={14} />, onClick: () => undefined },
{ label: 'Edit route', icon: '✏', onClick: () => undefined }, { label: 'Edit route', icon: <Pencil size={14} />, onClick: () => undefined },
{ divider: true, label: '' }, { divider: true, label: '' },
{ label: 'Restart', icon: '↺', onClick: () => undefined }, { label: 'Restart', icon: <RotateCcw size={14} />, onClick: () => undefined },
{ label: 'Delete', icon: '✕', onClick: () => undefined, disabled: true }, { label: 'Delete', icon: <Trash2 size={14} />, onClick: () => undefined, disabled: true },
]} ]}
/> />
</DemoCard> </DemoCard>

View File

@@ -1,6 +1,9 @@
import styles from './LayoutSection.module.css' import styles from './LayoutSection.module.css'
import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar' import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar'
import type { SidebarApp } from '../../../design-system/layout/Sidebar/Sidebar' import { SidebarTree } from '../../../design-system/layout/Sidebar/SidebarTree'
import type { SidebarTreeNode } from '../../../design-system/layout/Sidebar/SidebarTree'
import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot'
import { Box, Settings, FileText, ChevronRight } from 'lucide-react'
import { TopBar } from '../../../design-system/layout/TopBar/TopBar' import { TopBar } from '../../../design-system/layout/TopBar/TopBar'
// ── DemoCard helper ────────────────────────────────────────────────────────── // ── DemoCard helper ──────────────────────────────────────────────────────────
@@ -22,42 +25,42 @@ function DemoCard({ id, title, description, children }: DemoCardProps) {
) )
} }
// ── Sample data (hierarchical) ─────────────────────────────────────────────── // ── Sample tree nodes ────────────────────────────────────────────────────────
const SAMPLE_APPS: SidebarApp[] = [ const SAMPLE_APP_NODES: SidebarTreeNode[] = [
{ {
id: 'app1', id: 'app1',
name: 'cameleer-prod', label: 'cameleer-prod',
health: 'live' as const, icon: <StatusDot variant="live" />,
exchangeCount: 14320, badge: '14.3k',
routes: [ path: '/apps/app1',
{ id: 'r1', name: 'order-ingest', exchangeCount: 5421 }, starrable: true,
{ id: 'r2', name: 'payment-validate', exchangeCount: 3102 }, starKey: 'app:app1',
], children: [
agents: [ { id: 'app1/r1', label: 'order-ingest', icon: <ChevronRight size={12} />, badge: '5,421', path: '/apps/app1/r1' },
{ id: 'ag1', name: 'agent-prod-1', status: 'live' as const, tps: 42 }, { id: 'app1/r2', label: 'payment-validate', icon: <ChevronRight size={12} />, badge: '3,102', path: '/apps/app1/r2' },
{ id: 'ag2', name: 'agent-prod-2', status: 'live' as const, tps: 38 },
], ],
}, },
{ {
id: 'app2', id: 'app2',
name: 'cameleer-staging', label: 'cameleer-staging',
health: 'stale' as const, icon: <StatusDot variant="stale" />,
exchangeCount: 871, badge: '871',
routes: [ path: '/apps/app2',
{ id: 'r3', name: 'notify-customer', exchangeCount: 2201 }, starrable: true,
], starKey: 'app:app2',
agents: [ children: [
{ id: 'ag3', name: 'agent-staging-1', status: 'stale' as const, tps: 5 }, { id: 'app2/r3', label: 'notify-customer', icon: <ChevronRight size={12} />, badge: '2,201', path: '/apps/app2/r3' },
], ],
}, },
{ {
id: 'app3', id: 'app3',
name: 'cameleer-dev', label: 'cameleer-dev',
health: 'dead' as const, icon: <StatusDot variant="dead" />,
exchangeCount: 42, badge: '42',
routes: [], path: '/apps/app3',
agents: [], starrable: true,
starKey: 'app:app3',
}, },
] ]
@@ -99,10 +102,19 @@ export function LayoutSection() {
<DemoCard <DemoCard
id="sidebar" id="sidebar"
title="Sidebar" title="Sidebar"
description="Navigation sidebar with hierarchical app/route/agent trees, starring, search filter, and bottom links." description="Composable navigation sidebar with sections, tree navigation, and icon-rail collapse mode."
> >
<div className={styles.sidebarPreview}> <div className={styles.sidebarPreview}>
<Sidebar apps={SAMPLE_APPS} /> <Sidebar>
<Sidebar.Header logo={<span style={{ fontSize: 20 }}>&#x1F42A;</span>} title="cameleer" version="v3.2.1" />
<Sidebar.Section label="Applications" icon={<Box size={14} />} open={true} onToggle={() => {}} active={false}>
<SidebarTree nodes={SAMPLE_APP_NODES} isStarred={() => false} onToggleStar={() => {}} />
</Sidebar.Section>
<Sidebar.Footer>
<Sidebar.FooterLink icon={<Settings size={14} />} label="Admin" />
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" />
</Sidebar.Footer>
</Sidebar>
</div> </div>
</DemoCard> </DemoCard>

View File

@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Search } from 'lucide-react'
import styles from './PrimitivesSection.module.css' import styles from './PrimitivesSection.module.css'
import { import {
Alert, Alert,
@@ -358,7 +359,7 @@ export function PrimitivesSection() {
description="Text input with optional leading icon and placeholder." description="Text input with optional leading icon and placeholder."
> >
<Input placeholder="Plain input" /> <Input placeholder="Plain input" />
<Input icon="🔍" placeholder="With icon" /> <Input icon={<Search size={14} />} placeholder="With icon" />
</DemoCard> </DemoCard>
{/* 15b. InlineEdit */} {/* 15b. InlineEdit */}

View File

@@ -1,10 +1,9 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { AlertTriangle } from 'lucide-react'
import styles from './RouteDetail.module.css' import styles from './RouteDetail.module.css'
// Layout // Layout
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'
// Composites // Composites
@@ -21,7 +20,6 @@ import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCall
// Mock data // Mock data
import { routes } from '../../mocks/routes' import { routes } from '../../mocks/routes'
import { exchanges, type Exchange } from '../../mocks/exchanges' import { exchanges, type Exchange } from '../../mocks/exchanges'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
@@ -198,11 +196,7 @@ export function RouteDetail() {
// Not found state // Not found state
if (!route) { if (!route) {
return ( return (
<AppShell <>
sidebar={
<Sidebar apps={SIDEBAR_APPS} />
}
>
<TopBar <TopBar
breadcrumb={[ breadcrumb={[
{ label: 'Applications', href: '/apps' }, { label: 'Applications', href: '/apps' },
@@ -210,24 +204,19 @@ export function RouteDetail() {
{ label: id ?? 'Unknown' }, { label: id ?? 'Unknown' },
]} ]}
environment="PRODUCTION" environment="PRODUCTION"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />
<div className={styles.content}> <div className={styles.content}>
<InfoCallout variant="warning">Route "{id}" not found in mock data.</InfoCallout> <InfoCallout variant="warning">Route "{id}" not found in mock data.</InfoCallout>
</div> </div>
</AppShell> </>
) )
} }
const statusVariant = routeStatusVariant(route.status) const statusVariant = routeStatusVariant(route.status)
return ( return (
<AppShell <>
sidebar={
<Sidebar apps={SIDEBAR_APPS} />
}
>
{/* Top bar */} {/* Top bar */}
<TopBar <TopBar
breadcrumb={[ breadcrumb={[
@@ -337,7 +326,7 @@ export function RouteDetail() {
expandedContent={(row) => expandedContent={(row) =>
row.errorMessage ? ( row.errorMessage ? (
<div className={styles.inlineError}> <div className={styles.inlineError}>
<span className={styles.inlineErrorIcon}></span> <span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
<div> <div>
<div className={styles.errorClass}>{row.errorClass}</div> <div className={styles.errorClass}>{row.errorClass}</div>
<div className={styles.errorText}>{row.errorMessage}</div> <div className={styles.errorText}>{row.errorMessage}</div>
@@ -374,6 +363,6 @@ export function RouteDetail() {
)} )}
</div> </div>
</AppShell> </>
) )
} }

View File

@@ -3,8 +3,6 @@ import { useNavigate, useParams } from 'react-router-dom'
import styles from './Routes.module.css' import styles from './Routes.module.css'
// Layout // Layout
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'
// Composites // Composites
@@ -33,7 +31,7 @@ import {
type RouteMetricRow, type RouteMetricRow,
} from '../../mocks/metrics' } from '../../mocks/metrics'
import { routes } from '../../mocks/routes' import { routes } from '../../mocks/routes'
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar' import { buildRouteToAppMap } from '../../mocks/sidebar'
const ROUTE_TO_APP = buildRouteToAppMap() const ROUTE_TO_APP = buildRouteToAppMap()
@@ -410,7 +408,7 @@ export function Routes() {
// ── Route detail view ─────────────────────────────────────────────────────── // ── Route detail view ───────────────────────────────────────────────────────
if (routeId && appId && routeDef) { if (routeId && appId && routeDef) {
return ( return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}> <>
<TopBar <TopBar
breadcrumb={breadcrumb} breadcrumb={breadcrumb}
environment="PRODUCTION" environment="PRODUCTION"
@@ -448,13 +446,13 @@ export function Routes() {
<RouteFlow nodes={routeFlowNodes} /> <RouteFlow nodes={routeFlowNodes} />
</div> </div>
</div> </div>
</AppShell> </>
) )
} }
// ── Top level / Application level view ────────────────────────────────────── // ── Top level / Application level view ──────────────────────────────────────
return ( return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}> <>
<TopBar <TopBar
breadcrumb={breadcrumb} breadcrumb={breadcrumb}
environment="PRODUCTION" environment="PRODUCTION"
@@ -533,6 +531,6 @@ export function Routes() {
</Card> </Card>
</div> </div>
</div> </div>
</AppShell> </>
) )
} }