Compare commits

..

65 Commits

Author SHA1 Message Date
hsiegeln
8b32fe3994 chore: bump version to 0.1.45
All checks were successful
Build & Publish / publish (push) Successful in 1m40s
2026-04-12 17:24:58 +02:00
hsiegeln
250cbec7b7 fix: reduce version text size and increase separation from brand name
Version in sidebar header now 10px (was 12px), 8px margin (was 4px),
with reduced opacity for visual hierarchy.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:59:15 +02:00
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
132 changed files with 5727 additions and 1745 deletions

View File

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

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ dist/
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 type { Column, KpiItem, LogEntry } from '../design-system/composites'
import { AppShell } from '../design-system/layout/AppShell'
import { Sidebar } from '../design-system/layout/Sidebar/Sidebar'
import { SidebarTree } from '../design-system/layout/Sidebar/SidebarTree'
import type { SidebarTreeNode } from '../design-system/layout/Sidebar/SidebarTree'
import { useStarred } from '../design-system/layout/Sidebar/useStarred'
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
```
@@ -93,6 +97,10 @@ import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
// All components from single entry
import { Button, Input, Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, StatusText, AppShell } from '@cameleer/design-system'
// Sidebar (compound component — compose your own navigation)
import { Sidebar, SidebarTree, useStarred } from '@cameleer/design-system'
import type { SidebarTreeNode } from '@cameleer/design-system'
// Types
import type { Column, DataTableProps, SearchResult, KpiItem, LogEntry } from '@cameleer/design-system'
@@ -104,6 +112,121 @@ import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
// Utils
import { hashColor } from '@cameleer/design-system'
// Recharts theme (for advanced charts — treemap, radar, heatmap, etc.)
import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
import type { ChartSeries, DataPoint } from '@cameleer/design-system'
// Styles (once, at app root)
import '@cameleer/design-system/style.css'
// Brand assets (static files via ./assets/* export)
import logo from '@cameleer/design-system/assets/cameleer3-logo.png' // full resolution
import logo32 from '@cameleer/design-system/assets/cameleer3-32.png' // 32×32 favicon
import logo180 from '@cameleer/design-system/assets/cameleer3-180.png' // Apple touch icon
import logo192 from '@cameleer/design-system/assets/cameleer3-192.png' // Android/PWA icon
import logo512 from '@cameleer/design-system/assets/cameleer3-512.png' // PWA splash, og:image
import logoSvg from '@cameleer/design-system/assets/cameleer3-logo.svg' // high-detail SVG logo
import camelSvg from '@cameleer/design-system/assets/camel-logo.svg' // simplified camel SVG
```
<!-- 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**
### "I need navigation"
- App-level sidebar nav → **Sidebar** (via AppShell) — hierarchical trees with starring
- App-level sidebar nav → **Sidebar** (compound component — compose sections, trees, footer links)
- Sidebar tree section → **SidebarTree** (data-driven tree with expand/collapse, starring, keyboard nav)
- Starred items persistence → **useStarred** hook (localStorage-backed)
- Breadcrumb trail → **Breadcrumb**
- Paginated data → **Pagination** (standalone) or **DataTable** (built-in pagination)
- Hierarchical tree navigation → **TreeView** (generic) or **SidebarTree** (sidebar-specific, internal)
- Hierarchical tree navigation → **TreeView** (generic)
### "I need floating content"
- Tooltip on hover → **Tooltip**
@@ -55,6 +57,7 @@
- Time series → **LineChart**, **AreaChart**
- Categorical comparison → **BarChart**
- Inline trend → **Sparkline**
- Advanced charts (treemap, radar, heatmap, pie, etc.) → **Recharts** with `rechartsTheme` (see [Charting Strategy](#charting-strategy))
- Event log → **EventFeed**
- Processing pipeline (Gantt view) → **ProcessorTimeline**
- Processing pipeline (flow diagram) → **RouteFlow**
@@ -98,7 +101,24 @@
### Standard page layout
```
AppShell → Sidebar + TopBar + main content + optional DetailPanel
AppShell → Sidebar (compound) + TopBar + main content + optional DetailPanel
Sidebar compound API:
<Sidebar collapsed={bool} onCollapseToggle={fn} searchValue={str} onSearchChange={fn}>
<Sidebar.Header logo={node} title="str" version="str" />
<Sidebar.Section label="str" icon={node} open={bool} onToggle={fn} active={bool}>
<SidebarTree nodes={[...]} selectedPath="..." filterQuery="..." ... />
</Sidebar.Section>
<Sidebar.Footer>
<Sidebar.FooterLink icon={node} label="str" onClick={fn} active={bool} />
</Sidebar.Footer>
</Sidebar>
Notes:
- Search input auto-renders between Header and first Section (not above Header)
- Section headers have no chevron — the entire row is clickable to toggle
- The app controls all content — sections, order, tree data, collapse state
- Sidebar provides the frame, search input, and icon-rail collapse mode
```
### Data page pattern
@@ -160,6 +180,53 @@ StatCard strip (top, recalculates per scope)
URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/:instanceId
```
## Charting Strategy
The design system includes built-in **AreaChart**, **BarChart**, **LineChart**, and **Sparkline** components for standard use cases. For advanced chart types (treemap, radar, heatmap, pie, sankey, etc.), consuming apps should use **Recharts** directly with the design system's theme preset for visual consistency.
**Recharts is the app's dependency, not the design system's.** The design system only exports a theme config object.
### Setup in consuming app
```bash
npm install recharts
```
### Usage with theme preset
```tsx
import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
import {
ResponsiveContainer, LineChart, Line,
CartesianGrid, XAxis, YAxis, Tooltip, Legend,
} from 'recharts'
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid {...rechartsTheme.cartesianGrid} />
<XAxis dataKey="name" {...rechartsTheme.xAxis} />
<YAxis {...rechartsTheme.yAxis} />
<Tooltip {...rechartsTheme.tooltip} />
<Legend {...rechartsTheme.legend} />
<Line dataKey="value" stroke={CHART_COLORS[0]} strokeWidth={2} dot={false} />
<Line dataKey="other" stroke={CHART_COLORS[1]} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
```
### Exports
| Export | Description |
|--------|-------------|
| `rechartsTheme.cartesianGrid` | Dashed gridlines, subtle stroke |
| `rechartsTheme.xAxis` | Mono font axis ticks, subtle color |
| `rechartsTheme.yAxis` | Mono font axis ticks, no axis line |
| `rechartsTheme.tooltip` | Surface bg, border, shadow, monospace values |
| `rechartsTheme.legend` | Matching text size and color |
| `rechartsTheme.colors` | The 8 `CHART_COLORS` (CSS variables with light/dark support) |
| `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` |
| `ChartSeries` / `DataPoint` | Type interfaces for chart data |
## Component Index
| Component | Layer | When to use |
@@ -179,7 +246,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| Checkbox | primitive | Boolean input with label |
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
| Collapsible | primitive | Single expand/collapse section |
| CommandPalette | composite | Full-screen search and command interface |
| CommandPalette | composite | Full-screen search and command interface. `SearchCategory` is an open `string` type — known categories (application, exchange, attribute, route, agent) have built-in labels; custom categories render with title-cased labels and appear as dynamic tabs. |
| ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
| DateRangePicker | primitive | Date range selection with presets |
@@ -236,8 +303,10 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| Component | Purpose |
|-----------|---------|
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
| Sidebar | Hierarchical navigation with Applications/Agents/Routes trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) |
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
| Sidebar | Composable compound sidebar shell with icon-rail collapse mode. Sub-components: `Sidebar.Header`, `Sidebar.Section`, `Sidebar.Footer`, `Sidebar.FooterLink`. The app controls all content via children — the DS provides the frame. |
| SidebarTree | Data-driven tree for sidebar sections. Accepts `nodes: SidebarTreeNode[]` with expand/collapse, starring, keyboard nav, search filter, and path-based selection highlighting. |
| useStarred | Hook for localStorage-backed starred item IDs. Returns `{ starredIds, isStarred, toggleStar }`. |
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment slot (`ReactNode` — pass a string for a static label or a custom dropdown for interactive selection), user avatar |
## Import Paths
@@ -248,6 +317,10 @@ import { Button, Input, Badge } from './design-system/primitives'
import { DataTable, Modal, Toast } from './design-system/composites'
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
import { AppShell } from './design-system/layout/AppShell'
import { Sidebar } from './design-system/layout/Sidebar/Sidebar'
import { SidebarTree } from './design-system/layout/Sidebar/SidebarTree'
import type { SidebarTreeNode } from './design-system/layout/Sidebar/SidebarTree'
import { useStarred } from './design-system/layout/Sidebar/useStarred'
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
```
@@ -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.
## Brand Assets
The design system ships logo assets as static files via the `./assets/*` package export. These are not React components — they resolve to file URLs when imported via a bundler. All PNGs have transparent backgrounds.
| File | Size | Use case |
|------|------|----------|
| `cameleer3-logo.png` | Original | Full resolution for print/marketing |
| `cameleer3-16.png` | 16×16 | Browser tab favicon |
| `cameleer3-32.png` | 32×32 | Standard favicon, bookmarks |
| `cameleer3-48.png` | 48×48 | Windows taskbar |
| `cameleer3-180.png` | 180×180 | Apple touch icon |
| `cameleer3-192.png` | 192×192 | Android/PWA icon |
| `cameleer3-512.png` | 512×512 | PWA splash, og:image |
| `cameleer3-logo.svg` | Vector | High-detail SVG logo (traced from PNG, transparent) |
| `camel-logo.svg` | Vector | Simplified camel SVG logo |
### Usage
```tsx
import logo from '@cameleer/design-system/assets/cameleer3-512.png'
<img src={logo} alt="Cameleer3" />
```
```html
<!-- Favicons in index.html -->
<link rel="icon" type="image/png" sizes="32x32" href="/cameleer3-32.png">
<link rel="apple-touch-icon" sizes="180x180" href="/cameleer3-180.png">
```
## Styling Rules
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)

3
assets/camel-logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
assets/cameleer3-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 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.

741
package-lock.json generated
View File

@@ -1,13 +1,14 @@
{
"name": "@cameleer/design-system",
"version": "0.1.6",
"version": "0.1.44",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@cameleer/design-system",
"version": "0.1.6",
"version": "0.1.44",
"dependencies": {
"lucide-react": "^1.7.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
@@ -20,6 +21,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^3.2.4",
"happy-dom": "^20.8.4",
"typescript": "^5.6.0",
"vite": "^6.0.0",
@@ -39,6 +41,20 @@
"dev": true,
"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": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -331,6 +347,16 @@
"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": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -773,6 +799,34 @@
"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": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -931,6 +985,17 @@
"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": {
"version": "1.58.2",
"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"
}
},
"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": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -1917,9 +2016,9 @@
"license": "MIT"
},
"node_modules/@vue/language-core/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"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": {
@@ -2025,7 +2124,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -2074,6 +2172,25 @@
"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": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -2098,9 +2215,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2202,6 +2319,26 @@
"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": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
@@ -2236,6 +2373,21 @@
"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": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -2313,6 +2465,13 @@
"license": "MIT",
"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": {
"version": "1.5.313",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
@@ -2320,6 +2479,13 @@
"dev": true,
"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": {
"version": "7.0.1",
"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": {
"version": "11.3.4",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
@@ -2511,6 +2694,61 @@
"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": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -2519,9 +2757,9 @@
"license": "ISC"
},
"node_modules/happy-dom": {
"version": "20.8.4",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.4.tgz",
"integrity": "sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==",
"version": "20.8.9",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz",
"integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2569,6 +2807,13 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz",
@@ -2605,6 +2850,106 @@
"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": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz",
@@ -2714,6 +3059,15 @@
"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": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -2735,6 +3089,47 @@
"@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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -2761,6 +3156,16 @@
"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": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz",
@@ -2833,6 +3238,13 @@
"dev": true,
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -2840,6 +3252,16 @@
"dev": true,
"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": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -2847,6 +3269,30 @@
"dev": true,
"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": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -2872,9 +3318,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3194,6 +3640,29 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -3201,6 +3670,19 @@
"dev": true,
"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": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -3252,6 +3734,103 @@
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -3327,6 +3906,21 @@
"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": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -3672,6 +4266,22 @@
"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": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -3689,6 +4299,107 @@
"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": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@cameleer/design-system",
"version": "0.1.6",
"version": "0.1.45",
"type": "module",
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",
@@ -10,10 +10,12 @@
"types": "./dist/index.es.d.ts",
"import": "./dist/index.es.js"
},
"./style.css": "./dist/style.css"
"./style.css": "./dist/style.css",
"./assets/*": "./assets/*"
},
"files": [
"dist"
"dist",
"assets"
],
"sideEffects": [
"*.css"
@@ -35,6 +37,7 @@
"test:e2e": "playwright test"
},
"dependencies": {
"lucide-react": "^1.7.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
@@ -52,6 +55,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^3.2.4",
"happy-dom": "^20.8.4",
"typescript": "^5.6.0",
"vite": "^6.0.0",

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Search, X, ChevronUp, ChevronDown } from 'lucide-react'
import styles from './CommandPalette.module.css'
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
@@ -13,24 +14,35 @@ interface CommandPaletteProps {
data: SearchResult[]
onOpen?: () => void
onQueryChange?: (query: string) => void
/** Called when Enter is pressed without the user explicitly selecting a result (arrow keys/click).
* Useful for applying the query as a full-text search filter. */
onSubmit?: (query: string) => void
}
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
all: 'All',
const KNOWN_CATEGORY_LABELS: Record<string, string> = {
application: 'Applications',
exchange: 'Exchanges',
attribute: 'Attributes',
route: 'Routes',
agent: 'Agents',
}
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
'all',
/** Preferred display order for known categories */
const KNOWN_CATEGORY_ORDER: string[] = [
'application',
'exchange',
'attribute',
'route',
'agent',
]
function categoryLabel(cat: string): string {
if (cat === 'all') return 'All'
if (KNOWN_CATEGORY_LABELS[cat]) return KNOWN_CATEGORY_LABELS[cat]
// Title-case unknown categories: "my-thing" → "My Thing", "foo_bar" → "Foo Bar"
return cat.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
function highlightText(text: string, query: string, matchRanges?: [number, number][]): ReactNode {
if (!query && (!matchRanges || matchRanges.length === 0)) return text
@@ -61,12 +73,13 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
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 [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
const [activeCategory, setActiveCategory] = useState<string>('all')
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
const [focusedIdx, setFocusedIdx] = useState(0)
const [expandedId, setExpandedId] = useState<string | null>(null)
const userNavigated = useRef(false)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
@@ -89,6 +102,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
setQuery('')
setFocusedIdx(0)
setExpandedId(null)
userNavigated.current = false
}
}, [open])
@@ -120,7 +134,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
// Group results by category
const grouped = useMemo(() => {
const map = new Map<SearchCategory, SearchResult[]>()
const map = new Map<string, SearchResult[]>()
for (const r of filtered) {
if (!map.has(r.category)) map.set(r.category, [])
map.get(r.category)!.push(r)
@@ -140,6 +154,19 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
return counts
}, [queryFiltered])
// Build tab list dynamically: 'all' + known categories (in order) + any unknown categories found in data
const visibleCategories = useMemo(() => {
const dataCategories = new Set(data.map((r) => r.category))
const tabs: string[] = ['all']
for (const cat of KNOWN_CATEGORY_ORDER) {
if (dataCategories.has(cat)) tabs.push(cat)
}
for (const cat of dataCategories) {
if (!tabs.includes(cat)) tabs.push(cat)
}
return tabs
}, [data])
function handleKeyDown(e: React.KeyboardEvent) {
switch (e.key) {
case 'Escape':
@@ -147,15 +174,20 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
break
case 'ArrowDown':
e.preventDefault()
userNavigated.current = true
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
break
case 'ArrowUp':
e.preventDefault()
userNavigated.current = true
setFocusedIdx((i) => Math.max(i - 1, 0))
break
case 'Enter':
e.preventDefault()
if (flatResults[focusedIdx]) {
if (!userNavigated.current && onSubmit && query.trim()) {
onSubmit(query.trim())
onClose()
} else if (flatResults[focusedIdx]) {
onSelect(flatResults[focusedIdx])
onClose()
}
@@ -173,10 +205,23 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
setScopeFilters((prev) => prev.filter((_, i) => i !== idx))
}
function toggleExpanded(e: React.MouseEvent, id: string) {
e.stopPropagation()
setExpandedId((prev) => (prev === id ? null : id))
}
if (!open) return null
return createPortal(
<div className={styles.overlay} onClick={onClose} data-testid="command-palette-overlay">
<div
className={styles.overlay}
onClick={onClose}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClose() }}
role="button"
tabIndex={0}
aria-label="Close command palette"
data-testid="command-palette-overlay"
>
<div
className={styles.panel}
onClick={(e) => e.stopPropagation()}
@@ -187,7 +232,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
>
{/* Search input area */}
<div className={styles.searchArea}>
<span className={styles.searchIcon} aria-hidden="true"></span>
<span className={styles.searchIcon} aria-hidden="true"><Search size={14} /></span>
{scopeFilters.map((sf, i) => (
<span key={i} className={styles.scopeTag}>
<span className={styles.scopeField}>{sf.field}:</span>
@@ -197,7 +242,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
onClick={() => removeScopeFilter(i)}
aria-label={`Remove filter ${sf.field}:${sf.value}`}
>
×
<X size={10} />
</button>
</span>
))}
@@ -210,6 +255,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
onChange={(e) => {
setQuery(e.target.value)
setFocusedIdx(0)
userNavigated.current = false
onQueryChange?.(e.target.value)
}}
aria-label="Search"
@@ -219,7 +265,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
{/* Category tabs */}
<div className={styles.tabs} role="tablist">
{ALL_CATEGORIES.map((cat) => (
{visibleCategories.map((cat) => (
<button
key={cat}
role="tab"
@@ -235,7 +281,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
setFocusedIdx(0)
}}
>
{CATEGORY_LABELS[cat]}
{categoryLabel(cat)}
{categoryCounts[cat] != null && (
<span className={styles.tabCount}>{categoryCounts[cat]}</span>
)}
@@ -256,7 +302,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
Array.from(grouped.entries()).map(([category, items]) => (
<div key={category} className={styles.group}>
<div className={styles.groupHeader}>
<SectionHeader>{CATEGORY_LABELS[category]}</SectionHeader>
<SectionHeader>{categoryLabel(category)}</SectionHeader>
</div>
{items.map((result) => {
const flatIdx = flatResults.indexOf(result)
@@ -279,7 +325,13 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
onSelect(result)
onClose()
}}
onMouseEnter={() => setFocusedIdx(flatIdx)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onSelect(result)
onClose()
}
}}
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
>
<div className={styles.itemMain}>
{result.icon && (
@@ -314,14 +366,11 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
{result.expandedContent && (
<button
className={styles.expandBtn}
onClick={(e) => {
e.stopPropagation()
setExpandedId((prev) => (prev === result.id ? null : result.id))
}}
onClick={(e) => toggleExpanded(e, result.id)}
aria-expanded={isExpanded}
aria-label="Toggle detail"
>
{isExpanded ? '▲' : '▼'}
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
)}
</div>
@@ -350,7 +399,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
</div>
<div className={styles.shortcut}>
<KeyboardHint keys="Enter" />
<span>Open</span>
<span>Search</span>
</div>
<div className={styles.shortcut}>
<KeyboardHint keys="Esc" />

View File

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

View File

@@ -12,6 +12,23 @@
box-shadow: none;
}
.fillHeight {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.fillHeight .scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.fillHeight .footer {
flex-shrink: 0;
}
.scroll {
overflow-x: auto;
}
@@ -25,7 +42,7 @@
.th {
padding: 9px 14px;
text-align: left;
font-size: 10px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
@@ -35,6 +52,9 @@
background: var(--bg-raised);
border-bottom: 1px solid var(--border);
transition: color 0.12s;
position: sticky;
top: 0;
z-index: 1;
}
.th.sortable {
@@ -127,7 +147,7 @@
.rangeInfo {
font-family: var(--font-mono);
font-size: 11px;
font-size: 12px;
}
.paginationRight {
@@ -137,7 +157,7 @@
}
.pageSizeLabel {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
}
@@ -153,7 +173,7 @@
.pageNum {
font-family: var(--font-mono);
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
min-width: 40px;
text-align: center;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@
.timestamp {
flex-shrink: 0;
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
min-width: 56px;
}
@@ -56,6 +56,11 @@
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
}
.levelTrace {
color: var(--text-faint);
background: color-mix(in srgb, var(--text-faint) 8%, transparent);
}
.message {
font-size: 12px;
font-family: var(--font-mono);

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

View File

@@ -3,7 +3,7 @@ import styles from './LogViewer.module.css'
export interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error' | 'debug'
level: 'info' | 'warn' | 'error' | 'debug' | 'trace'
message: string
}
@@ -18,6 +18,7 @@ const LEVEL_CLASS: Record<LogEntry['level'], string> = {
warn: styles.levelWarn,
error: styles.levelError,
debug: styles.levelDebug,
trace: styles.levelTrace,
}
function formatTime(iso: string): string {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import type { ReactNode } from 'react'
import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-react'
import styles from './RouteFlow.module.css'
import { Dropdown } from '../Dropdown/Dropdown'
import { formatDuration } from '../../../utils/format-utils'
export interface NodeBadge {
label: string
@@ -41,12 +43,6 @@ interface RouteFlowProps {
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 {
if (status === 'fail') return styles.durBreach
if (ms < 50) return styles.durFast
@@ -55,12 +51,12 @@ function durationClass(ms: number, status: string): string {
return styles.durBreach
}
const TYPE_ICONS: Record<string, string> = {
'from': '\u25B6',
'process': '\u2699',
'to': '\u25A2',
'choice': '\u25C6',
'error-handler': '\u26A0',
const TYPE_ICONS: Record<string, ReactNode> = {
'from': <Play size={14} />,
'process': <Cog size={14} />,
'to': <Square size={14} />,
'choice': <Diamond size={14} />,
'error-handler': <AlertTriangle size={14} />,
}
const ICON_CLASSES: Record<string, string> = {
@@ -100,7 +96,7 @@ function renderActionTrigger(
aria-label={`Actions for ${node.name}`}
type="button"
>
<EllipsisVertical size={14} />
</button>
}
items={resolvedActions}
@@ -166,7 +162,7 @@ function renderNodeChain(
</span>
) : null}
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
{TYPE_ICONS[node.type] ?? '\u25A2'}
{TYPE_ICONS[node.type] ?? <Square size={14} />}
</div>
<div className={styles.info}>
<div className={styles.type}>{node.type}</div>
@@ -260,7 +256,7 @@ export function RouteFlow({ nodes, flows, onNodeClick, selectedIndex, actions, g
</span>
) : null}
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
{TYPE_ICONS[node.type] ?? '\u25A2'}
{TYPE_ICONS[node.type] ?? <Square size={14} />}
</div>
<div className={styles.info}>
<div className={styles.type}>{node.type}</div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
import styles from './Toast.module.css'
// ── Types ──────────────────────────────────────────────────────────────────
@@ -39,11 +40,11 @@ const MAX_TOASTS = 5
const DEFAULT_DURATION = 5000
const EXIT_ANIMATION_MS = 300
const ICONS: Record<ToastVariant, string> = {
info: '',
success: '✓',
warning: '⚠',
error: '✕',
const ICONS: Record<ToastVariant, ReactNode> = {
info: <Info size={16} />,
success: <CheckCircle size={16} />,
warning: <AlertTriangle size={16} />,
error: <XCircle size={16} />,
}
// ── Context ────────────────────────────────────────────────────────────────
@@ -56,6 +57,10 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const dismiss = useCallback((id: string) => {
// Clear auto-dismiss timer if running
const timer = timersRef.current.get(id)
@@ -70,16 +75,15 @@ export function ToastProvider({ children }: { children: ReactNode }) {
)
// Remove after animation completes
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, EXIT_ANIMATION_MS)
}, [])
setTimeout(() => removeToast(id), EXIT_ANIMATION_MS)
}, [removeToast])
const toast = useCallback(
(options: ToastOptions): string => {
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
const duration = options.duration ?? DEFAULT_DURATION
const variant = options.variant ?? 'info'
// Error toasts persist until manually dismissed; others auto-close after DEFAULT_DURATION
const duration = options.duration ?? (variant === 'error' ? 0 : DEFAULT_DURATION)
const newToast: ToastItem = {
id,
@@ -96,11 +100,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return next.slice(-MAX_TOASTS)
})
// Schedule auto-dismiss
const timer = setTimeout(() => {
dismiss(id)
}, duration)
timersRef.current.set(id, timer)
// Schedule auto-dismiss (duration 0 = persist until manual dismiss)
if (duration > 0) {
const timer = setTimeout(() => {
dismiss(id)
}, duration)
timersRef.current.set(id, timer)
}
return id
},
@@ -183,7 +189,7 @@ function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) {
aria-label="Dismiss notification"
type="button"
>
&times;
<X size={14} />
</button>
</div>
)

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ export type { Column, DataTableProps } from './DataTable/types'
export { DetailPanel } from './DetailPanel/DetailPanel'
export { EntityList } from './EntityList/EntityList'
export { Dropdown } from './Dropdown/Dropdown'
export type { DropdownItem } from './Dropdown/Dropdown'
export { EventFeed } from './EventFeed/EventFeed'
export { GroupCard } from './GroupCard/GroupCard'
export { KpiStrip } from './KpiStrip/KpiStrip'
@@ -41,3 +42,7 @@ export { SplitPane } from './SplitPane/SplitPane'
export { Tabs } from './Tabs/Tabs'
export { ToastProvider, useToast } from './Toast/Toast'
export { TreeView } from './TreeView/TreeView'
// Chart utilities for consumers using Recharts or custom charts
export { CHART_COLORS } from './_chart-utils'
export type { ChartSeries, DataPoint } from './_chart-utils'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,40 +1,32 @@
import { type ReactNode } from 'react'
import { Moon, Sun, Power } from 'lucide-react'
import styles from './TopBar.module.css'
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
import { Dropdown } from '../../composites/Dropdown/Dropdown'
import { Avatar } from '../../primitives/Avatar/Avatar'
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
import { useTheme } from '../../providers/ThemeProvider'
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
interface TopBarProps {
breadcrumb: BreadcrumbItem[]
environment?: string
environment?: ReactNode
user?: { name: string }
userMenuItems?: import('../../composites/Dropdown/Dropdown').DropdownItem[]
onLogout?: () => void
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({
breadcrumb,
environment,
user,
userMenuItems,
onLogout,
className,
children,
}: TopBarProps) {
const globalFilters = useGlobalFilters()
const commandPalette = useCommandPalette()
const { theme, toggleTheme } = useTheme()
const breadcrumbOverride = useBreadcrumbOverride()
@@ -43,57 +35,11 @@ export function TopBar({
{/* Left: Breadcrumb */}
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
{/* Search trigger */}
<button
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>
{/* Center: consumer-provided controls */}
{children}
{/* Status filter group */}
<ButtonGroup
items={STATUS_ITEMS}
value={globalFilters.statusFilters}
onChange={(selected) => {
// Sync with global filter by toggling the diff
const current = globalFilters.statusFilters
for (const v of selected) {
if (!current.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
for (const v of current) {
if (!selected.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
}}
/>
{/* Time range pills */}
<TimeRangeDropdown
value={globalFilters.timeRange}
onChange={globalFilters.setTimeRange}
/>
{/* Right: auto-refresh toggle, theme toggle, env badge, user */}
{/* Right: theme toggle, env badge, user */}
<div className={styles.right}>
<button
className={`${styles.liveToggle} ${globalFilters.autoRefresh ? styles.liveToggleActive : ''}`}
onClick={() => globalFilters.setAutoRefresh(!globalFilters.autoRefresh)}
type="button"
aria-label={globalFilters.autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh'}
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
>
<span className={styles.liveDot} />
{globalFilters.autoRefresh ? 'LIVE' : 'PAUSED'}
</button>
<button
className={styles.themeToggle}
onClick={toggleTheme}
@@ -101,10 +47,10 @@ export function TopBar({
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? '\u263E' : '\u2600'}
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
</button>
{environment && (
<span className={styles.env}>{environment}</span>
<div className={styles.env}>{environment}</div>
)}
{user && (
<Dropdown
@@ -115,7 +61,9 @@ export function TopBar({
</div>
}
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 { 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 { SearchTrigger } from './TopBar/SearchTrigger'
export { AutoRefreshToggle } from './TopBar/AutoRefreshToggle'

View File

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

View File

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

View File

@@ -8,5 +8,5 @@
flex-shrink: 0;
}
.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; }

View File

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

View File

@@ -13,7 +13,7 @@
.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; }
.primary {

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
}
.label {
font-size: 11px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
@@ -18,7 +18,7 @@
background: transparent;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 11px;
font-size: 12px;
text-align: left;
cursor: pointer;
border-radius: var(--radius-sm);
@@ -72,7 +72,7 @@
border: none;
background: none;
color: var(--text-muted);
font-size: 10px;
font-size: 12px;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
@@ -93,7 +93,7 @@
}
.dayHeader {
font-size: 10px;
font-size: 12px;
font-weight: 600;
color: var(--text-faint);
text-align: center;
@@ -151,7 +151,7 @@
}
.timeLabel {
font-size: 11px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
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);
color: var(--text-secondary);
font-family: var(--font-body);
font-size: 11px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
@@ -49,7 +49,7 @@
.count {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
font-weight: 600;
background: var(--bg-inset);
border-radius: 8px;

View File

@@ -6,13 +6,13 @@
}
.hint {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.error {
font-size: 11px;
font-size: 12px;
color: var(--error);
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={isEmpty ? styles.placeholder : styles.value}
role="button"
tabIndex={disabled ? undefined : 0}
onClick={startEdit}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
startEdit()
}
}}
>
{isEmpty ? placeholder : value}
</span>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ export type ExchangeStatus = 'completed' | 'failed' | 'running' | 'warning'
interface GlobalFilterContextValue {
timeRange: TimeRange
setTimeRange: (range: TimeRange) => void
refreshTimeRange: () => void
statusFilters: Set<ExchangeStatus>
toggleStatus: (status: ExchangeStatus) => void
clearStatusFilters: () => void
@@ -76,6 +77,14 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
return () => clearInterval(id)
}, [autoRefresh, timeRange.preset])
// Recompute time range from preset on demand (for manual refresh in PAUSED mode)
const refreshTimeRange = useCallback(() => {
if (timeRange.preset) {
const { start, end } = computePresetRange(timeRange.preset)
setTimeRangeState({ start, end, preset: timeRange.preset })
}
}, [timeRange.preset])
const isInTimeRange = useCallback(
(timestamp: Date) => {
if (timeRange.preset) {
@@ -90,7 +99,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
return (
<GlobalFilterContext.Provider
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
value={{ timeRange, setTimeRange, refreshTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
>
{children}
</GlobalFilterContext.Provider>

View File

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

View File

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

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