Compare commits

...

32 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
98 changed files with 1308 additions and 579 deletions

1
.gitignore vendored
View File

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

119
CLAUDE.md
View File

@@ -40,6 +40,10 @@ import { Button, Input } from '../design-system/primitives'
import { Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer } from '../design-system/composites'
import 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'
@@ -110,4 +118,115 @@ 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**
@@ -99,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
@@ -227,7 +246,7 @@ import {
| 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 |
@@ -284,8 +303,10 @@ import {
| 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
@@ -296,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'
```
@@ -309,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

View File

@@ -73,6 +73,7 @@ The outer shell. Renders the sidebar frame with an optional search input and col
- 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>`
@@ -119,13 +120,13 @@ An accordion section with a collapsible header and content area.
**Expanded rendering:**
```
v [icon] APPLICATIONS
[icon] APPLICATIONS
(children rendered here)
```
**Collapsed rendering:**
```
> [icon] APPLICATIONS
[icon] APPLICATIONS
```
**In sidebar icon-rail mode:**
@@ -133,7 +134,7 @@ v [icon] APPLICATIONS
[icon] <- centered, tooltip shows label on hover
```
Header has: chevron (left), icon, label. Chevron rotates on collapse/expand. Active section gets the amber left-border accent (existing pattern). Clicking the header calls `onToggle`.
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.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"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",

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"

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

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

@@ -19,8 +19,7 @@ interface CommandPaletteProps {
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',
@@ -28,8 +27,8 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
agent: 'Agents',
}
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
'all',
/** Preferred display order for known categories */
const KNOWN_CATEGORY_ORDER: string[] = [
'application',
'exchange',
'attribute',
@@ -37,6 +36,13 @@ const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
'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
@@ -69,7 +75,7 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
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)
@@ -128,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)
@@ -148,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':
@@ -186,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()}
@@ -233,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"
@@ -249,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>
)}
@@ -270,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)
@@ -293,6 +325,12 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
onSelect(result)
onClose()
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onSelect(result)
onClose()
}
}}
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
>
<div className={styles.itemMain}>
@@ -328,10 +366,7 @@ 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"
>

View File

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

View File

@@ -42,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;
@@ -147,7 +147,7 @@
.rangeInfo {
font-family: var(--font-mono);
font-size: 11px;
font-size: 12px;
}
.paginationRight {
@@ -157,7 +157,7 @@
}
.pageSizeLabel {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
}
@@ -173,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

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

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

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

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

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

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

@@ -2,6 +2,7 @@ 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
@@ -42,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

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

@@ -57,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)
@@ -71,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,
@@ -97,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
},

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'

View File

@@ -72,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 */
@@ -215,6 +216,8 @@
gap: 2px;
width: 100%;
padding: 8px 0 4px;
cursor: pointer;
user-select: none;
}
.treeSectionChevronBtn {
@@ -236,7 +239,7 @@
}
.treeSectionLabel {
font-size: 10px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
@@ -311,7 +314,7 @@
padding: 0;
margin: 0;
color: var(--sidebar-muted);
font-size: 11px;
font-size: 12px;
cursor: pointer;
line-height: 1;
display: flex;
@@ -343,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;

View File

@@ -1,11 +1,9 @@
import { type ReactNode } from 'react'
import { type ReactNode, Children, isValidElement } from 'react'
import {
Search,
X,
ChevronsLeft,
ChevronsRight,
ChevronRight,
ChevronDown,
} from 'lucide-react'
import styles from './Sidebar.module.css'
import { SidebarContext, useSidebarContext } from './SidebarContext'
@@ -115,15 +113,15 @@ function SidebarSection({
return (
<div className={`${styles.treeSection} ${active ? styles.treeSectionActive : ''} ${className ?? ''}`}>
<div className={styles.treeSectionToggle}>
<button
className={styles.treeSectionChevronBtn}
onClick={onToggle}
aria-expanded={open}
aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
>
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
<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>
@@ -196,35 +194,50 @@ function SidebarRoot({
</button>
)}
{/* Search (only when expanded and handler provided) */}
{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>
)}
{/* 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
{children}
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>
)

View File

@@ -124,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({
@@ -222,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
@@ -371,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}

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,41 +1,32 @@
import { Search, Moon, Sun, Power } from 'lucide-react'
import { type ReactNode } from 'react'
import { Moon, Sun, Power } from 'lucide-react'
import styles from './TopBar.module.css'
import { 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()
@@ -44,54 +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">
<Search size={13} />
</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}
@@ -102,7 +50,7 @@ export function TopBar({
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
</button>
{environment && (
<span className={styles.env}>{environment}</span>
<div className={styles.env}>{environment}</div>
)}
{user && (
<Dropdown
@@ -113,6 +61,8 @@ export function TopBar({
</div>
}
items={[
...(userMenuItems ?? []),
...(userMenuItems?.length ? [{ label: '', divider: true }] : []),
{ label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
]}
/>

View File

@@ -4,3 +4,5 @@ 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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,7 +42,7 @@
.scopeSep {
color: var(--text-muted);
font-size: 10px;
font-size: 12px;
}
.scopeCurrent {
@@ -82,7 +82,7 @@
}
.chartMeta {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}

View File

@@ -49,7 +49,7 @@
}
.tableMeta {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
@@ -97,7 +97,7 @@
align-items: center;
gap: 5px;
font-family: var(--font-mono);
font-size: 11px;
font-size: 12px;
color: var(--text-secondary);
}
@@ -128,14 +128,14 @@
}
.inlineErrorText {
font-size: 11px;
font-size: 12px;
color: var(--error);
font-family: var(--font-mono);
line-height: 1.4;
}
.inlineErrorHint {
font-size: 10px;
font-size: 12px;
color: var(--text-muted);
margin-top: 3px;
}
@@ -154,7 +154,7 @@
}
.panelSectionTitle {
font-size: 11px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -168,7 +168,7 @@
.panelSectionMeta {
margin-left: auto;
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
@@ -189,7 +189,7 @@
}
.overviewLabel {
font-size: 10px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
@@ -209,14 +209,14 @@
.errorClass {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
font-weight: 600;
color: var(--error);
margin-bottom: 4px;
}
.errorMessage {
font-size: 11px;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
font-family: var(--font-mono);

View File

@@ -25,6 +25,9 @@ import { Badge } from '../../design-system/primitives/Badge/Badge'
// Global filters
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
// Utils
import { formatDuration, statusToVariant, statusLabel, toRouteNodeType, durationClass } from '../../utils/format-utils'
// Mock data
import { exchanges, type Exchange } from '../../mocks/exchanges'
import { kpiMetrics, type KpiMetric } from '../../mocks/metrics'
@@ -66,12 +69,6 @@ const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
}))
// ─── Helpers ─────────────────────────────────────────────────────────────────
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 formatTimestamp(date: Date): string {
const y = date.getFullYear()
const mo = String(date.getMonth() + 1).padStart(2, '0')
@@ -82,24 +79,6 @@ function formatTimestamp(date: Date): string {
return `${y}-${mo}-${d} ${h}:${mi}:${s}`
}
function statusToVariant(status: Exchange['status']): 'success' | 'error' | 'running' | 'warning' {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'running': return 'running'
case 'warning': return 'warning'
}
}
function statusLabel(status: Exchange['status']): string {
switch (status) {
case 'completed': return 'OK'
case 'failed': return 'ERR'
case 'running': return 'RUN'
case 'warning': return 'WARN'
}
}
// ─── Table columns (base, without navigate action) ──────────────────────────
const BASE_COLUMNS: Column<Exchange>[] = [
{
@@ -150,7 +129,7 @@ const BASE_COLUMNS: Column<Exchange>[] = [
header: 'Duration',
sortable: true,
render: (_, row) => (
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}>
<MonoText size="sm" className={durationClass(row.durationMs, row.status, styles)}>
{formatDuration(row.durationMs)}
</MonoText>
),
@@ -167,14 +146,6 @@ const BASE_COLUMNS: Column<Exchange>[] = [
},
]
function durationClass(ms: number, status: Exchange['status']): string {
if (status === 'failed') return styles.durBreach
if (ms < 100) return styles.durFast
if (ms < 200) return styles.durNormal
if (ms < 300) return styles.durSlow
return styles.durBreach
}
const SHORTCUTS = [
{ keys: 'Ctrl+K', label: 'Search' },
{ keys: '↑↓', label: 'Navigate rows' },
@@ -257,16 +228,6 @@ export function Dashboard() {
return undefined
}
// Map processor types to RouteNode types
function toRouteNodeType(procType: string): RouteNode['type'] {
switch (procType) {
case 'consumer': return 'from'
case 'transform': return 'process'
case 'enrich': return 'process'
default: return procType as RouteNode['type']
}
}
// Build RouteFlow nodes from exchange processors
const routeNodes: RouteNode[] = selectedExchange
? selectedExchange.processors.map((p) => ({

View File

@@ -75,7 +75,7 @@
}
.headerStatLabel {
font-size: 10px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
@@ -105,7 +105,7 @@
}
.chainLabel {
font-size: 10px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -120,7 +120,7 @@
padding: 4px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle);
font-size: 11px;
font-size: 12px;
font-family: var(--font-mono);
cursor: pointer;
background: var(--bg-surface);
@@ -187,7 +187,7 @@
.procCount {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
font-weight: 500;
padding: 1px 8px;
border-radius: 10px;
@@ -205,7 +205,7 @@
.toggleBtn {
padding: 4px 12px;
font-size: 11px;
font-size: 12px;
font-family: var(--font-body);
border: none;
background: transparent;
@@ -297,7 +297,7 @@
.panelTag {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
padding: 1px 6px;
border-radius: 8px;
background: var(--bg-inset);
@@ -326,7 +326,7 @@
grid-template-columns: 140px 1fr;
padding: 4px 0;
border-bottom: 1px solid var(--border-subtle);
font-size: 11px;
font-size: 12px;
}
.headerKvRow:last-child {
@@ -356,7 +356,7 @@
}
.sectionLabel {
font-size: 10px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
@@ -369,7 +369,7 @@
.count {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
padding: 0 5px;
border-radius: 8px;
background: var(--bg-inset);
@@ -389,7 +389,7 @@
padding: 2px 8px;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
font-weight: 600;
background: var(--error-bg);
color: var(--error);
@@ -398,7 +398,7 @@
.errorMessageBox {
font-family: var(--font-mono);
font-size: 11px;
font-size: 12px;
color: var(--text-secondary);
background: var(--error-bg);
padding: 10px 12px;
@@ -414,7 +414,7 @@
display: grid;
grid-template-columns: 120px 1fr;
gap: 4px 12px;
font-size: 11px;
font-size: 12px;
}
.errorDetailLabel {

View File

@@ -18,6 +18,9 @@ import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
// Utils
import { formatDuration, statusToVariant, toRouteNodeType } from '../../utils/format-utils'
// Mock data
import { exchanges } from '../../mocks/exchanges'
import { buildRouteToAppMap } from '../../mocks/sidebar'
@@ -25,21 +28,6 @@ import { buildRouteToAppMap } from '../../mocks/sidebar'
const ROUTE_TO_APP = buildRouteToAppMap()
// ─── Helpers ──────────────────────────────────────────────────────────────────
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 statusToVariant(status: 'completed' | 'failed' | 'running' | 'warning'): 'success' | 'error' | 'running' | 'warning' {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'running': return 'running'
case 'warning': return 'warning'
}
}
function statusToLabel(status: 'completed' | 'failed' | 'running' | 'warning'): string {
switch (status) {
case 'completed': return 'COMPLETED'
@@ -145,16 +133,6 @@ function generateExchangeSnapshotOut(
}
}
// Map processor types to RouteNode types
function toRouteNodeType(procType: string): RouteNode['type'] {
switch (procType) {
case 'consumer': return 'from'
case 'transform': return 'process'
case 'enrich': return 'process'
default: return procType as RouteNode['type']
}
}
// ─── ExchangeDetail component ─────────────────────────────────────────────────
export function ExchangeDetail() {
const { id } = useParams<{ id: string }>()

View File

@@ -57,7 +57,7 @@
}
.navLabel {
font-size: 11px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;

View File

@@ -3,6 +3,7 @@ import styles from './Inventory.module.css'
import { PrimitivesSection } from './sections/PrimitivesSection'
import { CompositesSection } from './sections/CompositesSection'
import { LayoutSection } from './sections/LayoutSection'
import { BrandAssetsSection } from './sections/BrandAssetsSection'
const NAV_SECTIONS = [
{
@@ -93,6 +94,13 @@ const NAV_SECTIONS = [
{ label: 'TopBar', href: '#topbar' },
],
},
{
label: 'Brand Assets',
href: '#brand-assets',
components: [
{ label: 'Cameleer3 Logo', href: '#brand-assets' },
],
},
]
export function Inventory() {
@@ -121,6 +129,7 @@ export function Inventory() {
<PrimitivesSection />
<CompositesSection />
<LayoutSection />
<BrandAssetsSection />
</main>
</div>
</div>

View File

@@ -0,0 +1,86 @@
.section {
margin-bottom: 40px;
}
.sectionTitle {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 24px;
}
.componentCard {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--shadow-sm);
}
.componentTitle {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
}
.componentDesc {
font-size: 12px;
color: var(--text-muted);
margin: 0 0 16px;
}
.demoArea {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
}
.logoGrid {
display: flex;
flex-wrap: wrap;
gap: 20px;
align-items: flex-end;
}
.logoItem {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.logoPreview {
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
min-width: 48px;
min-height: 48px;
}
.logoPreview img {
display: block;
image-rendering: auto;
}
.logoLabel {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.logoExport {
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-muted);
background: var(--bg-body);
padding: 2px 6px;
border-radius: var(--radius-sm);
white-space: nowrap;
}

View File

@@ -0,0 +1,77 @@
import styles from './BrandAssetsSection.module.css'
import camelLogoSvg from '../../../assets/camel-logo.svg'
import cameleer3Logo from '../../../assets/cameleer3-logo.png'
import cameleer3LogoSvg from '../../../assets/cameleer3-logo.svg'
const LOGO_SIZES = [16, 32, 48, 180, 192, 512] as const
export function BrandAssetsSection() {
return (
<section id="brand-assets" className={styles.section}>
<h2 className={styles.sectionTitle}>Brand Assets</h2>
<div className={styles.componentCard}>
<h3 className={styles.componentTitle}>Cameleer3 Logo (PNG)</h3>
<p className={styles.componentDesc}>
Full-resolution logo and pre-generated size variants for favicons, PWA icons, and social images.
Shipped as static assets via <code>@cameleer/design-system/assets/*</code> export.
</p>
<div className={styles.demoArea}>
<div className={styles.logoGrid}>
{LOGO_SIZES.map((size) => (
<div key={size} className={styles.logoItem}>
<div className={styles.logoPreview}>
<img
src={cameleer3Logo}
alt={`Logo ${size}×${size}`}
width={Math.min(size, 96)}
height={Math.min(size, 96)}
/>
</div>
<span className={styles.logoLabel}>{size}×{size}</span>
<code className={styles.logoExport}>assets/cameleer3-{size}.png</code>
</div>
))}
<div className={styles.logoItem}>
<div className={styles.logoPreview}>
<img
src={cameleer3Logo}
alt="Full resolution logo"
width={96}
height={96}
/>
</div>
<span className={styles.logoLabel}>Original</span>
<code className={styles.logoExport}>assets/cameleer3-logo.png</code>
</div>
</div>
</div>
</div>
<div className={styles.componentCard}>
<h3 className={styles.componentTitle}>SVG Logos</h3>
<p className={styles.componentDesc}>
Vector logos for scalable usage. Transparent backgrounds, infinitely scalable.
</p>
<div className={styles.demoArea}>
<div className={styles.logoGrid}>
<div className={styles.logoItem}>
<div className={styles.logoPreview}>
<img src={cameleer3LogoSvg} alt="Cameleer3 SVG logo" width={96} height={96} />
</div>
<span className={styles.logoLabel}>Cameleer3 SVG</span>
<code className={styles.logoExport}>assets/cameleer3-logo.svg</code>
</div>
<div className={styles.logoItem}>
<div className={styles.logoPreview}>
<img src={camelLogoSvg} alt="Camel SVG logo" width={96} height={96} />
</div>
<span className={styles.logoLabel}>Camel SVG</span>
<code className={styles.logoExport}>assets/camel-logo.svg</code>
</div>
</div>
</div>
</div>
</section>
)
}

View File

@@ -56,7 +56,7 @@
}
.demoLabel {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}

View File

@@ -56,7 +56,7 @@
}
.demoLabel {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}
@@ -80,7 +80,7 @@
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
}

View File

@@ -5,6 +5,8 @@ import type { SidebarTreeNode } from '../../../design-system/layout/Sidebar/Side
import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot'
import { Box, Settings, FileText, ChevronRight } from 'lucide-react'
import { TopBar } from '../../../design-system/layout/TopBar/TopBar'
import { SearchTrigger } from '../../../design-system/layout/TopBar/SearchTrigger'
import { AutoRefreshToggle } from '../../../design-system/layout/TopBar/AutoRefreshToggle'
// ── DemoCard helper ──────────────────────────────────────────────────────────
@@ -122,7 +124,7 @@ export function LayoutSection() {
<DemoCard
id="topbar"
title="TopBar"
description="Top navigation bar with breadcrumb, search trigger, status filters, time range, environment badge, and user avatar."
description="Composable top navigation bar. Consumers pass children for the center slot (search, filters, etc.). Shell provides breadcrumb, theme toggle, env badge, and user menu."
>
<div className={styles.topbarPreview}>
<TopBar
@@ -132,9 +134,11 @@ export function LayoutSection() {
{ label: 'order-ingest' },
]}
environment="production"
user={{ name: 'Hendrik' }}
/>
>
<SearchTrigger onClick={() => {}} />
<AutoRefreshToggle active={true} onChange={() => {}} />
</TopBar>
</div>
</DemoCard>
</section>

View File

@@ -56,7 +56,7 @@
}
.demoLabel {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}

View File

@@ -45,7 +45,7 @@
}
.routeGroup {
font-size: 11px;
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-muted);
background: var(--bg-inset);
@@ -78,7 +78,7 @@
}
.kpiLabel {
font-size: 10px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
@@ -124,7 +124,7 @@
}
.sectionMeta {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
@@ -167,7 +167,7 @@
}
.tableMeta {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
@@ -196,7 +196,7 @@
align-items: center;
gap: 5px;
font-family: var(--font-mono);
font-size: 11px;
font-size: 12px;
color: var(--text-secondary);
}
@@ -228,14 +228,14 @@
.errorClass {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
font-weight: 600;
color: var(--error);
margin-bottom: 4px;
}
.errorText {
font-size: 11px;
font-size: 12px;
color: var(--error);
font-family: var(--font-mono);
line-height: 1.4;
@@ -264,7 +264,7 @@
}
.errorPatternMessage {
font-size: 11px;
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
line-height: 1.5;
@@ -274,7 +274,7 @@
}
.errorPatternTime {
font-size: 10px;
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}

View File

@@ -17,39 +17,14 @@ import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
// Utils
import { formatDuration, statusToVariant, statusLabel, formatTimestamp, durationClass } from '../../utils/format-utils'
// Mock data
import { routes } from '../../mocks/routes'
import { exchanges, type Exchange } from '../../mocks/exchanges'
// ─── Helpers ──────────────────────────────────────────────────────────────────
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 formatTimestamp(date: Date): string {
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function statusToVariant(status: Exchange['status']): 'success' | 'error' | 'running' | 'warning' {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'running': return 'running'
case 'warning': return 'warning'
}
}
function statusLabel(status: Exchange['status']): string {
switch (status) {
case 'completed': return 'OK'
case 'failed': return 'ERR'
case 'running': return 'RUN'
case 'warning': return 'WARN'
}
}
function routeStatusVariant(status: 'healthy' | 'degraded' | 'down'): 'success' | 'warning' | 'error' {
switch (status) {
case 'healthy': return 'success'
@@ -58,14 +33,6 @@ function routeStatusVariant(status: 'healthy' | 'degraded' | 'down'): 'success'
}
}
function durationClass(ms: number, status: Exchange['status']): string {
if (status === 'failed') return styles.durBreach
if (ms < 100) return styles.durFast
if (ms < 200) return styles.durNormal
if (ms < 300) return styles.durSlow
return styles.durBreach
}
// ─── Columns for exchanges table ────────────────────────────────────────────
const EXCHANGE_COLUMNS: Column<Exchange>[] = [
{
@@ -106,7 +73,7 @@ const EXCHANGE_COLUMNS: Column<Exchange>[] = [
header: 'Duration',
sortable: true,
render: (_, row) => (
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}>
<MonoText size="sm" className={durationClass(row.durationMs, row.status, styles)}>
{formatDuration(row.durationMs)}
</MonoText>
),

View File

@@ -30,7 +30,7 @@
}
.refreshText {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
@@ -66,7 +66,7 @@
}
.tableMeta {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}

55
src/utils/format-utils.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { RouteNode } from '../design-system/composites/RouteFlow/RouteFlow'
export 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`
}
export function statusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'running': return 'running'
case 'warning': return 'warning'
default: return 'warning'
}
}
export function statusLabel(s: string): string {
switch (s) {
case 'completed': return 'OK'
case 'failed': return 'ERR'
case 'running': return 'RUN'
case 'warning': return 'WARN'
default: return s
}
}
export function formatTimestamp(date: Date): string {
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
export function toRouteNodeType(procType: string): RouteNode['type'] {
switch (procType) {
case 'consumer': return 'from'
case 'transform': return 'process'
case 'enrich': return 'process'
default: return procType as RouteNode['type']
}
}
interface DurationStyles {
durBreach: string
durFast: string
durNormal: string
durSlow: string
}
export function durationClass(ms: number, status: string, styles: DurationStyles): string {
if (status === 'failed') return styles.durBreach
if (ms < 100) return styles.durFast
if (ms < 200) return styles.durNormal
if (ms < 300) return styles.durSlow
return styles.durBreach
}