Compare commits

...

67 Commits

Author SHA1 Message Date
hsiegeln
fd08d7a552 feat: add FileInput primitive — drag-and-drop file picker
All checks were successful
Build & Publish / publish (push) Successful in 1m24s
Dashed-border dropzone with icon, filename display, clear button,
and imperative handle (file/clear). Replaces native file inputs.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:19:39 +02:00
hsiegeln
a3afe3cb1b feat(assets): add high-detail cameleer3-logo.svg traced from PNG via Inkscape
All checks were successful
Build & Publish / publish (push) Successful in 1m4s
32-scan potrace vector trace with transparent background. Added to brand
assets inventory, documentation, and package exports.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:26:55 +01:00
141 changed files with 6581 additions and 1779 deletions

View File

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

View File

@@ -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/**"

4
.gitignore vendored
View File

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

123
CLAUDE.md
View File

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

View File

@@ -38,10 +38,12 @@
- Removable label → **Tag** - Removable label → **Tag**
### "I need navigation" ### "I need navigation"
- App-level sidebar nav → **Sidebar** (via AppShell) — hierarchical trees with starring - App-level sidebar nav → **Sidebar** (compound component — compose sections, trees, footer links)
- Sidebar tree section → **SidebarTree** (data-driven tree with expand/collapse, starring, keyboard nav)
- Starred items persistence → **useStarred** hook (localStorage-backed)
- Breadcrumb trail → **Breadcrumb** - Breadcrumb trail → **Breadcrumb**
- Paginated data → **Pagination** (standalone) or **DataTable** (built-in pagination) - Paginated data → **Pagination** (standalone) or **DataTable** (built-in pagination)
- Hierarchical tree navigation → **TreeView** (generic) or **SidebarTree** (sidebar-specific, internal) - Hierarchical tree navigation → **TreeView** (generic)
### "I need floating content" ### "I need floating content"
- Tooltip on hover → **Tooltip** - Tooltip on hover → **Tooltip**
@@ -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,8 +303,10 @@ 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. |
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar | | SidebarTree | Data-driven tree for sidebar sections. Accepts `nodes: SidebarTreeNode[]` with expand/collapse, starring, keyboard nav, search filter, and path-based selection highlighting. |
| useStarred | Hook for localStorage-backed starred item IDs. Returns `{ starredIds, isStarred, toggleStar }`. |
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment slot (`ReactNode` — pass a string for a static label or a custom dropdown for interactive selection), user avatar |
## Import Paths ## Import Paths
@@ -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'
``` ```
@@ -261,6 +334,35 @@ import type { Column, DataTableProps, SearchResult } from '@cameleer/design-syst
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions. See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
## Brand Assets
The design system ships logo assets as static files via the `./assets/*` package export. These are not React components — they resolve to file URLs when imported via a bundler. All PNGs have transparent backgrounds.
| File | Size | Use case |
|------|------|----------|
| `cameleer3-logo.png` | Original | Full resolution for print/marketing |
| `cameleer3-16.png` | 16×16 | Browser tab favicon |
| `cameleer3-32.png` | 32×32 | Standard favicon, bookmarks |
| `cameleer3-48.png` | 48×48 | Windows taskbar |
| `cameleer3-180.png` | 180×180 | Apple touch icon |
| `cameleer3-192.png` | 192×192 | Android/PWA icon |
| `cameleer3-512.png` | 512×512 | PWA splash, og:image |
| `cameleer3-logo.svg` | Vector | High-detail SVG logo (traced from PNG, transparent) |
| `camel-logo.svg` | Vector | Simplified camel SVG logo |
### Usage
```tsx
import logo from '@cameleer/design-system/assets/cameleer3-512.png'
<img src={logo} alt="Cameleer3" />
```
```html
<!-- Favicons in index.html -->
<link rel="icon" type="image/png" sizes="32x32" href="/cameleer3-32.png">
<link rel="apple-touch-icon" sizes="180x180" href="/cameleer3-180.png">
```
## Styling Rules ## Styling Rules
- **CSS Modules only** — no inline styles except dynamic values (width, color from props) - **CSS Modules only** — no inline styles except dynamic values (width, color from props)

3
assets/camel-logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
assets/cameleer3-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 B

BIN
assets/cameleer3-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
assets/cameleer3-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
assets/cameleer3-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
assets/cameleer3-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
assets/cameleer3-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

BIN
assets/cameleer3-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

112
assets/cameleer3-logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because it is too large Load Diff

View File

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

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

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

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

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

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

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

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

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

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

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

741
package-lock.json generated
View File

@@ -1,13 +1,14 @@
{ {
"name": "@cameleer/design-system", "name": "@cameleer/design-system",
"version": "0.1.6", "version": "0.1.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@cameleer/design-system", "name": "@cameleer/design-system",
"version": "0.1.6", "version": "0.1.7",
"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

@@ -1,6 +1,6 @@
{ {
"name": "@cameleer/design-system", "name": "@cameleer/design-system",
"version": "0.1.6", "version": "0.1.42",
"type": "module", "type": "module",
"main": "./dist/index.es.js", "main": "./dist/index.es.js",
"module": "./dist/index.es.js", "module": "./dist/index.es.js",
@@ -10,10 +10,12 @@
"types": "./dist/index.es.d.ts", "types": "./dist/index.es.d.ts",
"import": "./dist/index.es.js" "import": "./dist/index.es.js"
}, },
"./style.css": "./dist/style.css" "./style.css": "./dist/style.css",
"./assets/*": "./assets/*"
}, },
"files": [ "files": [
"dist" "dist",
"assets"
], ],
"sideEffects": [ "sideEffects": [
"*.css" "*.css"
@@ -35,6 +37,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 +55,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",

21
playwright.config.ts Normal file
View File

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

View File

@@ -19,7 +19,8 @@ import { buildSearchData } from './mocks/searchData'
import { exchanges } from './mocks/exchanges' import { 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -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

@@ -72,7 +72,7 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
padding: 6px 10px; padding: 6px 10px;
font-size: 11px; font-size: 12px;
pointer-events: none; pointer-events: none;
z-index: 10; z-index: 10;
white-space: nowrap; white-space: nowrap;
@@ -118,7 +118,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
font-size: 11px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -130,12 +130,12 @@
} }
.legendLabel { .legendLabel {
font-size: 11px; font-size: 12px;
} }
/* Axis labels */ /* Axis labels */
.yLabel { .yLabel {
font-size: 10px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
writing-mode: vertical-lr; writing-mode: vertical-lr;
transform: rotate(180deg); transform: rotate(180deg);
@@ -151,7 +151,7 @@
.xLabel { .xLabel {
text-align: center; text-align: center;
font-size: 10px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
padding-left: 48px; padding-left: 48px;
} }

View File

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

View File

@@ -53,7 +53,7 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
padding: 6px 10px; padding: 6px 10px;
font-size: 11px; font-size: 12px;
pointer-events: none; pointer-events: none;
z-index: 10; z-index: 10;
white-space: nowrap; white-space: nowrap;
@@ -63,7 +63,7 @@
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 4px; margin-bottom: 4px;
font-size: 11px; font-size: 12px;
} }
.tooltipRow { .tooltipRow {
@@ -105,7 +105,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
font-size: 11px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -117,11 +117,11 @@
} }
.legendLabel { .legendLabel {
font-size: 11px; font-size: 12px;
} }
.yLabel { .yLabel {
font-size: 10px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
writing-mode: vertical-lr; writing-mode: vertical-lr;
transform: rotate(180deg); transform: rotate(180deg);
@@ -137,7 +137,7 @@
.xLabel { .xLabel {
text-align: center; text-align: center;
font-size: 10px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
padding-left: 48px; padding-left: 48px;
} }

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

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

View File

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

View File

@@ -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,17 +102,14 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
setQuery('') setQuery('')
setFocusedIdx(0) setFocusedIdx(0)
setExpandedId(null) setExpandedId(null)
userNavigated.current = false
} }
}, [open]) }, [open])
// Filter results // Stage 1: apply text query + scope filters (used for counts)
const filtered = useMemo(() => { const queryFiltered = useMemo(() => {
let results = data let results = data
if (activeCategory !== 'all') {
results = results.filter((r) => r.category === activeCategory)
}
if (query.trim()) { if (query.trim()) {
const q = query.toLowerCase() const q = query.toLowerCase()
results = results.filter( results = results.filter(
@@ -107,7 +117,6 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
) )
} }
// Apply scope filters
for (const sf of scopeFilters) { for (const sf of scopeFilters) {
results = results.filter((r) => results = results.filter((r) =>
r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()), r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()),
@@ -115,11 +124,17 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
} }
return results return results
}, [data, query, activeCategory, scopeFilters]) }, [data, query, scopeFilters])
// Stage 2: apply category filter (used for display)
const filtered = useMemo(() => {
if (activeCategory === 'all') return queryFiltered
return queryFiltered.filter((r) => r.category === activeCategory)
}, [queryFiltered, activeCategory])
// Group results by category // 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)
@@ -130,13 +145,26 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
// Flatten for keyboard nav // Flatten for keyboard nav
const flatResults = useMemo(() => filtered, [filtered]) const flatResults = useMemo(() => filtered, [filtered])
// Counts per category // Counts per category (from query-filtered, before category filter)
const categoryCounts = useMemo(() => { const categoryCounts = useMemo(() => {
const counts: Record<string, number> = { all: data.length } const counts: Record<string, number> = { all: queryFiltered.length }
for (const r of data) { for (const r of queryFiltered) {
counts[r.category] = (counts[r.category] ?? 0) + 1 counts[r.category] = (counts[r.category] ?? 0) + 1
} }
return counts return counts
}, [queryFiltered])
// Build tab list dynamically: 'all' + known categories (in order) + any unknown categories found in data
const visibleCategories = useMemo(() => {
const dataCategories = new Set(data.map((r) => r.category))
const tabs: string[] = ['all']
for (const cat of KNOWN_CATEGORY_ORDER) {
if (dataCategories.has(cat)) tabs.push(cat)
}
for (const cat of dataCategories) {
if (!tabs.includes(cat)) tabs.push(cat)
}
return tabs
}, [data]) }, [data])
function handleKeyDown(e: React.KeyboardEvent) { function handleKeyDown(e: React.KeyboardEvent) {
@@ -146,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()
} }
@@ -172,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()}
@@ -186,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>
@@ -196,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>
))} ))}
@@ -209,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"
@@ -218,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"
@@ -234,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>
)} )}
@@ -255,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)
@@ -278,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 && (
@@ -303,18 +356,21 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
<div className={styles.itemMeta}> <div className={styles.itemMeta}>
{highlightText(result.meta, query)} {highlightText(result.meta, query)}
</div> </div>
{result.matchContext && (
<div
className={styles.matchContext}
dangerouslySetInnerHTML={{ __html: result.matchContext }}
/>
)}
</div> </div>
{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>
@@ -343,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
@@ -15,6 +16,8 @@ export interface SearchResult {
matchRanges?: [number, number][] matchRanges?: [number, number][]
/** Skip client-side query filtering (result already matched server-side) */ /** Skip client-side query filtering (result already matched server-side) */
serverFiltered?: boolean serverFiltered?: boolean
/** Server-side match snippet with <em> tags around matched text */
matchContext?: string
} }
export interface ScopeFilter { export interface ScopeFilter {

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

View File

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

View File

@@ -23,7 +23,9 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
const activeContent = tabs?.find((t) => t.value === activeTab)?.content const activeContent = tabs?.find((t) => t.value === activeTab)?.content
const panel = ( const content = (
<>
{open && <div className={styles.backdrop} onClick={onClose} aria-hidden="true" />}
<aside <aside
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`} className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
aria-hidden={!open} aria-hidden={!open}
@@ -65,9 +67,10 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
</div> </div>
)} )}
</aside> </aside>
</>
) )
// Portal to AppShell level if target exists, otherwise render in place // Portal to AppShell level if target exists, otherwise render in place
const portalTarget = document.getElementById('cameleer-detail-panel-root') const portalTarget = document.getElementById('cameleer-detail-panel-root')
return portalTarget ? createPortal(panel, portalTarget) : panel return portalTarget ? createPortal(content, portalTarget) : content
} }

View File

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

View File

@@ -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> = {
@@ -81,25 +82,25 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity)) .filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower)) .filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
// Auto-scroll to bottom // Auto-scroll to top (newest entries are at top in desc sort)
const scrollToBottom = useCallback(() => { const scrollToTop = useCallback(() => {
const el = scrollRef.current const el = scrollRef.current
if (el) { if (el) {
el.scrollTop = el.scrollHeight el.scrollTop = 0
} }
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!isPaused) { if (!isPaused) {
scrollToBottom() scrollToTop()
} }
}, [events, isPaused, scrollToBottom]) }, [events, isPaused, scrollToTop])
function handleScroll() { function handleScroll() {
const el = scrollRef.current const el = scrollRef.current
if (!el) return if (!el) return
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8 const atTop = el.scrollTop < 8
setIsPaused(!atBottom) setIsPaused(!atTop)
} }
function toggleFilter(severity: SeverityFilter) { function toggleFilter(severity: SeverityFilter) {
@@ -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>
@@ -196,10 +197,10 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
className={styles.resumeBtn} className={styles.resumeBtn}
onClick={() => { onClick={() => {
setIsPaused(false) setIsPaused(false)
scrollToBottom() scrollToTop()
}} }}
> >
Resume auto-scroll &uarr; Scroll to latest
</button> </button>
)} )}
</div> </div>

View File

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

View File

@@ -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

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

View File

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

View File

@@ -1,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

@@ -63,7 +63,7 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
padding: 6px 10px; padding: 6px 10px;
font-size: 11px; font-size: 12px;
pointer-events: none; pointer-events: none;
z-index: 10; z-index: 10;
white-space: nowrap; white-space: nowrap;
@@ -108,7 +108,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
font-size: 11px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -120,11 +120,11 @@
} }
.legendLabel { .legendLabel {
font-size: 11px; font-size: 12px;
} }
.yLabel { .yLabel {
font-size: 10px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
writing-mode: vertical-lr; writing-mode: vertical-lr;
transform: rotate(180deg); transform: rotate(180deg);
@@ -140,7 +140,7 @@
.xLabel { .xLabel {
text-align: center; text-align: center;
font-size: 10px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
padding-left: 48px; padding-left: 48px;
} }

View File

@@ -1,7 +1,5 @@
.container { .container {
overflow-y: auto; overflow-y: auto;
background: var(--bg-inset);
border-radius: var(--radius-md);
padding: 8px 0; padding: 8px 0;
font-family: var(--font-mono); font-family: var(--font-mono);
} }
@@ -20,7 +18,7 @@
.timestamp { .timestamp {
flex-shrink: 0; flex-shrink: 0;
font-size: 11px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
min-width: 56px; min-width: 56px;
} }
@@ -58,6 +56,11 @@
background: color-mix(in srgb, var(--text-muted) 10%, transparent); background: color-mix(in srgb, var(--text-muted) 10%, transparent);
} }
.levelTrace {
color: var(--text-faint);
background: color-mix(in srgb, var(--text-faint) 8%, transparent);
}
.message { .message {
font-size: 12px; font-size: 12px;
font-family: var(--font-mono); font-family: var(--font-mono);

View File

@@ -7,6 +7,7 @@ const entries: LogEntry[] = [
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' }, { timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' }, { timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' }, { timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
{ timestamp: '2024-01-15T10:30:20Z', level: 'trace', message: 'Entering handleRequest()' },
] ]
describe('LogViewer', () => { describe('LogViewer', () => {
@@ -16,14 +17,16 @@ describe('LogViewer', () => {
expect(screen.getByText('High memory usage')).toBeInTheDocument() expect(screen.getByText('High memory usage')).toBeInTheDocument()
expect(screen.getByText('Connection failed')).toBeInTheDocument() expect(screen.getByText('Connection failed')).toBeInTheDocument()
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument() expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
expect(screen.getByText('Entering handleRequest()')).toBeInTheDocument()
}) })
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG)', () => { it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG, TRACE)', () => {
render(<LogViewer entries={entries} />) render(<LogViewer entries={entries} />)
expect(screen.getByText('INFO')).toBeInTheDocument() expect(screen.getByText('INFO')).toBeInTheDocument()
expect(screen.getByText('WARN')).toBeInTheDocument() expect(screen.getByText('WARN')).toBeInTheDocument()
expect(screen.getByText('ERROR')).toBeInTheDocument() expect(screen.getByText('ERROR')).toBeInTheDocument()
expect(screen.getByText('DEBUG')).toBeInTheDocument() expect(screen.getByText('DEBUG')).toBeInTheDocument()
expect(screen.getByText('TRACE')).toBeInTheDocument()
}) })
it('renders with custom maxHeight (number)', () => { it('renders with custom maxHeight (number)', () => {

View File

@@ -3,7 +3,7 @@ import styles from './LogViewer.module.css'
export interface LogEntry { export interface LogEntry {
timestamp: string timestamp: string
level: 'info' | 'warn' | 'error' | 'debug' level: 'info' | 'warn' | 'error' | 'debug' | 'trace'
message: string message: string
} }
@@ -18,6 +18,7 @@ const LEVEL_CLASS: Record<LogEntry['level'], string> = {
warn: styles.levelWarn, warn: styles.levelWarn,
error: styles.levelError, error: styles.levelError,
debug: styles.levelDebug, debug: styles.levelDebug,
trace: styles.levelTrace,
} }
function formatTime(iso: string): string { function formatTime(iso: string): string {
@@ -35,18 +36,18 @@ function formatTime(iso: string): string {
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) { export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true) const isAtTopRef = useRef(true)
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const el = scrollRef.current const el = scrollRef.current
if (!el) return if (!el) return
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20 isAtTopRef.current = el.scrollTop < 20
}, []) }, [])
useEffect(() => { useEffect(() => {
const el = scrollRef.current const el = scrollRef.current
if (el && isAtBottomRef.current) { if (el && isAtTopRef.current) {
el.scrollTop = el.scrollHeight el.scrollTop = 0
} }
}, [entries]) }, [entries])

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

@@ -52,7 +52,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 10px; font-size: 12px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -97,7 +97,7 @@
.label { .label {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
color: var(--text-primary); color: var(--text-primary);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -112,7 +112,7 @@
.duration { .duration {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
} }
@@ -248,3 +248,40 @@
.badgeSuccess { background: var(--success); } .badgeSuccess { background: var(--success); }
.badgeWarning { background: var(--amber); } .badgeWarning { background: var(--amber); }
.badgeError { background: var(--error); } .badgeError { background: var(--error); }
/* Node wrapper (replaces inline style) */
.nodeWrapper {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
/* Multi-flow sections */
.flowSection {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.flowSectionSeparated {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--border);
}
.flowLabel {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
padding-left: 2px;
width: 100%;
}
.flowLabelDefault { color: var(--text-muted); }
.flowLabelError { color: var(--error); }
.flowLabelWarning { color: var(--warning); }
.flowLabelInfo { color: var(--running); }

View File

@@ -81,3 +81,80 @@ describe('RouteFlow', () => {
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for OrderValidator') expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for OrderValidator')
}) })
}) })
const multiFlows = [
{
label: 'Main Route',
nodes: [
{ name: 'timer:tick', type: 'from' as const, durationMs: 0, status: 'ok' as const },
{ name: 'Processor1', type: 'process' as const, durationMs: 8, status: 'ok' as const },
],
},
{
label: 'onException',
variant: 'error' as const,
nodes: [
{ name: 'LogHandler', type: 'process' as const, durationMs: 3, status: 'ok' as const },
{ name: 'dead-letter:errors', type: 'to' as const, durationMs: 8, status: 'fail' as const },
],
},
]
describe('RouteFlow (multi-flow)', () => {
it('renders all segment labels', () => {
render(<RouteFlow flows={multiFlows} />)
expect(screen.getByText('Main Route')).toBeInTheDocument()
expect(screen.getByText('onException')).toBeInTheDocument()
})
it('renders all nodes across segments', () => {
render(<RouteFlow flows={multiFlows} />)
expect(screen.getByText('timer:tick')).toBeInTheDocument()
expect(screen.getByText('Processor1')).toBeInTheDocument()
expect(screen.getByText('LogHandler')).toBeInTheDocument()
expect(screen.getByText('dead-letter:errors')).toBeInTheDocument()
})
it('uses global flat indexing for onNodeClick', async () => {
const onNodeClick = vi.fn()
const user = userEvent.setup()
render(<RouteFlow flows={multiFlows} onNodeClick={onNodeClick} />)
// Click the first node of the second flow (global index = 2)
await user.click(screen.getByText('LogHandler'))
expect(onNodeClick).toHaveBeenCalledWith(
expect.objectContaining({ name: 'LogHandler' }),
2,
)
})
it('selectedIndex highlights correct node across flows', () => {
const { container } = render(<RouteFlow flows={multiFlows} selectedIndex={3} />)
// Index 3 = dead-letter:errors (2nd node of 2nd flow)
const selectedNodes = container.querySelectorAll('[class*="nodeSelected"]')
expect(selectedNodes.length).toBe(1)
expect(selectedNodes[0]).toHaveTextContent('dead-letter:errors')
})
it('actions work in multi-flow mode', () => {
const { container } = render(
<RouteFlow
flows={multiFlows}
actions={[{ label: 'Test Action', onClick: () => {} }]}
/>,
)
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
expect(triggers.length).toBe(4)
})
it('flows takes precedence over nodes', () => {
render(
<RouteFlow
nodes={nodes}
flows={multiFlows}
/>,
)
// Should render flow content, not nodes content
expect(screen.getByText('Main Route')).toBeInTheDocument()
expect(screen.queryByText('jms:orders')).not.toBeInTheDocument()
})
})

View File

@@ -1,6 +1,8 @@
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'
import { formatDuration } from '../../../utils/format-utils'
export interface NodeBadge { export interface NodeBadge {
label: string label: string
@@ -25,8 +27,15 @@ export interface NodeAction {
divider?: boolean divider?: boolean
} }
interface RouteFlowProps { export interface FlowSegment {
label: string
nodes: RouteNode[] nodes: RouteNode[]
variant?: 'default' | 'error' | 'warning' | 'info'
}
interface RouteFlowProps {
nodes?: RouteNode[]
flows?: FlowSegment[]
onNodeClick?: (node: RouteNode, index: number) => void onNodeClick?: (node: RouteNode, index: number) => void
selectedIndex?: number selectedIndex?: number
actions?: NodeAction[] actions?: NodeAction[]
@@ -34,12 +43,6 @@ interface RouteFlowProps {
className?: string className?: string
} }
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
return `${ms}ms`
}
function durationClass(ms: number, status: string): string { function durationClass(ms: number, status: string): string {
if (status === 'fail') return styles.durBreach if (status === 'fail') return styles.durBreach
if (ms < 50) return styles.durFast if (ms < 50) return styles.durFast
@@ -48,12 +51,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> = {
@@ -93,7 +96,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}
@@ -102,12 +105,110 @@ function renderActionTrigger(
) )
} }
export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) { const FLOW_LABEL_CLASSES: Record<string, string> = {
const mainNodes = nodes.filter((n) => n.type !== 'error-handler') 'default': styles.flowLabelDefault,
const errorHandlers = nodes.filter((n) => n.type === 'error-handler') 'error': styles.flowLabelError,
'warning': styles.flowLabelWarning,
'info': styles.flowLabelInfo,
}
function renderNodeChain(
nodes: RouteNode[],
globalIndexOffset: number,
onNodeClick?: RouteFlowProps['onNodeClick'],
selectedIndex?: number,
actions?: NodeAction[],
getActions?: (node: RouteNode, index: number) => NodeAction[],
) {
const isClickable = !!onNodeClick
return nodes.map((node, i) => {
const globalIndex = globalIndexOffset + i
const isSelected = selectedIndex === globalIndex
return (
<div key={i} className={styles.nodeWrapper}>
{i > 0 && (
<div className={styles.connector}>
<div className={styles.connectorLine} />
<div className={styles.connectorArrow} />
</div>
)}
<div
className={`${styles.node} ${nodeStatusClass(node)} ${isSelected ? styles.nodeSelected : ''} ${isClickable ? styles.nodeClickable : ''}`}
onClick={() => onNodeClick?.(node, globalIndex)}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
onKeyDown={(e) => {
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
onNodeClick?.(node, globalIndex)
}
}}
>
{(node.isBottleneck || node.badges?.length) ? (
<span className={styles.badgeRow}>
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
{node.badges?.map((badge, bi) => (
<span
key={bi}
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
style={badge.onClick ? { cursor: 'pointer' } : undefined}
>
{badge.label}
</span>
))}
</span>
) : null}
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
{TYPE_ICONS[node.type] ?? <Square size={14} />}
</div>
<div className={styles.info}>
<div className={styles.type}>{node.type}</div>
<div className={styles.label} title={node.name}>{node.name}</div>
</div>
<div className={styles.stats}>
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
{formatDuration(node.durationMs)}
</div>
</div>
{renderActionTrigger(node, globalIndex, isSelected, actions, getActions)}
</div>
</div>
)
})
}
export function RouteFlow({ nodes, flows, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) {
// Multi-flow mode
if (flows && flows.length > 0) {
let globalOffset = 0
return (
<div className={`${styles.wrapper} ${className ?? ''}`}>
{flows.map((flow, fi) => {
const sectionOffset = globalOffset
globalOffset += flow.nodes.length
const variant = flow.variant ?? 'default'
const labelClass = FLOW_LABEL_CLASSES[variant] ?? styles.flowLabelDefault
return (
<div key={fi} className={`${styles.flowSection} ${fi > 0 ? styles.flowSectionSeparated : ''}`}>
<div className={`${styles.flowLabel} ${labelClass}`}>{flow.label}</div>
{renderNodeChain(flow.nodes, sectionOffset, onNodeClick, selectedIndex, actions, getActions)}
</div>
)
})}
</div>
)
}
// Legacy mode (single nodes array with automatic error-handler separation)
const allNodes = nodes ?? []
const mainNodes = allNodes.filter((n) => n.type !== 'error-handler')
const errorHandlers = allNodes.filter((n) => n.type === 'error-handler')
// Map from mainNodes index back to original nodes index // Map from mainNodes index back to original nodes index
const mainNodeOriginalIndices = nodes.reduce<number[]>((acc, n, idx) => { const mainNodeOriginalIndices = allNodes.reduce<number[]>((acc, n, idx) => {
if (n.type !== 'error-handler') acc.push(idx) if (n.type !== 'error-handler') acc.push(idx)
return acc return acc
}, []) }, [])
@@ -120,7 +221,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActio
const isClickable = !!onNodeClick const isClickable = !!onNodeClick
return ( return (
<div key={i} style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}> <div key={i} className={styles.nodeWrapper}>
{i > 0 && ( {i > 0 && (
<div className={styles.connector}> <div className={styles.connector}>
<div className={styles.connectorLine} /> <div className={styles.connectorLine} />
@@ -155,7 +256,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActio
</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>
@@ -176,7 +277,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActio
<div className={styles.errorSection}> <div className={styles.errorSection}>
<div className={styles.errorLabel}>Error Handler</div> <div className={styles.errorLabel}>Error Handler</div>
{errorHandlers.map((node, i) => { {errorHandlers.map((node, i) => {
const errOriginalIndex = nodes.indexOf(node) const errOriginalIndex = allNodes.indexOf(node)
return ( return (
<div key={i} className={`${styles.node} ${styles.nodeError}`}> <div key={i} className={`${styles.node} ${styles.nodeError}`}>
<div className={`${styles.icon} ${styles.iconErrorHandler}`}> <div className={`${styles.icon} ${styles.iconErrorHandler}`}>

View File

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

View File

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

View File

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

View File

@@ -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

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

View File

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

View File

@@ -13,6 +13,7 @@ export type { Column, DataTableProps } from './DataTable/types'
export { DetailPanel } from './DetailPanel/DetailPanel' export { DetailPanel } from './DetailPanel/DetailPanel'
export { EntityList } from './EntityList/EntityList' export { EntityList } from './EntityList/EntityList'
export { Dropdown } from './Dropdown/Dropdown' export { Dropdown } from './Dropdown/Dropdown'
export type { DropdownItem } from './Dropdown/Dropdown'
export { EventFeed } from './EventFeed/EventFeed' export { EventFeed } from './EventFeed/EventFeed'
export { GroupCard } from './GroupCard/GroupCard' export { GroupCard } from './GroupCard/GroupCard'
export { KpiStrip } from './KpiStrip/KpiStrip' export { KpiStrip } from './KpiStrip/KpiStrip'
@@ -34,10 +35,14 @@ export { Popover } from './Popover/Popover'
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline' export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline' export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline'
export { RouteFlow } from './RouteFlow/RouteFlow' export { RouteFlow } from './RouteFlow/RouteFlow'
export type { RouteNode, NodeAction, NodeBadge } from './RouteFlow/RouteFlow' export type { RouteNode, NodeAction, NodeBadge, FlowSegment } from './RouteFlow/RouteFlow'
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar' export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs' export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
export { SplitPane } from './SplitPane/SplitPane' 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

@@ -7,5 +7,8 @@ export * from './layout'
export * from './providers/ThemeProvider' export * from './providers/ThemeProvider'
export * from './providers/CommandPaletteProvider' export * from './providers/CommandPaletteProvider'
export * from './providers/GlobalFilterProvider' export * from './providers/GlobalFilterProvider'
export { BreadcrumbProvider, useBreadcrumb } 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

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

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 {
@@ -34,7 +70,7 @@
.version { .version {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
color: var(--sidebar-muted); color: var(--sidebar-muted);
margin-left: 4px; margin-left: 4px;
} }
@@ -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 {
@@ -239,7 +238,7 @@
} }
.treeSectionLabel { .treeSectionLabel {
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
@@ -314,7 +313,7 @@
padding: 0; padding: 0;
margin: 0; margin: 0;
color: var(--sidebar-muted); color: var(--sidebar-muted);
font-size: 11px; font-size: 12px;
cursor: pointer; cursor: pointer;
line-height: 1; line-height: 1;
display: flex; display: flex;
@@ -346,7 +345,7 @@
/* Badge */ /* Badge */
.treeBadge { .treeBadge {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 12px;
color: var(--sidebar-muted); color: var(--sidebar-muted);
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
padding: 1px 6px; padding: 1px 6px;
@@ -383,100 +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

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

View File

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

View File

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

View File

@@ -1,101 +1,45 @@
import { type ReactNode } from 'react'
import { Moon, Sun, Power } from 'lucide-react'
import styles from './TopBar.module.css' import styles from './TopBar.module.css'
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb' import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
import { Dropdown } from '../../composites/Dropdown/Dropdown' import { Dropdown } from '../../composites/Dropdown/Dropdown'
import { Avatar } from '../../primitives/Avatar/Avatar' import { Avatar } from '../../primitives/Avatar/Avatar'
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
import { useTheme } from '../../providers/ThemeProvider' import { useTheme } from '../../providers/ThemeProvider'
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
interface BreadcrumbItem { import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
label: string
href?: string
}
interface TopBarProps { interface TopBarProps {
breadcrumb: BreadcrumbItem[] breadcrumb: BreadcrumbItem[]
environment?: string environment?: ReactNode
user?: { name: string } user?: { name: string }
userMenuItems?: import('../../composites/Dropdown/Dropdown').DropdownItem[]
onLogout?: () => void onLogout?: () => void
className?: string className?: string
children?: ReactNode
} }
const STATUS_ITEMS: ButtonGroupItem[] = [
{ value: 'completed', label: 'OK', color: 'var(--success)' },
{ value: 'warning', label: 'Warn', color: 'var(--warning)' },
{ value: 'failed', label: 'Error', color: 'var(--error)' },
{ value: 'running', label: 'Running', color: 'var(--running)' },
]
export function TopBar({ export function TopBar({
breadcrumb, breadcrumb,
environment, environment,
user, user,
userMenuItems,
onLogout, onLogout,
className, className,
children,
}: TopBarProps) { }: TopBarProps) {
const globalFilters = useGlobalFilters()
const commandPalette = useCommandPalette()
const { theme, toggleTheme } = useTheme() const { theme, toggleTheme } = useTheme()
const breadcrumbOverride = useBreadcrumbOverride()
return ( return (
<header className={`${styles.topbar} ${className ?? ''}`}> <header className={`${styles.topbar} ${className ?? ''}`}>
{/* Left: Breadcrumb */} {/* Left: Breadcrumb */}
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} /> <Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
{/* Search trigger */} {/* Center: consumer-provided controls */}
<button {children}
className={styles.search}
onClick={() => commandPalette.setOpen(true)}
type="button"
aria-label="Open search"
>
<span className={styles.searchIcon} aria-hidden="true">
<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>
</span>
<span className={styles.searchPlaceholder}>Search... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span>
</button>
{/* Status filter group */} {/* Right: theme toggle, env badge, user */}
<ButtonGroup
items={STATUS_ITEMS}
value={globalFilters.statusFilters}
onChange={(selected) => {
// Sync with global filter by toggling the diff
const current = globalFilters.statusFilters
for (const v of selected) {
if (!current.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
for (const v of current) {
if (!selected.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
}}
/>
{/* Time range pills */}
<TimeRangeDropdown
value={globalFilters.timeRange}
onChange={globalFilters.setTimeRange}
/>
{/* Right: auto-refresh toggle, theme toggle, env badge, user */}
<div className={styles.right}> <div className={styles.right}>
<button
className={`${styles.liveToggle} ${globalFilters.autoRefresh ? styles.liveToggleActive : ''}`}
onClick={() => globalFilters.setAutoRefresh(!globalFilters.autoRefresh)}
type="button"
aria-label={globalFilters.autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh'}
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
>
<span className={styles.liveDot} />
{globalFilters.autoRefresh ? 'LIVE' : 'PAUSED'}
</button>
<button <button
className={styles.themeToggle} className={styles.themeToggle}
onClick={toggleTheme} onClick={toggleTheme}
@@ -103,10 +47,10 @@ 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> <div className={styles.env}>{environment}</div>
)} )}
{user && ( {user && (
<Dropdown <Dropdown
@@ -117,7 +61,9 @@ export function TopBar({
</div> </div>
} }
items={[ items={[
{ label: 'Logout', icon: '\u23FB', onClick: onLogout }, ...(userMenuItems ?? []),
...(userMenuItems?.length ? [{ label: '', divider: true }] : []),
{ label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
]} ]}
/> />
)} )}

View File

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

View File

@@ -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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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

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

View File

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

View File

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

View File

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

View File

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

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