Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf4144380 | ||
|
|
dba3aa5a85 | ||
|
|
d775df61e4 | ||
|
|
7c6d383ac9 | ||
|
|
70a5106cca | ||
|
|
b0bd9a4ce2 | ||
|
|
df31ec55d8 | ||
|
|
2ed7f8bb0c | ||
|
|
90dee0f43e | ||
|
|
57d60bf2ed | ||
|
|
7e2fce8b14 | ||
|
|
96e5f77a14 | ||
|
|
1c2c00d266 | ||
|
|
70a4db94c1 | ||
|
|
83722aeb7c | ||
|
|
2709d4c164 | ||
|
|
549553c05b | ||
|
|
ac3b69f864 | ||
|
|
a62ff5b064 | ||
|
|
53a9ed015a | ||
|
|
38e42d10bb | ||
|
|
ae420246c8 | ||
|
|
7d9643bd1b | ||
|
|
99ff461d4d | ||
|
|
ce93ba456c | ||
|
|
3fc5fb8267 | ||
|
|
e07afe37f2 | ||
|
|
89453121a0 | ||
|
|
232868b9e7 | ||
|
|
8b32fe3994 | ||
|
|
250cbec7b7 | ||
|
|
dfac0db564 | ||
|
|
bb8e6d9d65 | ||
|
|
fd08d7a552 | ||
|
|
6ea2a29a7c | ||
|
|
51d5d9337a | ||
|
|
cd00a2e0fa | ||
|
|
b443fc787e | ||
|
|
4a6e6dea96 | ||
|
|
638b868649 | ||
|
|
433d0926e6 | ||
|
|
7e545140a2 | ||
|
|
e572df8558 | ||
|
|
a3afe3cb1b | ||
|
|
4841a7ad7c | ||
|
|
32a49690fa | ||
|
|
20f7b2f5aa | ||
|
|
5cb51e65be | ||
|
|
4dcd4aaa27 | ||
|
|
58320b9762 | ||
|
|
c48dffaef2 | ||
|
|
3ef4c5686e | ||
|
|
78e28789a5 | ||
|
|
03ec34bb5c | ||
|
|
2f1df869db | ||
|
|
0cf696cded | ||
|
|
50a1296a9d | ||
|
|
9b8739b5d8 | ||
|
|
ba6028c2ea | ||
|
|
93776944b9 | ||
|
|
9240acddb6 | ||
|
|
912adb1070 | ||
|
|
eeb2713612 | ||
|
|
18bf644040 | ||
|
|
9fa7eb129d | ||
|
|
8cd3c3a99d | ||
|
|
36999941c0 | ||
|
|
5a91875723 | ||
|
|
c401516b2d | ||
|
|
d2c2b92183 | ||
|
|
357e497220 | ||
|
|
1173b3e363 | ||
|
|
7092271fdc | ||
|
|
3561147b42 | ||
|
|
9afe626a58 | ||
|
|
7758962564 | ||
|
|
4e2d5b2b2f | ||
|
|
af48bd2fa0 | ||
|
|
592b60c5fe | ||
|
|
0bb49b83e5 | ||
|
|
8070fdea7c | ||
|
|
7830ac5e0d | ||
|
|
fdccca5378 | ||
|
|
0d4215678a | ||
|
|
28690b2a7a | ||
|
|
5eb807c572 | ||
|
|
f359a2ba3d | ||
|
|
384ee97643 | ||
|
|
a12b374fb2 | ||
|
|
433d582da6 | ||
|
|
2ffc268b44 | ||
|
|
99ae66315b | ||
|
|
26de5ec58f | ||
|
|
d26dc6a8a5 | ||
|
|
c0b1cbdc5b | ||
|
|
d101d883a9 | ||
|
|
2a020c1e15 | ||
|
|
19303eefad | ||
|
|
5fe6321d30 | ||
|
|
90e3de2cdf | ||
|
|
499c86b680 | ||
|
|
63e16d2685 | ||
|
|
19dccb8685 | ||
|
|
4b873194c9 | ||
|
|
5f1b039056 | ||
|
|
095abe1751 | ||
|
|
e8859e53ce | ||
|
|
021f6c7811 | ||
|
|
c18ba7d085 | ||
|
|
795ffef9dc | ||
|
|
039f2fa5fe | ||
|
|
ac2fb9608f | ||
|
|
8926627c5c | ||
|
|
bd4e22eafb | ||
|
|
eb62c80daf | ||
|
|
043f631eac | ||
|
|
2a78f1535e | ||
|
|
65ad955b97 | ||
|
|
80678a0d61 | ||
|
|
08bac437f7 | ||
|
|
8c1c953259 | ||
|
|
4abf80144e | ||
|
|
5fe7752b46 | ||
|
|
22c098f9b6 | ||
|
|
c89c163068 | ||
|
|
f00dc797f2 | ||
|
|
e664e449c3 | ||
|
|
b168d7c867 | ||
|
|
c4cb2b2e31 | ||
|
|
ef28c0b546 | ||
|
|
a62b69b8e2 | ||
|
|
ff4ba9bb91 | ||
|
|
c1cb9fa536 | ||
|
|
fd9b5e4fef | ||
|
|
ec0db5a011 | ||
|
|
bda0d11fde | ||
|
|
5c02b52cb0 | ||
|
|
be23161582 | ||
|
|
6521bbcf44 |
@@ -15,7 +15,21 @@
|
|||||||
"Bash(echo \"EXIT:$?\")",
|
"Bash(echo \"EXIT:$?\")",
|
||||||
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.4\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
|
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.4\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
|
||||||
"Bash(echo \"EXIT_CODE=$?\")",
|
"Bash(echo \"EXIT_CODE=$?\")",
|
||||||
"Bash(echo \"EXIT=$?\")"
|
"Bash(echo \"EXIT=$?\")",
|
||||||
|
"mcp__gitea__actions_config_read",
|
||||||
|
"mcp__gitea__search_repos",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"Bash(node -e \"console.log\\(JSON.parse\\(require\\(''fs''\\).readFileSync\\(''package.json'',''utf8''\\)\\).devDependencies[''vite-plugin-dts'']\\)\")",
|
||||||
|
"Bash(npx vite:*)",
|
||||||
|
"Bash(cd:*)",
|
||||||
|
"mcp__gitea__actions_run_read",
|
||||||
|
"mcp__gitea__get_file_contents",
|
||||||
|
"WebFetch(domain:ui.shadcn.com)",
|
||||||
|
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.5\\\\skills\\\\brainstorming\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
|
||||||
|
"Bash(bash \"C:/Users/Hendrik/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.5/skills/brainstorming/scripts/stop-server.sh\" \"C:/Users/Hendrik/Documents/projects/design-system/.superpowers/brainstorm/470-1774344716\")",
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(xargs cat:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npx vitest run
|
run: npx vitest run --exclude 'e2e/**'
|
||||||
|
|
||||||
- name: Build library
|
- name: Build library
|
||||||
run: npm run build:lib
|
run: npm run build:lib
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*)
|
refs/tags/v*)
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
npm version "$VERSION" --no-git-tag-version
|
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
62
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
name: SonarQube Analysis
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sonarqube:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: npx vitest run --exclude 'e2e/**' --coverage --coverage.reporter=lcov
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 17
|
||||||
|
|
||||||
|
- name: Install sonar-scanner
|
||||||
|
run: |
|
||||||
|
SONAR_SCANNER_VERSION=6.2.1.4610
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
PLATFORM="linux-aarch64"
|
||||||
|
else
|
||||||
|
PLATFORM="linux-x64"
|
||||||
|
fi
|
||||||
|
curl -sSLo sonar-scanner.zip "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_SCANNER_VERSION}-${PLATFORM}.zip"
|
||||||
|
unzip -q sonar-scanner.zip
|
||||||
|
echo "$PWD/sonar-scanner-${SONAR_SCANNER_VERSION}-${PLATFORM}/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Run SonarQube analysis
|
||||||
|
env:
|
||||||
|
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$SONAR_HOST_URL" ] || ! echo "$SONAR_HOST_URL" | grep -qE '^https?://'; then
|
||||||
|
echo "::error::SONAR_HOST_URL is missing or invalid (got: '$SONAR_HOST_URL'). Set it as a repo variable with full URL (e.g. https://sonar.example.com)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sonar-scanner \
|
||||||
|
-Dsonar.host.url="$SONAR_HOST_URL" \
|
||||||
|
-Dsonar.login="$SONAR_TOKEN" \
|
||||||
|
-Dsonar.projectKey=cameleer-design-system \
|
||||||
|
-Dsonar.projectName="Cameleer Design System" \
|
||||||
|
-Dsonar.sources=src/design-system \
|
||||||
|
-Dsonar.tests=src/design-system \
|
||||||
|
-Dsonar.test.inclusions="**/*.test.tsx,**/*.test.ts" \
|
||||||
|
-Dsonar.javascript.lcov.reportPaths=coverage/lcov.info \
|
||||||
|
-Dsonar.exclusions="**/node_modules/**,**/dist/**"
|
||||||
5
.gitignore
vendored
@@ -1,4 +1,9 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.claude/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
test-results/
|
||||||
|
screenshots/
|
||||||
|
.playwright-mcp/
|
||||||
|
.gitnexus
|
||||||
|
|||||||
101
AGENTS.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<!-- gitnexus:start -->
|
||||||
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
|
This project is indexed by GitNexus as **design-system** (1536 symbols, 2408 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 -->
|
||||||
131
CLAUDE.md
@@ -37,9 +37,13 @@ Always read `COMPONENT_GUIDE.md` before building any UI feature. It contains dec
|
|||||||
### Import Paths
|
### Import Paths
|
||||||
```tsx
|
```tsx
|
||||||
import { Button, Input } from '../design-system/primitives'
|
import { Button, Input } from '../design-system/primitives'
|
||||||
import { Modal, DataTable } from '../design-system/composites'
|
import { Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, LineChart, AreaChart, BarChart } from '../design-system/composites'
|
||||||
import type { Column } from '../design-system/composites'
|
import type { Column, KpiItem, LogEntry, ChartSeries } from '../design-system/composites'
|
||||||
import { AppShell } from '../design-system/layout/AppShell'
|
import { AppShell } from '../design-system/layout/AppShell'
|
||||||
|
import { Sidebar } from '../design-system/layout/Sidebar/Sidebar'
|
||||||
|
import { SidebarTree } from '../design-system/layout/Sidebar/SidebarTree'
|
||||||
|
import type { SidebarTreeNode } from '../design-system/layout/Sidebar/SidebarTree'
|
||||||
|
import { useStarred } from '../design-system/layout/Sidebar/useStarred'
|
||||||
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
|
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -91,10 +95,14 @@ import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// All components from single entry
|
// All components from single entry
|
||||||
import { Button, Input, Modal, DataTable, AppShell } from '@cameleer/design-system'
|
import { Button, Input, Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, StatusText, AppShell, LineChart, AreaChart, BarChart } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Sidebar (compound component — compose your own navigation)
|
||||||
|
import { Sidebar, SidebarTree, useStarred } from '@cameleer/design-system'
|
||||||
|
import type { SidebarTreeNode } from '@cameleer/design-system'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
|
import type { Column, DataTableProps, SearchResult, KpiItem, LogEntry } from '@cameleer/design-system'
|
||||||
|
|
||||||
// Providers
|
// Providers
|
||||||
import { ThemeProvider, useTheme } from '@cameleer/design-system'
|
import { ThemeProvider, useTheme } from '@cameleer/design-system'
|
||||||
@@ -104,6 +112,121 @@ import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
|
|||||||
// Utils
|
// Utils
|
||||||
import { hashColor } from '@cameleer/design-system'
|
import { hashColor } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Recharts theme (for advanced charts — treemap, radar, heatmap, etc.)
|
||||||
|
import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
|
||||||
|
import type { ChartSeries, DataPoint } from '@cameleer/design-system'
|
||||||
|
|
||||||
// Styles (once, at app root)
|
// Styles (once, at app root)
|
||||||
import '@cameleer/design-system/style.css'
|
import '@cameleer/design-system/style.css'
|
||||||
|
|
||||||
|
// Brand assets (static files via ./assets/* export)
|
||||||
|
import logo from '@cameleer/design-system/assets/cameleer3-logo.png' // full resolution
|
||||||
|
import logo32 from '@cameleer/design-system/assets/cameleer3-32.png' // 32×32 favicon
|
||||||
|
import logo180 from '@cameleer/design-system/assets/cameleer3-180.png' // Apple touch icon
|
||||||
|
import logo192 from '@cameleer/design-system/assets/cameleer3-192.png' // Android/PWA icon
|
||||||
|
import logo512 from '@cameleer/design-system/assets/cameleer3-512.png' // PWA splash, og:image
|
||||||
|
import logoSvg from '@cameleer/design-system/assets/cameleer3-logo.svg' // high-detail SVG logo
|
||||||
|
import camelSvg from '@cameleer/design-system/assets/camel-logo.svg' // simplified camel SVG
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<!-- gitnexus:start -->
|
||||||
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
|
This project is indexed by GitNexus as **design-system** (1536 symbols, 2408 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 -->
|
||||||
|
|||||||
@@ -33,14 +33,17 @@
|
|||||||
|
|
||||||
### "I need to show status"
|
### "I need to show status"
|
||||||
- Dot indicator → **StatusDot** (live, stale, dead, success, warning, error, running)
|
- Dot indicator → **StatusDot** (live, stale, dead, success, warning, error, running)
|
||||||
|
- Inline colored status value → **StatusText** (success, warning, error, running, muted — with optional bold)
|
||||||
- Labeled status → **Badge** with semantic color
|
- Labeled status → **Badge** with semantic color
|
||||||
- Removable label → **Tag**
|
- Removable label → **Tag**
|
||||||
|
|
||||||
### "I need navigation"
|
### "I need navigation"
|
||||||
- App-level sidebar nav → **Sidebar** (via AppShell) — hierarchical trees with starring
|
- App-level sidebar nav → **Sidebar** (compound component — compose sections, trees, footer links)
|
||||||
|
- Sidebar tree section → **SidebarTree** (data-driven tree with expand/collapse, starring, keyboard nav)
|
||||||
|
- Starred items persistence → **useStarred** hook (localStorage-backed)
|
||||||
- Breadcrumb trail → **Breadcrumb**
|
- Breadcrumb trail → **Breadcrumb**
|
||||||
- Paginated data → **Pagination** (standalone) or **DataTable** (built-in pagination)
|
- Paginated data → **Pagination** (standalone) or **DataTable** (built-in pagination)
|
||||||
- Hierarchical tree navigation → **TreeView** (generic) or **SidebarTree** (sidebar-specific, internal)
|
- Hierarchical tree navigation → **TreeView** (generic)
|
||||||
|
|
||||||
### "I need floating content"
|
### "I need floating content"
|
||||||
- Tooltip on hover → **Tooltip**
|
- Tooltip on hover → **Tooltip**
|
||||||
@@ -51,12 +54,18 @@
|
|||||||
### "I need to display data"
|
### "I need to display data"
|
||||||
- Key metrics → **StatCard** (with optional sparkline/trend)
|
- Key metrics → **StatCard** (with optional sparkline/trend)
|
||||||
- Tabular data → **DataTable** (sortable, paginated)
|
- Tabular data → **DataTable** (sortable, paginated)
|
||||||
- Time series → **LineChart**, **AreaChart**
|
- Time series (quick) → **LineChart** or **AreaChart** (convenience wrappers with series data)
|
||||||
- Categorical comparison → **BarChart**
|
- Categorical comparison (quick) → **BarChart** (convenience wrapper with series data)
|
||||||
|
- Time series (custom) → **ThemedChart** with `<Line>` or `<Area>`
|
||||||
|
- Categorical comparison (custom) → **ThemedChart** with `<Bar>`
|
||||||
- Inline trend → **Sparkline**
|
- Inline trend → **Sparkline**
|
||||||
|
- Advanced charts (treemap, radar, heatmap, pie, etc.) → **Recharts** with `rechartsTheme` (see [Charting Strategy](#charting-strategy))
|
||||||
- Event log → **EventFeed**
|
- Event log → **EventFeed**
|
||||||
- Processing pipeline (Gantt view) → **ProcessorTimeline**
|
- Processing pipeline (Gantt view) → **ProcessorTimeline**
|
||||||
- Processing pipeline (flow diagram) → **RouteFlow**
|
- Processing pipeline (flow diagram) → **RouteFlow**
|
||||||
|
- Row of summary KPIs → **KpiStrip** (horizontal strip with colored borders, trends, sparklines)
|
||||||
|
- Scrollable log output → **LogViewer** (timestamped, severity-colored monospace entries)
|
||||||
|
- Searchable, selectable entity list → **EntityList** (search header, selection highlighting, pairs with SplitPane)
|
||||||
|
|
||||||
### "I need to organize content"
|
### "I need to organize content"
|
||||||
- Collapsible sections (standalone) → **Collapsible**
|
- Collapsible sections (standalone) → **Collapsible**
|
||||||
@@ -64,15 +73,17 @@
|
|||||||
- Tabbed content → **Tabs**
|
- Tabbed content → **Tabs**
|
||||||
- Tab switching with pill/segment style → **SegmentedTabs**
|
- Tab switching with pill/segment style → **SegmentedTabs**
|
||||||
- Side panel inspector → **DetailPanel**
|
- Side panel inspector → **DetailPanel**
|
||||||
|
- Master/detail split layout → **SplitPane** (list on left, detail on right, configurable ratio)
|
||||||
- Section with title + action → **SectionHeader**
|
- Section with title + action → **SectionHeader**
|
||||||
- Empty content placeholder → **EmptyState**
|
- Empty content placeholder → **EmptyState**
|
||||||
- Grouped content box → **Card** (with optional accent)
|
- Grouped content box → **Card** (with optional accent and title)
|
||||||
- Grouped items with header + meta + footer → **GroupCard** (e.g., app instances)
|
- Grouped items with header + meta + footer → **GroupCard** (e.g., app instances)
|
||||||
|
|
||||||
### "I need to display text"
|
### "I need to display text"
|
||||||
- Code/JSON payload → **CodeBlock** (with line numbers, copy button)
|
- Code/JSON payload → **CodeBlock** (with line numbers, copy button)
|
||||||
- Monospace inline text → **MonoText**
|
- Monospace inline text → **MonoText**
|
||||||
- Keyboard shortcut hint → **KeyboardHint**
|
- Keyboard shortcut hint → **KeyboardHint**
|
||||||
|
- Colored inline status text → **StatusText** (semantic color + optional bold, see also "I need to show status")
|
||||||
|
|
||||||
### "I need to show people/users"
|
### "I need to show people/users"
|
||||||
- Single user avatar → **Avatar**
|
- Single user avatar → **Avatar**
|
||||||
@@ -92,7 +103,32 @@
|
|||||||
|
|
||||||
### Standard page layout
|
### Standard page layout
|
||||||
```
|
```
|
||||||
AppShell → Sidebar + TopBar + main content + optional DetailPanel
|
AppShell → Sidebar (compound) + TopBar + main content + optional DetailPanel
|
||||||
|
|
||||||
|
Sidebar compound API:
|
||||||
|
<Sidebar collapsed={bool} onCollapseToggle={fn} searchValue={str} onSearchChange={fn}>
|
||||||
|
<Sidebar.Header logo={node} title="str" version="str" />
|
||||||
|
<Sidebar.Section label="str" icon={node} open={bool} onToggle={fn} active={bool}>
|
||||||
|
<SidebarTree nodes={[...]} selectedPath="..." filterQuery="..." ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Section label="str" icon={node} open={bool} onToggle={fn} position="bottom" maxHeight="200px">
|
||||||
|
<SidebarTree nodes={[...]} ... />
|
||||||
|
</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
|
||||||
|
- `position="bottom"` stacks sections above the footer; a spacer separates top/bottom groups
|
||||||
|
- `maxHeight` (CSS string) constrains the content area — section header stays visible, children scroll
|
||||||
|
- Both groups scroll independently when the viewport is short
|
||||||
|
- Custom thin scrollbars match the dark sidebar aesthetic
|
||||||
|
- No expand button when collapsed — clicking any section icon expands the sidebar and opens that section
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data page pattern
|
### Data page pattern
|
||||||
@@ -112,7 +148,14 @@ FormField wraps any input (Input, Textarea, Select, RadioGroup, etc.)
|
|||||||
### KPI dashboard
|
### KPI dashboard
|
||||||
```
|
```
|
||||||
Row of StatCard components (each with optional Sparkline and trend)
|
Row of StatCard components (each with optional Sparkline and trend)
|
||||||
Below: charts (AreaChart, LineChart, BarChart)
|
Below: charts (ThemedChart with Line, Area, or Bar)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Master/detail management pattern
|
||||||
|
```
|
||||||
|
SplitPane + EntityList for CRUD list/detail screens (users, groups, roles)
|
||||||
|
EntityList provides: search header, add button, selectable list
|
||||||
|
SplitPane provides: responsive two-column layout with empty state
|
||||||
```
|
```
|
||||||
|
|
||||||
### Detail/inspector pattern
|
### Detail/inspector pattern
|
||||||
@@ -142,11 +185,84 @@ StatCard strip (top, recalculates per scope)
|
|||||||
→ GroupCard grid (2-col for all, full-width for single app)
|
→ GroupCard grid (2-col for all, full-width for single app)
|
||||||
Each GroupCard: header (app name + count) + meta (TPS, routes) + instance rows
|
Each GroupCard: header (app name + count) + meta (TPS, routes) + instance rows
|
||||||
Instance rows: StatusDot + name + Badge + metrics
|
Instance rows: StatusDot + name + Badge + metrics
|
||||||
Single instance: expanded with LineChart panels
|
Single instance: expanded with ThemedChart panels
|
||||||
→ EventFeed (bottom, filtered by scope)
|
→ EventFeed (bottom, filtered by scope)
|
||||||
URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/:instanceId
|
URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/:instanceId
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Charting Strategy
|
||||||
|
|
||||||
|
The design system provides convenience chart wrappers (**LineChart**, **AreaChart**, **BarChart**) for common use cases, plus a lower-level **ThemedChart** wrapper for full Recharts control. Recharts is bundled as a dependency — consumers do not need to install it separately.
|
||||||
|
|
||||||
|
### Quick Charts (convenience wrappers)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { LineChart, AreaChart, BarChart } from '@cameleer/design-system'
|
||||||
|
import type { ChartSeries } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
const series: ChartSeries[] = [
|
||||||
|
{ label: 'CPU', data: [{ x: '10:00', y: 45 }, { x: '10:05', y: 62 }] },
|
||||||
|
{ label: 'Memory', data: [{ x: '10:00', y: 70 }, { x: '10:05', y: 72 }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
<LineChart series={series} height={200} yLabel="%" />
|
||||||
|
<AreaChart series={series} height={200} yLabel="%" thresholdValue={85} thresholdLabel="Alert" />
|
||||||
|
<BarChart series={series} height={200} stacked />
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prop | LineChart | AreaChart | BarChart | Description |
|
||||||
|
|------|:---------:|:---------:|:--------:|-------------|
|
||||||
|
| `series` | required | required | required | `ChartSeries[]` — `{ label, data: { x, y }[], color? }` |
|
||||||
|
| `height` | optional | optional | optional | Chart height in pixels |
|
||||||
|
| `width` | optional | optional | optional | Container width in pixels |
|
||||||
|
| `yLabel` | optional | optional | optional | Y-axis label |
|
||||||
|
| `xLabel` | optional | optional | optional | X-axis label |
|
||||||
|
| `className` | optional | optional | optional | Container CSS class |
|
||||||
|
| `threshold` | `{ value, label }` | — | — | Horizontal reference line |
|
||||||
|
| `thresholdValue` | — | optional | — | Threshold y-value |
|
||||||
|
| `thresholdLabel` | — | optional | — | Threshold label |
|
||||||
|
| `stacked` | — | — | optional | Stack bars instead of grouping |
|
||||||
|
|
||||||
|
### Custom Charts (ThemedChart)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemedChart, Line, Area, ReferenceLine, CHART_COLORS } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
const data = metrics.map(m => ({ time: m.timestamp, cpu: m.value * 100 }))
|
||||||
|
|
||||||
|
<ThemedChart data={data} height={160} xDataKey="time" yLabel="%">
|
||||||
|
<Area dataKey="cpu" stroke={CHART_COLORS[0]} fill={CHART_COLORS[0]} fillOpacity={0.1} />
|
||||||
|
<ReferenceLine y={85} stroke="var(--error)" strokeDasharray="5 3" label="Alert" />
|
||||||
|
</ThemedChart>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ThemedChart Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `data` | `Record<string, any>[]` | required | Flat array of data objects |
|
||||||
|
| `height` | `number` | `200` | Chart height in pixels |
|
||||||
|
| `xDataKey` | `string` | `"time"` | Key for x-axis values |
|
||||||
|
| `xType` | `'number' \| 'category'` | `"category"` | X-axis scale type |
|
||||||
|
| `xTickFormatter` | `(value: any) => string` | — | Custom x-axis label formatter |
|
||||||
|
| `yTickFormatter` | `(value: any) => string` | — | Custom y-axis label formatter |
|
||||||
|
| `yLabel` | `string` | — | Y-axis label text |
|
||||||
|
| `children` | `ReactNode` | required | Recharts elements (Line, Area, Bar, etc.) |
|
||||||
|
| `className` | `string` | — | Container CSS class |
|
||||||
|
|
||||||
|
### Available Recharts Re-exports
|
||||||
|
|
||||||
|
`Line`, `Area`, `Bar`, `ReferenceLine`, `ReferenceArea`, `Legend`, `Brush`
|
||||||
|
|
||||||
|
For chart types not covered (treemap, radar, pie, sankey), import from `recharts` directly and use `rechartsTheme` for consistent styling.
|
||||||
|
|
||||||
|
### Theme Utilities
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` |
|
||||||
|
| `rechartsTheme` | Pre-configured prop objects for Recharts sub-components |
|
||||||
|
|
||||||
## Component Index
|
## Component Index
|
||||||
|
|
||||||
| Component | Layer | When to use |
|
| Component | Layer | When to use |
|
||||||
@@ -154,19 +270,19 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| Accordion | composite | Multiple collapsible sections, single or multi-open mode |
|
| Accordion | composite | Multiple collapsible sections, single or multi-open mode |
|
||||||
| Alert | primitive | Page-level attention banner with variant colors |
|
| Alert | primitive | Page-level attention banner with variant colors |
|
||||||
| AlertDialog | composite | Confirmation dialog for destructive/important actions |
|
| AlertDialog | composite | Confirmation dialog for destructive/important actions |
|
||||||
| AreaChart | composite | Time series visualization with filled area |
|
| AreaChart | composite | Convenience area chart wrapper — pass `series` data, get themed chart with fills |
|
||||||
|
| BarChart | composite | Convenience bar chart wrapper — grouped or `stacked` mode |
|
||||||
| Avatar | primitive | User representation with initials and color |
|
| Avatar | primitive | User representation with initials and color |
|
||||||
| AvatarGroup | composite | Stacked overlapping avatars with overflow count |
|
| AvatarGroup | composite | Stacked overlapping avatars with overflow count |
|
||||||
| Badge | primitive | Labeled status indicator with semantic colors |
|
| Badge | primitive | Labeled status indicator with semantic colors |
|
||||||
| BarChart | composite | Categorical data comparison, optional stacking |
|
|
||||||
| Breadcrumb | composite | Navigation path showing current location |
|
| Breadcrumb | composite | Navigation path showing current location |
|
||||||
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
|
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
|
||||||
| ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange |
|
| ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange |
|
||||||
| Card | primitive | Content container with optional accent border |
|
| Card | primitive | Content container with optional accent border and title header |
|
||||||
| Checkbox | primitive | Boolean input with label |
|
| Checkbox | primitive | Boolean input with label |
|
||||||
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
||||||
| Collapsible | primitive | Single expand/collapse section |
|
| Collapsible | primitive | Single expand/collapse section |
|
||||||
| CommandPalette | composite | Full-screen search and command interface |
|
| CommandPalette | composite | Full-screen search and command interface. `SearchCategory` is an open `string` type — known categories (application, exchange, attribute, route, agent) have built-in labels; custom categories render with title-cased labels and appear as dynamic tabs. |
|
||||||
| ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
|
| ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
|
||||||
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
|
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
|
||||||
| DateRangePicker | primitive | Date range selection with presets |
|
| DateRangePicker | primitive | Date range selection with presets |
|
||||||
@@ -174,6 +290,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| DetailPanel | composite | Slide-in side panel with tabs or children for scrollable content |
|
| DetailPanel | composite | Slide-in side panel with tabs or children for scrollable content |
|
||||||
| Dropdown | composite | Action menu triggered by any element |
|
| Dropdown | composite | Action menu triggered by any element |
|
||||||
| EmptyState | primitive | Placeholder for empty content areas |
|
| EmptyState | primitive | Placeholder for empty content areas |
|
||||||
|
| EntityList | composite | Searchable, selectable entity list with add button. Pair with SplitPane for CRUD management screens |
|
||||||
| EventFeed | composite | Chronological event log with severity |
|
| EventFeed | composite | Chronological event log with severity |
|
||||||
| FilterBar | composite | Search + filter controls for data views |
|
| FilterBar | composite | Search + filter controls for data views |
|
||||||
| GroupCard | composite | Card with header, meta row, children, and optional footer/alert. Used for grouping instances by application. |
|
| GroupCard | composite | Card with header, meta row, children, and optional footer/alert. Used for grouping instances by application. |
|
||||||
@@ -183,16 +300,18 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| InlineEdit | primitive | Click-to-edit text field. Enter saves, Escape/blur cancels. Props: value, onSave, placeholder, disabled, className |
|
| InlineEdit | primitive | Click-to-edit text field. Enter saves, Escape/blur cancels. Props: value, onSave, placeholder, disabled, className |
|
||||||
| Input | primitive | Single-line text input with optional icon |
|
| Input | primitive | Single-line text input with optional icon |
|
||||||
| KeyboardHint | primitive | Keyboard shortcut display |
|
| KeyboardHint | primitive | Keyboard shortcut display |
|
||||||
|
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
|
||||||
| Label | primitive | Form label with optional required asterisk |
|
| Label | primitive | Form label with optional required asterisk |
|
||||||
| LineChart | composite | Time series line visualization |
|
| LineChart | composite | Convenience line chart wrapper — pass `series` data, get themed chart with lines |
|
||||||
|
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
|
||||||
| MenuItem | composite | Sidebar navigation item with health/count |
|
| MenuItem | composite | Sidebar navigation item with health/count |
|
||||||
| Modal | composite | Generic dialog overlay with backdrop |
|
| Modal | composite | Generic dialog overlay with backdrop |
|
||||||
| MultiSelect | composite | Dropdown with searchable checkbox list and Apply action. Props: options, value, onChange, placeholder, searchable, disabled, className |
|
| MultiSelect | composite | Dropdown with searchable checkbox list and Apply action. Props: options, value, onChange, placeholder, searchable, disabled, className |
|
||||||
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
||||||
| Pagination | primitive | Page navigation controls |
|
| Pagination | primitive | Page navigation controls |
|
||||||
| Popover | composite | Click-triggered floating panel with arrow |
|
| Popover | composite | Click-triggered floating panel with arrow |
|
||||||
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows. Props: processors, totalMs, onProcessorClick?, selectedIndex? |
|
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows and optional action menus. Props: processors, totalMs, onProcessorClick?, selectedIndex?, actions?, getActions?. Use `actions` for static menus or `getActions` for per-processor dynamic actions. |
|
||||||
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, and click support. Props: nodes, onNodeClick?, selectedIndex? |
|
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, click support, and optional action menus. Props: nodes, onNodeClick?, selectedIndex?, actions?, getActions?. Same action pattern as ProcessorTimeline. |
|
||||||
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
|
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
|
||||||
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
||||||
| RadioItem | primitive | Individual radio option within RadioGroup |
|
| RadioItem | primitive | Individual radio option within RadioGroup |
|
||||||
@@ -202,9 +321,11 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
|
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
|
||||||
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
|
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
|
||||||
| Sparkline | primitive | Inline mini chart for trends |
|
| Sparkline | primitive | Inline mini chart for trends |
|
||||||
|
| SplitPane | composite | Two-column master/detail layout with configurable ratio and empty state |
|
||||||
| Spinner | primitive | Animated loading indicator |
|
| Spinner | primitive | Animated loading indicator |
|
||||||
| StatCard | primitive | KPI card with value, trend, optional sparkline |
|
| StatCard | primitive | KPI card with value, trend, optional sparkline |
|
||||||
| StatusDot | primitive | Colored dot for status indication |
|
| StatusDot | primitive | Colored dot for status indication |
|
||||||
|
| StatusText | primitive | Inline colored status span (success, warning, error, running, muted) with optional bold |
|
||||||
| Tabs | composite | Tabbed content switcher with optional counts |
|
| Tabs | composite | Tabbed content switcher with optional counts |
|
||||||
| Tag | primitive | Removable colored label |
|
| Tag | primitive | Removable colored label |
|
||||||
| Textarea | primitive | Multi-line text input with resize control |
|
| Textarea | primitive | Multi-line text input with resize control |
|
||||||
@@ -218,8 +339,10 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
|
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
|
||||||
| Sidebar | Hierarchical navigation with Applications/Agents/Routes trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) |
|
| Sidebar | Composable compound sidebar shell with icon-rail collapse mode. Sub-components: `Sidebar.Header`, `Sidebar.Section` (supports `position="bottom"` and `maxHeight`), `Sidebar.Footer`, `Sidebar.FooterLink`. The app controls all content via children — the DS provides the frame. |
|
||||||
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
|
| SidebarTree | Data-driven tree for sidebar sections. Accepts `nodes: SidebarTreeNode[]` with expand/collapse, starring, keyboard nav, search filter, and path-based selection highlighting. |
|
||||||
|
| useStarred | Hook for localStorage-backed starred item IDs. Returns `{ starredIds, isStarred, toggleStar }`. |
|
||||||
|
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment slot (`ReactNode` — pass a string for a static label or a custom dropdown for interactive selection), user avatar |
|
||||||
|
|
||||||
## Import Paths
|
## Import Paths
|
||||||
|
|
||||||
@@ -230,6 +353,10 @@ import { Button, Input, Badge } from './design-system/primitives'
|
|||||||
import { DataTable, Modal, Toast } from './design-system/composites'
|
import { DataTable, Modal, Toast } from './design-system/composites'
|
||||||
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
|
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
|
||||||
import { AppShell } from './design-system/layout/AppShell'
|
import { AppShell } from './design-system/layout/AppShell'
|
||||||
|
import { Sidebar } from './design-system/layout/Sidebar/Sidebar'
|
||||||
|
import { SidebarTree } from './design-system/layout/Sidebar/SidebarTree'
|
||||||
|
import type { SidebarTreeNode } from './design-system/layout/Sidebar/SidebarTree'
|
||||||
|
import { useStarred } from './design-system/layout/Sidebar/useStarred'
|
||||||
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
|
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -243,6 +370,35 @@ import type { Column, DataTableProps, SearchResult } from '@cameleer/design-syst
|
|||||||
|
|
||||||
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
|
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
|
||||||
|
|
||||||
|
## Brand Assets
|
||||||
|
|
||||||
|
The design system ships logo assets as static files via the `./assets/*` package export. These are not React components — they resolve to file URLs when imported via a bundler. All PNGs have transparent backgrounds.
|
||||||
|
|
||||||
|
| File | Size | Use case |
|
||||||
|
|------|------|----------|
|
||||||
|
| `cameleer3-logo.png` | Original | Full resolution for print/marketing |
|
||||||
|
| `cameleer3-16.png` | 16×16 | Browser tab favicon |
|
||||||
|
| `cameleer3-32.png` | 32×32 | Standard favicon, bookmarks |
|
||||||
|
| `cameleer3-48.png` | 48×48 | Windows taskbar |
|
||||||
|
| `cameleer3-180.png` | 180×180 | Apple touch icon |
|
||||||
|
| `cameleer3-192.png` | 192×192 | Android/PWA icon |
|
||||||
|
| `cameleer3-512.png` | 512×512 | PWA splash, og:image |
|
||||||
|
| `cameleer3-logo.svg` | Vector | High-detail SVG logo (traced from PNG, transparent) |
|
||||||
|
| `camel-logo.svg` | Vector | Simplified camel SVG logo |
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import logo from '@cameleer/design-system/assets/cameleer3-512.png'
|
||||||
|
<img src={logo} alt="Cameleer3" />
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Favicons in index.html -->
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/cameleer3-32.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/cameleer3-180.png">
|
||||||
|
```
|
||||||
|
|
||||||
## Styling Rules
|
## Styling Rules
|
||||||
|
|
||||||
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)
|
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)
|
||||||
|
|||||||
3
assets/camel-logo.svg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
assets/cameleer-16.png
Normal file
|
After Width: | Height: | Size: 941 B |
BIN
assets/cameleer-180.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/cameleer-192.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
assets/cameleer-32.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
assets/cameleer-48.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
assets/cameleer-512.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
assets/cameleer-logo.png
Normal file
|
After Width: | Height: | Size: 342 KiB |
112
assets/cameleer-logo.svg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
573
docs/superpowers/plans/2026-03-24-admin-components.md
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
# Admin Components Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add SplitPane and EntityList composites to provide reusable master/detail layout and searchable entity list patterns, replacing ~150 lines of duplicated CSS and structure across admin RBAC tabs.
|
||||||
|
|
||||||
|
**Architecture:** SplitPane is a layout-only component providing a two-column grid with configurable ratio. EntityList provides a searchable, selectable list with render props for item content. They compose together naturally: EntityList slots into SplitPane's list panel.
|
||||||
|
|
||||||
|
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 2, 2b)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `src/design-system/composites/SplitPane/SplitPane.tsx` | Create | Two-column grid layout with list/detail slots and empty state |
|
||||||
|
| `src/design-system/composites/SplitPane/SplitPane.module.css` | Create | Grid layout, scrollable panels, empty state styling |
|
||||||
|
| `src/design-system/composites/SplitPane/SplitPane.test.tsx` | Create | 5 test cases for SplitPane |
|
||||||
|
| `src/design-system/composites/EntityList/EntityList.tsx` | Create | Generic searchable, selectable list with render props |
|
||||||
|
| `src/design-system/composites/EntityList/EntityList.module.css` | Create | Header, scrollable list, item hover/selected states |
|
||||||
|
| `src/design-system/composites/EntityList/EntityList.test.tsx` | Create | 11 test cases for EntityList |
|
||||||
|
| `src/design-system/composites/index.ts` | Modify | Add SplitPane and EntityList exports |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: SplitPane composite
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/design-system/composites/SplitPane/SplitPane.tsx`
|
||||||
|
- Create: `src/design-system/composites/SplitPane/SplitPane.module.css`
|
||||||
|
- Create: `src/design-system/composites/SplitPane/SplitPane.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write SplitPane tests**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/SplitPane/SplitPane.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { SplitPane } from './SplitPane'
|
||||||
|
|
||||||
|
describe('SplitPane', () => {
|
||||||
|
it('renders list and detail content', () => {
|
||||||
|
render(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>User list</div>}
|
||||||
|
detail={<div>User detail</div>}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('User list')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('User detail')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows default empty message when detail is null', () => {
|
||||||
|
render(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>User list</div>}
|
||||||
|
detail={null}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Select an item to view details')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows custom empty message when detail is null', () => {
|
||||||
|
render(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>User list</div>}
|
||||||
|
detail={null}
|
||||||
|
emptyMessage="Pick a user to see info"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Pick a user to see info')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with different ratios', () => {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<SplitPane list={<div>List</div>} detail={<div>Detail</div>} ratio="1:1" />,
|
||||||
|
)
|
||||||
|
const pane = container.firstChild as HTMLElement
|
||||||
|
expect(pane.style.getPropertyValue('--split-columns')).toBe('1fr 1fr')
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<SplitPane list={<div>List</div>} detail={<div>Detail</div>} ratio="2:3" />,
|
||||||
|
)
|
||||||
|
expect(pane.style.getPropertyValue('--split-columns')).toBe('2fr 3fr')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts className', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>List</div>}
|
||||||
|
detail={<div>Detail</div>}
|
||||||
|
className="custom"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(container.firstChild).toHaveClass('custom')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/SplitPane/SplitPane.test.tsx`
|
||||||
|
Expected: FAIL — module not found
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create SplitPane CSS module**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/SplitPane/SplitPane.module.css`:
|
||||||
|
|
||||||
|
CSS extracted from `src/pages/Admin/UserManagement/UserManagement.module.css` (`.splitPane`, `.listPane`, `.detailPane`, `.emptyDetail`), generalized with a CSS custom property for the column ratio.
|
||||||
|
|
||||||
|
```css
|
||||||
|
.splitPane {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--split-columns, 1fr 2fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listPane {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPane {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyDetail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create SplitPane component**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/SplitPane/SplitPane.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import styles from './SplitPane.module.css'
|
||||||
|
|
||||||
|
interface SplitPaneProps {
|
||||||
|
list: ReactNode
|
||||||
|
detail: ReactNode | null
|
||||||
|
emptyMessage?: string
|
||||||
|
ratio?: '1:1' | '1:2' | '2:3'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratioMap: Record<string, string> = {
|
||||||
|
'1:1': '1fr 1fr',
|
||||||
|
'1:2': '1fr 2fr',
|
||||||
|
'2:3': '2fr 3fr',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SplitPane({
|
||||||
|
list,
|
||||||
|
detail,
|
||||||
|
emptyMessage = 'Select an item to view details',
|
||||||
|
ratio = '1:2',
|
||||||
|
className,
|
||||||
|
}: SplitPaneProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.splitPane} ${className ?? ''}`}
|
||||||
|
style={{ '--split-columns': ratioMap[ratio] } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<div className={styles.listPane}>{list}</div>
|
||||||
|
<div className={styles.detailPane}>
|
||||||
|
{detail !== null ? detail : (
|
||||||
|
<div className={styles.emptyDetail}>{emptyMessage}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/SplitPane/SplitPane.test.tsx`
|
||||||
|
Expected: 5 tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/SplitPane/SplitPane.tsx \
|
||||||
|
src/design-system/composites/SplitPane/SplitPane.module.css \
|
||||||
|
src/design-system/composites/SplitPane/SplitPane.test.tsx
|
||||||
|
git commit -m "feat: add SplitPane composite for master/detail layouts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: EntityList composite
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/design-system/composites/EntityList/EntityList.tsx`
|
||||||
|
- Create: `src/design-system/composites/EntityList/EntityList.module.css`
|
||||||
|
- Create: `src/design-system/composites/EntityList/EntityList.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write EntityList tests**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/EntityList/EntityList.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { EntityList } from './EntityList'
|
||||||
|
|
||||||
|
interface TestItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: TestItem[] = [
|
||||||
|
{ id: '1', name: 'Alice' },
|
||||||
|
{ id: '2', name: 'Bob' },
|
||||||
|
{ id: '3', name: 'Charlie' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
items,
|
||||||
|
renderItem: (item: TestItem) => <span>{item.name}</span>,
|
||||||
|
getItemId: (item: TestItem) => item.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EntityList', () => {
|
||||||
|
it('renders all items', () => {
|
||||||
|
render(<EntityList {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Bob')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Charlie')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSelect when item clicked', async () => {
|
||||||
|
const onSelect = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<EntityList {...defaultProps} onSelect={onSelect} />)
|
||||||
|
await user.click(screen.getByText('Bob'))
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('highlights selected item', () => {
|
||||||
|
render(<EntityList {...defaultProps} selectedId="2" />)
|
||||||
|
const selectedOption = screen.getByText('Bob').closest('[role="option"]')
|
||||||
|
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
|
||||||
|
expect(selectedOption).toHaveClass(/selected/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders search input when onSearch provided', () => {
|
||||||
|
render(<EntityList {...defaultProps} onSearch={vi.fn()} searchPlaceholder="Search users..." />)
|
||||||
|
expect(screen.getByPlaceholderText('Search users...')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSearch when typing in search', async () => {
|
||||||
|
const onSearch = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<EntityList {...defaultProps} onSearch={onSearch} />)
|
||||||
|
await user.type(screen.getByPlaceholderText('Search...'), 'alice')
|
||||||
|
expect(onSearch).toHaveBeenLastCalledWith('alice')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders add button when onAdd provided', () => {
|
||||||
|
render(<EntityList {...defaultProps} onAdd={vi.fn()} addLabel="+ Add user" />)
|
||||||
|
expect(screen.getByRole('button', { name: '+ Add user' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onAdd when add button clicked', async () => {
|
||||||
|
const onAdd = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<EntityList {...defaultProps} onAdd={onAdd} addLabel="+ Add user" />)
|
||||||
|
await user.click(screen.getByRole('button', { name: '+ Add user' }))
|
||||||
|
expect(onAdd).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides header when no search or add', () => {
|
||||||
|
const { container } = render(<EntityList {...defaultProps} />)
|
||||||
|
// No header element should be rendered (no search input, no add button)
|
||||||
|
expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument()
|
||||||
|
expect(container.querySelector('[class*="listHeader"]')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty message when items is empty', () => {
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={[]}
|
||||||
|
renderItem={() => <span />}
|
||||||
|
getItemId={() => ''}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('No items found')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows custom empty message', () => {
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={[]}
|
||||||
|
renderItem={() => <span />}
|
||||||
|
getItemId={() => ''}
|
||||||
|
emptyMessage="No users match your search"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('No users match your search')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts className', () => {
|
||||||
|
const { container } = render(<EntityList {...defaultProps} className="custom" />)
|
||||||
|
expect(container.firstChild).toHaveClass('custom')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/EntityList/EntityList.test.tsx`
|
||||||
|
Expected: FAIL — module not found
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create EntityList CSS module**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/EntityList/EntityList.module.css`:
|
||||||
|
|
||||||
|
CSS extracted from `src/pages/Admin/UserManagement/UserManagement.module.css` (`.listHeader`, `.listHeaderSearch`, `.entityList`, `.entityItem`, `.entityItemSelected`), generalized for reuse.
|
||||||
|
|
||||||
|
```css
|
||||||
|
.entityListRoot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listHeaderSearch {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItem:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItemSelected {
|
||||||
|
background: var(--amber-bg);
|
||||||
|
border-left: 3px solid var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyMessage {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create EntityList component**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/EntityList/EntityList.tsx`:
|
||||||
|
|
||||||
|
The component uses `role="listbox"` / `role="option"` for accessibility, matching the pattern in `UsersTab.tsx`. It delegates search input and add button to the existing `Input` and `Button` primitives.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { Input } from '../../primitives/Input/Input'
|
||||||
|
import { Button } from '../../primitives/Button/Button'
|
||||||
|
import styles from './EntityList.module.css'
|
||||||
|
|
||||||
|
interface EntityListProps<T> {
|
||||||
|
items: T[]
|
||||||
|
renderItem: (item: T, isSelected: boolean) => ReactNode
|
||||||
|
getItemId: (item: T) => string
|
||||||
|
selectedId?: string
|
||||||
|
onSelect?: (id: string) => void
|
||||||
|
searchPlaceholder?: string
|
||||||
|
onSearch?: (query: string) => void
|
||||||
|
addLabel?: string
|
||||||
|
onAdd?: () => void
|
||||||
|
emptyMessage?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityList<T>({
|
||||||
|
items,
|
||||||
|
renderItem,
|
||||||
|
getItemId,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
searchPlaceholder = 'Search...',
|
||||||
|
onSearch,
|
||||||
|
addLabel,
|
||||||
|
onAdd,
|
||||||
|
emptyMessage = 'No items found',
|
||||||
|
className,
|
||||||
|
}: EntityListProps<T>) {
|
||||||
|
const [searchValue, setSearchValue] = useState('')
|
||||||
|
const showHeader = !!onSearch || !!onAdd
|
||||||
|
|
||||||
|
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const value = e.target.value
|
||||||
|
setSearchValue(value)
|
||||||
|
onSearch?.(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchClear() {
|
||||||
|
setSearchValue('')
|
||||||
|
onSearch?.('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.entityListRoot} ${className ?? ''}`}>
|
||||||
|
{showHeader && (
|
||||||
|
<div className={styles.listHeader}>
|
||||||
|
{onSearch && (
|
||||||
|
<Input
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={searchValue}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
onClear={handleSearchClear}
|
||||||
|
className={styles.listHeaderSearch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onAdd && addLabel && (
|
||||||
|
<Button size="sm" variant="secondary" onClick={onAdd}>
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.list} role="listbox">
|
||||||
|
{items.map((item) => {
|
||||||
|
const id = getItemId(item)
|
||||||
|
const isSelected = id === selectedId
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
||||||
|
onClick={() => onSelect?.(id)}
|
||||||
|
role="option"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onSelect?.(id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderItem(item, isSelected)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className={styles.emptyMessage}>{emptyMessage}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/EntityList/EntityList.test.tsx`
|
||||||
|
Expected: 11 tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/EntityList/EntityList.tsx \
|
||||||
|
src/design-system/composites/EntityList/EntityList.module.css \
|
||||||
|
src/design-system/composites/EntityList/EntityList.test.tsx
|
||||||
|
git commit -m "feat: add EntityList composite for searchable, selectable lists"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Barrel exports & full test suite
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/design-system/composites/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add exports to barrel**
|
||||||
|
|
||||||
|
Add these lines to `src/design-system/composites/index.ts` in alphabetical position.
|
||||||
|
|
||||||
|
After the `DetailPanel` export (line 13), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { EntityList } from './EntityList/EntityList'
|
||||||
|
```
|
||||||
|
|
||||||
|
After the `LineChart` export (line 19), before `LoginDialog`, add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// (no change needed here — LoginDialog is already present)
|
||||||
|
```
|
||||||
|
|
||||||
|
After the `ShortcutsBar` export (line 33), before `SegmentedTabs`, add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { SplitPane } from './SplitPane/SplitPane'
|
||||||
|
```
|
||||||
|
|
||||||
|
The resulting new lines in `index.ts` (in their alphabetical positions):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { EntityList } from './EntityList/EntityList'
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { SplitPane } from './SplitPane/SplitPane'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the full component test suite**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/SplitPane/ src/design-system/composites/EntityList/`
|
||||||
|
Expected: All 16 tests PASS (5 SplitPane + 11 EntityList)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the full project test suite to check for regressions**
|
||||||
|
|
||||||
|
Run: `npx vitest run`
|
||||||
|
Expected: All tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/index.ts
|
||||||
|
git commit -m "feat: export SplitPane and EntityList from composites barrel"
|
||||||
|
```
|
||||||
431
docs/superpowers/plans/2026-03-24-documentation-updates.md
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
# Documentation Updates Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Update COMPONENT_GUIDE.md and Inventory page with entries and demos for all new components: KpiStrip, SplitPane, EntityList, LogViewer, StatusText, and Card title extension.
|
||||||
|
|
||||||
|
**Architecture:** COMPONENT_GUIDE.md gets new decision tree entries and component index rows. Inventory page gets DemoCard sections with realistic sample data for each new component.
|
||||||
|
|
||||||
|
**Tech Stack:** React, TypeScript, CSS Modules
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Documentation Updates section)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Update COMPONENT_GUIDE.md
|
||||||
|
|
||||||
|
**File:** `COMPONENT_GUIDE.md`
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
- [ ] **1a.** In the `"I need to show status"` decision tree (line ~34), add StatusText entry after StatusDot:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Inline colored status value → **StatusText** (success, warning, error, running, muted — with optional bold)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **1b.** In the `"I need to display data"` decision tree (line ~51), add three entries after the EventFeed line:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Row of summary KPIs → **KpiStrip** (horizontal strip with colored borders, trends, sparklines)
|
||||||
|
- Scrollable log output → **LogViewer** (timestamped, severity-colored monospace entries)
|
||||||
|
- Searchable, selectable entity list → **EntityList** (search header, selection highlighting, pairs with SplitPane)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **1c.** In the `"I need to organize content"` decision tree (line ~62), add SplitPane entry after DetailPanel and update the Card entry:
|
||||||
|
|
||||||
|
After the `- Side panel inspector → **DetailPanel**` line, add:
|
||||||
|
```markdown
|
||||||
|
- Master/detail split layout → **SplitPane** (list on left, detail on right, configurable ratio)
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the existing Card line from:
|
||||||
|
```markdown
|
||||||
|
- Grouped content box → **Card** (with optional accent)
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```markdown
|
||||||
|
- Grouped content box → **Card** (with optional accent and title)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **1d.** In the `"I need to display text"` decision tree (line ~72), add StatusText cross-reference:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Colored inline status text → **StatusText** (semantic color + optional bold, see also "I need to show status")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **1e.** Add a new composition pattern after the existing "KPI dashboard" pattern (line ~113):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Master/detail management pattern
|
||||||
|
```
|
||||||
|
SplitPane + EntityList for CRUD list/detail screens (users, groups, roles)
|
||||||
|
EntityList provides: search header, add button, selectable list
|
||||||
|
SplitPane provides: responsive two-column layout with empty state
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **1f.** Add five new rows to the Component Index table (maintaining alphabetical order):
|
||||||
|
|
||||||
|
After the `EventFeed` row:
|
||||||
|
```markdown
|
||||||
|
| EntityList | composite | Searchable, selectable entity list with add button. Pair with SplitPane for CRUD management screens |
|
||||||
|
```
|
||||||
|
|
||||||
|
After the `KeyboardHint` row:
|
||||||
|
```markdown
|
||||||
|
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
|
||||||
|
```
|
||||||
|
|
||||||
|
After the `LineChart` row:
|
||||||
|
```markdown
|
||||||
|
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
|
||||||
|
```
|
||||||
|
|
||||||
|
After the `Sparkline` row:
|
||||||
|
```markdown
|
||||||
|
| SplitPane | composite | Two-column master/detail layout with configurable ratio and empty state |
|
||||||
|
| StatusText | primitive | Inline colored status span (success, warning, error, running, muted) with optional bold |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **1g.** Update the existing `Card` row in the Component Index from:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| Card | primitive | Content container with optional accent border |
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| Card | primitive | Content container with optional accent border and title header |
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add StatusText demo to PrimitivesSection
|
||||||
|
|
||||||
|
**File:** `src/pages/Inventory/sections/PrimitivesSection.tsx`
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
- [ ] **2a.** Add `StatusText` to the import from `'../../../design-system/primitives'` (insert alphabetically after `StatCard`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
StatusText,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **2b.** Add a new DemoCard after the StatusDot demo (after line ~560, before the Tag demo). Insert this block:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* 29. StatusText */}
|
||||||
|
<DemoCard
|
||||||
|
id="statustext"
|
||||||
|
title="StatusText"
|
||||||
|
description="Inline coloured text for status values — five semantic variants with optional bold."
|
||||||
|
>
|
||||||
|
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
|
||||||
|
<div className={styles.demoAreaRow}>
|
||||||
|
<StatusText variant="success">99.8% uptime</StatusText>
|
||||||
|
<StatusText variant="warning">SLA at risk</StatusText>
|
||||||
|
<StatusText variant="error">BREACH</StatusText>
|
||||||
|
<StatusText variant="running">Processing</StatusText>
|
||||||
|
<StatusText variant="muted">N/A</StatusText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.demoAreaRow}>
|
||||||
|
<StatusText variant="success" bold>99.8% uptime</StatusText>
|
||||||
|
<StatusText variant="warning" bold>SLA at risk</StatusText>
|
||||||
|
<StatusText variant="error" bold>BREACH</StatusText>
|
||||||
|
<StatusText variant="running" bold>Processing</StatusText>
|
||||||
|
<StatusText variant="muted" bold>N/A</StatusText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Renumber subsequent demos (Tag becomes 30, Textarea becomes 31, Toggle becomes 32, Tooltip becomes 33).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Update Card demo in PrimitivesSection
|
||||||
|
|
||||||
|
**File:** `src/pages/Inventory/sections/PrimitivesSection.tsx`
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
- [ ] **3a.** Update the Card DemoCard description from:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
description="Surface container with optional left-border accent colour."
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
description="Surface container with optional left-border accent colour and title header."
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **3b.** Add a title prop example to the Card demo. After the existing `Card accent="error"` line (~212), add:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card title="Throughput (msg/s)">
|
||||||
|
<div style={{ padding: '8px 12px', fontSize: 13 }}>Card with title header and separator</div>
|
||||||
|
</Card>
|
||||||
|
<Card accent="amber" title="Error Rate">
|
||||||
|
<div style={{ padding: '8px 12px', fontSize: 13 }}>Title + accent combined</div>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Add composite demos to CompositesSection
|
||||||
|
|
||||||
|
**File:** `src/pages/Inventory/sections/CompositesSection.tsx`
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
- [ ] **4a.** Add new imports. Add `KpiStrip`, `SplitPane`, `EntityList`, `LogViewer` to the import from `'../../../design-system/composites'` (insert alphabetically):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
EntityList,
|
||||||
|
KpiStrip,
|
||||||
|
LogViewer,
|
||||||
|
SplitPane,
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add `Badge` and `Avatar` to the import from `'../../../design-system/primitives'` (needed for EntityList demo renderItem):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Avatar, Badge, Button } from '../../../design-system/primitives'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **4b.** Add sample data constants after the existing sample data section (before the `CompositesSection` function). Add:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ── Sample data for new composites ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const KPI_ITEMS = [
|
||||||
|
{
|
||||||
|
label: 'Exchanges',
|
||||||
|
value: '12,847',
|
||||||
|
trend: { label: '↑ +8.2%', variant: 'success' as const },
|
||||||
|
subtitle: 'Last 24h',
|
||||||
|
sparkline: [40, 55, 48, 62, 70, 65, 78],
|
||||||
|
borderColor: 'var(--amber)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Error Rate',
|
||||||
|
value: '0.34%',
|
||||||
|
trend: { label: '↑ +0.12pp', variant: 'error' as const },
|
||||||
|
subtitle: 'Above threshold',
|
||||||
|
sparkline: [10, 12, 11, 15, 18, 22, 19],
|
||||||
|
borderColor: 'var(--error)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Avg Latency',
|
||||||
|
value: '142ms',
|
||||||
|
trend: { label: '↓ -12ms', variant: 'success' as const },
|
||||||
|
subtitle: 'P95: 380ms',
|
||||||
|
borderColor: 'var(--success)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Active Routes',
|
||||||
|
value: '37',
|
||||||
|
trend: { label: '±0', variant: 'muted' as const },
|
||||||
|
subtitle: '3 paused',
|
||||||
|
borderColor: 'var(--running)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const ENTITY_LIST_ITEMS = [
|
||||||
|
{ id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin' },
|
||||||
|
{ id: '2', name: 'Bob Chen', email: 'bob@example.com', role: 'Editor' },
|
||||||
|
{ id: '3', name: 'Carol Smith', email: 'carol@example.com', role: 'Viewer' },
|
||||||
|
{ id: '4', name: 'David Park', email: 'david@example.com', role: 'Editor' },
|
||||||
|
{ id: '5', name: 'Eva Martinez', email: 'eva@example.com', role: 'Admin' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const LOG_ENTRIES = [
|
||||||
|
{ timestamp: '2026-03-24T10:00:01Z', level: 'info' as const, message: 'Route timer-aggregator started successfully' },
|
||||||
|
{ timestamp: '2026-03-24T10:00:03Z', level: 'debug' as const, message: 'Polling endpoint https://api.internal/health — 200 OK' },
|
||||||
|
{ timestamp: '2026-03-24T10:00:15Z', level: 'warn' as const, message: 'Retry queue depth at 847 — approaching threshold (1000)' },
|
||||||
|
{ timestamp: '2026-03-24T10:00:22Z', level: 'error' as const, message: 'Exchange failed: Connection refused to jdbc:postgresql://db-primary:5432/orders' },
|
||||||
|
{ timestamp: '2026-03-24T10:00:23Z', level: 'info' as const, message: 'Failover activated — routing to db-secondary' },
|
||||||
|
{ timestamp: '2026-03-24T10:00:30Z', level: 'info' as const, message: 'Exchange completed in 142ms via fallback route' },
|
||||||
|
{ timestamp: '2026-03-24T10:00:45Z', level: 'debug' as const, message: 'Metrics flush: 328 data points written to InfluxDB' },
|
||||||
|
{ timestamp: '2026-03-24T10:01:00Z', level: 'warn' as const, message: 'Memory usage at 78% — GC scheduled' },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **4c.** Add state variables inside the `CompositesSection` function for EntityList demo:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// EntityList state
|
||||||
|
const [selectedEntityId, setSelectedEntityId] = useState<string | undefined>('1')
|
||||||
|
const [entitySearch, setEntitySearch] = useState('')
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **4d.** Add KpiStrip demo after the existing GroupCard demo. Insert a new DemoCard:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* KpiStrip */}
|
||||||
|
<DemoCard
|
||||||
|
id="kpistrip"
|
||||||
|
title="KpiStrip"
|
||||||
|
description="Horizontal row of KPI cards with coloured left border, trend indicator, subtitle, and optional sparkline."
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<KpiStrip items={KPI_ITEMS} />
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **4e.** Add SplitPane demo:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* SplitPane */}
|
||||||
|
<DemoCard
|
||||||
|
id="splitpane"
|
||||||
|
title="SplitPane"
|
||||||
|
description="Two-column master/detail layout with configurable ratio and empty-state placeholder."
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', height: 200 }}>
|
||||||
|
<SplitPane
|
||||||
|
list={
|
||||||
|
<div style={{ padding: 16, fontSize: 13 }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 8 }}>Items</div>
|
||||||
|
<div>Item A</div>
|
||||||
|
<div>Item B</div>
|
||||||
|
<div>Item C</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
detail={
|
||||||
|
<div style={{ padding: 16, fontSize: 13 }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 8 }}>Detail View</div>
|
||||||
|
<div>Select an item on the left to see its details here.</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
ratio="1:2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **4f.** Add EntityList demo:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* EntityList */}
|
||||||
|
<DemoCard
|
||||||
|
id="entitylist"
|
||||||
|
title="EntityList"
|
||||||
|
description="Searchable, selectable entity list with add button — designed to pair with SplitPane."
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', height: 260 }}>
|
||||||
|
<EntityList
|
||||||
|
items={ENTITY_LIST_ITEMS.filter(u =>
|
||||||
|
u.name.toLowerCase().includes(entitySearch.toLowerCase())
|
||||||
|
)}
|
||||||
|
renderItem={(item, isSelected) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<Avatar name={item.name} size="sm" />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: isSelected ? 600 : 400 }}>{item.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.email}</div>
|
||||||
|
</div>
|
||||||
|
<Badge label={item.role} style={{ marginLeft: 'auto' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
selectedId={selectedEntityId}
|
||||||
|
onSelect={setSelectedEntityId}
|
||||||
|
searchPlaceholder="Search users..."
|
||||||
|
onSearch={setEntitySearch}
|
||||||
|
addLabel="+ Add user"
|
||||||
|
onAdd={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **4g.** Add LogViewer demo:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* LogViewer */}
|
||||||
|
<DemoCard
|
||||||
|
id="logviewer"
|
||||||
|
title="LogViewer"
|
||||||
|
description="Scrollable log output with timestamped, severity-coloured monospace entries and auto-scroll."
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<LogViewer entries={LOG_ENTRIES} maxHeight={240} />
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **4h.** Verify all four new DemoCards are placed in alphabetical order among existing demos — EntityList after EventFeed, KpiStrip after GroupCard, LogViewer after LoginForm, SplitPane after ShortcutsBar. Adjust comment numbering accordingly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Update Inventory nav
|
||||||
|
|
||||||
|
**File:** `src/pages/Inventory/Inventory.tsx`
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
- [ ] **5a.** Add `StatusText` to the Primitives nav components array (insert alphabetically after `StatusDot`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{ label: 'StatusText', href: '#statustext' },
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **5b.** Add four entries to the Composites nav components array (insert alphabetically):
|
||||||
|
|
||||||
|
After `EventFeed`:
|
||||||
|
```tsx
|
||||||
|
{ label: 'EntityList', href: '#entitylist' },
|
||||||
|
```
|
||||||
|
|
||||||
|
After `GroupCard`:
|
||||||
|
```tsx
|
||||||
|
{ label: 'KpiStrip', href: '#kpistrip' },
|
||||||
|
```
|
||||||
|
|
||||||
|
After `LoginForm`:
|
||||||
|
```tsx
|
||||||
|
{ label: 'LogViewer', href: '#logviewer' },
|
||||||
|
```
|
||||||
|
|
||||||
|
After `ShortcutsBar`:
|
||||||
|
```tsx
|
||||||
|
{ label: 'SplitPane', href: '#splitpane' },
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Commit all documentation
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
- [ ] **6a.** Run `npx vitest run src/pages/Inventory` to verify Inventory page has no import/type errors (if tests exist for it).
|
||||||
|
- [ ] **6b.** Stage changed files:
|
||||||
|
- `COMPONENT_GUIDE.md`
|
||||||
|
- `src/pages/Inventory/Inventory.tsx`
|
||||||
|
- `src/pages/Inventory/sections/PrimitivesSection.tsx`
|
||||||
|
- `src/pages/Inventory/sections/CompositesSection.tsx`
|
||||||
|
- [ ] **6c.** Commit with message: `docs: add COMPONENT_GUIDE entries and Inventory demos for KpiStrip, SplitPane, EntityList, LogViewer, StatusText, Card title`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency Notes
|
||||||
|
|
||||||
|
- **Tasks 1-5 are independent** and can be worked in any order.
|
||||||
|
- **Task 6 depends on Tasks 1-5** being complete.
|
||||||
|
- **All tasks depend on the components already existing** — StatusText, Card title extension, KpiStrip, SplitPane, EntityList, and LogViewer must be built and exported from their barrel files before the Inventory demos will compile.
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `COMPONENT_GUIDE.md` | Decision tree entries + component index rows |
|
||||||
|
| `src/pages/Inventory/Inventory.tsx` | 5 new nav entries (1 primitive + 4 composites) |
|
||||||
|
| `src/pages/Inventory/sections/PrimitivesSection.tsx` | StatusText demo + Card title demo update |
|
||||||
|
| `src/pages/Inventory/sections/CompositesSection.tsx` | KpiStrip, SplitPane, EntityList, LogViewer demos with sample data |
|
||||||
770
docs/superpowers/plans/2026-03-24-login-dialog.md
Normal file
@@ -0,0 +1,770 @@
|
|||||||
|
# Login Dialog Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add composable `LoginForm` and `LoginDialog` components to the Cameleer3 design system with credential + social login support, client-side validation, and full dark mode compatibility.
|
||||||
|
|
||||||
|
**Architecture:** `LoginForm` is the core content component with all form logic, validation, and layout. `LoginDialog` is a thin wrapper that renders `LoginForm` inside `Modal size="sm"`. Both live in `src/design-system/composites/LoginForm/` and are exported from the composites barrel.
|
||||||
|
|
||||||
|
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-24-login-dialog-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `src/design-system/composites/LoginForm/LoginForm.tsx` | Create | Core form component with validation, social providers, all layout |
|
||||||
|
| `src/design-system/composites/LoginForm/LoginForm.module.css` | Create | All styles using design tokens |
|
||||||
|
| `src/design-system/composites/LoginForm/LoginForm.test.tsx` | Create | 21 test cases for LoginForm |
|
||||||
|
| `src/design-system/composites/LoginForm/LoginDialog.tsx` | Create | Thin Modal wrapper |
|
||||||
|
| `src/design-system/composites/LoginForm/LoginDialog.test.tsx` | Create | 5 test cases for LoginDialog |
|
||||||
|
| `src/design-system/composites/index.ts` | Modify | Add LoginForm, LoginDialog, and type exports |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: LoginForm — Rendering Tests & Basic Structure
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/design-system/composites/LoginForm/LoginForm.tsx`
|
||||||
|
- Create: `src/design-system/composites/LoginForm/LoginForm.module.css`
|
||||||
|
- Create: `src/design-system/composites/LoginForm/LoginForm.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write rendering tests**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/LoginForm/LoginForm.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { LoginForm } from './LoginForm'
|
||||||
|
|
||||||
|
const socialProviders = [
|
||||||
|
{ label: 'Continue with Google', onClick: vi.fn() },
|
||||||
|
{ label: 'Continue with GitHub', onClick: vi.fn() },
|
||||||
|
]
|
||||||
|
|
||||||
|
const allProps = {
|
||||||
|
logo: <div data-testid="logo">Logo</div>,
|
||||||
|
title: 'Welcome back',
|
||||||
|
socialProviders,
|
||||||
|
onSubmit: vi.fn(),
|
||||||
|
onForgotPassword: vi.fn(),
|
||||||
|
onSignUp: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LoginForm', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders all elements when all props provided', () => {
|
||||||
|
render(<LoginForm {...allProps} />)
|
||||||
|
expect(screen.getByTestId('logo')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Welcome back')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Continue with GitHub')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('or')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/sign up/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders default title when title prop omitted', () => {
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Sign in')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides social section when socialProviders is empty', () => {
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} socialProviders={[]} />)
|
||||||
|
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides social section when socialProviders is omitted', () => {
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides forgot password link when onForgotPassword omitted', () => {
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.queryByText(/forgot password/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides sign up link when onSignUp omitted', () => {
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.queryByText(/sign up/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides credentials section when onSubmit omitted (social only)', () => {
|
||||||
|
render(<LoginForm socialProviders={socialProviders} />)
|
||||||
|
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||||
|
// Social buttons should still render
|
||||||
|
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows server error Alert when error prop set', () => {
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} error="Invalid credentials" />)
|
||||||
|
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx`
|
||||||
|
Expected: FAIL — module not found
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create LoginForm component with basic rendering**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/LoginForm/LoginForm.module.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.loginForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socialSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socialButton {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dividerLine {
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dividerText {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rememberRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgotLink {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--amber);
|
||||||
|
font-weight: 500;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgotLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitButton {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signUpText {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signUpLink {
|
||||||
|
color: var(--amber);
|
||||||
|
font-weight: 500;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signUpLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/design-system/composites/LoginForm/LoginForm.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect, useRef, useState, type ReactNode, type FormEvent } from 'react'
|
||||||
|
import { Button } from '../../primitives/Button/Button'
|
||||||
|
import { Input } from '../../primitives/Input/Input'
|
||||||
|
import { Checkbox } from '../../primitives/Checkbox/Checkbox'
|
||||||
|
import { FormField } from '../../primitives/FormField/FormField'
|
||||||
|
import { Alert } from '../../primitives/Alert/Alert'
|
||||||
|
import styles from './LoginForm.module.css'
|
||||||
|
|
||||||
|
export interface SocialProvider {
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginFormProps {
|
||||||
|
logo?: ReactNode
|
||||||
|
title?: string
|
||||||
|
socialProviders?: SocialProvider[]
|
||||||
|
onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void
|
||||||
|
onForgotPassword?: () => void
|
||||||
|
onSignUp?: () => void
|
||||||
|
error?: string
|
||||||
|
loading?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldErrors {
|
||||||
|
email?: string
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
function validate(email: string, password: string): FieldErrors {
|
||||||
|
const errors: FieldErrors = {}
|
||||||
|
if (!email) {
|
||||||
|
errors.email = 'Email is required'
|
||||||
|
} else if (!EMAIL_REGEX.test(email)) {
|
||||||
|
errors.email = 'Please enter a valid email address'
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
errors.password = 'Password is required'
|
||||||
|
} else if (password.length < 8) {
|
||||||
|
errors.password = 'Password must be at least 8 characters'
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm({
|
||||||
|
logo,
|
||||||
|
title = 'Sign in',
|
||||||
|
socialProviders,
|
||||||
|
onSubmit,
|
||||||
|
onForgotPassword,
|
||||||
|
onSignUp,
|
||||||
|
error,
|
||||||
|
loading = false,
|
||||||
|
className,
|
||||||
|
}: LoginFormProps) {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [remember, setRemember] = useState(false)
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const emailRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Auto-focus first input on mount
|
||||||
|
useEffect(() => {
|
||||||
|
emailRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Reset submitted flag when error prop changes (new server error from re-attempt)
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) setSubmitted(false)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
// Server error is shown from prop, hidden after next submit attempt
|
||||||
|
const showServerError = error && !submitted
|
||||||
|
|
||||||
|
const hasSocial = socialProviders && socialProviders.length > 0
|
||||||
|
const hasCredentials = !!onSubmit
|
||||||
|
const showDivider = hasSocial && hasCredentials
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitted(true)
|
||||||
|
const errors = validate(email, password)
|
||||||
|
setFieldErrors(errors)
|
||||||
|
if (Object.keys(errors).length === 0) {
|
||||||
|
onSubmit?.({ email, password, remember })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.loginForm} ${className ?? ''}`}>
|
||||||
|
{logo && <div className={styles.logo}>{logo}</div>}
|
||||||
|
<h2 className={styles.title}>{title}</h2>
|
||||||
|
|
||||||
|
{showServerError && (
|
||||||
|
<div className={styles.error}>
|
||||||
|
<Alert variant="error">{error}</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSocial && (
|
||||||
|
<div className={styles.socialSection}>
|
||||||
|
{socialProviders.map((provider) => (
|
||||||
|
<Button
|
||||||
|
key={provider.label}
|
||||||
|
variant="secondary"
|
||||||
|
className={styles.socialButton}
|
||||||
|
onClick={provider.onClick}
|
||||||
|
disabled={loading}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{provider.icon}
|
||||||
|
{provider.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDivider && (
|
||||||
|
<div className={styles.divider}>
|
||||||
|
<div className={styles.dividerLine} />
|
||||||
|
<span className={styles.dividerText}>or</span>
|
||||||
|
<div className={styles.dividerLine} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasCredentials && (
|
||||||
|
<form
|
||||||
|
className={styles.fields}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
aria-label="Sign in"
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<FormField label="Email" htmlFor="login-email" required error={fieldErrors.email}>
|
||||||
|
<Input
|
||||||
|
ref={emailRef}
|
||||||
|
id="login-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value)
|
||||||
|
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }))
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Password" htmlFor="login-password" required error={fieldErrors.password}>
|
||||||
|
<Input
|
||||||
|
id="login-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPassword(e.target.value)
|
||||||
|
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }))
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className={styles.rememberRow}>
|
||||||
|
<Checkbox
|
||||||
|
label="Remember me"
|
||||||
|
checked={remember}
|
||||||
|
onChange={(e) => setRemember(e.target.checked)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{onForgotPassword && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.forgotLink}
|
||||||
|
onClick={onForgotPassword}
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{onSignUp && (
|
||||||
|
<div className={styles.signUpText}>
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.signUpLink}
|
||||||
|
onClick={onSignUp}
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasCredentials && onSignUp && (
|
||||||
|
<div className={styles.signUpText}>
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.signUpLink}
|
||||||
|
onClick={onSignUp}
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx`
|
||||||
|
Expected: 8 tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/LoginForm/LoginForm.tsx \
|
||||||
|
src/design-system/composites/LoginForm/LoginForm.module.css \
|
||||||
|
src/design-system/composites/LoginForm/LoginForm.test.tsx
|
||||||
|
git commit -m "feat: add LoginForm component with rendering tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: LoginForm — Validation Tests & Behavior
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/design-system/composites/LoginForm/LoginForm.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add validation and interaction tests**
|
||||||
|
|
||||||
|
Append to the `describe('LoginForm')` block in `LoginForm.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
|
||||||
|
// Add these inside the existing describe('LoginForm') block, after the rendering describe:
|
||||||
|
|
||||||
|
describe('validation', () => {
|
||||||
|
it('validates required email', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(screen.getByText('Email is required')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates email format', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
await user.type(screen.getByLabelText(/email/i), 'notanemail')
|
||||||
|
await user.type(screen.getByLabelText(/password/i), 'password123')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates required password', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(screen.getByText('Password is required')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates password minimum length', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
||||||
|
await user.type(screen.getByLabelText(/password/i), 'short')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears field errors on typing', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(screen.getByText('Email is required')).toBeInTheDocument()
|
||||||
|
await user.type(screen.getByLabelText(/email/i), 't')
|
||||||
|
expect(screen.queryByText('Email is required')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSubmit with credentials when valid', async () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={onSubmit} />)
|
||||||
|
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
||||||
|
await user.type(screen.getByLabelText(/password/i), 'password123')
|
||||||
|
await user.click(screen.getByLabelText(/remember me/i))
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
remember: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call onSubmit when validation fails', async () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={onSubmit} />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loading state', () => {
|
||||||
|
it('disables form inputs when loading', () => {
|
||||||
|
render(<LoginForm {...allProps} loading />)
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText(/password/i)).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText(/remember me/i)).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows spinner on submit button when loading', () => {
|
||||||
|
render(<LoginForm {...allProps} loading />)
|
||||||
|
const submitBtn = screen.getByRole('button', { name: 'Sign in' })
|
||||||
|
expect(submitBtn).toBeDisabled()
|
||||||
|
// Button component renders Spinner when loading=true
|
||||||
|
expect(submitBtn.querySelector('[class*="spinner"]')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables social buttons when loading', () => {
|
||||||
|
render(<LoginForm {...allProps} loading />)
|
||||||
|
expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeDisabled()
|
||||||
|
expect(screen.getByRole('button', { name: 'Continue with GitHub' })).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('callbacks', () => {
|
||||||
|
it('calls social provider onClick when clicked', async () => {
|
||||||
|
const onClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm socialProviders={[{ label: 'Continue with Google', onClick }]} onSubmit={vi.fn()} />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Continue with Google' }))
|
||||||
|
expect(onClick).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onForgotPassword when link clicked', async () => {
|
||||||
|
const onForgotPassword = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} onForgotPassword={onForgotPassword} />)
|
||||||
|
await user.click(screen.getByText(/forgot password/i))
|
||||||
|
expect(onForgotPassword).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSignUp when link clicked', async () => {
|
||||||
|
const onSignUp = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} onSignUp={onSignUp} />)
|
||||||
|
await user.click(screen.getByText(/sign up/i))
|
||||||
|
expect(onSignUp).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx`
|
||||||
|
Expected: 21 tests PASS (8 rendering + 7 validation + 3 loading + 3 callbacks)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/LoginForm/LoginForm.test.tsx
|
||||||
|
git commit -m "test: add validation and interaction tests for LoginForm"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: LoginDialog — Component & Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/design-system/composites/LoginForm/LoginDialog.tsx`
|
||||||
|
- Create: `src/design-system/composites/LoginForm/LoginDialog.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write LoginDialog tests**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/LoginForm/LoginDialog.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { LoginDialog } from './LoginDialog'
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onSubmit: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LoginDialog', () => {
|
||||||
|
it('renders Modal with LoginForm when open', () => {
|
||||||
|
render(<LoginDialog {...defaultProps} />)
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Sign in')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
render(<LoginDialog {...defaultProps} open={false} />)
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose on Esc', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginDialog {...defaultProps} onClose={onClose} />)
|
||||||
|
await user.keyboard('{Escape}')
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose on backdrop click', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginDialog {...defaultProps} onClose={onClose} />)
|
||||||
|
await user.click(screen.getByTestId('modal-backdrop'))
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes LoginForm props through', () => {
|
||||||
|
render(
|
||||||
|
<LoginDialog
|
||||||
|
{...defaultProps}
|
||||||
|
title="Welcome"
|
||||||
|
socialProviders={[{ label: 'Continue with Google', onClick: vi.fn() }]}
|
||||||
|
error="Bad credentials"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Welcome')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Bad credentials')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/LoginForm/LoginDialog.test.tsx`
|
||||||
|
Expected: FAIL — module not found
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create LoginDialog component**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/LoginForm/LoginDialog.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Modal } from '../Modal/Modal'
|
||||||
|
import { LoginForm, type LoginFormProps } from './LoginForm'
|
||||||
|
|
||||||
|
export interface LoginDialogProps extends LoginFormProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginDialog({ open, onClose, className, ...formProps }: LoginDialogProps) {
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} size="sm" className={className}>
|
||||||
|
<LoginForm {...formProps} />
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/LoginForm/LoginDialog.test.tsx`
|
||||||
|
Expected: 5 tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/LoginForm/LoginDialog.tsx \
|
||||||
|
src/design-system/composites/LoginForm/LoginDialog.test.tsx
|
||||||
|
git commit -m "feat: add LoginDialog modal wrapper component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Barrel Exports & Full Test Suite
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/design-system/composites/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add exports to barrel**
|
||||||
|
|
||||||
|
Add these lines to `src/design-system/composites/index.ts` in alphabetical position (after the `LineChart` export, before `MenuItem`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { LoginForm } from './LoginForm/LoginForm'
|
||||||
|
export type { LoginFormProps, SocialProvider } from './LoginForm/LoginForm'
|
||||||
|
export { LoginDialog } from './LoginForm/LoginDialog'
|
||||||
|
export type { LoginDialogProps } from './LoginForm/LoginDialog'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the full test suite**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/LoginForm/`
|
||||||
|
Expected: All tests PASS (21 LoginForm + 5 LoginDialog = 26 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the full project test suite to check for regressions**
|
||||||
|
|
||||||
|
Run: `npx vitest run`
|
||||||
|
Expected: All tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/index.ts
|
||||||
|
git commit -m "feat: export LoginForm and LoginDialog from composites barrel"
|
||||||
|
```
|
||||||
703
docs/superpowers/plans/2026-03-24-metrics-components.md
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
# Metrics Components Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add StatusText primitive, Card title prop, and KpiStrip composite to eliminate ~320 lines of duplicated KPI layout code across Dashboard, Routes, and AgentHealth pages.
|
||||||
|
|
||||||
|
**Architecture:** StatusText is a tiny inline span primitive with semantic color variants. Card gets an optional title prop for a header row. KpiStrip is a new composite that renders a horizontal row of metric cards with labels, values, trends, subtitles, and sparklines.
|
||||||
|
|
||||||
|
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 1, 5, 6)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| Action | File | Task |
|
||||||
|
|--------|------|------|
|
||||||
|
| CREATE | `src/design-system/primitives/StatusText/StatusText.tsx` | 1 |
|
||||||
|
| CREATE | `src/design-system/primitives/StatusText/StatusText.module.css` | 1 |
|
||||||
|
| CREATE | `src/design-system/primitives/StatusText/StatusText.test.tsx` | 1 |
|
||||||
|
| MODIFY | `src/design-system/primitives/index.ts` | 1 |
|
||||||
|
| MODIFY | `src/design-system/primitives/Card/Card.tsx` | 2 |
|
||||||
|
| MODIFY | `src/design-system/primitives/Card/Card.module.css` | 2 |
|
||||||
|
| CREATE | `src/design-system/primitives/Card/Card.test.tsx` | 2 |
|
||||||
|
| CREATE | `src/design-system/composites/KpiStrip/KpiStrip.tsx` | 3 |
|
||||||
|
| CREATE | `src/design-system/composites/KpiStrip/KpiStrip.module.css` | 3 |
|
||||||
|
| CREATE | `src/design-system/composites/KpiStrip/KpiStrip.test.tsx` | 3 |
|
||||||
|
| MODIFY | `src/design-system/composites/index.ts` | 3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: StatusText Primitive
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- CREATE `src/design-system/primitives/StatusText/StatusText.tsx`
|
||||||
|
- CREATE `src/design-system/primitives/StatusText/StatusText.module.css`
|
||||||
|
- CREATE `src/design-system/primitives/StatusText/StatusText.test.tsx`
|
||||||
|
- MODIFY `src/design-system/primitives/index.ts`
|
||||||
|
|
||||||
|
### Step 1.1 — Write test (RED)
|
||||||
|
|
||||||
|
- [ ] Create `src/design-system/primitives/StatusText/StatusText.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { StatusText } from './StatusText'
|
||||||
|
|
||||||
|
describe('StatusText', () => {
|
||||||
|
it('renders children text', () => {
|
||||||
|
render(<StatusText variant="success">OK</StatusText>)
|
||||||
|
expect(screen.getByText('OK')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders as a span element', () => {
|
||||||
|
render(<StatusText variant="success">OK</StatusText>)
|
||||||
|
expect(screen.getByText('OK').tagName).toBe('SPAN')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies variant class', () => {
|
||||||
|
render(<StatusText variant="error">BREACH</StatusText>)
|
||||||
|
expect(screen.getByText('BREACH')).toHaveClass('error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies bold class when bold=true', () => {
|
||||||
|
render(<StatusText variant="warning" bold>HIGH</StatusText>)
|
||||||
|
expect(screen.getByText('HIGH')).toHaveClass('bold')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not apply bold class by default', () => {
|
||||||
|
render(<StatusText variant="muted">idle</StatusText>)
|
||||||
|
expect(screen.getByText('idle')).not.toHaveClass('bold')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts custom className', () => {
|
||||||
|
render(<StatusText variant="running" className="custom">active</StatusText>)
|
||||||
|
expect(screen.getByText('active')).toHaveClass('custom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all variant classes correctly', () => {
|
||||||
|
const { rerender } = render(<StatusText variant="success">text</StatusText>)
|
||||||
|
expect(screen.getByText('text')).toHaveClass('success')
|
||||||
|
|
||||||
|
rerender(<StatusText variant="warning">text</StatusText>)
|
||||||
|
expect(screen.getByText('text')).toHaveClass('warning')
|
||||||
|
|
||||||
|
rerender(<StatusText variant="error">text</StatusText>)
|
||||||
|
expect(screen.getByText('text')).toHaveClass('error')
|
||||||
|
|
||||||
|
rerender(<StatusText variant="running">text</StatusText>)
|
||||||
|
expect(screen.getByText('text')).toHaveClass('running')
|
||||||
|
|
||||||
|
rerender(<StatusText variant="muted">text</StatusText>)
|
||||||
|
expect(screen.getByText('text')).toHaveClass('muted')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Run test — expect FAIL (module not found):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/design-system/primitives/StatusText/StatusText.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1.2 — Implement (GREEN)
|
||||||
|
|
||||||
|
- [ ] Create `src/design-system/primitives/StatusText/StatusText.module.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.statusText {
|
||||||
|
/* Inherits font-size from parent */
|
||||||
|
}
|
||||||
|
|
||||||
|
.success { color: var(--success); }
|
||||||
|
.warning { color: var(--warning); }
|
||||||
|
.error { color: var(--error); }
|
||||||
|
.running { color: var(--running); }
|
||||||
|
.muted { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.bold { font-weight: 600; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Create `src/design-system/primitives/StatusText/StatusText.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import styles from './StatusText.module.css'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface StatusTextProps {
|
||||||
|
variant: 'success' | 'warning' | 'error' | 'running' | 'muted'
|
||||||
|
bold?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusText({ variant, bold = false, children, className }: StatusTextProps) {
|
||||||
|
const classes = [
|
||||||
|
styles.statusText,
|
||||||
|
styles[variant],
|
||||||
|
bold ? styles.bold : '',
|
||||||
|
className ?? '',
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
return <span className={classes}>{children}</span>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Run test — expect PASS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/design-system/primitives/StatusText/StatusText.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1.3 — Barrel export
|
||||||
|
|
||||||
|
- [ ] Add to `src/design-system/primitives/index.ts` (alphabetical, after `StatusDot`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { StatusText } from './StatusText/StatusText'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1.4 — Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/primitives/StatusText/ src/design-system/primitives/index.ts
|
||||||
|
git commit -m "feat: add StatusText primitive with semantic color variants"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Card Title Extension
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- MODIFY `src/design-system/primitives/Card/Card.tsx`
|
||||||
|
- MODIFY `src/design-system/primitives/Card/Card.module.css`
|
||||||
|
- CREATE `src/design-system/primitives/Card/Card.test.tsx`
|
||||||
|
|
||||||
|
### Step 2.1 — Write test (RED)
|
||||||
|
|
||||||
|
- [ ] Create `src/design-system/primitives/Card/Card.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { Card } from './Card'
|
||||||
|
|
||||||
|
describe('Card', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
render(<Card>Card content</Card>)
|
||||||
|
expect(screen.getByText('Card content')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders title when provided', () => {
|
||||||
|
render(<Card title="Section Title">content</Card>)
|
||||||
|
expect(screen.getByText('Section Title')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render title header when title is omitted', () => {
|
||||||
|
const { container } = render(<Card>content</Card>)
|
||||||
|
expect(container.querySelector('.titleHeader')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps children in body div when title is provided', () => {
|
||||||
|
render(<Card title="Header">body text</Card>)
|
||||||
|
const body = screen.getByText('body text').closest('div')
|
||||||
|
expect(body).toHaveClass('body')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with accent and title together', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Card accent="success" title="Status">
|
||||||
|
details
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
expect(container.firstChild).toHaveClass('accent-success')
|
||||||
|
expect(screen.getByText('Status')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('details')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts className prop', () => {
|
||||||
|
const { container } = render(<Card className="custom">content</Card>)
|
||||||
|
expect(container.firstChild).toHaveClass('custom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders children directly when no title (no wrapper div)', () => {
|
||||||
|
const { container } = render(<Card><span data-testid="direct">hi</span></Card>)
|
||||||
|
expect(screen.getByTestId('direct')).toBeInTheDocument()
|
||||||
|
// Should not have a body wrapper when there is no title
|
||||||
|
expect(container.querySelector('.body')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Run test — expect FAIL (title prop not supported yet, body class missing):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/design-system/primitives/Card/Card.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2.2 — Implement (GREEN)
|
||||||
|
|
||||||
|
- [ ] Add to `src/design-system/primitives/Card/Card.module.css` (append after existing rules):
|
||||||
|
|
||||||
|
```css
|
||||||
|
.titleHeader {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleText {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Replace `src/design-system/primitives/Card/Card.tsx` with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import styles from './Card.module.css'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: ReactNode
|
||||||
|
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' | 'none'
|
||||||
|
title?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, accent = 'none', title, className }: CardProps) {
|
||||||
|
const classes = [
|
||||||
|
styles.card,
|
||||||
|
accent !== 'none' ? styles[`accent-${accent}`] : '',
|
||||||
|
className ?? '',
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes}>
|
||||||
|
{title && (
|
||||||
|
<div className={styles.titleHeader}>
|
||||||
|
<h3 className={styles.titleText}>{title}</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{title ? <div className={styles.body}>{children}</div> : children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Run test — expect PASS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/design-system/primitives/Card/Card.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2.3 — Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/primitives/Card/
|
||||||
|
git commit -m "feat: add optional title prop to Card primitive"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: KpiStrip Composite
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- CREATE `src/design-system/composites/KpiStrip/KpiStrip.tsx`
|
||||||
|
- CREATE `src/design-system/composites/KpiStrip/KpiStrip.module.css`
|
||||||
|
- CREATE `src/design-system/composites/KpiStrip/KpiStrip.test.tsx`
|
||||||
|
- MODIFY `src/design-system/composites/index.ts`
|
||||||
|
|
||||||
|
### Step 3.1 — Write test (RED)
|
||||||
|
|
||||||
|
- [ ] Create `src/design-system/composites/KpiStrip/KpiStrip.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { KpiStrip } from './KpiStrip'
|
||||||
|
|
||||||
|
const sampleItems = [
|
||||||
|
{
|
||||||
|
label: 'Total Throughput',
|
||||||
|
value: '12,847',
|
||||||
|
trend: { label: '\u25B2 +8%', variant: 'success' as const },
|
||||||
|
subtitle: '35.7 msg/s',
|
||||||
|
sparkline: [44, 46, 45, 47, 48, 46, 47],
|
||||||
|
borderColor: 'var(--amber)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Error Rate',
|
||||||
|
value: '0.42%',
|
||||||
|
trend: { label: '\u25BC -0.1%', variant: 'success' as const },
|
||||||
|
subtitle: '54 errors / 12,847 total',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Active Routes',
|
||||||
|
value: 14,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('KpiStrip', () => {
|
||||||
|
it('renders all items', () => {
|
||||||
|
render(<KpiStrip items={sampleItems} />)
|
||||||
|
expect(screen.getByText('Total Throughput')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Error Rate')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Active Routes')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders labels and values', () => {
|
||||||
|
render(<KpiStrip items={sampleItems} />)
|
||||||
|
expect(screen.getByText('12,847')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('0.42%')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('14')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders trend with correct text', () => {
|
||||||
|
render(<KpiStrip items={sampleItems} />)
|
||||||
|
expect(screen.getByText('\u25B2 +8%')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('\u25BC -0.1%')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies variant class to trend', () => {
|
||||||
|
render(<KpiStrip items={sampleItems} />)
|
||||||
|
const trend = screen.getByText('\u25B2 +8%')
|
||||||
|
expect(trend).toHaveClass('trendSuccess')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides trend when omitted', () => {
|
||||||
|
render(<KpiStrip items={[{ label: 'Routes', value: 14 }]} />)
|
||||||
|
// Should only have label and value, no trend element
|
||||||
|
const card = screen.getByText('Routes').closest('[class*="kpiCard"]')
|
||||||
|
expect(card?.querySelector('[class*="trend"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders subtitle', () => {
|
||||||
|
render(<KpiStrip items={sampleItems} />)
|
||||||
|
expect(screen.getByText('35.7 msg/s')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('54 errors / 12,847 total')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders sparkline when data provided', () => {
|
||||||
|
const { container } = render(<KpiStrip items={sampleItems} />)
|
||||||
|
// Sparkline renders an SVG with aria-hidden
|
||||||
|
const svgs = container.querySelectorAll('svg[aria-hidden="true"]')
|
||||||
|
expect(svgs.length).toBe(1) // Only first item has sparkline
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts className prop', () => {
|
||||||
|
const { container } = render(<KpiStrip items={sampleItems} className="custom" />)
|
||||||
|
expect(container.firstChild).toHaveClass('custom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty items array', () => {
|
||||||
|
const { container } = render(<KpiStrip items={[]} />)
|
||||||
|
expect(container.firstChild).toBeInTheDocument()
|
||||||
|
// No cards rendered
|
||||||
|
expect(container.querySelectorAll('[class*="kpiCard"]').length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses default border color when borderColor is omitted', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<KpiStrip items={[{ label: 'Test', value: 100 }]} />
|
||||||
|
)
|
||||||
|
const card = container.querySelector('[class*="kpiCard"]')
|
||||||
|
expect(card).toBeInTheDocument()
|
||||||
|
// The default borderColor is applied via inline style
|
||||||
|
expect(card).toHaveStyle({ '--kpi-border-color': 'var(--amber)' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies custom borderColor', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<KpiStrip items={[{ label: 'Errors', value: 5, borderColor: 'var(--error)' }]} />
|
||||||
|
)
|
||||||
|
const card = container.querySelector('[class*="kpiCard"]')
|
||||||
|
expect(card).toHaveStyle({ '--kpi-border-color': 'var(--error)' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders trend with muted variant by default', () => {
|
||||||
|
render(
|
||||||
|
<KpiStrip items={[{ label: 'Test', value: 1, trend: { label: '~ stable' } }]} />
|
||||||
|
)
|
||||||
|
const trend = screen.getByText('~ stable')
|
||||||
|
expect(trend).toHaveClass('trendMuted')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Run test — expect FAIL (module not found):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/design-system/composites/KpiStrip/KpiStrip.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3.2 — Implement (GREEN)
|
||||||
|
|
||||||
|
- [ ] Create `src/design-system/composites/KpiStrip/KpiStrip.module.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* KpiStrip — horizontal row of metric cards */
|
||||||
|
.kpiStrip {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Individual card ─────────────────────────────────────────────── */
|
||||||
|
.kpiCard {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 16px 18px 12px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiCard:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top gradient border — color driven by CSS custom property */
|
||||||
|
.kpiCard::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, var(--kpi-border-color), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Label ───────────────────────────────────────────────────────── */
|
||||||
|
.label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Value row ───────────────────────────────────────────────────── */
|
||||||
|
.valueRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Trend ────────────────────────────────────────────────────────── */
|
||||||
|
.trend {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trendSuccess { color: var(--success); }
|
||||||
|
.trendWarning { color: var(--warning); }
|
||||||
|
.trendError { color: var(--error); }
|
||||||
|
.trendMuted { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ── Subtitle ─────────────────────────────────────────────────────── */
|
||||||
|
.subtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sparkline ────────────────────────────────────────────────────── */
|
||||||
|
.sparkline {
|
||||||
|
margin-top: 8px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Create `src/design-system/composites/KpiStrip/KpiStrip.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import styles from './KpiStrip.module.css'
|
||||||
|
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
|
||||||
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface KpiItem {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
|
||||||
|
subtitle?: string
|
||||||
|
sparkline?: number[]
|
||||||
|
borderColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KpiStripProps {
|
||||||
|
items: KpiItem[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendClassMap: Record<string, string> = {
|
||||||
|
success: styles.trendSuccess,
|
||||||
|
warning: styles.trendWarning,
|
||||||
|
error: styles.trendError,
|
||||||
|
muted: styles.trendMuted,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KpiStrip({ items, className }: KpiStripProps) {
|
||||||
|
const stripClasses = [styles.kpiStrip, className ?? ''].filter(Boolean).join(' ')
|
||||||
|
const gridStyle: CSSProperties = {
|
||||||
|
gridTemplateColumns: items.length > 0 ? `repeat(${items.length}, 1fr)` : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={stripClasses} style={gridStyle}>
|
||||||
|
{items.map((item) => {
|
||||||
|
const borderColor = item.borderColor ?? 'var(--amber)'
|
||||||
|
const cardStyle: CSSProperties & Record<string, string> = {
|
||||||
|
'--kpi-border-color': borderColor,
|
||||||
|
}
|
||||||
|
const trendVariant = item.trend?.variant ?? 'muted'
|
||||||
|
const trendClass = trendClassMap[trendVariant] ?? styles.trendMuted
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.label} className={styles.kpiCard} style={cardStyle}>
|
||||||
|
<div className={styles.label}>{item.label}</div>
|
||||||
|
<div className={styles.valueRow}>
|
||||||
|
<span className={styles.value}>{item.value}</span>
|
||||||
|
{item.trend && (
|
||||||
|
<span className={`${styles.trend} ${trendClass}`}>
|
||||||
|
{item.trend.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.subtitle && (
|
||||||
|
<div className={styles.subtitle}>{item.subtitle}</div>
|
||||||
|
)}
|
||||||
|
{item.sparkline && item.sparkline.length >= 2 && (
|
||||||
|
<div className={styles.sparkline}>
|
||||||
|
<Sparkline
|
||||||
|
data={item.sparkline}
|
||||||
|
color={borderColor}
|
||||||
|
width={200}
|
||||||
|
height={32}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Run test — expect PASS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/design-system/composites/KpiStrip/KpiStrip.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3.3 — Barrel export
|
||||||
|
|
||||||
|
- [ ] Add to `src/design-system/composites/index.ts` (alphabetical, after `GroupCard`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { KpiStrip } from './KpiStrip/KpiStrip'
|
||||||
|
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3.4 — Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/KpiStrip/ src/design-system/composites/index.ts
|
||||||
|
git commit -m "feat: add KpiStrip composite for reusable metric card rows"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Barrel Exports Verification & Full Test Run
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- VERIFY `src/design-system/primitives/index.ts` (modified in Task 1)
|
||||||
|
- VERIFY `src/design-system/composites/index.ts` (modified in Task 3)
|
||||||
|
|
||||||
|
### Step 4.1 — Verify barrel exports
|
||||||
|
|
||||||
|
- [ ] Confirm `src/design-system/primitives/index.ts` contains:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { StatusText } from './StatusText/StatusText'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Confirm `src/design-system/composites/index.ts` contains:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { KpiStrip } from './KpiStrip/KpiStrip'
|
||||||
|
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4.2 — Run full test suite
|
||||||
|
|
||||||
|
- [ ] Run all tests to confirm nothing is broken:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Verify zero failures. If any test fails, fix and re-run before proceeding.
|
||||||
|
|
||||||
|
### Step 4.3 — Final commit (if barrel-only changes remain)
|
||||||
|
|
||||||
|
If the barrel export changes were not already committed in their respective tasks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/primitives/index.ts src/design-system/composites/index.ts
|
||||||
|
git commit -m "chore: add StatusText and KpiStrip to barrel exports"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Expected Barrel Export Additions
|
||||||
|
|
||||||
|
**`src/design-system/primitives/index.ts`** — insert after `StatusDot` line:
|
||||||
|
```ts
|
||||||
|
export { StatusText } from './StatusText/StatusText'
|
||||||
|
```
|
||||||
|
|
||||||
|
**`src/design-system/composites/index.ts`** — insert after `GroupCard` line:
|
||||||
|
```ts
|
||||||
|
export { KpiStrip } from './KpiStrip/KpiStrip'
|
||||||
|
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Commands Quick Reference
|
||||||
|
|
||||||
|
| Scope | Command |
|
||||||
|
|-------|---------|
|
||||||
|
| StatusText only | `npx vitest run src/design-system/primitives/StatusText/StatusText.test.tsx` |
|
||||||
|
| Card only | `npx vitest run src/design-system/primitives/Card/Card.test.tsx` |
|
||||||
|
| KpiStrip only | `npx vitest run src/design-system/composites/KpiStrip/KpiStrip.test.tsx` |
|
||||||
|
| All tests | `npx vitest run` |
|
||||||
506
docs/superpowers/plans/2026-03-24-observability-components.md
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
# Observability Components Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add LogViewer composite for log display and refactor AgentHealth to use DataTable instead of raw HTML tables.
|
||||||
|
|
||||||
|
**Architecture:** LogViewer is a scrollable log display with timestamped, severity-colored entries and auto-scroll behavior. The AgentHealth refactor replaces raw `<table>` elements with the existing DataTable composite.
|
||||||
|
|
||||||
|
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 3, 4)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: LogViewer composite
|
||||||
|
|
||||||
|
Create a new composite component that renders a scrollable log viewer with timestamped, severity-colored entries. This replaces the custom log rendering in `AgentInstance.tsx`.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- **Create** `src/design-system/composites/LogViewer/LogViewer.tsx`
|
||||||
|
- **Create** `src/design-system/composites/LogViewer/LogViewer.module.css`
|
||||||
|
- **Create** `src/design-system/composites/LogViewer/LogViewer.test.tsx`
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
- [ ] **1.1** Create `src/design-system/composites/LogViewer/LogViewer.tsx` with the component and exported types
|
||||||
|
- [ ] **1.2** Create `src/design-system/composites/LogViewer/LogViewer.module.css` with all styles
|
||||||
|
- [ ] **1.3** Create `src/design-system/composites/LogViewer/LogViewer.test.tsx` with tests
|
||||||
|
- [ ] **1.4** Run `npx vitest run src/design-system/composites/LogViewer` and fix any failures
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: string
|
||||||
|
level: 'info' | 'warn' | 'error' | 'debug'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogViewerProps {
|
||||||
|
entries: LogEntry[]
|
||||||
|
maxHeight?: number | string // Default: 400
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component implementation — `LogViewer.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useRef, useEffect, useCallback } from 'react'
|
||||||
|
import styles from './LogViewer.module.css'
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: string
|
||||||
|
level: 'info' | 'warn' | 'error' | 'debug'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogViewerProps {
|
||||||
|
entries: LogEntry[]
|
||||||
|
maxHeight?: number | string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_CLASS: Record<LogEntry['level'], string> = {
|
||||||
|
info: styles.levelInfo,
|
||||||
|
warn: styles.levelWarn,
|
||||||
|
error: styles.levelError,
|
||||||
|
debug: styles.levelDebug,
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isAtBottomRef = useRef(true)
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
// Consider "at bottom" when within 20px of the end
|
||||||
|
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when entries change, but only if user hasn't scrolled up
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (el && isAtBottomRef.current) {
|
||||||
|
el.scrollTop = el.scrollHeight
|
||||||
|
}
|
||||||
|
}, [entries])
|
||||||
|
|
||||||
|
const heightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className={[styles.container, className].filter(Boolean).join(' ')}
|
||||||
|
style={{ maxHeight: heightStyle }}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
role="log"
|
||||||
|
>
|
||||||
|
{entries.map((entry, i) => (
|
||||||
|
<div key={i} className={styles.line}>
|
||||||
|
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
|
||||||
|
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
|
||||||
|
{entry.level.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className={styles.message}>{entry.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<div className={styles.empty}>No log entries.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styles — `LogViewer.module.css`
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Scrollable container */
|
||||||
|
.container {
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 8px 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each log line */
|
||||||
|
.line {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 3px 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timestamp */
|
||||||
|
.timestamp {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Level badge — pill with tinted background */
|
||||||
|
.levelBadge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelInfo {
|
||||||
|
color: var(--running);
|
||||||
|
background: color-mix(in srgb, var(--running) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelWarn {
|
||||||
|
color: var(--warning);
|
||||||
|
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelError {
|
||||||
|
color: var(--error);
|
||||||
|
background: color-mix(in srgb, var(--error) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelDebug {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message text */
|
||||||
|
.message {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests — `LogViewer.test.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { LogViewer, type LogEntry } from './LogViewer'
|
||||||
|
import { ThemeProvider } from '../../providers/ThemeProvider'
|
||||||
|
|
||||||
|
const wrap = (ui: React.ReactElement) => render(<ThemeProvider>{ui}</ThemeProvider>)
|
||||||
|
|
||||||
|
const sampleEntries: LogEntry[] = [
|
||||||
|
{ timestamp: '2026-03-24T10:00:00Z', level: 'info', message: 'Server started' },
|
||||||
|
{ timestamp: '2026-03-24T10:01:00Z', level: 'warn', message: 'Slow query detected' },
|
||||||
|
{ timestamp: '2026-03-24T10:02:00Z', level: 'error', message: 'Connection refused' },
|
||||||
|
{ timestamp: '2026-03-24T10:03:00Z', level: 'debug', message: 'Cache hit ratio: 0.95' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('LogViewer', () => {
|
||||||
|
it('renders entries with timestamps and messages', () => {
|
||||||
|
wrap(<LogViewer entries={sampleEntries} />)
|
||||||
|
expect(screen.getByText('Server started')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Slow query detected')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Connection refused')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Cache hit ratio: 0.95')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders level badges with correct text', () => {
|
||||||
|
wrap(<LogViewer entries={sampleEntries} />)
|
||||||
|
expect(screen.getByText('INFO')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('WARN')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('ERROR')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('DEBUG')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with custom maxHeight', () => {
|
||||||
|
const { container } = wrap(<LogViewer entries={sampleEntries} maxHeight={200} />)
|
||||||
|
const el = container.querySelector('[role="log"]')
|
||||||
|
expect(el).toHaveStyle({ maxHeight: '200px' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with string maxHeight', () => {
|
||||||
|
const { container } = wrap(<LogViewer entries={sampleEntries} maxHeight="50vh" />)
|
||||||
|
const el = container.querySelector('[role="log"]')
|
||||||
|
expect(el).toHaveStyle({ maxHeight: '50vh' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty entries', () => {
|
||||||
|
wrap(<LogViewer entries={[]} />)
|
||||||
|
expect(screen.getByText('No log entries.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts className prop', () => {
|
||||||
|
const { container } = wrap(<LogViewer entries={sampleEntries} className="custom-class" />)
|
||||||
|
const el = container.querySelector('[role="log"]')
|
||||||
|
expect(el?.className).toContain('custom-class')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has role="log" for accessibility', () => {
|
||||||
|
wrap(<LogViewer entries={sampleEntries} />)
|
||||||
|
expect(screen.getByRole('log')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key design decisions
|
||||||
|
|
||||||
|
- **Auto-scroll behavior:** Uses a `useRef` to track whether the user is at the bottom of the scroll container. On new entries (via `useEffect` on `entries`), scrolls to bottom only if `isAtBottomRef.current` is `true`. Pauses when user scrolls up (more than 20px from bottom). Resumes when user scrolls back to bottom.
|
||||||
|
- **Level colors:** Map to existing design tokens: `info` -> `var(--running)`, `warn` -> `var(--warning)`, `error` -> `var(--error)`, `debug` -> `var(--text-muted)`. Pill backgrounds use `color-mix` with 12% opacity tint.
|
||||||
|
- **No Badge dependency:** The level badge is a styled `<span>` rather than using the `Badge` primitive. This avoids pulling in `hashColor`/`useTheme` and keeps the badge styling tightly scoped (9px pill vs Badge's larger size). The spec calls for a very compact pill at 9px mono — a custom element is cleaner.
|
||||||
|
- **`role="log"`** on the container for accessibility (indicates a log region to screen readers).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Barrel exports for LogViewer
|
||||||
|
|
||||||
|
Add LogViewer and its types to the composites barrel export.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- **Modify** `src/design-system/composites/index.ts`
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
- [ ] **2.1** Add LogViewer export and type exports to `src/design-system/composites/index.ts`
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
Add these lines to `src/design-system/composites/index.ts`, in alphabetical position (after the `LineChart` export):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { LogViewer } from './LogViewer/LogViewer'
|
||||||
|
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
|
||||||
|
```
|
||||||
|
|
||||||
|
The full insertion point — after line 19 (`export { LineChart } from './LineChart/LineChart'`) and before line 20 (`export { LoginDialog } from './LoginForm/LoginDialog'`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { LineChart } from './LineChart/LineChart'
|
||||||
|
export { LogViewer } from './LogViewer/LogViewer'
|
||||||
|
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
|
||||||
|
export { LoginDialog } from './LoginForm/LoginDialog'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: AgentHealth DataTable refactor
|
||||||
|
|
||||||
|
Replace the raw HTML `<table>` in `AgentHealth.tsx` with the existing `DataTable` composite. This is a **page-level refactor** — no design system components are changed.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- **Modify** `src/pages/AgentHealth/AgentHealth.tsx` — replace `<table>` with `<DataTable>`
|
||||||
|
- **Modify** `src/pages/AgentHealth/AgentHealth.module.css` — remove table CSS
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
- [ ] **3.1** Add `DataTable` and `Column` imports to `AgentHealth.tsx`
|
||||||
|
- [ ] **3.2** Define the instance columns array
|
||||||
|
- [ ] **3.3** Replace the `<table>` block inside each `<GroupCard>` with `<DataTable>`
|
||||||
|
- [ ] **3.4** Remove unused table CSS classes from `AgentHealth.module.css`
|
||||||
|
- [ ] **3.5** Visually verify the page looks identical (run dev server, navigate to `/agents`)
|
||||||
|
|
||||||
|
### 3.1 — Add imports
|
||||||
|
|
||||||
|
Add to the composites import block in `AgentHealth.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
|
||||||
|
import type { Column } from '../../design-system/composites/DataTable/types'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 — Define columns
|
||||||
|
|
||||||
|
Add a column definition constant above the `AgentHealth` component function. The columns mirror the existing `<th>` headers. Custom `render` functions handle the StatusDot and Badge cells.
|
||||||
|
|
||||||
|
**Important:** DataTable requires rows with an `id: string` field. The `AgentHealthData` type already has `id`, so no transformation is needed.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const instanceColumns: Column<AgentHealthData>[] = [
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: '',
|
||||||
|
width: '12px',
|
||||||
|
render: (_value, row) => (
|
||||||
|
<StatusDot variant={row.status === 'live' ? 'live' : row.status === 'stale' ? 'stale' : 'dead'} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Instance',
|
||||||
|
render: (_value, row) => (
|
||||||
|
<MonoText size="sm" className={styles.instanceName}>{row.name}</MonoText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'state',
|
||||||
|
header: 'State',
|
||||||
|
render: (_value, row) => (
|
||||||
|
<Badge
|
||||||
|
label={row.status.toUpperCase()}
|
||||||
|
color={row.status === 'live' ? 'success' : row.status === 'stale' ? 'warning' : 'error'}
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'uptime',
|
||||||
|
header: 'Uptime',
|
||||||
|
render: (_value, row) => (
|
||||||
|
<MonoText size="xs" className={styles.instanceMeta}>{row.uptime}</MonoText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tps',
|
||||||
|
header: 'TPS',
|
||||||
|
render: (_value, row) => (
|
||||||
|
<MonoText size="xs" className={styles.instanceMeta}>{row.tps.toFixed(1)}/s</MonoText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'errorRate',
|
||||||
|
header: 'Errors',
|
||||||
|
render: (_value, row) => (
|
||||||
|
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}>
|
||||||
|
{row.errorRate ?? '0 err/h'}
|
||||||
|
</MonoText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastSeen',
|
||||||
|
header: 'Heartbeat',
|
||||||
|
render: (_value, row) => (
|
||||||
|
<MonoText size="xs" className={
|
||||||
|
row.status === 'dead' ? styles.instanceHeartbeatDead :
|
||||||
|
row.status === 'stale' ? styles.instanceHeartbeatStale :
|
||||||
|
styles.instanceMeta
|
||||||
|
}>
|
||||||
|
{row.lastSeen}
|
||||||
|
</MonoText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 — Replace `<table>` with `<DataTable>`
|
||||||
|
|
||||||
|
Replace the entire `<table className={styles.instanceTable}>...</table>` block (lines 365-423 of `AgentHealth.tsx`) inside each `<GroupCard>` with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DataTable
|
||||||
|
columns={instanceColumns}
|
||||||
|
data={group.instances}
|
||||||
|
flush
|
||||||
|
selectedId={selectedInstance?.id}
|
||||||
|
onRowClick={handleInstanceClick}
|
||||||
|
pageSize={50}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Key props:
|
||||||
|
- `flush` — strips DataTable's outer border/radius/shadow so it sits seamlessly inside the GroupCard
|
||||||
|
- `selectedId` — highlights the currently selected row (replaces the manual `instanceRowActive` CSS class)
|
||||||
|
- `onRowClick` — replaces the manual `onClick` on `<tr>` elements
|
||||||
|
- `pageSize={50}` — high enough to avoid pagination for typical instance counts per app group
|
||||||
|
|
||||||
|
### 3.4 — Remove unused CSS
|
||||||
|
|
||||||
|
Remove these CSS classes from `AgentHealth.module.css` (they were only used by the raw `<table>`):
|
||||||
|
|
||||||
|
```
|
||||||
|
.instanceTable
|
||||||
|
.instanceTable thead th
|
||||||
|
.thStatus
|
||||||
|
.tdStatus
|
||||||
|
.instanceRow
|
||||||
|
.instanceRow td
|
||||||
|
.instanceRow:last-child td
|
||||||
|
.instanceRow:hover td
|
||||||
|
.instanceRowActive td
|
||||||
|
.instanceRowActive td:first-child
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keep** these classes (still used by DataTable `render` functions):
|
||||||
|
|
||||||
|
```
|
||||||
|
.instanceName
|
||||||
|
.instanceMeta
|
||||||
|
.instanceError
|
||||||
|
.instanceHeartbeatStale
|
||||||
|
.instanceHeartbeatDead
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual verification checklist
|
||||||
|
|
||||||
|
After the refactor, verify at `/agents`:
|
||||||
|
- [ ] StatusDot column renders colored dots in the first column
|
||||||
|
- [ ] Instance name renders in mono bold
|
||||||
|
- [ ] State column shows Badge with correct color variant
|
||||||
|
- [ ] Uptime, TPS, Errors, Heartbeat columns show muted mono text
|
||||||
|
- [ ] Error values show in `var(--error)` red
|
||||||
|
- [ ] Stale/dead heartbeat timestamps show warning/error colors
|
||||||
|
- [ ] Row click opens the DetailPanel
|
||||||
|
- [ ] Selected row is visually highlighted
|
||||||
|
- [ ] Table sits flush inside GroupCard (no double borders)
|
||||||
|
- [ ] Alert banner still renders below the table for groups with dead instances
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution order
|
||||||
|
|
||||||
|
1. **Task 1** — LogViewer composite (no dependencies)
|
||||||
|
2. **Task 2** — Barrel exports (depends on Task 1)
|
||||||
|
3. **Task 3** — AgentHealth DataTable refactor (independent of Tasks 1-2)
|
||||||
|
|
||||||
|
Tasks 1+2 and Task 3 can be parallelized since they touch different parts of the codebase.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run LogViewer tests
|
||||||
|
npx vitest run src/design-system/composites/LogViewer
|
||||||
|
|
||||||
|
# Run all tests to check nothing broke
|
||||||
|
npx vitest run
|
||||||
|
|
||||||
|
# Start dev server for visual verification
|
||||||
|
npm run dev
|
||||||
|
# Then navigate to /agents and /agents/{appId}/{instanceId}
|
||||||
|
```
|
||||||
1609
docs/superpowers/plans/2026-04-02-composable-sidebar.md
Normal file
845
docs/superpowers/plans/2026-04-12-recharts-migration.md
Normal file
@@ -0,0 +1,845 @@
|
|||||||
|
# Recharts Migration Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the design system's hand-rolled SVG chart components with a single `ThemedChart` wrapper around Recharts, using Recharts-native data format.
|
||||||
|
|
||||||
|
**Architecture:** Add Recharts as a DS dependency. Create `ThemedChart` component that renders `ResponsiveContainer` + `ComposedChart` with pre-themed grid/axes/tooltip. Consumers compose Recharts elements (`<Line>`, `<Area>`, `<Bar>`, `<ReferenceLine>`) as children. Delete old `LineChart/`, `AreaChart/`, `BarChart/`, `_chart-utils.ts`. Migrate the server UI's `AgentInstance.tsx` to the new API.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, Recharts, CSS Modules, Vitest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Design System (`C:\Users\Hendrik\Documents\projects\design-system`):**
|
||||||
|
|
||||||
|
| Action | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Create | `src/design-system/composites/ThemedChart/ThemedChart.tsx` | Wrapper component: ResponsiveContainer + ComposedChart + themed axes/grid/tooltip |
|
||||||
|
| Create | `src/design-system/composites/ThemedChart/ChartTooltip.tsx` | Custom tooltip with timestamp header + series values |
|
||||||
|
| Create | `src/design-system/composites/ThemedChart/ChartTooltip.module.css` | Tooltip styles using DS tokens |
|
||||||
|
| Create | `src/design-system/composites/ThemedChart/ThemedChart.test.tsx` | Render tests for ThemedChart |
|
||||||
|
| Modify | `src/design-system/utils/rechartsTheme.ts` | Move `CHART_COLORS` definition here (was in `_chart-utils.ts`) |
|
||||||
|
| Modify | `src/design-system/composites/index.ts` | Remove old chart exports, add ThemedChart + Recharts re-exports |
|
||||||
|
| Modify | `COMPONENT_GUIDE.md` | Update charting strategy section |
|
||||||
|
| Modify | `package.json` | Add `recharts` dependency, bump version |
|
||||||
|
| Delete | `src/design-system/composites/LineChart/` | Old hand-rolled SVG line chart |
|
||||||
|
| Delete | `src/design-system/composites/AreaChart/` | Old hand-rolled SVG area chart |
|
||||||
|
| Delete | `src/design-system/composites/BarChart/` | Old hand-rolled SVG bar chart |
|
||||||
|
| Delete | `src/design-system/composites/_chart-utils.ts` | Old chart utilities |
|
||||||
|
|
||||||
|
**Server UI (`C:\Users\Hendrik\Documents\projects\cameleer3-server`):**
|
||||||
|
|
||||||
|
| Action | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Modify | `ui/src/pages/AgentInstance/AgentInstance.tsx` | Migrate 6 charts to ThemedChart + Recharts children |
|
||||||
|
| Modify | `ui/package.json` | Update `@cameleer/design-system` to new version |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Recharts Dependency and Move CHART_COLORS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json`
|
||||||
|
- Modify: `src/design-system/utils/rechartsTheme.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install recharts**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\Hendrik\Documents\projects\design-system
|
||||||
|
npm install recharts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Move CHART_COLORS into rechartsTheme.ts**
|
||||||
|
|
||||||
|
Replace the entire file `src/design-system/utils/rechartsTheme.ts` with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const CHART_COLORS = [
|
||||||
|
'var(--chart-1)',
|
||||||
|
'var(--chart-2)',
|
||||||
|
'var(--chart-3)',
|
||||||
|
'var(--chart-4)',
|
||||||
|
'var(--chart-5)',
|
||||||
|
'var(--chart-6)',
|
||||||
|
'var(--chart-7)',
|
||||||
|
'var(--chart-8)',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-configured Recharts prop objects that match the design system's
|
||||||
|
* chart styling. Used internally by ThemedChart and available for
|
||||||
|
* consumers composing Recharts directly.
|
||||||
|
*/
|
||||||
|
export const rechartsTheme = {
|
||||||
|
colors: CHART_COLORS,
|
||||||
|
|
||||||
|
cartesianGrid: {
|
||||||
|
stroke: 'var(--border-subtle)',
|
||||||
|
strokeDasharray: '3 3',
|
||||||
|
vertical: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
xAxis: {
|
||||||
|
tick: { fontSize: 9, fontFamily: 'var(--font-mono)', fill: 'var(--text-faint)' },
|
||||||
|
axisLine: { stroke: 'var(--border-subtle)' },
|
||||||
|
tickLine: false as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
yAxis: {
|
||||||
|
tick: { fontSize: 9, fontFamily: 'var(--font-mono)', fill: 'var(--text-faint)' },
|
||||||
|
axisLine: false as const,
|
||||||
|
tickLine: false as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
tooltip: {
|
||||||
|
contentStyle: {
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '6px 10px',
|
||||||
|
},
|
||||||
|
labelStyle: {
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontSize: 11,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
cursor: { stroke: 'var(--text-faint)' },
|
||||||
|
},
|
||||||
|
|
||||||
|
legend: {
|
||||||
|
wrapperStyle: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify build compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds. The old chart components still import `CHART_COLORS` from `_chart-utils.ts` which still exists — they'll be deleted in Task 4.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json package-lock.json src/design-system/utils/rechartsTheme.ts
|
||||||
|
git commit -m "chore: add recharts dependency, move CHART_COLORS to rechartsTheme"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create ChartTooltip Component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/design-system/composites/ThemedChart/ChartTooltip.tsx`
|
||||||
|
- Create: `src/design-system/composites/ThemedChart/ChartTooltip.module.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create tooltip CSS**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/ThemedChart/ChartTooltip.module.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.tooltip {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create ChartTooltip component**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/ThemedChart/ChartTooltip.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { TooltipProps } from 'recharts'
|
||||||
|
import styles from './ChartTooltip.module.css'
|
||||||
|
|
||||||
|
function formatValue(val: number): string {
|
||||||
|
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
|
||||||
|
if (val >= 1000) return `${(val / 1000).toFixed(1)}k`
|
||||||
|
if (Number.isInteger(val)) return String(val)
|
||||||
|
return val.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(val: unknown): string | null {
|
||||||
|
if (val == null) return null
|
||||||
|
const str = String(val)
|
||||||
|
const ms = typeof val === 'number' && val > 1e12 ? val
|
||||||
|
: typeof val === 'number' && val > 1e9 ? val * 1000
|
||||||
|
: Date.parse(str)
|
||||||
|
if (isNaN(ms)) return str
|
||||||
|
return new Date(ms).toLocaleString([], {
|
||||||
|
month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartTooltip({ active, payload, label }: TooltipProps<number, string>) {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
|
||||||
|
const timeLabel = formatTimestamp(label)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tooltip}>
|
||||||
|
{timeLabel && <div className={styles.time}>{timeLabel}</div>}
|
||||||
|
{payload.map((entry) => (
|
||||||
|
<div key={entry.dataKey as string} className={styles.row}>
|
||||||
|
<span className={styles.dot} style={{ background: entry.color }} />
|
||||||
|
<span className={styles.label}>{entry.name}:</span>
|
||||||
|
<span className={styles.value}>{formatValue(entry.value as number)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/ThemedChart/
|
||||||
|
git commit -m "feat: add ChartTooltip component for ThemedChart"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create ThemedChart Component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/design-system/composites/ThemedChart/ThemedChart.tsx`
|
||||||
|
- Create: `src/design-system/composites/ThemedChart/ThemedChart.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create ThemedChart component**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/ThemedChart/ThemedChart.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
ComposedChart,
|
||||||
|
CartesianGrid,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
} from 'recharts'
|
||||||
|
import { rechartsTheme } from '../../utils/rechartsTheme'
|
||||||
|
import { ChartTooltip } from './ChartTooltip'
|
||||||
|
|
||||||
|
interface ThemedChartProps {
|
||||||
|
data: Record<string, any>[]
|
||||||
|
height?: number
|
||||||
|
xDataKey?: string
|
||||||
|
xType?: 'number' | 'category'
|
||||||
|
xTickFormatter?: (value: any) => string
|
||||||
|
yTickFormatter?: (value: any) => string
|
||||||
|
yLabel?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemedChart({
|
||||||
|
data,
|
||||||
|
height = 200,
|
||||||
|
xDataKey = 'time',
|
||||||
|
xType = 'category',
|
||||||
|
xTickFormatter,
|
||||||
|
yTickFormatter,
|
||||||
|
yLabel,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: ThemedChartProps) {
|
||||||
|
if (!data.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ width: '100%', height }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid {...rechartsTheme.cartesianGrid} />
|
||||||
|
<XAxis
|
||||||
|
dataKey={xDataKey}
|
||||||
|
type={xType}
|
||||||
|
{...rechartsTheme.xAxis}
|
||||||
|
tickFormatter={xTickFormatter}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
{...rechartsTheme.yAxis}
|
||||||
|
tickFormatter={yTickFormatter}
|
||||||
|
label={yLabel ? {
|
||||||
|
value: yLabel,
|
||||||
|
angle: -90,
|
||||||
|
position: 'insideLeft',
|
||||||
|
style: { fontSize: 11, fill: 'var(--text-muted)' },
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<ChartTooltip />} cursor={rechartsTheme.tooltip.cursor} />
|
||||||
|
{children}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write test**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/ThemedChart/ThemedChart.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { ThemedChart } from './ThemedChart'
|
||||||
|
import { Line } from 'recharts'
|
||||||
|
|
||||||
|
// Recharts uses ResizeObserver internally
|
||||||
|
class ResizeObserverMock {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver = ResizeObserverMock as any
|
||||||
|
|
||||||
|
describe('ThemedChart', () => {
|
||||||
|
it('renders nothing when data is empty', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={[]}>
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.innerHTML).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a chart container when data is provided', () => {
|
||||||
|
const data = [
|
||||||
|
{ time: '10:00', value: 10 },
|
||||||
|
{ time: '10:01', value: 20 },
|
||||||
|
]
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={data} height={160}>
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('.recharts-responsive-container')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
const data = [{ time: '10:00', value: 5 }]
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={data} className="my-chart">
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('.my-chart')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/design-system/composites/ThemedChart/ThemedChart.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 3 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify lib build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/ThemedChart/
|
||||||
|
git commit -m "feat: add ThemedChart wrapper component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Update Barrel Exports and Delete Old Charts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/design-system/composites/index.ts`
|
||||||
|
- Delete: `src/design-system/composites/LineChart/`
|
||||||
|
- Delete: `src/design-system/composites/AreaChart/`
|
||||||
|
- Delete: `src/design-system/composites/BarChart/`
|
||||||
|
- Delete: `src/design-system/composites/_chart-utils.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update composites/index.ts**
|
||||||
|
|
||||||
|
Remove these lines:
|
||||||
|
```
|
||||||
|
export { AreaChart } from './AreaChart/AreaChart'
|
||||||
|
export { BarChart } from './BarChart/BarChart'
|
||||||
|
export { LineChart } from './LineChart/LineChart'
|
||||||
|
export { CHART_COLORS } from './_chart-utils'
|
||||||
|
export type { ChartSeries, DataPoint } from './_chart-utils'
|
||||||
|
```
|
||||||
|
|
||||||
|
Add in their place:
|
||||||
|
```tsx
|
||||||
|
// Charts — ThemedChart wrapper + Recharts re-exports
|
||||||
|
export { ThemedChart } from './ThemedChart/ThemedChart'
|
||||||
|
export { CHART_COLORS, rechartsTheme } from '../utils/rechartsTheme'
|
||||||
|
export {
|
||||||
|
Line, Area, Bar,
|
||||||
|
ReferenceLine, ReferenceArea,
|
||||||
|
Legend, Brush,
|
||||||
|
} from 'recharts'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the rechartsTheme re-export from main index.ts**
|
||||||
|
|
||||||
|
In `src/design-system/index.ts`, remove this line (it's now re-exported via composites):
|
||||||
|
```
|
||||||
|
export * from './utils/rechartsTheme'
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with a targeted export that avoids double-exporting `CHART_COLORS`:
|
||||||
|
```tsx
|
||||||
|
export { rechartsTheme } from './utils/rechartsTheme'
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait — actually `composites/index.ts` already re-exports both `CHART_COLORS` and `rechartsTheme`. And `index.ts` does `export * from './composites'`. So the main `index.ts` line `export * from './utils/rechartsTheme'` would cause a duplicate export of both symbols. Remove it entirely:
|
||||||
|
|
||||||
|
Delete this line from `src/design-system/index.ts`:
|
||||||
|
```
|
||||||
|
export * from './utils/rechartsTheme'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Delete old chart directories and utilities**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf src/design-system/composites/LineChart
|
||||||
|
rm -rf src/design-system/composites/AreaChart
|
||||||
|
rm -rf src/design-system/composites/BarChart
|
||||||
|
rm src/design-system/composites/_chart-utils.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify lib build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds. The old components are gone, ThemedChart and Recharts re-exports are the new public API.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All tests pass. No test files existed for the deleted components.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat!: replace custom chart components with ThemedChart + Recharts
|
||||||
|
|
||||||
|
BREAKING: LineChart, AreaChart, BarChart, ChartSeries, DataPoint removed.
|
||||||
|
Use ThemedChart with Recharts children (Line, Area, Bar, etc.) instead."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update COMPONENT_GUIDE.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `COMPONENT_GUIDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the charting strategy section**
|
||||||
|
|
||||||
|
Find the section starting with `## Charting Strategy` (around line 183) and replace through line 228 with:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Charting Strategy
|
||||||
|
|
||||||
|
The design system provides a **ThemedChart** wrapper component that applies consistent styling to Recharts charts. Recharts is bundled as a dependency — consumers do not need to install it separately.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemedChart, Line, Area, ReferenceLine, CHART_COLORS } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
const data = metrics.map(m => ({ time: m.timestamp, cpu: m.value * 100 }))
|
||||||
|
|
||||||
|
<ThemedChart data={data} height={160} xDataKey="time" yLabel="%">
|
||||||
|
<Area dataKey="cpu" stroke={CHART_COLORS[0]} fill={CHART_COLORS[0]} fillOpacity={0.1} />
|
||||||
|
<ReferenceLine y={85} stroke="var(--error)" strokeDasharray="5 3" label="Alert" />
|
||||||
|
</ThemedChart>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ThemedChart Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `data` | `Record<string, any>[]` | required | Flat array of data objects |
|
||||||
|
| `height` | `number` | `200` | Chart height in pixels |
|
||||||
|
| `xDataKey` | `string` | `"time"` | Key for x-axis values |
|
||||||
|
| `xType` | `'number' \| 'category'` | `"category"` | X-axis scale type |
|
||||||
|
| `xTickFormatter` | `(value: any) => string` | — | Custom x-axis label formatter |
|
||||||
|
| `yTickFormatter` | `(value: any) => string` | — | Custom y-axis label formatter |
|
||||||
|
| `yLabel` | `string` | — | Y-axis label text |
|
||||||
|
| `children` | `ReactNode` | required | Recharts elements (Line, Area, Bar, etc.) |
|
||||||
|
| `className` | `string` | — | Container CSS class |
|
||||||
|
|
||||||
|
### Available Recharts Re-exports
|
||||||
|
|
||||||
|
`Line`, `Area`, `Bar`, `ReferenceLine`, `ReferenceArea`, `Legend`, `Brush`
|
||||||
|
|
||||||
|
For chart types not covered (treemap, radar, pie, sankey), import from `recharts` directly and use `rechartsTheme` for consistent styling.
|
||||||
|
|
||||||
|
### Theme Utilities
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` |
|
||||||
|
| `rechartsTheme` | Pre-configured prop objects for Recharts sub-components |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the component index table**
|
||||||
|
|
||||||
|
Find the rows for `AreaChart`, `BarChart`, `LineChart` in the component index table and replace all three with:
|
||||||
|
|
||||||
|
```
|
||||||
|
| ThemedChart | composite | Recharts wrapper with themed axes, grid, and tooltip |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the decision tree**
|
||||||
|
|
||||||
|
Find lines 57-60 (the chart decision tree entries):
|
||||||
|
```
|
||||||
|
- Time series → **LineChart**, **AreaChart**
|
||||||
|
- Categorical comparison → **BarChart**
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```
|
||||||
|
- Time series → **ThemedChart** with `<Line>` or `<Area>`
|
||||||
|
- Categorical comparison → **ThemedChart** with `<Bar>`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add COMPONENT_GUIDE.md
|
||||||
|
git commit -m "docs: update COMPONENT_GUIDE for ThemedChart migration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Publish Design System
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json` (version bump)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Bump version**
|
||||||
|
|
||||||
|
In `package.json`, change `"version"` to `"0.1.47"`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build and verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit and tag**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json
|
||||||
|
git commit -m "chore: bump version to 0.1.47"
|
||||||
|
git tag v0.1.47
|
||||||
|
git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wait for CI to publish**
|
||||||
|
|
||||||
|
Wait for the Gitea CI pipeline to build and publish `@cameleer/design-system@0.1.47` to the npm registry. Verify with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm view @cameleer/design-system@0.1.47 version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Migrate Server UI AgentInstance Charts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `C:\Users\Hendrik\Documents\projects\cameleer3-server\ui\src\pages\AgentInstance\AgentInstance.tsx`
|
||||||
|
- Modify: `C:\Users\Hendrik\Documents\projects\cameleer3-server\ui\package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update design system dependency**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\Hendrik\Documents\projects\cameleer3-server\ui
|
||||||
|
npm install @cameleer/design-system@0.1.47
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update imports in AgentInstance.tsx**
|
||||||
|
|
||||||
|
Replace the chart-related imports:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
StatCard, StatusDot, Badge, LineChart, AreaChart, BarChart,
|
||||||
|
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
|
||||||
|
LogViewer, ButtonGroup, useGlobalFilters,
|
||||||
|
} from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
StatCard, StatusDot, Badge,
|
||||||
|
ThemedChart, Line, Area, ReferenceLine, CHART_COLORS,
|
||||||
|
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
|
||||||
|
LogViewer, ButtonGroup, useGlobalFilters,
|
||||||
|
} from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace data prep — JVM metrics**
|
||||||
|
|
||||||
|
Replace the 4 JVM series useMemo blocks (cpuSeries, heapSeries, threadSeries, gcSeries) with flat data builders:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const formatTime = (t: string) =>
|
||||||
|
new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
// JVM chart data — merge all metrics into flat objects by time bucket
|
||||||
|
const cpuData = useMemo(() => {
|
||||||
|
const pts = jvmMetrics?.metrics?.['process.cpu.usage.value'];
|
||||||
|
if (!pts?.length) return [];
|
||||||
|
return pts.map((p: any) => ({ time: p.time, cpu: p.value * 100 }));
|
||||||
|
}, [jvmMetrics]);
|
||||||
|
|
||||||
|
const heapData = useMemo(() => {
|
||||||
|
const pts = jvmMetrics?.metrics?.['jvm.memory.used.value'];
|
||||||
|
if (!pts?.length) return [];
|
||||||
|
return pts.map((p: any) => ({ time: p.time, heap: p.value / (1024 * 1024) }));
|
||||||
|
}, [jvmMetrics]);
|
||||||
|
|
||||||
|
const threadData = useMemo(() => {
|
||||||
|
const pts = jvmMetrics?.metrics?.['jvm.threads.live.value'];
|
||||||
|
if (!pts?.length) return [];
|
||||||
|
return pts.map((p: any) => ({ time: p.time, threads: p.value }));
|
||||||
|
}, [jvmMetrics]);
|
||||||
|
|
||||||
|
const gcData = useMemo(() => {
|
||||||
|
const pts = jvmMetrics?.metrics?.['jvm.gc.pause.total_time'];
|
||||||
|
if (!pts?.length) return [];
|
||||||
|
return pts.map((p: any) => ({ time: p.time, gc: p.value }));
|
||||||
|
}, [jvmMetrics]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace data prep — throughput and error**
|
||||||
|
|
||||||
|
Replace the throughputSeries and errorSeries useMemo blocks:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const throughputData = useMemo(() => {
|
||||||
|
if (!chartData.length) return [];
|
||||||
|
return chartData.map((d: any) => ({ time: d.date.toISOString(), throughput: d.throughput }));
|
||||||
|
}, [chartData]);
|
||||||
|
|
||||||
|
const errorData = useMemo(() => {
|
||||||
|
if (!chartData.length) return [];
|
||||||
|
return chartData.map((d: any) => ({ time: d.date.toISOString(), errorPct: d.errorPct }));
|
||||||
|
}, [chartData]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Replace chart JSX — CPU Usage**
|
||||||
|
|
||||||
|
Replace the CPU chart card content:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{cpuData.length ? (
|
||||||
|
<ThemedChart data={cpuData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="%">
|
||||||
|
<Area dataKey="cpu" name="CPU %" stroke={CHART_COLORS[0]}
|
||||||
|
fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
|
||||||
|
<ReferenceLine y={85} stroke="var(--error)" strokeDasharray="5 3"
|
||||||
|
label={{ value: 'Alert', position: 'right', fill: 'var(--error)', fontSize: 9 }} />
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No CPU metrics available" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Replace chart JSX — Memory (Heap)**
|
||||||
|
|
||||||
|
Replace the heap chart card content:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{heapData.length ? (
|
||||||
|
<ThemedChart data={heapData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="MB">
|
||||||
|
<Area dataKey="heap" name="Heap MB" stroke={CHART_COLORS[0]}
|
||||||
|
fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
|
||||||
|
{heapMax != null && (
|
||||||
|
<ReferenceLine y={heapMax / (1024 * 1024)} stroke="var(--error)" strokeDasharray="5 3"
|
||||||
|
label={{ value: 'Max Heap', position: 'right', fill: 'var(--error)', fontSize: 9 }} />
|
||||||
|
)}
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No heap metrics available" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Replace chart JSX — Throughput**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{throughputData.length ? (
|
||||||
|
<ThemedChart data={throughputData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="msg/s">
|
||||||
|
<Line dataKey="throughput" name="msg/s" stroke={CHART_COLORS[0]}
|
||||||
|
strokeWidth={2} dot={false} />
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No throughput data in range" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Replace chart JSX — Error Rate**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{errorData.length ? (
|
||||||
|
<ThemedChart data={errorData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="%">
|
||||||
|
<Line dataKey="errorPct" name="Error %" stroke={CHART_COLORS[0]}
|
||||||
|
strokeWidth={2} dot={false} />
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No error data in range" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: Replace chart JSX — Thread Count**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{threadData.length ? (
|
||||||
|
<ThemedChart data={threadData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="threads">
|
||||||
|
<Line dataKey="threads" name="Threads" stroke={CHART_COLORS[0]}
|
||||||
|
strokeWidth={2} dot={false} />
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No thread metrics available" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 10: Replace chart JSX — GC Pauses**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{gcData.length ? (
|
||||||
|
<ThemedChart data={gcData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="ms">
|
||||||
|
<Area dataKey="gc" name="GC ms" stroke={CHART_COLORS[1]}
|
||||||
|
fill={CHART_COLORS[1]} fillOpacity={0.1} strokeWidth={2} dot={false} />
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No GC metrics available" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 11: Update the Thread Count meta display**
|
||||||
|
|
||||||
|
The thread count meta currently reads from `threadSeries`. Update to read from `threadData`:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```tsx
|
||||||
|
{threadSeries
|
||||||
|
? `${threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active`
|
||||||
|
: ''}
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```tsx
|
||||||
|
{threadData.length
|
||||||
|
? `${threadData[threadData.length - 1].threads.toFixed(0)} active`
|
||||||
|
: ''}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 12: Build server UI**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\Hendrik\Documents\projects\cameleer3-server\ui
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 13: Commit and push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\Hendrik\Documents\projects\cameleer3-server
|
||||||
|
git add ui/
|
||||||
|
git commit -m "feat: migrate agent charts to ThemedChart + Recharts
|
||||||
|
|
||||||
|
Replace custom LineChart/AreaChart/BarChart usage with ThemedChart
|
||||||
|
wrapper. Data format changed from ChartSeries[] to Recharts-native
|
||||||
|
flat objects. Uses DS v0.1.47."
|
||||||
|
git push
|
||||||
|
```
|
||||||
541
docs/superpowers/plans/2026-04-15-sidebar-section-layout.md
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
# Sidebar Section Layout Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add `position` and `maxHeight` props to `Sidebar.Section` so sections can stack at the top or bottom of the sidebar with optional scrollable content areas, and style all scrollbars to match the dark sidebar aesthetic.
|
||||||
|
|
||||||
|
**Architecture:** Extend the existing flexbox column layout inside `SidebarRoot` by partitioning section children into top/bottom groups with a flex spacer between them. Each group wrapper scrolls independently when the viewport is short. `SidebarSection` gets a content wrapper div that accepts an inline `maxHeight` and scrolls its children. Custom thin scrollbar styles are applied via CSS.
|
||||||
|
|
||||||
|
**Tech Stack:** React, CSS Modules, Vitest + React Testing Library
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `src/design-system/layout/Sidebar/Sidebar.tsx` | Modify | Add `position` and `maxHeight` props to `SidebarSectionProps`, wrap children in `.sectionContent` div, partition children in `SidebarRoot` into top/bottom groups |
|
||||||
|
| `src/design-system/layout/Sidebar/Sidebar.module.css` | Modify | Add `.sectionGroup`, `.sectionSpacer`, `.sectionContent` classes, custom scrollbar styles |
|
||||||
|
| `src/design-system/layout/Sidebar/Sidebar.test.tsx` | Modify | Add tests for position partitioning, maxHeight content wrapper, spacer rendering, collapsed behavior |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add CSS classes for section groups, spacer, content wrapper, and scrollbars
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/design-system/layout/Sidebar/Sidebar.module.css:392` (before the bottom links section)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `.sectionGroup`, `.sectionSpacer`, `.sectionContent`, and scrollbar styles**
|
||||||
|
|
||||||
|
Insert the following block before the `/* ── Bottom links */` comment at line 392:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ── Section groups (top/bottom positioning) ───────────────────────────── */
|
||||||
|
|
||||||
|
.sectionGroup {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionGroup::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionGroup::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionGroup::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionGroup::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionSpacer {
|
||||||
|
flex: 1 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section content (scrollable maxHeight) ────────────────────────────── */
|
||||||
|
|
||||||
|
.sectionContent {
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContent::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContent::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContent::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContent::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/layout/Sidebar/Sidebar.module.css
|
||||||
|
git commit -m "style: add section group, spacer, content, and scrollbar CSS classes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add `maxHeight` prop and content wrapper to `SidebarSection`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/design-system/layout/Sidebar/Sidebar.tsx:21-29` (SidebarSectionProps)
|
||||||
|
- Modify: `src/design-system/layout/Sidebar/Sidebar.tsx:78-131` (SidebarSection component)
|
||||||
|
- Test: `src/design-system/layout/Sidebar/Sidebar.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add the following tests to the end of the `describe` block in `Sidebar.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 17. renders sectionContent wrapper with maxHeight when open
|
||||||
|
it('renders section content wrapper with maxHeight style when open', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<span>ic</span>}
|
||||||
|
label="Apps"
|
||||||
|
open
|
||||||
|
onToggle={vi.fn()}
|
||||||
|
maxHeight="200px"
|
||||||
|
>
|
||||||
|
<div>child</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const contentWrapper = container.querySelector('.sectionContent')
|
||||||
|
expect(contentWrapper).toBeInTheDocument()
|
||||||
|
expect(contentWrapper).toHaveStyle({ maxHeight: '200px' })
|
||||||
|
expect(screen.getByText('child')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 18. renders sectionContent wrapper without maxHeight when not provided
|
||||||
|
it('renders section content wrapper without inline maxHeight when maxHeight is not provided', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<span>ic</span>}
|
||||||
|
label="Apps"
|
||||||
|
open
|
||||||
|
onToggle={vi.fn()}
|
||||||
|
>
|
||||||
|
<div>child</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const contentWrapper = container.querySelector('.sectionContent')
|
||||||
|
expect(contentWrapper).toBeInTheDocument()
|
||||||
|
expect(contentWrapper).not.toHaveStyle({ maxHeight: '200px' })
|
||||||
|
expect(screen.getByText('child')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 19. does not render sectionContent wrapper when section is closed
|
||||||
|
it('does not render section content wrapper when section is closed', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<span>ic</span>}
|
||||||
|
label="Apps"
|
||||||
|
open={false}
|
||||||
|
onToggle={vi.fn()}
|
||||||
|
maxHeight="200px"
|
||||||
|
>
|
||||||
|
<div>child</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const contentWrapper = container.querySelector('.sectionContent')
|
||||||
|
expect(contentWrapper).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx`
|
||||||
|
Expected: FAIL — `maxHeight` prop is not recognized, no `.sectionContent` wrapper exists.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `position` and `maxHeight` to `SidebarSectionProps`**
|
||||||
|
|
||||||
|
Update the interface at line 21:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface SidebarSectionProps {
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
open: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
active?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
position?: 'top' | 'bottom'
|
||||||
|
maxHeight?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `SidebarSection` to destructure new props and wrap children**
|
||||||
|
|
||||||
|
Update the function signature at line 78 to destructure the new props (they are accepted but `position` is only used by `SidebarRoot`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function SidebarSection({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
position: _position,
|
||||||
|
maxHeight,
|
||||||
|
}: SidebarSectionProps) {
|
||||||
|
```
|
||||||
|
|
||||||
|
Then replace `{open && children}` (line 128) with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className={styles.sectionContent}
|
||||||
|
style={maxHeight ? { maxHeight } : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/layout/Sidebar/Sidebar.tsx src/design-system/layout/Sidebar/Sidebar.test.tsx
|
||||||
|
git commit -m "feat: add maxHeight prop with sectionContent wrapper to SidebarSection"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Partition children in `SidebarRoot` into top/bottom groups
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/design-system/layout/Sidebar/Sidebar.tsx:167-244` (SidebarRoot component)
|
||||||
|
- Test: `src/design-system/layout/Sidebar/Sidebar.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add the following tests to the end of the `describe` block in `Sidebar.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 20. renders top sections in sectionGroup wrapper
|
||||||
|
it('renders sections in top group wrapper by default', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
|
||||||
|
<div>apps content</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const topGroup = container.querySelector('.sectionGroup')
|
||||||
|
expect(topGroup).toBeInTheDocument()
|
||||||
|
expect(topGroup!.textContent).toContain('Apps')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 21. renders bottom sections in separate group with spacer
|
||||||
|
it('renders bottom sections in a separate group with spacer between', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
|
||||||
|
<div>apps content</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Routes" open onToggle={vi.fn()} position="bottom">
|
||||||
|
<div>routes content</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const groups = container.querySelectorAll('.sectionGroup')
|
||||||
|
expect(groups).toHaveLength(2)
|
||||||
|
expect(groups[0].textContent).toContain('Apps')
|
||||||
|
expect(groups[1].textContent).toContain('Routes')
|
||||||
|
|
||||||
|
const spacer = container.querySelector('.sectionSpacer')
|
||||||
|
expect(spacer).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 22. does not render spacer when no bottom sections
|
||||||
|
it('does not render spacer when all sections are top', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
|
||||||
|
<div>apps content</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Agents" open onToggle={vi.fn()}>
|
||||||
|
<div>agents content</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const spacer = container.querySelector('.sectionSpacer')
|
||||||
|
expect(spacer).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 23. collapsed sidebar renders bottom sections in bottom group
|
||||||
|
it('renders bottom sections in bottom group when sidebar is collapsed', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar collapsed onCollapseToggle={vi.fn()}>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Apps" open={false} onToggle={vi.fn()}>
|
||||||
|
<div>apps</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Routes" open={false} onToggle={vi.fn()} position="bottom">
|
||||||
|
<div>routes</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const groups = container.querySelectorAll('.sectionGroup')
|
||||||
|
expect(groups).toHaveLength(2)
|
||||||
|
|
||||||
|
// Bottom group should contain the Routes rail item
|
||||||
|
const bottomGroup = groups[1]
|
||||||
|
expect(bottomGroup.querySelector('[title="Routes"]')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx`
|
||||||
|
Expected: FAIL — no `.sectionGroup` wrappers exist yet, sections render directly in `{rest}`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite the child partitioning logic in `SidebarRoot`**
|
||||||
|
|
||||||
|
Replace the entire IIFE inside the `<aside>` (lines 198–240) with the following:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{(() => {
|
||||||
|
const childArray = Children.toArray(children)
|
||||||
|
|
||||||
|
// Extract header
|
||||||
|
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
|
||||||
|
|
||||||
|
// Extract footer
|
||||||
|
const footerIdx = rest.findIndex(
|
||||||
|
(child) => isValidElement(child) && child.type === SidebarFooter,
|
||||||
|
)
|
||||||
|
const footer = footerIdx >= 0 ? rest[footerIdx] : null
|
||||||
|
const sections = footerIdx >= 0
|
||||||
|
? [...rest.slice(0, footerIdx), ...rest.slice(footerIdx + 1)]
|
||||||
|
: rest
|
||||||
|
|
||||||
|
// Partition sections into top/bottom by position prop
|
||||||
|
const topSections: typeof sections = []
|
||||||
|
const bottomSections: typeof sections = []
|
||||||
|
for (const child of sections) {
|
||||||
|
if (
|
||||||
|
isValidElement<SidebarSectionProps>(child) &&
|
||||||
|
child.props.position === 'bottom'
|
||||||
|
) {
|
||||||
|
bottomSections.push(child)
|
||||||
|
} else {
|
||||||
|
topSections.push(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBottomSections = bottomSections.length > 0
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
<div className={styles.sectionGroup}>
|
||||||
|
{topSections}
|
||||||
|
</div>
|
||||||
|
{hasBottomSections && <div className={styles.sectionSpacer} />}
|
||||||
|
{hasBottomSections && (
|
||||||
|
<div className={styles.sectionGroup}>
|
||||||
|
{bottomSections}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{footer}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full test suite to check for regressions**
|
||||||
|
|
||||||
|
Run: `npx vitest run`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/layout/Sidebar/Sidebar.tsx src/design-system/layout/Sidebar/Sidebar.test.tsx
|
||||||
|
git commit -m "feat: partition sidebar sections into top/bottom groups with spacer"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Update `LayoutShell` to use `position="bottom"` on Routes and Starred sections
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/layout/LayoutShell.tsx:378-429` (the Routes and Starred section declarations)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `position="bottom"` to the Routes section**
|
||||||
|
|
||||||
|
In `LayoutShell.tsx`, find the Routes `<Sidebar.Section>` (around line 378) and add `position="bottom"`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sidebar.Section
|
||||||
|
label="Routes"
|
||||||
|
icon={<GitBranch size={14} />}
|
||||||
|
open={!routesCollapsed}
|
||||||
|
onToggle={toggleRoutesCollapsed}
|
||||||
|
active={location.pathname.startsWith('/routes')}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `position="bottom"` to the Starred section**
|
||||||
|
|
||||||
|
Find the Starred `<Sidebar.Section>` (around line 396) and add `position="bottom"`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{hasStarred && (
|
||||||
|
<Sidebar.Section
|
||||||
|
label="★ Starred"
|
||||||
|
icon={<span />}
|
||||||
|
open={true}
|
||||||
|
onToggle={() => {}}
|
||||||
|
active={false}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the full test suite**
|
||||||
|
|
||||||
|
Run: `npx vitest run`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/layout/LayoutShell.tsx
|
||||||
|
git commit -m "feat: position Routes and Starred sections at bottom of sidebar"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Visual verification in the browser
|
||||||
|
|
||||||
|
**Files:** None (manual verification)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Start the dev server**
|
||||||
|
|
||||||
|
Run: `npm run dev`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify top/bottom layout**
|
||||||
|
|
||||||
|
Open the app in a browser. Confirm:
|
||||||
|
- Applications and Agents sections are at the top
|
||||||
|
- Routes and Starred sections are at the bottom, above the footer
|
||||||
|
- A flexible gap separates the two groups
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify scrollbar styling**
|
||||||
|
|
||||||
|
If a section has enough content to overflow (or temporarily add `maxHeight="100px"` to the Applications section in `LayoutShell.tsx`), confirm:
|
||||||
|
- Content scrolls within the section
|
||||||
|
- Scrollbar is thin (4px), with a muted thumb on a transparent track
|
||||||
|
- Scrollbar thumb brightens on hover
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify collapsed sidebar**
|
||||||
|
|
||||||
|
Collapse the sidebar and confirm:
|
||||||
|
- Top section icons stay at the top
|
||||||
|
- Bottom section icons cluster near the footer
|
||||||
|
- Spacer separates the two groups in the rail
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify short viewport**
|
||||||
|
|
||||||
|
Resize the browser window very short. Confirm:
|
||||||
|
- Both top and bottom groups scroll independently
|
||||||
|
- No content is permanently clipped or inaccessible
|
||||||
|
|
||||||
|
- [ ] **Step 6: Remove any temporary `maxHeight` props added for testing**
|
||||||
|
|
||||||
|
If you added `maxHeight="100px"` to Applications in Step 3, remove it now.
|
||||||
173
docs/superpowers/specs/2026-03-24-login-dialog-design.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Login Dialog Design Spec
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A composable login component for the Cameleer3 design system. Provides a `LoginForm` content component and a `LoginDialog` wrapper that puts it inside a Modal. Supports username/password credentials, configurable social/SSO providers, and built-in client-side validation.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### LoginForm
|
||||||
|
|
||||||
|
Core form component. Lives in `src/design-system/composites/LoginForm/`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface SocialProvider {
|
||||||
|
label: string // e.g. "Continue with Google"
|
||||||
|
icon?: ReactNode // SVG icon, optional
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginFormProps {
|
||||||
|
logo?: ReactNode
|
||||||
|
title?: string // Default: "Sign in"
|
||||||
|
socialProviders?: SocialProvider[] // Omit or [] to hide social section + divider
|
||||||
|
onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void // Omit to hide credentials section
|
||||||
|
onForgotPassword?: () => void // Omit to hide link
|
||||||
|
onSignUp?: () => void // Omit to hide "Don't have an account?"
|
||||||
|
error?: string // Server-side error, rendered as Alert
|
||||||
|
loading?: boolean // Disables form, spinner on submit button
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### LoginDialog
|
||||||
|
|
||||||
|
Thin wrapper — passes all `LoginFormProps` through to `LoginForm`, adds Modal control.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface LoginDialogProps extends LoginFormProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses `Modal` with `size="sm"` (400px).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
Social-first ordering, top to bottom:
|
||||||
|
|
||||||
|
1. **Logo slot** — optional `ReactNode` rendered centered above title
|
||||||
|
2. **Title** — "Sign in" default, centered
|
||||||
|
3. **Server error** — `Alert variant="error"` shown when `error` prop is set, between title and social buttons
|
||||||
|
4. **Social buttons** — stacked vertically, each is a `Button variant="secondary"` with icon + label. Hidden when `socialProviders` is empty/omitted.
|
||||||
|
5. **Divider** — horizontal rule with "or" text, centered. Hidden when social section is hidden.
|
||||||
|
6. **Email field** — `FormField` + `Input`, required, placeholder "you@example.com"
|
||||||
|
7. **Password field** — `FormField` + `Input type="password"`, required, placeholder "••••••••"
|
||||||
|
8. **Remember me / Forgot password row** — `Checkbox` on the left, amber link on the right. Forgot password link hidden when `onForgotPassword` omitted.
|
||||||
|
9. **Submit button** — `Button variant="primary"`, full width, label "Sign in"
|
||||||
|
10. **Sign up link** — "Don't have an account? Sign up" centered below. Hidden when `onSignUp` omitted.
|
||||||
|
|
||||||
|
### Configuration Variants
|
||||||
|
|
||||||
|
The form adapts automatically based on props:
|
||||||
|
|
||||||
|
- **Full** — `socialProviders` + `onSubmit` both provided. Social buttons, divider, and credentials all shown.
|
||||||
|
- **Credentials only** — `onSubmit` provided, no `socialProviders`. Social section and divider hidden.
|
||||||
|
- **Social only** — `socialProviders` provided, `onSubmit` omitted. Credentials section (email, password, remember me, submit button) and divider hidden.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Client-side, triggered on form submit (not on blur):
|
||||||
|
|
||||||
|
| Field | Rule | Error message |
|
||||||
|
|----------|---------------------------------------------------|----------------------------------------|
|
||||||
|
| Email | Required | "Email is required" |
|
||||||
|
| Email | Basic format: `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` | "Please enter a valid email address" |
|
||||||
|
| Password | Required | "Password is required" |
|
||||||
|
| Password | Minimum 8 characters | "Password must be at least 8 characters" |
|
||||||
|
|
||||||
|
- `onSubmit` only fires when all validation passes
|
||||||
|
- Field errors displayed inline below each input using `FormField` error pattern (red border + message)
|
||||||
|
- Field errors clear when the user starts typing in that field
|
||||||
|
- Server `error` prop clears automatically on next submit attempt
|
||||||
|
|
||||||
|
## States
|
||||||
|
|
||||||
|
### Loading
|
||||||
|
|
||||||
|
When `loading={true}`:
|
||||||
|
- All inputs disabled
|
||||||
|
- All social buttons disabled
|
||||||
|
- Submit button shows `Spinner` component, text hidden (matches existing `Button loading` pattern)
|
||||||
|
- Form cannot be submitted
|
||||||
|
|
||||||
|
### Error
|
||||||
|
|
||||||
|
- Server error: `Alert variant="error"` rendered between title and social buttons
|
||||||
|
- Field errors: inline below each input via `FormField` error styling (red border, error text)
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
- CSS Modules: `LoginForm.module.css`
|
||||||
|
- All colors via CSS custom properties from `tokens.css`
|
||||||
|
- Dark mode works automatically — no extra overrides needed
|
||||||
|
- Social buttons: `var(--bg-surface)` background, `var(--border)` border, hover uses `var(--bg-hover)`
|
||||||
|
- Divider: `var(--border)` line, `var(--text-muted)` "or" text
|
||||||
|
- Forgot password + Sign up links: `var(--amber)` color, `font-weight: 500`
|
||||||
|
- Form gap: 14px between fields
|
||||||
|
- Social button gap: 8px between buttons
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- `<form>` element with `aria-label="Sign in"`
|
||||||
|
- Labels tied to inputs via `htmlFor`/`id`
|
||||||
|
- Error messages linked with `aria-describedby`
|
||||||
|
- First input auto-focused on mount
|
||||||
|
- `LoginDialog` traps focus via Modal
|
||||||
|
- Social buttons are `<button>` elements, keyboard-navigable
|
||||||
|
- Alert uses `role="alert"` for screen readers
|
||||||
|
- Enter key submits form (standard `<form onSubmit>`)
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/design-system/composites/LoginForm/
|
||||||
|
LoginForm.tsx
|
||||||
|
LoginForm.module.css
|
||||||
|
LoginForm.test.tsx
|
||||||
|
LoginDialog.tsx
|
||||||
|
LoginDialog.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Exports added to `src/design-system/composites/index.ts`.
|
||||||
|
|
||||||
|
## Primitives Reused
|
||||||
|
|
||||||
|
- `FormField` — label + error display wrapper
|
||||||
|
- `Input` — email and password fields
|
||||||
|
- `Checkbox` — remember me
|
||||||
|
- `Button` — submit (primary) + social buttons (secondary)
|
||||||
|
- `Alert` — server error display
|
||||||
|
- `Spinner` — loading state in submit button
|
||||||
|
- `Modal` — LoginDialog wrapper
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests with Vitest + React Testing Library, wrapped in `ThemeProvider`.
|
||||||
|
|
||||||
|
### LoginForm tests:
|
||||||
|
- Renders all elements when all props provided
|
||||||
|
- Hides social section when `socialProviders` is empty
|
||||||
|
- Hides divider when no social providers
|
||||||
|
- Hides forgot password link when `onForgotPassword` omitted
|
||||||
|
- Hides sign up link when `onSignUp` omitted
|
||||||
|
- Shows server error Alert when `error` prop set
|
||||||
|
- Validates required email
|
||||||
|
- Validates email format
|
||||||
|
- Validates required password
|
||||||
|
- Validates password minimum length
|
||||||
|
- Clears field errors on typing
|
||||||
|
- Calls `onSubmit` with credentials when valid
|
||||||
|
- Does not call `onSubmit` when validation fails
|
||||||
|
- Disables form when `loading={true}`
|
||||||
|
- Shows spinner on submit button when loading
|
||||||
|
- Calls social provider `onClick` when clicked
|
||||||
|
- Calls `onForgotPassword` when link clicked
|
||||||
|
- Calls `onSignUp` when link clicked
|
||||||
|
|
||||||
|
### LoginDialog tests:
|
||||||
|
- Renders Modal with LoginForm when `open={true}`
|
||||||
|
- Does not render when `open={false}`
|
||||||
|
- Calls `onClose` on backdrop click / Esc
|
||||||
|
- Passes all LoginForm props through
|
||||||
295
docs/superpowers/specs/2026-03-24-mock-deviations-design.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# Mock UI Deviations — Design Spec
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The mock pages in `src/pages/` build several UI patterns using raw CSS and inline HTML that should either be promoted into the design system or refactored to use existing components. This spec captures each deviation and its resolution to minimize rework when transitioning to the real application.
|
||||||
|
|
||||||
|
## Decision Framework
|
||||||
|
|
||||||
|
A pattern is promoted to the design system when it:
|
||||||
|
- Appears on 2+ pages with the same structure
|
||||||
|
- Is visually distinctive and would be inconsistent if reimplemented
|
||||||
|
- Will be needed by the real application
|
||||||
|
|
||||||
|
A pattern stays in the pages when it is page-specific composition or a one-off layout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. KpiStrip — New Composite
|
||||||
|
|
||||||
|
**Problem:** Dashboard, Routes, and AgentHealth each build a custom KPI header strip (~320 lines of duplicated layout code). Same visual structure: horizontal row of cards with colored left border, uppercase label, large value, trend indicator, subtitle, and optional sparkline.
|
||||||
|
|
||||||
|
**Solution:** New composite `KpiStrip`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface KpiItem {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
|
||||||
|
subtitle?: string
|
||||||
|
sparkline?: number[]
|
||||||
|
borderColor?: string // CSS token, e.g. "var(--success)"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KpiStripProps {
|
||||||
|
items: KpiItem[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
- Horizontal flex row with equal-width cards
|
||||||
|
- Each card: 3px left border (colored via `borderColor`, default `var(--amber)`), padding 16px 20px
|
||||||
|
- Card surface: `var(--bg-surface)`, border: `var(--border-subtle)`, radius: `var(--radius-md)`
|
||||||
|
- Label: 11px uppercase, monospace weight 500, `var(--text-muted)`
|
||||||
|
- Value: 28px, weight 700, `var(--text-primary)`
|
||||||
|
- Trend: inline next to value, 11px. Color controlled by `trend.variant` (maps to semantic tokens). Default `'muted'`. The caller decides what color a trend should be — "↑ +12%" on error count is `'error'`, on throughput is `'success'`.
|
||||||
|
- Subtitle: 11px, `var(--text-secondary)`
|
||||||
|
- Sparkline: existing `Sparkline` primitive rendered top-right of card
|
||||||
|
|
||||||
|
**Note:** KpiStrip builds its own card-like containers internally. It does NOT reuse the `Card` primitive because `Card` uses a top accent border while KpiStrip needs a left border. The visual surface (bg, border, radius, shadow) uses the same tokens but the layout is distinct.
|
||||||
|
|
||||||
|
**File location:** `src/design-system/composites/KpiStrip/`
|
||||||
|
|
||||||
|
**Pages to refactor:** Dashboard.tsx, Routes.tsx, AgentHealth.tsx — replace inline `KpiHeader` functions with `<KpiStrip items={[...]} />`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. SplitPane — New Composite
|
||||||
|
|
||||||
|
**Problem:** Admin RBAC tabs (UsersTab, GroupsTab, RolesTab) each build a custom CSS grid split-pane layout with scrollable list, detail panel, and empty state placeholder.
|
||||||
|
|
||||||
|
**Solution:** New composite `SplitPane`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface SplitPaneProps {
|
||||||
|
list: ReactNode
|
||||||
|
detail: ReactNode | null // null renders empty state
|
||||||
|
emptyMessage?: string // Default: "Select an item to view details"
|
||||||
|
ratio?: '1:1' | '1:2' | '2:3' // Default: '1:2'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
- CSS grid with two columns at the specified ratio
|
||||||
|
- Left panel: scrollable, `var(--bg-surface)` background, right border `var(--border-subtle)`
|
||||||
|
- Right panel: scrollable, `var(--bg-raised)` background
|
||||||
|
- Empty state: centered text, `var(--text-muted)`, italic
|
||||||
|
- Both panels fill available height (the parent controls the overall height)
|
||||||
|
|
||||||
|
**File location:** `src/design-system/composites/SplitPane/`
|
||||||
|
|
||||||
|
**Pages to refactor:** UsersTab.tsx, GroupsTab.tsx, RolesTab.tsx — replace custom grid CSS with `<SplitPane>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2b. EntityList — New Composite
|
||||||
|
|
||||||
|
**Problem:** The left-side list panels in UsersTab, GroupsTab, and RolesTab all build the same frame: a search input + "Add" button header, a scrollable list of items (avatar + text + badges), and selection highlighting. Each tab re-implements this frame with ~50 lines of identical structure.
|
||||||
|
|
||||||
|
**Solution:** New composite `EntityList`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface EntityListProps<T> {
|
||||||
|
items: T[]
|
||||||
|
renderItem: (item: T, isSelected: boolean) => ReactNode
|
||||||
|
getItemId: (item: T) => string
|
||||||
|
selectedId?: string
|
||||||
|
onSelect?: (id: string) => void
|
||||||
|
searchPlaceholder?: string // Default: "Search..."
|
||||||
|
onSearch?: (query: string) => void
|
||||||
|
addLabel?: string // e.g. "+ Add user" — omit to hide button
|
||||||
|
onAdd?: () => void
|
||||||
|
emptyMessage?: string // Default: "No items found"
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
- Header row: `Input` (search, with icon) on the left, `Button variant="secondary" size="sm"` (add) on the right. Header hidden when both `onSearch` and `onAdd` are omitted.
|
||||||
|
- Scrollable list below header, `var(--bg-surface)` background
|
||||||
|
- Each item: clickable row with `var(--bg-hover)` on hover, `var(--amber-bg)` + left amber border when selected
|
||||||
|
- Items rendered via `renderItem` — the component provides the clickable row wrapper, the caller provides the content
|
||||||
|
- `role="listbox"` on the list, `role="option"` on each item for accessibility
|
||||||
|
- Empty state: centered `emptyMessage` text when `items` is empty
|
||||||
|
|
||||||
|
**Typical item content (provided by caller via `renderItem`):**
|
||||||
|
- Avatar + name + subtitle + badge tags — but this is not prescribed by EntityList. The component is agnostic about item content.
|
||||||
|
|
||||||
|
**Combined usage with SplitPane:**
|
||||||
|
```tsx
|
||||||
|
<SplitPane
|
||||||
|
list={
|
||||||
|
<EntityList
|
||||||
|
items={filteredUsers}
|
||||||
|
renderItem={(user, isSelected) => (
|
||||||
|
<>
|
||||||
|
<Avatar name={user.name} size="sm" />
|
||||||
|
<div>
|
||||||
|
<div>{user.name}</div>
|
||||||
|
<div>{user.email}</div>
|
||||||
|
<div>{user.roles.map(r => <Badge key={r} label={r} />)}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
getItemId={(u) => u.id}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
searchPlaceholder="Search users..."
|
||||||
|
onSearch={setSearchQuery}
|
||||||
|
addLabel="+ Add user"
|
||||||
|
onAdd={() => setAddDialogOpen(true)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
detail={selectedUser ? <UserDetail user={selectedUser} /> : null}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**File location:** `src/design-system/composites/EntityList/`
|
||||||
|
|
||||||
|
**Pages to refactor:** UsersTab.tsx, GroupsTab.tsx, RolesTab.tsx — replace custom list rendering with `<EntityList>`. Combined with SplitPane, each tab reduces from ~200 lines to ~50 lines of domain-specific render logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Refactor AgentHealth Instance Table to DataTable
|
||||||
|
|
||||||
|
**Problem:** AgentHealth builds instance tables using raw HTML `<table>` elements instead of the existing `DataTable` composite.
|
||||||
|
|
||||||
|
**Solution:** Refactor to use `DataTable` with column definitions and custom cell renderers. No design system changes needed.
|
||||||
|
|
||||||
|
**Refactor scope:**
|
||||||
|
- Replace `<table>` blocks in AgentHealth.tsx (~60 lines) with `<DataTable>` using `flush` prop
|
||||||
|
- Define columns with `render` functions for State (Badge) and StatusDot columns
|
||||||
|
- Remove associated table CSS from AgentHealth.module.css
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. LogViewer — New Composite
|
||||||
|
|
||||||
|
**Problem:** AgentInstance renders log entries as custom HTML with inline styling — timestamped lines with severity levels in monospace.
|
||||||
|
|
||||||
|
**Solution:** New composite `LogViewer`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface LogEntry {
|
||||||
|
timestamp: string
|
||||||
|
level: 'info' | 'warn' | 'error' | 'debug'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogViewerProps {
|
||||||
|
entries: LogEntry[]
|
||||||
|
maxHeight?: number | string // Default: 400
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
- Scrollable container with `max-height`, `var(--bg-inset)` background, `var(--radius-md)` border-radius
|
||||||
|
- Each line: flex row with timestamp (muted, monospace, 11px) + level badge + message (monospace, 12px)
|
||||||
|
- Level badge colors: info=`var(--running)`, warn=`var(--warning)`, error=`var(--error)`, debug=`var(--text-muted)`
|
||||||
|
- Level badge: uppercase, 9px, `var(--font-mono)`, pill-shaped with tinted background
|
||||||
|
- Auto-scroll to bottom on new entries; pauses when user scrolls up; resumes on scroll-to-bottom
|
||||||
|
|
||||||
|
**File location:** `src/design-system/composites/LogViewer/`
|
||||||
|
|
||||||
|
**Pages to refactor:** AgentInstance.tsx — replace custom log rendering with `<LogViewer entries={logs} />`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. StatusText — New Primitive
|
||||||
|
|
||||||
|
**Problem:** Dashboard and Routes use inline `style={{ color: 'var(--error)', fontWeight: 600 }}` for status values like "BREACH", "OK", colored percentages.
|
||||||
|
|
||||||
|
**Solution:** New primitive `StatusText`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface StatusTextProps {
|
||||||
|
variant: 'success' | 'warning' | 'error' | 'running' | 'muted'
|
||||||
|
bold?: boolean // Default: false
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Styling:**
|
||||||
|
- Inline `<span>` element
|
||||||
|
- Color mapped to semantic tokens: success=`var(--success)`, warning=`var(--warning)`, error=`var(--error)`, running=`var(--running)`, muted=`var(--text-muted)`
|
||||||
|
- `bold` adds `font-weight: 600`
|
||||||
|
- Inherits font-size from parent
|
||||||
|
|
||||||
|
**File location:** `src/design-system/primitives/StatusText/`
|
||||||
|
|
||||||
|
**Pages to refactor:** Dashboard.tsx, Routes.tsx — replace inline style attributes with `<StatusText>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Card Title Extension
|
||||||
|
|
||||||
|
**Problem:** Routes page wraps charts in custom divs with uppercase titles. The existing `Card` component has no title support.
|
||||||
|
|
||||||
|
**Solution:** Add optional `title` prop to existing `Card` primitive.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface CardProps {
|
||||||
|
children: ReactNode
|
||||||
|
accent?: string // Existing
|
||||||
|
title?: string // NEW
|
||||||
|
className?: string // Existing
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When `title` is provided:**
|
||||||
|
- Renders a header div inside the card, above children
|
||||||
|
- Title: 11px uppercase, `var(--font-mono)`, weight 600, `var(--text-secondary)`, letter-spacing 0.5px
|
||||||
|
- Separated from content by 1px `var(--border-subtle)` bottom border and 12px padding-bottom
|
||||||
|
- Content area gets 16px padding-top
|
||||||
|
|
||||||
|
**File location:** Modify existing `src/design-system/primitives/Card/Card.tsx`
|
||||||
|
|
||||||
|
**Pages to refactor:** Routes.tsx — replace custom chart wrapper divs with `<Card title="Throughput (msg/s)">`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
|
||||||
|
1. **KpiStrip** — highest impact, 3 pages, ~320 lines eliminated
|
||||||
|
2. **StatusText** — smallest scope, quick win, unblocks cleaner page code
|
||||||
|
3. **Card title** — small change to existing component, unblocks Routes cleanup
|
||||||
|
4. **SplitPane + EntityList** — 3 admin tabs, clean pattern. Build together since EntityList is the natural content for SplitPane's list slot.
|
||||||
|
5. **LogViewer** — 1 page but important for real app
|
||||||
|
6. **AgentHealth DataTable refactor** — pure page cleanup, no DS changes
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
All new components tested with Vitest + React Testing Library, co-located test files. Page refactors verified by running existing tests + visual check that pages look identical before and after.
|
||||||
|
|
||||||
|
## Barrel Exports
|
||||||
|
|
||||||
|
New components added to respective barrel exports:
|
||||||
|
- `src/design-system/primitives/index.ts` — StatusText
|
||||||
|
- `src/design-system/composites/index.ts` — KpiStrip, SplitPane, EntityList, LogViewer
|
||||||
|
|
||||||
|
## Documentation Updates
|
||||||
|
|
||||||
|
### COMPONENT_GUIDE.md
|
||||||
|
|
||||||
|
Add entries for each new component to the appropriate decision trees:
|
||||||
|
|
||||||
|
- **Data Display section:** Add KpiStrip — "Use KpiStrip for a row of summary metrics at the top of a page (exchanges, error rate, latency, etc.)"
|
||||||
|
- **Data Display section:** Add LogViewer — "Use LogViewer for scrollable log output with timestamped, severity-colored entries"
|
||||||
|
- **Layout section:** Add SplitPane — "Use SplitPane for master/detail layouts: selectable list on the left, detail view on the right"
|
||||||
|
- **Data Display section:** Add EntityList — "Use EntityList for searchable, selectable lists of entities (users, groups, roles, etc.). Combine with SplitPane for CRUD management screens."
|
||||||
|
- **Text & Labels section:** Add StatusText — "Use StatusText for inline colored status values (success rates, SLA status, trend indicators). Use StatusDot for colored dot indicators."
|
||||||
|
- **Card section:** Document new `title` prop — "Pass `title` to Card for a titled content container (e.g., chart cards). Title renders as an uppercase header with separator."
|
||||||
|
|
||||||
|
### Inventory Page
|
||||||
|
|
||||||
|
Add demos for each new component to `src/pages/Inventory/sections/`:
|
||||||
|
|
||||||
|
- **CompositesSection.tsx:** Add KpiStrip, SplitPane, EntityList, LogViewer demos with realistic sample data
|
||||||
|
- **PrimitivesSection.tsx:** Add StatusText demo showing all variants
|
||||||
|
- **Card demo:** Update existing Card demo to show the `title` prop variant
|
||||||
|
|
||||||
|
Each demo follows the existing DemoCard pattern with `id` anchors, and nav entries are added to `Inventory.tsx`.
|
||||||
399
docs/superpowers/specs/2026-04-02-composable-sidebar-design.md
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
# Composable Sidebar Refactor
|
||||||
|
|
||||||
|
**Date:** 2026-04-02
|
||||||
|
**Upstream issue:** cameleer3-server #112
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The current `Sidebar` component is monolithic. It hardcodes three navigation sections (Applications, Agents, Routes), a starred items section, bottom links (Admin, API Docs), and all tree-building logic (`buildAppTreeNodes`, `buildRouteTreeNodes`, `buildAgentTreeNodes`). The consuming application can only pass `SidebarApp[]` data — it cannot control what sections exist, what order they appear in, or add new sections without modifying this package.
|
||||||
|
|
||||||
|
This blocks two features the consuming application needs:
|
||||||
|
1. **Admin accordion** — when the user enters admin context, the sidebar should expand an Admin section and collapse operational sections, all controlled by the application
|
||||||
|
2. **Icon-rail collapse** — the sidebar should collapse to a narrow icon strip, like modern app sidebars (Linear, VS Code, etc.)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Refactor `Sidebar` into a composable compound component. The DS provides the frame and building blocks. The consuming application controls all content.
|
||||||
|
|
||||||
|
## Current Exports (to be replaced)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Current — monolithic
|
||||||
|
export { Sidebar } from './Sidebar/Sidebar'
|
||||||
|
export type { SidebarApp, SidebarRoute, SidebarAgent } from './Sidebar/Sidebar'
|
||||||
|
```
|
||||||
|
|
||||||
|
## New Exports
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// New — composable
|
||||||
|
export { Sidebar } from './Sidebar/Sidebar'
|
||||||
|
export { SidebarTree } from './Sidebar/SidebarTree'
|
||||||
|
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
|
||||||
|
export { useStarred } from './Sidebar/useStarred'
|
||||||
|
```
|
||||||
|
|
||||||
|
`SidebarApp`, `SidebarRoute`, `SidebarAgent` types are removed — they are application-domain types that move to the consuming app.
|
||||||
|
|
||||||
|
## Compound Component API
|
||||||
|
|
||||||
|
### `<Sidebar>`
|
||||||
|
|
||||||
|
The outer shell. Renders the sidebar frame with an optional search input and collapse toggle.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sidebar
|
||||||
|
collapsed={false}
|
||||||
|
onCollapseToggle={() => {}}
|
||||||
|
searchValue=""
|
||||||
|
onSearchChange={(query) => {}}
|
||||||
|
className=""
|
||||||
|
>
|
||||||
|
<Sidebar.Header ... />
|
||||||
|
<Sidebar.Section ... />
|
||||||
|
<Sidebar.Section ... />
|
||||||
|
<Sidebar.Footer ... />
|
||||||
|
</Sidebar>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `collapsed` | `boolean` | `false` | Render as ~48px icon rail |
|
||||||
|
| `onCollapseToggle` | `() => void` | - | Collapse/expand toggle clicked |
|
||||||
|
| `onSearchChange` | `(query: string) => void` | - | Search input changed. Omit to hide search. |
|
||||||
|
| `searchValue` | `string` | `''` | Controlled value for the search input |
|
||||||
|
| `children` | `ReactNode` | - | Sidebar.Header, Sidebar.Section, Sidebar.Footer |
|
||||||
|
| `className` | `string` | - | Additional CSS class |
|
||||||
|
|
||||||
|
**Search state ownership:** The DS renders the search input as a dumb controlled input and calls `onSearchChange` on every keystroke. The consuming application owns the search state and passes it to each `SidebarTree` as `filterQuery`. This lets the app control filtering behavior (e.g., clear search when switching sections, filter only certain sections). The DS does not hold any search state internally.
|
||||||
|
|
||||||
|
**Rendering rules:**
|
||||||
|
- Expanded: full width (~260px), all content visible
|
||||||
|
- Collapsed: ~48px wide, only icons visible, tooltips on hover
|
||||||
|
- Width transition: `transition: width 200ms ease`
|
||||||
|
- Collapse toggle button (`<<` / `>>` chevron) in top-right corner
|
||||||
|
- Search input hidden when collapsed
|
||||||
|
- Search input auto-positioned between `Sidebar.Header` and first `Sidebar.Section` (not above Header)
|
||||||
|
|
||||||
|
### `<Sidebar.Header>`
|
||||||
|
|
||||||
|
Logo, title, and version. In collapsed mode, renders only the logo centered.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sidebar.Header
|
||||||
|
logo={<img src="..." />}
|
||||||
|
title="cameleer"
|
||||||
|
version="v3.2.1"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `logo` | `ReactNode` | - | Logo element |
|
||||||
|
| `title` | `string` | - | App name (hidden when collapsed) |
|
||||||
|
| `version` | `string` | - | Version text (hidden when collapsed) |
|
||||||
|
|
||||||
|
### `<Sidebar.Section>`
|
||||||
|
|
||||||
|
An accordion section with a collapsible header and content area.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sidebar.Section
|
||||||
|
label="APPLICATIONS"
|
||||||
|
icon={<Box size={14} />}
|
||||||
|
collapsed={false}
|
||||||
|
onToggle={() => {}}
|
||||||
|
active={false}
|
||||||
|
>
|
||||||
|
<SidebarTree nodes={nodes} ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `label` | `string` | - | Section header text (rendered uppercase via CSS) |
|
||||||
|
| `icon` | `ReactNode` | - | Icon for header and collapsed rail |
|
||||||
|
| `collapsed` | `boolean` | `false` | Whether children are hidden |
|
||||||
|
| `onToggle` | `() => void` | - | Header clicked |
|
||||||
|
| `children` | `ReactNode` | - | Content when expanded |
|
||||||
|
| `active` | `boolean` | - | Override active highlight. If omitted, not highlighted. |
|
||||||
|
|
||||||
|
**Expanded rendering:**
|
||||||
|
```
|
||||||
|
[icon] APPLICATIONS
|
||||||
|
(children rendered here)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Collapsed rendering:**
|
||||||
|
```
|
||||||
|
[icon] APPLICATIONS
|
||||||
|
```
|
||||||
|
|
||||||
|
**In sidebar icon-rail mode:**
|
||||||
|
```
|
||||||
|
[icon] <- centered, tooltip shows label on hover
|
||||||
|
```
|
||||||
|
|
||||||
|
Header has: icon and label (no chevron — the entire row is clickable). Active section gets the amber left-border accent (existing pattern). Clicking anywhere on the header row calls `onToggle`.
|
||||||
|
|
||||||
|
**Implementation detail:** `Sidebar.Section` and `Sidebar.Header` need to know the parent's `collapsed` state to switch between expanded and icon-rail rendering. The `<Sidebar>` component provides `collapsed` and `onCollapseToggle` via React context (`SidebarContext`). Sub-components read from context — no prop drilling needed.
|
||||||
|
|
||||||
|
**Icon-rail click behavior:** In collapsed mode, clicking a section icon fires both `onCollapseToggle` and `onToggle` simultaneously on the same click. The sidebar expands and the section opens in one motion. No navigation occurs — the user is expanding the sidebar to see what's inside, not committing to a destination. They click a tree item after the section is visible to navigate.
|
||||||
|
|
||||||
|
### `<Sidebar.Footer>`
|
||||||
|
|
||||||
|
Pinned to the bottom of the sidebar. Container for `Sidebar.FooterLink` items.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sidebar.Footer>
|
||||||
|
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => {}} />
|
||||||
|
</Sidebar.Footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
In collapsed mode, footer links render as centered icons with tooltips.
|
||||||
|
|
||||||
|
### `<Sidebar.FooterLink>`
|
||||||
|
|
||||||
|
A single bottom link.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `icon` | `ReactNode` | - | Link icon |
|
||||||
|
| `label` | `string` | - | Link text (hidden when collapsed, shown as tooltip) |
|
||||||
|
| `onClick` | `() => void` | - | Click handler |
|
||||||
|
| `active` | `boolean` | `false` | Active state highlight |
|
||||||
|
|
||||||
|
### `<SidebarTree>` (no changes, newly exported)
|
||||||
|
|
||||||
|
Already exists at `Sidebar/SidebarTree.tsx`. No modifications needed — it already accepts all data via props. Just export it from the package.
|
||||||
|
|
||||||
|
**Current props (unchanged):**
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `nodes` | `SidebarTreeNode[]` | Tree data |
|
||||||
|
| `selectedPath` | `string` | Currently active path for highlighting |
|
||||||
|
| `filterQuery` | `string` | Search filter text |
|
||||||
|
| `onNavigate` | `(path: string) => void` | Navigation callback |
|
||||||
|
| `persistKey` | `string` | localStorage key for expand state |
|
||||||
|
| `autoRevealPath` | `string \| null` | Path to auto-expand to |
|
||||||
|
| `isStarred` | `(id: string) => boolean` | Star state checker |
|
||||||
|
| `onToggleStar` | `(id: string) => void` | Star toggle callback |
|
||||||
|
|
||||||
|
### `useStarred` hook (no changes, newly exported)
|
||||||
|
|
||||||
|
Already exists at `Sidebar/useStarred.ts`. Export as-is.
|
||||||
|
|
||||||
|
**Returns:** `{ starredIds, isStarred, toggleStar }`
|
||||||
|
|
||||||
|
## What Gets Removed
|
||||||
|
|
||||||
|
All of this application-specific logic is deleted from the DS:
|
||||||
|
|
||||||
|
1. **`buildAppTreeNodes()`** (~30 lines) — transforms `SidebarApp[]` into `SidebarTreeNode[]`
|
||||||
|
2. **`buildRouteTreeNodes()`** (~20 lines) — transforms apps into route tree nodes
|
||||||
|
3. **`buildAgentTreeNodes()`** (~25 lines) — transforms apps into agent tree nodes with live-count badges
|
||||||
|
4. **`collectStarredItems()`** (~20 lines) — gathers starred items across types
|
||||||
|
5. **`StarredGroup`** sub-component (~30 lines) — renders grouped starred items
|
||||||
|
6. **Hardcoded sections** (~100 lines) — Applications, Agents, Routes section rendering with localStorage persistence
|
||||||
|
7. **Hardcoded bottom links** (~30 lines) — Admin and API Docs links
|
||||||
|
8. **Auto-reveal effect** (~20 lines) — `sidebarRevealPath` effect
|
||||||
|
9. **`SidebarApp`, `SidebarRoute`, `SidebarAgent` types** — domain types, not DS types
|
||||||
|
10. **`formatCount()` helper** — number formatting, moves to consuming app
|
||||||
|
|
||||||
|
Total: ~300 lines of application logic removed, replaced by ~150 lines of compound component shell.
|
||||||
|
|
||||||
|
## CSS Changes
|
||||||
|
|
||||||
|
### New styles needed
|
||||||
|
|
||||||
|
- `.sidebarCollapsed` — narrow width (48px), centered icons
|
||||||
|
- `.collapseToggle` — `<<` / `>>` button positioning
|
||||||
|
- `.sectionIcon` — icon rendering in section headers
|
||||||
|
- `.tooltip` — hover tooltips for collapsed mode
|
||||||
|
- Width transition: `transition: width 200ms ease` on `.sidebar`
|
||||||
|
|
||||||
|
### Styles that stay
|
||||||
|
|
||||||
|
- `.sidebar` (modified: width becomes conditional)
|
||||||
|
- `.searchWrap`, `.searchInput` (unchanged)
|
||||||
|
- `.navArea` (unchanged)
|
||||||
|
- All tree styles in `SidebarTree` (unchanged)
|
||||||
|
|
||||||
|
### Styles removed
|
||||||
|
|
||||||
|
- `.bottom`, `.bottomItem`, `.bottomItemActive` — replaced by `Sidebar.Footer` / `Sidebar.FooterLink` styles
|
||||||
|
- `.starredSection`, `.starredGroup`, `.starredItem`, `.starredRemove` — starred rendering moves to app
|
||||||
|
- `.section` — replaced by `Sidebar.Section` styles
|
||||||
|
|
||||||
|
## File Structure After Refactor
|
||||||
|
|
||||||
|
```
|
||||||
|
Sidebar/
|
||||||
|
├── Sidebar.tsx # Compound component: Sidebar, Sidebar.Header,
|
||||||
|
│ # Sidebar.Section, Sidebar.Footer, Sidebar.FooterLink
|
||||||
|
├── Sidebar.module.css # Updated styles (shell + section + footer + collapsed)
|
||||||
|
├── SidebarTree.tsx # Unchanged
|
||||||
|
├── SidebarTree.module.css # Unchanged (if separate, otherwise stays in Sidebar.module.css)
|
||||||
|
├── useStarred.ts # Unchanged
|
||||||
|
├── useStarred.test.ts # Unchanged
|
||||||
|
└── Sidebar.test.tsx # Updated for new compound API
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Update `Sidebar.test.tsx` to test the compound component API:
|
||||||
|
|
||||||
|
- Renders Header with logo, title, version
|
||||||
|
- Renders Sections with labels and icons
|
||||||
|
- Section toggle calls `onToggle`
|
||||||
|
- Collapsed sections hide children
|
||||||
|
- Sidebar collapsed mode renders icon rail
|
||||||
|
- Collapse toggle calls `onCollapseToggle`
|
||||||
|
- Footer links render with icons and labels
|
||||||
|
- Collapsed mode hides labels, shows tooltips
|
||||||
|
- Search input calls `onSearchChange`
|
||||||
|
- Search hidden when sidebar collapsed
|
||||||
|
- Section icon click in collapsed mode calls both `onCollapseToggle` and `onToggle`
|
||||||
|
|
||||||
|
`SidebarTree` tests are unaffected.
|
||||||
|
|
||||||
|
## Usage Example (for reference)
|
||||||
|
|
||||||
|
This is how the consuming application (cameleer3-server) will use the new API. This code does NOT live in the design system — it's shown for context only.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// In LayoutShell.tsx (consuming app)
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const [filterQuery, setFilterQuery] = useState('');
|
||||||
|
const [appsCollapsed, setAppsCollapsed] = useState(false);
|
||||||
|
const [agentsCollapsed, setAgentsCollapsed] = useState(false);
|
||||||
|
const [routesCollapsed, setRoutesCollapsed] = useState(true);
|
||||||
|
const [adminCollapsed, setAdminCollapsed] = useState(true);
|
||||||
|
|
||||||
|
// Accordion: entering admin expands admin, collapses others
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdminPage) {
|
||||||
|
setAdminCollapsed(false);
|
||||||
|
setAppsCollapsed(true);
|
||||||
|
setAgentsCollapsed(true);
|
||||||
|
setRoutesCollapsed(true);
|
||||||
|
} else {
|
||||||
|
setAdminCollapsed(true);
|
||||||
|
// restore previous operational states
|
||||||
|
}
|
||||||
|
}, [isAdminPage]);
|
||||||
|
|
||||||
|
<Sidebar
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onCollapseToggle={() => setSidebarCollapsed(v => !v)}
|
||||||
|
searchValue={filterQuery}
|
||||||
|
onSearchChange={setFilterQuery}
|
||||||
|
>
|
||||||
|
<Sidebar.Header logo={<CameleerLogo />} title="cameleer" version="v3.2.1" />
|
||||||
|
|
||||||
|
{isAdminPage && (
|
||||||
|
<Sidebar.Section label="ADMIN" icon={<Settings size={14} />}
|
||||||
|
collapsed={adminCollapsed} onToggle={() => setAdminCollapsed(v => !v)}>
|
||||||
|
<SidebarTree nodes={adminNodes} ... filterQuery={filterQuery} />
|
||||||
|
</Sidebar.Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Sidebar.Section label="APPLICATIONS" icon={<Box size={14} />}
|
||||||
|
collapsed={appsCollapsed} onToggle={() => { setAppsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
|
||||||
|
<SidebarTree nodes={appNodes} ... filterQuery={filterQuery} />
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section label="AGENTS" icon={<Cpu size={14} />}
|
||||||
|
collapsed={agentsCollapsed} onToggle={() => { setAgentsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
|
||||||
|
<SidebarTree nodes={agentNodes} ... filterQuery={filterQuery} />
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section label="ROUTES" icon={<GitBranch size={14} />}
|
||||||
|
collapsed={routesCollapsed} onToggle={() => { setRoutesCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
|
||||||
|
<SidebarTree nodes={routeNodes} ... filterQuery={filterQuery} />
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Footer>
|
||||||
|
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => nav('/api-docs')} />
|
||||||
|
</Sidebar.Footer>
|
||||||
|
</Sidebar>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mock App Migration — LayoutShell
|
||||||
|
|
||||||
|
The 11 page files currently duplicating `<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>` will be consolidated into a single `LayoutShell` component.
|
||||||
|
|
||||||
|
### `src/layout/LayoutShell.tsx`
|
||||||
|
|
||||||
|
Composes the sidebar once using the new compound API. All page-specific content is rendered via `<Outlet />`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/layout/LayoutShell.tsx
|
||||||
|
export function LayoutShell() {
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
const [filterQuery, setFilterQuery] = useState('')
|
||||||
|
const [appsCollapsed, setAppsCollapsed] = useState(false)
|
||||||
|
const [agentsCollapsed, setAgentsCollapsed] = useState(false)
|
||||||
|
const [routesCollapsed, setRoutesCollapsed] = useState(false)
|
||||||
|
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||||
|
const location = useLocation()
|
||||||
|
// ... build tree nodes from SIDEBAR_APPS, starred section, etc.
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
sidebar={
|
||||||
|
<Sidebar
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onCollapseToggle={() => setSidebarCollapsed(v => !v)}
|
||||||
|
searchValue={filterQuery}
|
||||||
|
onSearchChange={setFilterQuery}
|
||||||
|
>
|
||||||
|
<Sidebar.Header logo={...} title="cameleer" version="v3.2.1" />
|
||||||
|
<Sidebar.Section label="Applications" icon={...}
|
||||||
|
collapsed={appsCollapsed} onToggle={() => setAppsCollapsed(v => !v)}>
|
||||||
|
<SidebarTree nodes={appNodes} filterQuery={filterQuery} ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Section label="Agents" icon={...}
|
||||||
|
collapsed={agentsCollapsed} onToggle={() => setAgentsCollapsed(v => !v)}>
|
||||||
|
<SidebarTree nodes={agentNodes} filterQuery={filterQuery} ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Section label="Routes" icon={...}
|
||||||
|
collapsed={routesCollapsed} onToggle={() => setRoutesCollapsed(v => !v)}>
|
||||||
|
<SidebarTree nodes={routeNodes} filterQuery={filterQuery} ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
{/* Starred section built from useStarred + SIDEBAR_APPS */}
|
||||||
|
<Sidebar.Footer>
|
||||||
|
<Sidebar.FooterLink icon={...} label="Admin" ... />
|
||||||
|
<Sidebar.FooterLink icon={...} label="API Docs" ... />
|
||||||
|
</Sidebar.Footer>
|
||||||
|
</Sidebar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route structure change
|
||||||
|
|
||||||
|
`App.tsx` switches from per-page `<Route element={<Page />}>` to a layout route:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Route element={<LayoutShell />}>
|
||||||
|
<Route path="/apps" element={<Dashboard />} />
|
||||||
|
<Route path="/apps/:id" element={<Dashboard />} />
|
||||||
|
...all existing routes...
|
||||||
|
</Route>
|
||||||
|
```
|
||||||
|
|
||||||
|
All tree-building helpers (`buildAppTreeNodes`, `buildRouteTreeNodes`, `buildAgentTreeNodes`), starred section logic (`collectStarredItems`, `StarredGroup`), `formatCount`, and `sidebarRevealPath` handling move from `Sidebar.tsx` into `LayoutShell.tsx`. Each page file loses its `<AppShell sidebar={...}>` wrapper and becomes just the page content.
|
||||||
|
|
||||||
|
The Inventory page's `LayoutSection` keeps its own inline `<Sidebar>` demo with `SAMPLE_APPS` data — it's a showcase, not a navigation shell.
|
||||||
|
|
||||||
|
## Breaking Change
|
||||||
|
|
||||||
|
This is a **breaking change** to the `Sidebar` API. The old `<Sidebar apps={[...]} onNavigate={...} />` signature is removed entirely. The consuming application must migrate to the compound component API in the same release cycle.
|
||||||
|
|
||||||
|
Coordinate: bump DS version, update server UI, deploy together.
|
||||||
121
docs/superpowers/specs/2026-04-12-recharts-migration-design.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Recharts Migration Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the design system's hand-rolled SVG chart components (LineChart, AreaChart, BarChart) with Recharts-based implementations. The current custom charts have responsiveness issues (preserveAspectRatio distorting text), limited tooltip/axis formatting, and growing maintenance burden.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Add Recharts as a DS dependency. Replace the three custom chart components and `_chart-utils.ts` with a single `ThemedChart` wrapper component. Consumers compose Recharts elements (`<Line>`, `<Area>`, `<Bar>`, etc.) as children inside `<ThemedChart>`. The DS re-exports Recharts components so consumers don't need to add Recharts as a separate dependency.
|
||||||
|
|
||||||
|
Sparkline stays hand-rolled SVG (no axes, no tooltips — Recharts is overkill).
|
||||||
|
|
||||||
|
### What gets removed
|
||||||
|
|
||||||
|
- `composites/LineChart/` directory (LineChart.tsx, LineChart.module.css)
|
||||||
|
- `composites/AreaChart/` directory (AreaChart.tsx, AreaChart.module.css)
|
||||||
|
- `composites/BarChart/` directory (BarChart.tsx, BarChart.module.css)
|
||||||
|
- `composites/_chart-utils.ts`
|
||||||
|
- `ChartSeries` and `DataPoint` type exports
|
||||||
|
|
||||||
|
### What gets added
|
||||||
|
|
||||||
|
- `composites/ThemedChart/ThemedChart.tsx` — wrapper component
|
||||||
|
- `composites/ThemedChart/ChartTooltip.tsx` — internal themed tooltip
|
||||||
|
- `composites/ThemedChart/ThemedChart.module.css`
|
||||||
|
- Recharts re-exports from the DS barrel
|
||||||
|
|
||||||
|
### What stays unchanged
|
||||||
|
|
||||||
|
- `utils/rechartsTheme.ts` (now also used internally by ThemedChart)
|
||||||
|
- `CHART_COLORS`
|
||||||
|
- `primitives/Sparkline/`
|
||||||
|
|
||||||
|
## Data Format
|
||||||
|
|
||||||
|
Consumers use Recharts-native flat data format instead of the current `ChartSeries[]`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before (custom)
|
||||||
|
const series = [
|
||||||
|
{ label: 'CPU %', data: pts.map(p => ({ x: new Date(p.time), y: p.value * 100 })) }
|
||||||
|
]
|
||||||
|
<AreaChart series={series} height={160} yLabel="%" />
|
||||||
|
|
||||||
|
// After (Recharts-native)
|
||||||
|
const data = pts.map(p => ({ time: p.time, cpu: p.value * 100 }))
|
||||||
|
<ThemedChart data={data} height={160} xDataKey="time" yLabel="%">
|
||||||
|
<Area dataKey="cpu" stroke={CHART_COLORS[0]} fill={CHART_COLORS[0]} fillOpacity={0.1} />
|
||||||
|
</ThemedChart>
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a breaking change. The migration cost is bounded — only `AgentInstance.tsx` in the server repo uses these components.
|
||||||
|
|
||||||
|
## ThemedChart API
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface ThemedChartProps {
|
||||||
|
data: Record<string, any>[]
|
||||||
|
height?: number // default 200
|
||||||
|
xDataKey?: string // default "time"
|
||||||
|
xType?: 'number' | 'category' // default "category"
|
||||||
|
xTickFormatter?: (value: any) => string
|
||||||
|
yTickFormatter?: (value: any) => string
|
||||||
|
yLabel?: string
|
||||||
|
children: React.ReactNode // Recharts elements
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ThemedChart renders internally:
|
||||||
|
- `ResponsiveContainer` (width 100%, height from prop)
|
||||||
|
- `ComposedChart` (supports mixing Line + Bar + Area in one chart)
|
||||||
|
- `CartesianGrid` with `rechartsTheme.cartesianGrid`
|
||||||
|
- `XAxis` with `rechartsTheme.xAxis` + formatter
|
||||||
|
- `YAxis` with `rechartsTheme.yAxis` + formatter + label
|
||||||
|
- `Tooltip` with custom `ChartTooltip` component
|
||||||
|
|
||||||
|
## Tooltip
|
||||||
|
|
||||||
|
ThemedChart provides a custom `ChartTooltip` component (internal, not exported) that:
|
||||||
|
- Shows the x-value formatted as date/time in a header row (mono font, subtle border separator)
|
||||||
|
- Shows each series with colored dot + label + formatted value
|
||||||
|
- Uses DS tokens for styling (surface bg, border, shadow, mono font)
|
||||||
|
|
||||||
|
Consumers can override by passing their own `<Tooltip content={...} />` as a child. Recharts uses the last Tooltip it finds, so a consumer-provided one replaces the default.
|
||||||
|
|
||||||
|
## DS Re-exports
|
||||||
|
|
||||||
|
Selected Recharts components re-exported so consumers don't need `recharts` in their own package.json:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export { ThemedChart } from './ThemedChart/ThemedChart'
|
||||||
|
export {
|
||||||
|
Line, Area, Bar, ReferenceLine, ReferenceArea,
|
||||||
|
Legend, Brush, ComposedChart,
|
||||||
|
} from 'recharts'
|
||||||
|
```
|
||||||
|
|
||||||
|
More can be added on demand.
|
||||||
|
|
||||||
|
## Consumer Migration — Server UI
|
||||||
|
|
||||||
|
`AgentInstance.tsx` has 6 charts to migrate:
|
||||||
|
|
||||||
|
| Chart | Before | After |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| CPU Usage | `<AreaChart series={cpuSeries} threshold={...}>` | `<ThemedChart><Area dataKey="cpu" /><ReferenceLine y={85} /></ThemedChart>` |
|
||||||
|
| Memory (Heap) | `<AreaChart series={heapSeries} threshold={...}>` | `<ThemedChart><Area dataKey="heap" /><ReferenceLine y={max} /></ThemedChart>` |
|
||||||
|
| Throughput | `<LineChart series={throughputSeries}>` | `<ThemedChart><Line dataKey="throughput" /></ThemedChart>` |
|
||||||
|
| Error Rate | `<LineChart series={errorSeries}>` | `<ThemedChart><Line dataKey="errorPct" /></ThemedChart>` |
|
||||||
|
| Thread Count | `<LineChart series={threadSeries}>` | `<ThemedChart><Line dataKey="threads" /></ThemedChart>` |
|
||||||
|
| GC Pauses | `<AreaChart series={gcSeries}>` | `<ThemedChart><Area dataKey="gc" /></ThemedChart>` |
|
||||||
|
|
||||||
|
Data prep changes from building `ChartSeries[]` arrays to flat objects with named keys.
|
||||||
|
|
||||||
|
## Not in Scope
|
||||||
|
|
||||||
|
- Sparkline migration (no responsiveness issues, no axes/tooltips)
|
||||||
|
- SaaS UI (no chart component usage)
|
||||||
|
- Dashboard tab (already uses Recharts directly with `rechartsTheme`)
|
||||||
|
- New chart types (treemap, radar, etc. — consumers compose these directly)
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Sidebar Section Layout: Top/Bottom Positioning & Scrollable Sections
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Extend `Sidebar.Section` with two new optional props:
|
||||||
|
|
||||||
|
- `position: 'top' | 'bottom'` — controls whether a section stacks from the top of the sidebar or from the bottom (above the footer). Default: `'top'`.
|
||||||
|
- `maxHeight: string` — CSS length value (e.g. `"250px"`, `"30vh"`) that constrains the section's content area. When content exceeds this height, it scrolls. The section header/toggle always remains visible.
|
||||||
|
|
||||||
|
This enables a layout where primary sections (Applications, Agents) occupy the top of the sidebar, while secondary sections (Routes, Starred) cluster near the bottom — with a flexible spacer absorbing remaining vertical space between the two groups.
|
||||||
|
|
||||||
|
## API Changes
|
||||||
|
|
||||||
|
### `Sidebar.Section` — new optional props
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface SidebarSectionProps {
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
open: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
active?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
position?: 'top' | 'bottom' // default: 'top'
|
||||||
|
maxHeight?: string // CSS length, e.g. "250px", "30vh"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No changes to `Sidebar.Header`, `Sidebar.Footer`, `Sidebar.FooterLink`, or `SidebarRoot` props.
|
||||||
|
|
||||||
|
### Consumer usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Header ... />
|
||||||
|
|
||||||
|
<Sidebar.Section label="Applications" ...>
|
||||||
|
<SidebarTree ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section label="Agents" ...>
|
||||||
|
<SidebarTree ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section label="Routes" position="bottom" maxHeight="200px" ...>
|
||||||
|
<SidebarTree ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section label="Starred" position="bottom" ...>
|
||||||
|
...
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Footer>...</Sidebar.Footer>
|
||||||
|
</Sidebar>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout Model
|
||||||
|
|
||||||
|
### SidebarRoot child partitioning
|
||||||
|
|
||||||
|
`SidebarRoot` already inspects children to extract `Header`. This extends the same pattern:
|
||||||
|
|
||||||
|
1. Extract `Header` children (existing)
|
||||||
|
2. Extract `Footer` children
|
||||||
|
3. Partition remaining children into `topSections` and `bottomSections` based on `position` prop (default `'top'`)
|
||||||
|
|
||||||
|
### Render structure
|
||||||
|
|
||||||
|
```
|
||||||
|
<aside class="sidebar">
|
||||||
|
[collapse toggle]
|
||||||
|
{header}
|
||||||
|
{search bar}
|
||||||
|
<div class="sectionGroup sectionGroupTop">
|
||||||
|
{topSections}
|
||||||
|
</div>
|
||||||
|
<div class="sectionSpacer" />
|
||||||
|
<div class="sectionGroup sectionGroupBottom">
|
||||||
|
{bottomSections}
|
||||||
|
</div>
|
||||||
|
{footer}
|
||||||
|
</aside>
|
||||||
|
```
|
||||||
|
|
||||||
|
When there are no bottom sections, the spacer is omitted. The layout behaves identically to today — footer's `margin-top: auto` handles positioning. Zero breaking changes for existing consumers.
|
||||||
|
|
||||||
|
### SidebarSection content wrapper
|
||||||
|
|
||||||
|
A new `.sectionContent` div wraps only the `children` inside `SidebarSection` (not the toggle header). When `maxHeight` is provided, it receives the value as an inline `max-height` style.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className={styles.sectionContent}
|
||||||
|
style={maxHeight ? { maxHeight } : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS Changes
|
||||||
|
|
||||||
|
### Group wrappers
|
||||||
|
|
||||||
|
```css
|
||||||
|
.sectionGroup {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionSpacer {
|
||||||
|
flex: 1 0 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Section content scrolling
|
||||||
|
|
||||||
|
```css
|
||||||
|
.sectionContent {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`maxHeight` is applied as an inline style, not a CSS class, since it varies per section instance.
|
||||||
|
|
||||||
|
### Custom scrollbars
|
||||||
|
|
||||||
|
Applied to both `.sectionGroup` and `.sectionContent` to keep the dark sidebar aesthetic:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Standard (Firefox, modern Chrome/Edge) */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||||
|
|
||||||
|
/* WebKit (Safari, older Chrome) */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Collapsed Sidebar Behavior
|
||||||
|
|
||||||
|
In collapsed (icon rail) mode, sections render as single icon buttons with no scrollable content:
|
||||||
|
|
||||||
|
- **`position`**: Respected — bottom sections render in the bottom group, so their icons cluster near the footer in the rail.
|
||||||
|
- **`maxHeight`**: Ignored — no content to constrain.
|
||||||
|
|
||||||
|
The group wrapper and spacer structure remains active in collapsed mode for consistent icon positioning.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| All sections `"top"` (no bottom sections) | No spacer rendered. Identical to current layout. |
|
||||||
|
| All sections `"bottom"` | Top group empty. Sections cluster above footer. |
|
||||||
|
| `maxHeight` set but content is shorter | No visual effect — wrapper is naturally smaller than max. |
|
||||||
|
| Very short viewport | Both group wrappers scroll independently via `overflow-y: auto` on `.sectionGroup`. |
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- `src/design-system/layout/Sidebar/Sidebar.tsx` — add `position` and `maxHeight` props to `SidebarSectionProps`, add `sectionContent` wrapper to `SidebarSection`, partition children in `SidebarRoot`
|
||||||
|
- `src/design-system/layout/Sidebar/Sidebar.module.css` — add `.sectionGroup`, `.sectionSpacer`, `.sectionContent`, custom scrollbar styles
|
||||||
|
- `src/design-system/layout/Sidebar/Sidebar.test.tsx` — new test cases for positioning and scrolling behavior
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Section with `maxHeight` renders content wrapper with correct inline style and `overflow-y: auto`
|
||||||
|
- Sections with `position="bottom"` render inside the bottom group wrapper
|
||||||
|
- Default `position` (omitted) renders in the top group
|
||||||
|
- When no bottom sections exist, no spacer is rendered
|
||||||
|
- Collapsed sidebar renders bottom sections in the bottom group
|
||||||
165
e2e/admin.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Admin - User Management (/admin/rbac)', () => {
|
||||||
|
test('renders admin tabs and user table', async ({ page }) => {
|
||||||
|
await page.goto('/admin/rbac')
|
||||||
|
|
||||||
|
// Admin navigation tabs
|
||||||
|
await expect(page.getByRole('tab', { name: 'User Management' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Audit Log' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'OIDC' })).toBeVisible()
|
||||||
|
|
||||||
|
// User Management sub-tabs
|
||||||
|
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Groups' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Roles' })).toBeVisible()
|
||||||
|
|
||||||
|
// TopBar breadcrumb
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('User Management')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching between Users, Groups, and Roles tabs', async ({ page }) => {
|
||||||
|
await page.goto('/admin/rbac')
|
||||||
|
|
||||||
|
// Default tab is Users
|
||||||
|
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
|
||||||
|
|
||||||
|
// Switch to Groups tab
|
||||||
|
await page.getByRole('tab', { name: 'Groups' }).click()
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Switch to Roles tab
|
||||||
|
await page.getByRole('tab', { name: 'Roles' }).click()
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Switch back to Users
|
||||||
|
await page.getByRole('tab', { name: 'Users' }).click()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigating between admin sections via tabs', async ({ page }) => {
|
||||||
|
await page.goto('/admin/rbac')
|
||||||
|
|
||||||
|
// Click Audit Log tab
|
||||||
|
await page.getByRole('tab', { name: 'Audit Log' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin\/audit/)
|
||||||
|
|
||||||
|
// Click OIDC tab
|
||||||
|
await page.getByRole('tab', { name: 'OIDC' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin\/oidc/)
|
||||||
|
|
||||||
|
// Back to User Management
|
||||||
|
await page.getByRole('tab', { name: 'User Management' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin\/rbac/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Admin - Audit Log (/admin/audit)', () => {
|
||||||
|
test('renders audit table with filters', async ({ page }) => {
|
||||||
|
await page.goto('/admin/audit')
|
||||||
|
|
||||||
|
// Table headers
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Timestamp' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'User' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Category' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Action' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Result' })).toBeVisible()
|
||||||
|
|
||||||
|
// Table has data
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Filter inputs exist
|
||||||
|
await expect(page.getByPlaceholder('Filter by user...')).toBeVisible()
|
||||||
|
await expect(page.getByPlaceholder('Search action or target...')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('filtering audit events by search', async ({ page }) => {
|
||||||
|
await page.goto('/admin/audit')
|
||||||
|
|
||||||
|
const searchInput = page.getByPlaceholder('Search action or target...')
|
||||||
|
await searchInput.fill('deploy')
|
||||||
|
|
||||||
|
// Table should update
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
const count = await rows.count()
|
||||||
|
expect(count).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Admin - OIDC Config (/admin/oidc)', () => {
|
||||||
|
test('renders OIDC form with all fields', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
// Section headers
|
||||||
|
await expect(page.getByText('Behavior')).toBeVisible()
|
||||||
|
await expect(page.getByText('Provider Settings')).toBeVisible()
|
||||||
|
await expect(page.getByText('Claim Mapping')).toBeVisible()
|
||||||
|
await expect(page.getByText('Default Roles')).toBeVisible()
|
||||||
|
await expect(page.getByText('Danger Zone')).toBeVisible()
|
||||||
|
|
||||||
|
// Form fields by id
|
||||||
|
await expect(page.locator('#issuer')).toBeVisible()
|
||||||
|
await expect(page.locator('#client-id')).toBeVisible()
|
||||||
|
await expect(page.locator('#client-secret')).toBeVisible()
|
||||||
|
await expect(page.locator('#roles-claim')).toBeVisible()
|
||||||
|
await expect(page.locator('#name-claim')).toBeVisible()
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
await expect(page.getByRole('button', { name: 'Test Connection' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: /Delete OIDC/i })).toBeVisible()
|
||||||
|
|
||||||
|
// Default roles tags
|
||||||
|
await expect(page.getByText('USER').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('VIEWER').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('toggling Enabled switch', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
// The Toggle's checkbox is visually hidden — click the label wrapper instead
|
||||||
|
const enabledLabel = page.locator('label').filter({ hasText: 'Enabled' })
|
||||||
|
await enabledLabel.click()
|
||||||
|
|
||||||
|
// Should not crash; label still visible
|
||||||
|
await expect(enabledLabel).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adding and removing a role tag', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
// Add a new role
|
||||||
|
const roleInput = page.getByPlaceholder('Add role...')
|
||||||
|
await roleInput.fill('EDITOR')
|
||||||
|
// Use the Add button next to the input (scoped to same row)
|
||||||
|
await roleInput.press('Enter')
|
||||||
|
|
||||||
|
// New role tag should appear
|
||||||
|
await expect(page.getByText('EDITOR')).toBeVisible()
|
||||||
|
|
||||||
|
// Remove it via aria-label on the tag's remove button
|
||||||
|
await page.getByRole('button', { name: 'Remove EDITOR' }).click()
|
||||||
|
|
||||||
|
// EDITOR tag should be gone
|
||||||
|
await expect(page.getByText('EDITOR')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Save button shows success toast', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click()
|
||||||
|
|
||||||
|
// Toast notification
|
||||||
|
await expect(page.getByText('Settings saved')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Delete button shows confirmation dialog', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Delete OIDC/i }).click()
|
||||||
|
|
||||||
|
// Confirmation dialog should appear
|
||||||
|
await expect(page.getByText('Delete OIDC configuration?')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
80
e2e/agents.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Agent Health (/agents)', () => {
|
||||||
|
test('renders stat cards and group cards', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// Stat strip
|
||||||
|
await expect(page.getByText('Total Agents')).toBeVisible()
|
||||||
|
await expect(page.getByText('Total TPS')).toBeVisible()
|
||||||
|
|
||||||
|
// Group cards for each application
|
||||||
|
await expect(page.getByText('order-service').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('payment-svc').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('notification-hub').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Instance tables have data
|
||||||
|
const instanceRows = page.locator('table tbody tr')
|
||||||
|
expect(await instanceRows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Instance table headers
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Instance' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'State' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Uptime' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'TPS' }).first()).toBeVisible()
|
||||||
|
|
||||||
|
// Timeline section
|
||||||
|
await expect(page.getByText('Timeline').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking an instance row opens the detail panel', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// Click first instance row
|
||||||
|
const instanceRow = page.locator('table tbody tr').first()
|
||||||
|
await instanceRow.click()
|
||||||
|
|
||||||
|
// Detail panel opens — look for detail-specific labels
|
||||||
|
await expect(page.getByText('Version').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Throughput').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detail panel has Performance tab with charts', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// Click an instance to open detail panel
|
||||||
|
const instanceRow = page.locator('table tbody tr').first()
|
||||||
|
await instanceRow.click()
|
||||||
|
|
||||||
|
// Wait for panel to open
|
||||||
|
await expect(page.getByText('Version').first()).toBeVisible()
|
||||||
|
|
||||||
|
// DetailPanel tabs are plain buttons (not role="tab")
|
||||||
|
// Switch to Performance tab
|
||||||
|
const perfTab = page.getByRole('button', { name: 'Performance' })
|
||||||
|
await perfTab.click()
|
||||||
|
|
||||||
|
// Performance charts should render
|
||||||
|
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Error Rate (err/h)').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('app-scoped agents view', async ({ page }) => {
|
||||||
|
await page.goto('/agents/order-service')
|
||||||
|
|
||||||
|
// Breadcrumb/scope shows app
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('Agents')).toBeVisible()
|
||||||
|
|
||||||
|
// Only order-service agents should show
|
||||||
|
await expect(page.getByText('ord-1').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('ord-2').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('ord-3').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dead agent shows alert banner', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// notification-hub has a dead instance, should show alert
|
||||||
|
await expect(page.getByText('Single point of failure')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
90
e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
/** Click the 7d time range preset so hardcoded mock data (March 18) is visible. */
|
||||||
|
async function widenTimeRange(page: import('@playwright/test').Page) {
|
||||||
|
await page.getByRole('tab', { name: '7d' }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Dashboard (/apps)', () => {
|
||||||
|
test('renders KPI stat cards and exchange table', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
await widenTimeRange(page)
|
||||||
|
|
||||||
|
// KPI health strip renders
|
||||||
|
await expect(page.getByText('Recent Exchanges')).toBeVisible()
|
||||||
|
|
||||||
|
// Table headers
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Application' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Exchange ID' })).toBeVisible()
|
||||||
|
|
||||||
|
// Table has data rows
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
await expect(rows.first()).toBeVisible()
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Sidebar renders with app names
|
||||||
|
await expect(page.getByText('order-service').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('payment-svc').first()).toBeVisible()
|
||||||
|
|
||||||
|
// TopBar renders
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('Applications')).toBeVisible()
|
||||||
|
await expect(page.getByText('PRODUCTION')).toBeVisible()
|
||||||
|
|
||||||
|
// Shortcuts bar
|
||||||
|
await expect(page.getByText('Ctrl+K').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking a table row opens the detail panel', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
await widenTimeRange(page)
|
||||||
|
|
||||||
|
// Click the first data row
|
||||||
|
const firstRow = page.locator('table tbody tr').first()
|
||||||
|
await expect(firstRow).toBeVisible()
|
||||||
|
await firstRow.click()
|
||||||
|
|
||||||
|
// Detail panel should open — look for "Open full details" link
|
||||||
|
await expect(page.getByText('Open full details')).toBeVisible()
|
||||||
|
// Overview section
|
||||||
|
await expect(page.getByText('Correlation').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigating to app-scoped dashboard filters exchanges', async ({ page }) => {
|
||||||
|
await page.goto('/apps/order-service')
|
||||||
|
|
||||||
|
// Breadcrumb shows app scope
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
|
||||||
|
|
||||||
|
// Table should still render
|
||||||
|
await expect(page.getByText('Recent Exchanges')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sidebar navigation works', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
|
||||||
|
// Click on an app in the sidebar
|
||||||
|
const sidebarApp = page.getByText('order-service').first()
|
||||||
|
await sidebarApp.click()
|
||||||
|
|
||||||
|
// URL should change to the app scope
|
||||||
|
await expect(page).toHaveURL(/\/apps\/order-service/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inspect button navigates to exchange detail', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
await widenTimeRange(page)
|
||||||
|
|
||||||
|
// Wait for table rows to appear
|
||||||
|
const firstRow = page.locator('table tbody tr').first()
|
||||||
|
await expect(firstRow).toBeVisible()
|
||||||
|
|
||||||
|
// Click the inspect button (↗) on first row
|
||||||
|
const inspectBtn = firstRow.locator('button[title="Inspect exchange"]')
|
||||||
|
await inspectBtn.click()
|
||||||
|
|
||||||
|
// Should navigate to exchange detail page
|
||||||
|
await expect(page).toHaveURL(/\/exchanges\//)
|
||||||
|
})
|
||||||
|
})
|
||||||
60
e2e/exchanges.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Exchange Detail (/exchanges/:id)', () => {
|
||||||
|
test('renders exchange header, timeline, and message panels', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/E-2026-03-18-00201')
|
||||||
|
|
||||||
|
// Exchange header — use the one NOT in the breadcrumb
|
||||||
|
await expect(page.getByText('E-2026-03-18-00201').nth(1)).toBeVisible()
|
||||||
|
|
||||||
|
// Header stats
|
||||||
|
await expect(page.getByText('Duration').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Processors').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Processor Timeline section
|
||||||
|
await expect(page.getByText('Processor Timeline').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Timeline/Flow toggle buttons
|
||||||
|
await expect(page.getByRole('button', { name: 'Timeline' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Flow' })).toBeVisible()
|
||||||
|
|
||||||
|
// Message IN panel
|
||||||
|
await expect(page.getByText('Message IN')).toBeVisible()
|
||||||
|
await expect(page.getByText('Headers').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Body').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching between Timeline and Flow view', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/E-2026-03-18-00201')
|
||||||
|
|
||||||
|
// Default view is timeline (gantt)
|
||||||
|
const timelineBtn = page.getByRole('button', { name: 'Timeline' })
|
||||||
|
const flowBtn = page.getByRole('button', { name: 'Flow' })
|
||||||
|
|
||||||
|
// Switch to Flow view
|
||||||
|
await flowBtn.click()
|
||||||
|
|
||||||
|
// Flow view should render (RouteFlow component)
|
||||||
|
await expect(flowBtn).toHaveClass(/active|Active/)
|
||||||
|
|
||||||
|
// Switch back to Timeline
|
||||||
|
await timelineBtn.click()
|
||||||
|
await expect(timelineBtn).toHaveClass(/active|Active/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('not-found exchange shows warning', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/nonexistent-id')
|
||||||
|
|
||||||
|
await expect(page.getByText('not found', { exact: false })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('breadcrumb navigation works', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/E-2026-03-18-00201')
|
||||||
|
|
||||||
|
// Click Applications breadcrumb to go back
|
||||||
|
const appsBreadcrumb = page.getByRole('link', { name: 'Applications' })
|
||||||
|
await appsBreadcrumb.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/apps/)
|
||||||
|
})
|
||||||
|
})
|
||||||
63
e2e/routes.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Routes (/routes)', () => {
|
||||||
|
test('renders KPI cards, route table, and charts', async ({ page }) => {
|
||||||
|
await page.goto('/routes')
|
||||||
|
|
||||||
|
// KPI cards
|
||||||
|
await expect(page.getByText('Total Throughput')).toBeVisible()
|
||||||
|
await expect(page.getByText('System Error Rate')).toBeVisible()
|
||||||
|
await expect(page.getByText('Latency Percentiles')).toBeVisible()
|
||||||
|
await expect(page.getByText('Active Routes')).toBeVisible()
|
||||||
|
await expect(page.getByText('In-Flight Exchanges')).toBeVisible()
|
||||||
|
|
||||||
|
// Route performance table
|
||||||
|
await expect(page.getByText('Per-Route Performance')).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Exchanges' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Success %' })).toBeVisible()
|
||||||
|
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Charts render
|
||||||
|
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Latency (ms)')).toBeVisible()
|
||||||
|
await expect(page.getByText('Errors by Route')).toBeVisible()
|
||||||
|
await expect(page.getByText('Message Volume (msg/min)')).toBeVisible()
|
||||||
|
|
||||||
|
// Auto-refresh indicator
|
||||||
|
await expect(page.getByText('Auto-refresh: 30s')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking a route row navigates to route detail', async ({ page }) => {
|
||||||
|
await page.goto('/routes')
|
||||||
|
|
||||||
|
// Click first route row
|
||||||
|
const firstRow = page.locator('table tbody tr').first()
|
||||||
|
await firstRow.click()
|
||||||
|
|
||||||
|
// Should navigate to route detail
|
||||||
|
await expect(page).toHaveURL(/\/routes\/[^/]+\/[^/]+/)
|
||||||
|
|
||||||
|
// Route detail view: processor performance table
|
||||||
|
await expect(page.getByText('Processor Performance')).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Processor' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Type' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Invocations' })).toBeVisible()
|
||||||
|
|
||||||
|
// Route Flow diagram
|
||||||
|
await expect(page.getByText('Route Flow')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('app-scoped routes view filters data', async ({ page }) => {
|
||||||
|
await page.goto('/routes/order-service')
|
||||||
|
|
||||||
|
// Breadcrumb shows scope
|
||||||
|
await expect(page.getByRole('link', { name: 'Routes' })).toBeVisible()
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
|
||||||
|
|
||||||
|
// Table still renders
|
||||||
|
await expect(page.getByText('Per-Route Performance')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
1221
package-lock.json
generated
23
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cameleer/design-system",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.1.0",
|
"version": "0.1.56",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.es.js",
|
"main": "./dist/index.es.js",
|
||||||
"module": "./dist/index.es.js",
|
"module": "./dist/index.es.js",
|
||||||
@@ -10,10 +10,16 @@
|
|||||||
"types": "./dist/index.es.d.ts",
|
"types": "./dist/index.es.d.ts",
|
||||||
"import": "./dist/index.es.js"
|
"import": "./dist/index.es.js"
|
||||||
},
|
},
|
||||||
"./style.css": "./dist/style.css"
|
"./style.css": "./dist/style.css",
|
||||||
|
"./assets/*": "./assets/*"
|
||||||
},
|
},
|
||||||
"files": ["dist"],
|
"files": [
|
||||||
"sideEffects": ["*.css"],
|
"dist",
|
||||||
|
"assets"
|
||||||
|
],
|
||||||
|
"sideEffects": [
|
||||||
|
"*.css"
|
||||||
|
],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
||||||
},
|
},
|
||||||
@@ -27,12 +33,15 @@
|
|||||||
"build:lib": "vite build --config vite.lib.config.ts",
|
"build:lib": "vite build --config vite.lib.config.ts",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest"
|
"test": "vitest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0",
|
||||||
|
"recharts": "^3.8.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -40,12 +49,14 @@
|
|||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"happy-dom": "^20.8.4",
|
"happy-dom": "^20.8.4",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
|
|||||||
21
playwright.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
timeout: 30_000,
|
||||||
|
retries: 0,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
headless: true,
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { browserName: 'chromium' } },
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
port: 5173,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 15_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -19,7 +19,8 @@ import { buildSearchData } from './mocks/searchData'
|
|||||||
import { exchanges } from './mocks/exchanges'
|
import { exchanges } from './mocks/exchanges'
|
||||||
import { routes } from './mocks/routes'
|
import { routes } from './mocks/routes'
|
||||||
import { agents } from './mocks/agents'
|
import { agents } from './mocks/agents'
|
||||||
import { SIDEBAR_APPS, buildRouteToAppMap } from './mocks/sidebar'
|
import { buildRouteToAppMap } from './mocks/sidebar'
|
||||||
|
import { LayoutShell } from './layout/LayoutShell'
|
||||||
|
|
||||||
const routeToApp = buildRouteToAppMap()
|
const routeToApp = buildRouteToAppMap()
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route element={<LayoutShell />}>
|
||||||
<Route path="/" element={<Navigate to="/apps" replace />} />
|
<Route path="/" element={<Navigate to="/apps" replace />} />
|
||||||
<Route path="/apps" element={<Dashboard />} />
|
<Route path="/apps" element={<Dashboard />} />
|
||||||
<Route path="/apps/:id" element={<Dashboard />} />
|
<Route path="/apps/:id" element={<Dashboard />} />
|
||||||
@@ -93,6 +95,7 @@ export default function App() {
|
|||||||
<Route path="/admin/oidc" element={<OidcConfig />} />
|
<Route path="/admin/oidc" element={<OidcConfig />} />
|
||||||
<Route path="/admin/rbac" element={<UserManagement />} />
|
<Route path="/admin/rbac" element={<UserManagement />} />
|
||||||
<Route path="/api-docs" element={<ApiDocs />} />
|
<Route path="/api-docs" element={<ApiDocs />} />
|
||||||
|
</Route>
|
||||||
<Route path="/inventory" element={<Inventory />} />
|
<Route path="/inventory" element={<Inventory />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
|
|||||||
BIN
src/assets/cameleer3-logo.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
112
src/assets/cameleer3-logo.svg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
@@ -78,17 +78,16 @@ describe('AlertDialog', () => {
|
|||||||
|
|
||||||
it('renders danger variant icon', () => {
|
it('renders danger variant icon', () => {
|
||||||
render(<AlertDialog {...defaultProps} variant="danger" />)
|
render(<AlertDialog {...defaultProps} variant="danger" />)
|
||||||
// Icon area should be present (aria-hidden)
|
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders warning variant icon', () => {
|
it('renders warning variant icon', () => {
|
||||||
render(<AlertDialog {...defaultProps} variant="warning" />)
|
render(<AlertDialog {...defaultProps} variant="warning" />)
|
||||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders info variant icon', () => {
|
it('renders info variant icon', () => {
|
||||||
render(<AlertDialog {...defaultProps} variant="info" />)
|
render(<AlertDialog {...defaultProps} variant="info" />)
|
||||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import React, { useEffect, useRef } from 'react'
|
||||||
|
import { XCircle, AlertTriangle, Info } from 'lucide-react'
|
||||||
import { Modal } from '../Modal/Modal'
|
import { Modal } from '../Modal/Modal'
|
||||||
import { Button } from '../../primitives/Button/Button'
|
import { Button } from '../../primitives/Button/Button'
|
||||||
import styles from './AlertDialog.module.css'
|
import styles from './AlertDialog.module.css'
|
||||||
@@ -16,10 +17,10 @@ interface AlertDialogProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, string> = {
|
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, React.ReactNode> = {
|
||||||
danger: '✕',
|
danger: <XCircle size={20} />,
|
||||||
warning: '⚠',
|
warning: <AlertTriangle size={20} />,
|
||||||
info: 'ℹ',
|
info: <Info size={20} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AlertDialog({
|
export function AlertDialog({
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg {
|
|
||||||
display: block;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grid */
|
|
||||||
.gridLine {
|
|
||||||
stroke: var(--border-subtle);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.axisLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Threshold line */
|
|
||||||
.thresholdLine {
|
|
||||||
stroke: var(--error);
|
|
||||||
stroke-width: 1.5;
|
|
||||||
stroke-dasharray: 5 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Area + line */
|
|
||||||
.area {
|
|
||||||
opacity: 0.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line {
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Crosshair */
|
|
||||||
.crosshair {
|
|
||||||
stroke: var(--text-faint);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltip */
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipLabel {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipValue {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Legend */
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendDot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Axis labels */
|
|
||||||
.yLabel {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
writing-mode: vertical-lr;
|
|
||||||
transform: rotate(180deg);
|
|
||||||
text-align: center;
|
|
||||||
position: absolute;
|
|
||||||
left: 4px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xLabel {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
@@ -1,254 +1,107 @@
|
|||||||
import { useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import styles from './AreaChart.module.css'
|
import { Area, ReferenceLine } from 'recharts'
|
||||||
import {
|
import { ThemedChart } from '../ThemedChart/ThemedChart'
|
||||||
computeYScale,
|
import { CHART_COLORS } from '../../utils/rechartsTheme'
|
||||||
computeXScale,
|
|
||||||
seriesPoints,
|
|
||||||
seriesPath,
|
|
||||||
formatAxisLabel,
|
|
||||||
CHART_COLORS,
|
|
||||||
type ChartSeries,
|
|
||||||
} from '../_chart-utils'
|
|
||||||
|
|
||||||
interface Threshold {
|
export interface DataPoint {
|
||||||
value: number
|
x: any
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartSeries {
|
||||||
label: string
|
label: string
|
||||||
|
data: DataPoint[]
|
||||||
|
color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AreaChartProps {
|
interface AreaChartProps {
|
||||||
series: ChartSeries[]
|
series: ChartSeries[]
|
||||||
xLabel?: string
|
|
||||||
yLabel?: string
|
|
||||||
threshold?: Threshold
|
|
||||||
height?: number
|
height?: number
|
||||||
width?: number
|
width?: number
|
||||||
|
yLabel?: string
|
||||||
|
xLabel?: string
|
||||||
|
thresholdValue?: number
|
||||||
|
thresholdLabel?: string
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Y_TICK_COUNT = 4
|
function formatTime(d: Date): string {
|
||||||
const DIMS = {
|
const h = String(d.getHours()).padStart(2, '0')
|
||||||
paddingTop: 12,
|
const m = String(d.getMinutes()).padStart(2, '0')
|
||||||
paddingRight: 16,
|
return `${h}:${m}`
|
||||||
paddingBottom: 28,
|
|
||||||
paddingLeft: 48,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AreaChart({
|
export function AreaChart({
|
||||||
series,
|
series,
|
||||||
xLabel,
|
height,
|
||||||
|
width,
|
||||||
yLabel,
|
yLabel,
|
||||||
threshold,
|
xLabel,
|
||||||
height = 200,
|
thresholdValue,
|
||||||
width = 400,
|
thresholdLabel,
|
||||||
className,
|
className,
|
||||||
}: AreaChartProps) {
|
}: AreaChartProps) {
|
||||||
const [tooltip, setTooltip] = useState<{
|
const { data, hasDateX } = useMemo(() => {
|
||||||
x: number
|
const map = new Map<string, Record<string, any>>()
|
||||||
y: number
|
let dateDetected = false
|
||||||
values: { label: string; value: number; color: string }[]
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
const dims = { ...DIMS, width, height }
|
for (const s of series) {
|
||||||
const allData = series.flatMap((s) => s.data)
|
for (const pt of s.data) {
|
||||||
|
const isDate = pt.x instanceof Date
|
||||||
if (allData.length === 0) {
|
if (isDate) dateDetected = true
|
||||||
return <div className={`${styles.empty} ${className ?? ''}`}>No data</div>
|
const key = isDate ? pt.x.getTime().toString() : String(pt.x)
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, { _x: isDate ? formatTime(pt.x) : pt.x })
|
||||||
|
}
|
||||||
|
map.get(key)![s.label] = pt.y
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { max, toY } = computeYScale(series, dims, threshold?.value)
|
return { data: Array.from(map.values()), hasDateX: dateDetected }
|
||||||
const { toX } = computeXScale(series, dims)
|
}, [series])
|
||||||
const plotH = height - dims.paddingTop - dims.paddingBottom
|
|
||||||
const plotW = width - dims.paddingLeft - dims.paddingRight
|
|
||||||
const bottomY = dims.paddingTop + plotH
|
|
||||||
|
|
||||||
// Y-axis ticks
|
const chart = (
|
||||||
const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) =>
|
<ThemedChart
|
||||||
Math.round((max / Y_TICK_COUNT) * i),
|
data={data}
|
||||||
)
|
|
||||||
|
|
||||||
// X-axis ticks (first, middle, last)
|
|
||||||
const firstSeries = series[0]
|
|
||||||
const xSamples =
|
|
||||||
firstSeries && firstSeries.data.length > 0
|
|
||||||
? [
|
|
||||||
firstSeries.data[0].x,
|
|
||||||
firstSeries.data[Math.floor(firstSeries.data.length / 2)]?.x,
|
|
||||||
firstSeries.data[firstSeries.data.length - 1].x,
|
|
||||||
].filter(Boolean)
|
|
||||||
: []
|
|
||||||
|
|
||||||
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
|
||||||
const mx = e.clientX - rect.left
|
|
||||||
const my = e.clientY - rect.top
|
|
||||||
|
|
||||||
// Find closest x value
|
|
||||||
const pctX = (mx - dims.paddingLeft) / plotW
|
|
||||||
const values = series.map((s, i) => {
|
|
||||||
const idx = Math.round(pctX * (s.data.length - 1))
|
|
||||||
const clamped = Math.max(0, Math.min(s.data.length - 1, idx))
|
|
||||||
const pt = s.data[clamped]
|
|
||||||
return {
|
|
||||||
label: s.label,
|
|
||||||
value: pt?.y ?? 0,
|
|
||||||
color: s.color ?? CHART_COLORS[i % CHART_COLORS.length],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setTooltip({ x: mx, y: my, values })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
|
||||||
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
|
||||||
<svg
|
|
||||||
width={width}
|
|
||||||
height={height}
|
height={height}
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
xDataKey="_x"
|
||||||
className={styles.svg}
|
xType={hasDateX ? 'category' : 'category'}
|
||||||
onMouseMove={handleMouseMove}
|
xTickFormatter={hasDateX ? (v: any) => String(v) : undefined}
|
||||||
onMouseLeave={() => setTooltip(null)}
|
yLabel={yLabel}
|
||||||
aria-label="Area chart"
|
className={className}
|
||||||
role="img"
|
|
||||||
>
|
>
|
||||||
{/* Grid lines */}
|
|
||||||
{yTicks.map((val) => {
|
|
||||||
const y = toY(val)
|
|
||||||
return (
|
|
||||||
<g key={val}>
|
|
||||||
<line
|
|
||||||
x1={dims.paddingLeft}
|
|
||||||
y1={y}
|
|
||||||
x2={width - dims.paddingRight}
|
|
||||||
y2={y}
|
|
||||||
className={styles.gridLine}
|
|
||||||
/>
|
|
||||||
<text x={dims.paddingLeft - 6} y={y + 4} className={styles.axisLabel} textAnchor="end">
|
|
||||||
{formatAxisLabel(val)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* X-axis labels */}
|
|
||||||
{xSamples.map((xVal, i) => {
|
|
||||||
const xPos = toX(xVal)
|
|
||||||
const xv = xVal instanceof Date ? xVal : new Date(xVal as number)
|
|
||||||
const label =
|
|
||||||
xVal instanceof Date || (typeof xVal === 'number' && xVal > 1e10)
|
|
||||||
? xv.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
: formatAxisLabel(xVal as number)
|
|
||||||
const anchor = i === 0 ? 'start' : i === xSamples.length - 1 ? 'end' : 'middle'
|
|
||||||
return (
|
|
||||||
<text
|
|
||||||
key={i}
|
|
||||||
x={xPos}
|
|
||||||
y={height - dims.paddingBottom + 16}
|
|
||||||
className={styles.axisLabel}
|
|
||||||
textAnchor={anchor}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* SLA threshold line */}
|
|
||||||
{threshold && (
|
|
||||||
<g>
|
|
||||||
<line
|
|
||||||
x1={dims.paddingLeft}
|
|
||||||
y1={toY(threshold.value)}
|
|
||||||
x2={width - dims.paddingRight}
|
|
||||||
y2={toY(threshold.value)}
|
|
||||||
className={styles.thresholdLine}
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={width - dims.paddingRight - 4}
|
|
||||||
y={toY(threshold.value) - 4}
|
|
||||||
className={styles.thresholdLabel}
|
|
||||||
textAnchor="end"
|
|
||||||
>
|
|
||||||
{threshold.label}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Area fills */}
|
|
||||||
{series.map((s, i) => {
|
{series.map((s, i) => {
|
||||||
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
|
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
|
||||||
const areaD = seriesPath(s, toX, toY, bottomY)
|
|
||||||
return (
|
return (
|
||||||
<path
|
<Area
|
||||||
key={`area-${i}`}
|
key={s.label}
|
||||||
d={areaD}
|
type="monotone"
|
||||||
fill={color}
|
dataKey={s.label}
|
||||||
className={styles.area}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Lines */}
|
|
||||||
{series.map((s, i) => {
|
|
||||||
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
|
|
||||||
const pts = seriesPoints(s, toX, toY)
|
|
||||||
return (
|
|
||||||
<polyline
|
|
||||||
key={`line-${i}`}
|
|
||||||
points={pts}
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
stroke={color}
|
||||||
className={styles.line}
|
fill={color}
|
||||||
|
fillOpacity={0.15}
|
||||||
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{thresholdValue != null && (
|
||||||
{/* Crosshair */}
|
<ReferenceLine
|
||||||
{tooltip && (
|
y={thresholdValue}
|
||||||
<line
|
stroke="var(--text-muted)"
|
||||||
x1={tooltip.x}
|
strokeDasharray="4 4"
|
||||||
y1={dims.paddingTop}
|
label={thresholdLabel ? {
|
||||||
x2={tooltip.x}
|
value: thresholdLabel,
|
||||||
y2={bottomY}
|
position: 'insideTopRight',
|
||||||
className={styles.crosshair}
|
style: { fontSize: 10, fill: 'var(--text-muted)' },
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</svg>
|
</ThemedChart>
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
{tooltip && (
|
|
||||||
<div
|
|
||||||
className={styles.tooltip}
|
|
||||||
style={{
|
|
||||||
left: tooltip.x + 12,
|
|
||||||
top: tooltip.y,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tooltip.values.map((v) => (
|
|
||||||
<div key={v.label} className={styles.tooltipRow}>
|
|
||||||
<span className={styles.tooltipDot} style={{ background: v.color }} />
|
|
||||||
<span className={styles.tooltipLabel}>{v.label}:</span>
|
|
||||||
<span className={styles.tooltipValue}>{formatAxisLabel(v.value)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
{series.length > 1 && (
|
|
||||||
<div className={styles.legend}>
|
|
||||||
{series.map((s, i) => (
|
|
||||||
<div key={s.label} className={styles.legendItem}>
|
|
||||||
<span
|
|
||||||
className={styles.legendDot}
|
|
||||||
style={{ background: s.color ?? CHART_COLORS[i % CHART_COLORS.length] }}
|
|
||||||
/>
|
|
||||||
<span className={styles.legendLabel}>{s.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{xLabel && <div className={styles.xLabel}>{xLabel}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (width) {
|
||||||
|
return <div style={{ width }}>{chart}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return chart
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
background-color: var(--bg-inset);
|
background-color: var(--bg-inset);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: -8px;
|
margin-left: -8px;
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg {
|
|
||||||
display: block;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gridLine {
|
|
||||||
stroke: var(--border-subtle);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.axisLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.catLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipTitle {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipLabel {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipValue {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendDot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yLabel {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
writing-mode: vertical-lr;
|
|
||||||
transform: rotate(180deg);
|
|
||||||
text-align: center;
|
|
||||||
position: absolute;
|
|
||||||
left: 4px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xLabel {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
@@ -1,251 +1,88 @@
|
|||||||
import { useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import styles from './BarChart.module.css'
|
import { Bar } from 'recharts'
|
||||||
import { formatAxisLabel, CHART_COLORS } from '../_chart-utils'
|
import { ThemedChart } from '../ThemedChart/ThemedChart'
|
||||||
|
import { CHART_COLORS } from '../../utils/rechartsTheme'
|
||||||
|
|
||||||
interface BarSeries {
|
export interface DataPoint {
|
||||||
|
x: any
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartSeries {
|
||||||
label: string
|
label: string
|
||||||
data: { x: string; y: number }[]
|
data: DataPoint[]
|
||||||
color?: string
|
color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BarChartProps {
|
interface BarChartProps {
|
||||||
series: BarSeries[]
|
series: ChartSeries[]
|
||||||
stacked?: boolean
|
|
||||||
height?: number
|
height?: number
|
||||||
width?: number
|
width?: number
|
||||||
xLabel?: string
|
|
||||||
yLabel?: string
|
yLabel?: string
|
||||||
|
xLabel?: string
|
||||||
|
stacked?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const PADDING = { top: 12, right: 16, bottom: 40, left: 48 }
|
function formatTime(d: Date): string {
|
||||||
const Y_TICK_COUNT = 4
|
const h = String(d.getHours()).padStart(2, '0')
|
||||||
const BAR_GAP = 0.2 // fraction of bar group width reserved for gaps
|
const m = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return `${h}:${m}`
|
||||||
|
}
|
||||||
|
|
||||||
export function BarChart({
|
export function BarChart({
|
||||||
series,
|
series,
|
||||||
stacked = false,
|
height,
|
||||||
height = 200,
|
width,
|
||||||
width = 400,
|
|
||||||
xLabel,
|
|
||||||
yLabel,
|
yLabel,
|
||||||
|
xLabel,
|
||||||
|
stacked,
|
||||||
className,
|
className,
|
||||||
}: BarChartProps) {
|
}: BarChartProps) {
|
||||||
const [tooltip, setTooltip] = useState<{
|
const { data, hasDateX } = useMemo(() => {
|
||||||
x: number
|
const map = new Map<string, Record<string, any>>()
|
||||||
y: number
|
let dateDetected = false
|
||||||
label: string
|
|
||||||
values: { series: string; value: number; color: string }[]
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
if (series.length === 0 || series[0].data.length === 0) {
|
for (const s of series) {
|
||||||
return <div className={`${styles.empty} ${className ?? ''}`}>No data</div>
|
for (const pt of s.data) {
|
||||||
|
const isDate = pt.x instanceof Date
|
||||||
|
if (isDate) dateDetected = true
|
||||||
|
const key = isDate ? pt.x.getTime().toString() : String(pt.x)
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, { _x: isDate ? formatTime(pt.x) : pt.x })
|
||||||
|
}
|
||||||
|
map.get(key)![s.label] = pt.y
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all x categories (union across all series)
|
return { data: Array.from(map.values()), hasDateX: dateDetected }
|
||||||
const categories = Array.from(new Set(series.flatMap((s) => s.data.map((d) => d.x))))
|
}, [series])
|
||||||
const numCats = categories.length
|
|
||||||
|
|
||||||
const plotW = width - PADDING.left - PADDING.right
|
const chart = (
|
||||||
const plotH = height - PADDING.top - PADDING.bottom
|
<ThemedChart
|
||||||
|
data={data}
|
||||||
// Compute max Y
|
|
||||||
let maxY = 0
|
|
||||||
if (stacked) {
|
|
||||||
for (const cat of categories) {
|
|
||||||
const sum = series.reduce((acc, s) => {
|
|
||||||
const pt = s.data.find((d) => d.x === cat)
|
|
||||||
return acc + (pt?.y ?? 0)
|
|
||||||
}, 0)
|
|
||||||
maxY = Math.max(maxY, sum)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
maxY = Math.max(...series.flatMap((s) => s.data.map((d) => d.y)))
|
|
||||||
}
|
|
||||||
maxY = maxY || 1
|
|
||||||
|
|
||||||
const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) =>
|
|
||||||
Math.round((maxY / Y_TICK_COUNT) * i),
|
|
||||||
)
|
|
||||||
const toY = (val: number) => PADDING.top + plotH - (val / maxY) * plotH
|
|
||||||
const bottomY = PADDING.top + plotH
|
|
||||||
|
|
||||||
const catWidth = plotW / numCats
|
|
||||||
const groupGap = catWidth * BAR_GAP
|
|
||||||
const groupW = catWidth - groupGap
|
|
||||||
|
|
||||||
function handleMouseEnter(
|
|
||||||
catLabel: string,
|
|
||||||
mx: number,
|
|
||||||
my: number,
|
|
||||||
values: { series: string; value: number; color: string }[],
|
|
||||||
) {
|
|
||||||
setTooltip({ x: mx, y: my, label: catLabel, values })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
|
||||||
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
|
||||||
<svg
|
|
||||||
width={width}
|
|
||||||
height={height}
|
height={height}
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
xDataKey="_x"
|
||||||
className={styles.svg}
|
xType="category"
|
||||||
onMouseLeave={() => setTooltip(null)}
|
xTickFormatter={hasDateX ? (v: any) => String(v) : undefined}
|
||||||
aria-label="Bar chart"
|
yLabel={yLabel}
|
||||||
role="img"
|
className={className}
|
||||||
>
|
>
|
||||||
{/* Grid lines */}
|
|
||||||
{yTicks.map((val) => {
|
|
||||||
const y = toY(val)
|
|
||||||
return (
|
|
||||||
<g key={val}>
|
|
||||||
<line
|
|
||||||
x1={PADDING.left}
|
|
||||||
y1={y}
|
|
||||||
x2={width - PADDING.right}
|
|
||||||
y2={y}
|
|
||||||
className={styles.gridLine}
|
|
||||||
/>
|
|
||||||
<text x={PADDING.left - 6} y={y + 4} className={styles.axisLabel} textAnchor="end">
|
|
||||||
{formatAxisLabel(val)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Bars */}
|
|
||||||
{categories.map((cat, ci) => {
|
|
||||||
const groupX = PADDING.left + ci * catWidth + groupGap / 2
|
|
||||||
|
|
||||||
if (stacked) {
|
|
||||||
let stackY = bottomY
|
|
||||||
return (
|
|
||||||
<g key={cat}>
|
|
||||||
{series.map((s, si) => {
|
|
||||||
const pt = s.data.find((d) => d.x === cat)
|
|
||||||
const val = pt?.y ?? 0
|
|
||||||
const barH = (val / maxY) * plotH
|
|
||||||
const color = s.color ?? CHART_COLORS[si % CHART_COLORS.length]
|
|
||||||
const y = stackY - barH
|
|
||||||
stackY -= barH
|
|
||||||
return (
|
|
||||||
<rect
|
|
||||||
key={si}
|
|
||||||
x={groupX}
|
|
||||||
y={y}
|
|
||||||
width={groupW}
|
|
||||||
height={barH}
|
|
||||||
fill={color}
|
|
||||||
className={styles.bar}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
const rect = e.currentTarget.closest('svg')!.getBoundingClientRect()
|
|
||||||
handleMouseEnter(
|
|
||||||
cat,
|
|
||||||
e.clientX - rect.left,
|
|
||||||
e.clientY - rect.top,
|
|
||||||
series.map((ss, ssi) => ({
|
|
||||||
series: ss.label,
|
|
||||||
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
|
|
||||||
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<text
|
|
||||||
x={groupX + groupW / 2}
|
|
||||||
y={bottomY + 14}
|
|
||||||
className={styles.catLabel}
|
|
||||||
textAnchor="middle"
|
|
||||||
>
|
|
||||||
{cat}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grouped
|
|
||||||
const barW = groupW / series.length
|
|
||||||
return (
|
|
||||||
<g key={cat}>
|
|
||||||
{series.map((s, si) => {
|
|
||||||
const pt = s.data.find((d) => d.x === cat)
|
|
||||||
const val = pt?.y ?? 0
|
|
||||||
const barH = (val / maxY) * plotH
|
|
||||||
const color = s.color ?? CHART_COLORS[si % CHART_COLORS.length]
|
|
||||||
return (
|
|
||||||
<rect
|
|
||||||
key={si}
|
|
||||||
x={groupX + si * barW}
|
|
||||||
y={toY(val)}
|
|
||||||
width={barW - 1}
|
|
||||||
height={barH}
|
|
||||||
fill={color}
|
|
||||||
className={styles.bar}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
const svgEl = e.currentTarget.closest('svg')!.getBoundingClientRect()
|
|
||||||
handleMouseEnter(
|
|
||||||
cat,
|
|
||||||
e.clientX - svgEl.left,
|
|
||||||
e.clientY - svgEl.top,
|
|
||||||
series.map((ss, ssi) => ({
|
|
||||||
series: ss.label,
|
|
||||||
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
|
|
||||||
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<text
|
|
||||||
x={groupX + groupW / 2}
|
|
||||||
y={bottomY + 14}
|
|
||||||
className={styles.catLabel}
|
|
||||||
textAnchor="middle"
|
|
||||||
>
|
|
||||||
{cat}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
{tooltip && (
|
|
||||||
<div
|
|
||||||
className={styles.tooltip}
|
|
||||||
style={{ left: tooltip.x + 12, top: tooltip.y }}
|
|
||||||
>
|
|
||||||
<div className={styles.tooltipTitle}>{tooltip.label}</div>
|
|
||||||
{tooltip.values.map((v) => (
|
|
||||||
<div key={v.series} className={styles.tooltipRow}>
|
|
||||||
<span className={styles.tooltipDot} style={{ background: v.color }} />
|
|
||||||
<span className={styles.tooltipLabel}>{v.series}:</span>
|
|
||||||
<span className={styles.tooltipValue}>{formatAxisLabel(v.value)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
{series.length > 1 && (
|
|
||||||
<div className={styles.legend}>
|
|
||||||
{series.map((s, i) => (
|
{series.map((s, i) => (
|
||||||
<div key={s.label} className={styles.legendItem}>
|
<Bar
|
||||||
<span
|
key={s.label}
|
||||||
className={styles.legendDot}
|
dataKey={s.label}
|
||||||
style={{ background: s.color ?? CHART_COLORS[i % CHART_COLORS.length] }}
|
fill={s.color ?? CHART_COLORS[i % CHART_COLORS.length]}
|
||||||
|
radius={[2, 2, 0, 0]}
|
||||||
|
{...(stacked ? { stackId: 'stack' } : {})}
|
||||||
/>
|
/>
|
||||||
<span className={styles.legendLabel}>{s.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</ThemedChart>
|
||||||
)}
|
|
||||||
|
|
||||||
{xLabel && <div className={styles.xLabel}>{xLabel}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (width) {
|
||||||
|
return <div style={{ width }}>{chart}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return chart
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
.sep {
|
.sep {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ interface BreadcrumbItem {
|
|||||||
interface BreadcrumbProps {
|
interface BreadcrumbProps {
|
||||||
items: BreadcrumbItem[]
|
items: BreadcrumbItem[]
|
||||||
className?: string
|
className?: string
|
||||||
|
onNavigate?: (href: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Breadcrumb({ items, className }: BreadcrumbProps) {
|
export function Breadcrumb({ items, className, onNavigate }: BreadcrumbProps) {
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Breadcrumb" className={className}>
|
<nav aria-label="Breadcrumb" className={className}>
|
||||||
<ol className={styles.list}>
|
<ol className={styles.list}>
|
||||||
@@ -22,7 +23,11 @@ export function Breadcrumb({ items, className }: BreadcrumbProps) {
|
|||||||
{isLast ? (
|
{isLast ? (
|
||||||
<span className={styles.active}>{item.label}</span>
|
<span className={styles.active}>{item.label}</span>
|
||||||
) : item.href ? (
|
) : item.href ? (
|
||||||
<a href={item.href} className={styles.link}>
|
<a
|
||||||
|
href={item.href}
|
||||||
|
className={styles.link}
|
||||||
|
onClick={onNavigate ? (e) => { e.preventDefault(); onNavigate(item.href!) } : undefined}
|
||||||
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
border: 1px solid var(--amber-light);
|
border: 1px solid var(--amber-light);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 1px 7px;
|
padding: 1px 7px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
@@ -239,13 +239,13 @@
|
|||||||
|
|
||||||
.itemTime {
|
.itemTime {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemMeta {
|
.itemMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -277,6 +277,23 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Match context snippet */
|
||||||
|
.matchContext {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin-top: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matchContext em {
|
||||||
|
font-style: normal;
|
||||||
|
color: var(--amber);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* Match highlight */
|
/* Match highlight */
|
||||||
.mark {
|
.mark {
|
||||||
background: none;
|
background: none;
|
||||||
@@ -299,6 +316,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
|
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Search, X, ChevronUp, ChevronDown } from 'lucide-react'
|
||||||
import styles from './CommandPalette.module.css'
|
import styles from './CommandPalette.module.css'
|
||||||
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
|
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
|
||||||
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
|
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
|
||||||
@@ -12,24 +13,36 @@ interface CommandPaletteProps {
|
|||||||
onSelect: (result: SearchResult) => void
|
onSelect: (result: SearchResult) => void
|
||||||
data: SearchResult[]
|
data: SearchResult[]
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
|
onQueryChange?: (query: string) => void
|
||||||
|
/** Called when Enter is pressed without the user explicitly selecting a result (arrow keys/click).
|
||||||
|
* Useful for applying the query as a full-text search filter. */
|
||||||
|
onSubmit?: (query: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
const KNOWN_CATEGORY_LABELS: Record<string, string> = {
|
||||||
all: 'All',
|
|
||||||
application: 'Applications',
|
application: 'Applications',
|
||||||
exchange: 'Exchanges',
|
exchange: 'Exchanges',
|
||||||
|
attribute: 'Attributes',
|
||||||
route: 'Routes',
|
route: 'Routes',
|
||||||
agent: 'Agents',
|
agent: 'Agents',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
/** Preferred display order for known categories */
|
||||||
'all',
|
const KNOWN_CATEGORY_ORDER: string[] = [
|
||||||
'application',
|
'application',
|
||||||
'exchange',
|
'exchange',
|
||||||
|
'attribute',
|
||||||
'route',
|
'route',
|
||||||
'agent',
|
'agent',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function categoryLabel(cat: string): string {
|
||||||
|
if (cat === 'all') return 'All'
|
||||||
|
if (KNOWN_CATEGORY_LABELS[cat]) return KNOWN_CATEGORY_LABELS[cat]
|
||||||
|
// Title-case unknown categories: "my-thing" → "My Thing", "foo_bar" → "Foo Bar"
|
||||||
|
return cat.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
function highlightText(text: string, query: string, matchRanges?: [number, number][]): ReactNode {
|
function highlightText(text: string, query: string, matchRanges?: [number, number][]): ReactNode {
|
||||||
if (!query && (!matchRanges || matchRanges.length === 0)) return text
|
if (!query && (!matchRanges || matchRanges.length === 0)) return text
|
||||||
|
|
||||||
@@ -60,12 +73,13 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
|
|||||||
return <>{parts}</>
|
return <>{parts}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandPalette({ open, onClose, onSelect, data, onOpen }: CommandPaletteProps) {
|
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
|
const [activeCategory, setActiveCategory] = useState<string>('all')
|
||||||
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
||||||
const [focusedIdx, setFocusedIdx] = useState(0)
|
const [focusedIdx, setFocusedIdx] = useState(0)
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const userNavigated = useRef(false)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const listRef = useRef<HTMLDivElement>(null)
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -88,25 +102,21 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
setQuery('')
|
setQuery('')
|
||||||
setFocusedIdx(0)
|
setFocusedIdx(0)
|
||||||
setExpandedId(null)
|
setExpandedId(null)
|
||||||
|
userNavigated.current = false
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// Filter results
|
// Stage 1: apply text query + scope filters (used for counts)
|
||||||
const filtered = useMemo(() => {
|
const queryFiltered = useMemo(() => {
|
||||||
let results = data
|
let results = data
|
||||||
|
|
||||||
if (activeCategory !== 'all') {
|
|
||||||
results = results.filter((r) => r.category === activeCategory)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
const q = query.toLowerCase()
|
const q = query.toLowerCase()
|
||||||
results = results.filter(
|
results = results.filter(
|
||||||
(r) => r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
|
(r) => r.serverFiltered || r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply scope filters
|
|
||||||
for (const sf of scopeFilters) {
|
for (const sf of scopeFilters) {
|
||||||
results = results.filter((r) =>
|
results = results.filter((r) =>
|
||||||
r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()),
|
r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()),
|
||||||
@@ -114,11 +124,17 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}, [data, query, activeCategory, scopeFilters])
|
}, [data, query, scopeFilters])
|
||||||
|
|
||||||
|
// Stage 2: apply category filter (used for display)
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (activeCategory === 'all') return queryFiltered
|
||||||
|
return queryFiltered.filter((r) => r.category === activeCategory)
|
||||||
|
}, [queryFiltered, activeCategory])
|
||||||
|
|
||||||
// Group results by category
|
// Group results by category
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const map = new Map<SearchCategory, SearchResult[]>()
|
const map = new Map<string, SearchResult[]>()
|
||||||
for (const r of filtered) {
|
for (const r of filtered) {
|
||||||
if (!map.has(r.category)) map.set(r.category, [])
|
if (!map.has(r.category)) map.set(r.category, [])
|
||||||
map.get(r.category)!.push(r)
|
map.get(r.category)!.push(r)
|
||||||
@@ -129,13 +145,26 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
// Flatten for keyboard nav
|
// Flatten for keyboard nav
|
||||||
const flatResults = useMemo(() => filtered, [filtered])
|
const flatResults = useMemo(() => filtered, [filtered])
|
||||||
|
|
||||||
// Counts per category
|
// Counts per category (from query-filtered, before category filter)
|
||||||
const categoryCounts = useMemo(() => {
|
const categoryCounts = useMemo(() => {
|
||||||
const counts: Record<string, number> = { all: data.length }
|
const counts: Record<string, number> = { all: queryFiltered.length }
|
||||||
for (const r of data) {
|
for (const r of queryFiltered) {
|
||||||
counts[r.category] = (counts[r.category] ?? 0) + 1
|
counts[r.category] = (counts[r.category] ?? 0) + 1
|
||||||
}
|
}
|
||||||
return counts
|
return counts
|
||||||
|
}, [queryFiltered])
|
||||||
|
|
||||||
|
// Build tab list dynamically: 'all' + known categories (in order) + any unknown categories found in data
|
||||||
|
const visibleCategories = useMemo(() => {
|
||||||
|
const dataCategories = new Set(data.map((r) => r.category))
|
||||||
|
const tabs: string[] = ['all']
|
||||||
|
for (const cat of KNOWN_CATEGORY_ORDER) {
|
||||||
|
if (dataCategories.has(cat)) tabs.push(cat)
|
||||||
|
}
|
||||||
|
for (const cat of dataCategories) {
|
||||||
|
if (!tabs.includes(cat)) tabs.push(cat)
|
||||||
|
}
|
||||||
|
return tabs
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
@@ -145,15 +174,20 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
break
|
break
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
userNavigated.current = true
|
||||||
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
|
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
|
||||||
break
|
break
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
userNavigated.current = true
|
||||||
setFocusedIdx((i) => Math.max(i - 1, 0))
|
setFocusedIdx((i) => Math.max(i - 1, 0))
|
||||||
break
|
break
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (flatResults[focusedIdx]) {
|
if (!userNavigated.current && onSubmit && query.trim()) {
|
||||||
|
onSubmit(query.trim())
|
||||||
|
onClose()
|
||||||
|
} else if (flatResults[focusedIdx]) {
|
||||||
onSelect(flatResults[focusedIdx])
|
onSelect(flatResults[focusedIdx])
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -171,10 +205,23 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
setScopeFilters((prev) => prev.filter((_, i) => i !== idx))
|
setScopeFilters((prev) => prev.filter((_, i) => i !== idx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleExpanded(e: React.MouseEvent, id: string) {
|
||||||
|
e.stopPropagation()
|
||||||
|
setExpandedId((prev) => (prev === id ? null : id))
|
||||||
|
}
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className={styles.overlay} onClick={onClose} data-testid="command-palette-overlay">
|
<div
|
||||||
|
className={styles.overlay}
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClose() }}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Close command palette"
|
||||||
|
data-testid="command-palette-overlay"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={styles.panel}
|
className={styles.panel}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -185,7 +232,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
>
|
>
|
||||||
{/* Search input area */}
|
{/* Search input area */}
|
||||||
<div className={styles.searchArea}>
|
<div className={styles.searchArea}>
|
||||||
<span className={styles.searchIcon} aria-hidden="true">⌕</span>
|
<span className={styles.searchIcon} aria-hidden="true"><Search size={14} /></span>
|
||||||
{scopeFilters.map((sf, i) => (
|
{scopeFilters.map((sf, i) => (
|
||||||
<span key={i} className={styles.scopeTag}>
|
<span key={i} className={styles.scopeTag}>
|
||||||
<span className={styles.scopeField}>{sf.field}:</span>
|
<span className={styles.scopeField}>{sf.field}:</span>
|
||||||
@@ -195,7 +242,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
onClick={() => removeScopeFilter(i)}
|
onClick={() => removeScopeFilter(i)}
|
||||||
aria-label={`Remove filter ${sf.field}:${sf.value}`}
|
aria-label={`Remove filter ${sf.field}:${sf.value}`}
|
||||||
>
|
>
|
||||||
×
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -208,6 +255,8 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value)
|
setQuery(e.target.value)
|
||||||
setFocusedIdx(0)
|
setFocusedIdx(0)
|
||||||
|
userNavigated.current = false
|
||||||
|
onQueryChange?.(e.target.value)
|
||||||
}}
|
}}
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
/>
|
/>
|
||||||
@@ -216,7 +265,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
|
|
||||||
{/* Category tabs */}
|
{/* Category tabs */}
|
||||||
<div className={styles.tabs} role="tablist">
|
<div className={styles.tabs} role="tablist">
|
||||||
{ALL_CATEGORIES.map((cat) => (
|
{visibleCategories.map((cat) => (
|
||||||
<button
|
<button
|
||||||
key={cat}
|
key={cat}
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -232,7 +281,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
setFocusedIdx(0)
|
setFocusedIdx(0)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{CATEGORY_LABELS[cat]}
|
{categoryLabel(cat)}
|
||||||
{categoryCounts[cat] != null && (
|
{categoryCounts[cat] != null && (
|
||||||
<span className={styles.tabCount}>{categoryCounts[cat]}</span>
|
<span className={styles.tabCount}>{categoryCounts[cat]}</span>
|
||||||
)}
|
)}
|
||||||
@@ -253,7 +302,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
Array.from(grouped.entries()).map(([category, items]) => (
|
Array.from(grouped.entries()).map(([category, items]) => (
|
||||||
<div key={category} className={styles.group}>
|
<div key={category} className={styles.group}>
|
||||||
<div className={styles.groupHeader}>
|
<div className={styles.groupHeader}>
|
||||||
<SectionHeader>{CATEGORY_LABELS[category]}</SectionHeader>
|
<SectionHeader>{categoryLabel(category)}</SectionHeader>
|
||||||
</div>
|
</div>
|
||||||
{items.map((result) => {
|
{items.map((result) => {
|
||||||
const flatIdx = flatResults.indexOf(result)
|
const flatIdx = flatResults.indexOf(result)
|
||||||
@@ -276,7 +325,13 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
onSelect(result)
|
onSelect(result)
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setFocusedIdx(flatIdx)}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
onSelect(result)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
|
||||||
>
|
>
|
||||||
<div className={styles.itemMain}>
|
<div className={styles.itemMain}>
|
||||||
{result.icon && (
|
{result.icon && (
|
||||||
@@ -301,18 +356,21 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
<div className={styles.itemMeta}>
|
<div className={styles.itemMeta}>
|
||||||
{highlightText(result.meta, query)}
|
{highlightText(result.meta, query)}
|
||||||
</div>
|
</div>
|
||||||
|
{result.matchContext && (
|
||||||
|
<div
|
||||||
|
className={styles.matchContext}
|
||||||
|
dangerouslySetInnerHTML={{ __html: result.matchContext }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{result.expandedContent && (
|
{result.expandedContent && (
|
||||||
<button
|
<button
|
||||||
className={styles.expandBtn}
|
className={styles.expandBtn}
|
||||||
onClick={(e) => {
|
onClick={(e) => toggleExpanded(e, result.id)}
|
||||||
e.stopPropagation()
|
|
||||||
setExpandedId((prev) => (prev === result.id ? null : result.id))
|
|
||||||
}}
|
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
aria-label="Toggle detail"
|
aria-label="Toggle detail"
|
||||||
>
|
>
|
||||||
{isExpanded ? '▲' : '▼'}
|
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -341,7 +399,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.shortcut}>
|
<div className={styles.shortcut}>
|
||||||
<KeyboardHint keys="Enter" />
|
<KeyboardHint keys="Enter" />
|
||||||
<span>Open</span>
|
<span>Search</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.shortcut}>
|
<div className={styles.shortcut}>
|
||||||
<KeyboardHint keys="Esc" />
|
<KeyboardHint keys="Esc" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent'
|
/** Known categories: 'application' | 'exchange' | 'attribute' | 'route' | 'agent'. Custom categories are rendered with title-cased labels and a default icon. */
|
||||||
|
export type SearchCategory = string
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
id: string
|
id: string
|
||||||
@@ -13,6 +14,10 @@ export interface SearchResult {
|
|||||||
path?: string
|
path?: string
|
||||||
expandedContent?: string
|
expandedContent?: string
|
||||||
matchRanges?: [number, number][]
|
matchRanges?: [number, number][]
|
||||||
|
/** Skip client-side query filtering (result already matched server-side) */
|
||||||
|
serverFiltered?: boolean
|
||||||
|
/** Server-side match snippet with <em> tags around matched text */
|
||||||
|
matchContext?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScopeFilter {
|
export interface ScopeFilter {
|
||||||
|
|||||||
@@ -12,6 +12,23 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fillHeight {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fillHeight .scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fillHeight .footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.scroll {
|
.scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@@ -25,7 +42,7 @@
|
|||||||
.th {
|
.th {
|
||||||
padding: 9px 14px;
|
padding: 9px 14px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.8px;
|
letter-spacing: 0.8px;
|
||||||
@@ -35,6 +52,9 @@
|
|||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
transition: color 0.12s;
|
transition: color 0.12s;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.th.sortable {
|
.th.sortable {
|
||||||
@@ -127,7 +147,7 @@
|
|||||||
|
|
||||||
.rangeInfo {
|
.rangeInfo {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paginationRight {
|
.paginationRight {
|
||||||
@@ -137,7 +157,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pageSizeLabel {
|
.pageSizeLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +173,7 @@
|
|||||||
|
|
||||||
.pageNum {
|
.pageNum {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
min-width: 40px;
|
min-width: 40px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export function DataTable<T extends { id: string }>({
|
|||||||
rowAccent,
|
rowAccent,
|
||||||
expandedContent,
|
expandedContent,
|
||||||
flush = false,
|
flush = false,
|
||||||
|
fillHeight = false,
|
||||||
|
onSortChange,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||||
@@ -31,14 +33,16 @@ export function DataTable<T extends { id: string }>({
|
|||||||
const [pageSize, setPageSize] = useState(initialPageSize)
|
const [pageSize, setPageSize] = useState(initialPageSize)
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// When onSortChange is provided (controlled mode), skip client-side sorting
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
|
if (onSortChange) return data
|
||||||
if (!sortKey) return data
|
if (!sortKey) return data
|
||||||
return [...data].sort((a, b) => {
|
return [...data].sort((a, b) => {
|
||||||
const av = (a as Record<string, unknown>)[sortKey]
|
const av = (a as Record<string, unknown>)[sortKey]
|
||||||
const bv = (b as Record<string, unknown>)[sortKey]
|
const bv = (b as Record<string, unknown>)[sortKey]
|
||||||
return compareValues(av, bv, sortDir)
|
return compareValues(av, bv, sortDir)
|
||||||
})
|
})
|
||||||
}, [data, sortKey, sortDir])
|
}, [data, sortKey, sortDir, onSortChange])
|
||||||
|
|
||||||
const totalRows = sorted.length
|
const totalRows = sorted.length
|
||||||
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize))
|
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize))
|
||||||
@@ -52,13 +56,17 @@ export function DataTable<T extends { id: string }>({
|
|||||||
|
|
||||||
function handleHeaderClick(col: Column<T>) {
|
function handleHeaderClick(col: Column<T>) {
|
||||||
if (!sortable && !col.sortable) return
|
if (!sortable && !col.sortable) return
|
||||||
|
let newDir: SortDir
|
||||||
if (sortKey === col.key) {
|
if (sortKey === col.key) {
|
||||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
newDir = sortDir === 'asc' ? 'desc' : 'asc'
|
||||||
|
setSortDir(newDir)
|
||||||
} else {
|
} else {
|
||||||
|
newDir = 'asc'
|
||||||
setSortKey(col.key)
|
setSortKey(col.key)
|
||||||
setSortDir('asc')
|
setSortDir(newDir)
|
||||||
}
|
}
|
||||||
setPage(1)
|
setPage(1)
|
||||||
|
onSortChange?.(col.key, newDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRowClick(row: T) {
|
function handleRowClick(row: T) {
|
||||||
@@ -74,7 +82,7 @@ export function DataTable<T extends { id: string }>({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.wrapper} ${flush ? styles.flush : ''}`}>
|
<div className={`${styles.wrapper} ${flush ? styles.flush : ''} ${fillHeight ? styles.fillHeight : ''}`}>
|
||||||
<div className={styles.scroll}>
|
<div className={styles.scroll}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -20,4 +20,12 @@ export interface DataTableProps<T extends { id: string }> {
|
|||||||
expandedContent?: (row: T) => ReactNode | null
|
expandedContent?: (row: T) => ReactNode | null
|
||||||
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
||||||
flush?: boolean
|
flush?: boolean
|
||||||
|
/** Make the table fill remaining vertical space in a flex parent.
|
||||||
|
* The table body scrolls while the header stays sticky and the
|
||||||
|
* pagination footer stays pinned at the bottom. */
|
||||||
|
fillHeight?: boolean
|
||||||
|
/** Controlled sort: called when the user clicks a sortable column header.
|
||||||
|
* When provided, the component skips client-side sorting — the caller is
|
||||||
|
* responsible for providing `data` in the desired order. */
|
||||||
|
onSortChange?: (key: string, dir: 'asc' | 'desc') => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 99;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100vh;
|
||||||
width: 0;
|
width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: width 0.25s ease, opacity 0.2s ease;
|
transition: width 0.25s ease, opacity 0.2s ease;
|
||||||
@@ -7,13 +24,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
flex-shrink: 0;
|
z-index: 100;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel.open {
|
.panel.open {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
border-left-color: var(--border);
|
border-left-color: var(--border);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
animation: slideInRight 0.25s ease-out both;
|
animation: slideInRight 0.25s ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, type ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import styles from './DetailPanel.module.css'
|
import styles from './DetailPanel.module.css'
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
@@ -22,7 +23,9 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
|
|||||||
|
|
||||||
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
|
<>
|
||||||
|
{open && <div className={styles.backdrop} onClick={onClose} aria-hidden="true" />}
|
||||||
<aside
|
<aside
|
||||||
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
@@ -64,5 +67,10 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Portal to AppShell level if target exists, otherwise render in place
|
||||||
|
const portalTarget = document.getElementById('cameleer-detail-panel-root')
|
||||||
|
return portalTarget ? createPortal(content, portalTarget) : content
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
.entityListRoot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listHeaderSearch {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItem:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItemSelected {
|
||||||
|
background: var(--amber-bg);
|
||||||
|
border-left: 3px solid var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyMessage {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
167
src/design-system/composites/EntityList/EntityList.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { EntityList } from './EntityList'
|
||||||
|
|
||||||
|
interface TestItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: TestItem[] = [
|
||||||
|
{ id: '1', name: 'Alpha' },
|
||||||
|
{ id: '2', name: 'Beta' },
|
||||||
|
{ id: '3', name: 'Gamma' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('EntityList', () => {
|
||||||
|
it('renders all items', () => {
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Gamma')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSelect when item clicked', async () => {
|
||||||
|
const onSelect = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
await user.click(screen.getByText('Beta'))
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('highlights selected item (aria-selected="true" and has selected class)', () => {
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
selectedId="2"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const selectedOption = screen.getByText('Beta').closest('[role="option"]')
|
||||||
|
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
|
||||||
|
|
||||||
|
const unselectedOption = screen.getByText('Alpha').closest('[role="option"]')
|
||||||
|
expect(unselectedOption).toHaveAttribute('aria-selected', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders search input when onSearch provided', () => {
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
onSearch={() => {}}
|
||||||
|
searchPlaceholder="Filter items..."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByPlaceholderText('Filter items...')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSearch when typing in search', async () => {
|
||||||
|
const onSearch = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
onSearch={onSearch}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const input = screen.getByPlaceholderText('Search...')
|
||||||
|
await user.type(input, 'test')
|
||||||
|
expect(onSearch).toHaveBeenLastCalledWith('test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders add button when onAdd provided', () => {
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
onAdd={() => {}}
|
||||||
|
addLabel="Add Item"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Add Item')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onAdd when add button clicked', async () => {
|
||||||
|
const onAdd = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
onAdd={onAdd}
|
||||||
|
addLabel="Add Item"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
await user.click(screen.getByText('Add Item'))
|
||||||
|
expect(onAdd).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides header when no search or add', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
// No input or button should be present in the header area
|
||||||
|
expect(container.querySelector('input')).toBeNull()
|
||||||
|
expect(container.querySelector('button')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty message when items is empty', () => {
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={[]}
|
||||||
|
renderItem={(item: TestItem) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item: TestItem) => item.id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('No items found')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows custom empty message', () => {
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={[]}
|
||||||
|
renderItem={(item: TestItem) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item: TestItem) => item.id}
|
||||||
|
emptyMessage="Nothing here"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Nothing here')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts className', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
className="custom-class"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(container.firstChild).toHaveClass('custom-class')
|
||||||
|
})
|
||||||
|
})
|
||||||
97
src/design-system/composites/EntityList/EntityList.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { Input } from '../../primitives/Input/Input'
|
||||||
|
import { Button } from '../../primitives/Button/Button'
|
||||||
|
import styles from './EntityList.module.css'
|
||||||
|
|
||||||
|
interface EntityListProps<T> {
|
||||||
|
items: T[]
|
||||||
|
renderItem: (item: T, isSelected: boolean) => ReactNode
|
||||||
|
getItemId: (item: T) => string
|
||||||
|
selectedId?: string
|
||||||
|
onSelect?: (id: string) => void
|
||||||
|
searchPlaceholder?: string
|
||||||
|
onSearch?: (query: string) => void
|
||||||
|
addLabel?: string
|
||||||
|
onAdd?: () => void
|
||||||
|
emptyMessage?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityList<T>({
|
||||||
|
items,
|
||||||
|
renderItem,
|
||||||
|
getItemId,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
searchPlaceholder = 'Search...',
|
||||||
|
onSearch,
|
||||||
|
addLabel,
|
||||||
|
onAdd,
|
||||||
|
emptyMessage = 'No items found',
|
||||||
|
className,
|
||||||
|
}: EntityListProps<T>) {
|
||||||
|
const [searchValue, setSearchValue] = useState('')
|
||||||
|
const showHeader = !!onSearch || !!onAdd
|
||||||
|
|
||||||
|
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const value = e.target.value
|
||||||
|
setSearchValue(value)
|
||||||
|
onSearch?.(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchClear() {
|
||||||
|
setSearchValue('')
|
||||||
|
onSearch?.('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.entityListRoot} ${className ?? ''}`}>
|
||||||
|
{showHeader && (
|
||||||
|
<div className={styles.listHeader}>
|
||||||
|
{onSearch && (
|
||||||
|
<Input
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={searchValue}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
onClear={handleSearchClear}
|
||||||
|
className={styles.listHeaderSearch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onAdd && addLabel && (
|
||||||
|
<Button size="sm" variant="secondary" onClick={onAdd}>
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.list} role="listbox">
|
||||||
|
{items.map((item) => {
|
||||||
|
const id = getItemId(item)
|
||||||
|
const isSelected = id === selectedId
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
||||||
|
onClick={() => onSelect?.(id)}
|
||||||
|
role="option"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onSelect?.(id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderItem(item, isSelected)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className={styles.emptyMessage}>{emptyMessage}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
border: 1px solid var(--amber-light);
|
border: 1px solid var(--amber-light);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
color: var(--amber-deep);
|
color: var(--amber-deep);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
|
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import { X as XIcon, AlertTriangle, Play, Loader } from 'lucide-react'
|
||||||
import styles from './EventFeed.module.css'
|
import styles from './EventFeed.module.css'
|
||||||
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||||
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||||
@@ -47,11 +48,11 @@ function getSearchableText(event: FeedEvent): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ICONS: Record<SeverityFilter, string> = {
|
const DEFAULT_ICONS: Record<SeverityFilter, ReactNode> = {
|
||||||
error: '\u2715', // ✕
|
error: <XIcon size={14} />,
|
||||||
warning: '\u26A0', // ⚠
|
warning: <AlertTriangle size={14} />,
|
||||||
success: '\u25B6', // ▶
|
success: <Play size={14} />,
|
||||||
running: '\u2699', // ⚙
|
running: <Loader size={14} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
|
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
|
||||||
@@ -81,25 +82,25 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
|
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
|
||||||
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
|
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
// Auto-scroll to top (newest entries are at top in desc sort)
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToTop = useCallback(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollTop = el.scrollHeight
|
el.scrollTop = 0
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPaused) {
|
if (!isPaused) {
|
||||||
scrollToBottom()
|
scrollToTop()
|
||||||
}
|
}
|
||||||
}, [events, isPaused, scrollToBottom])
|
}, [events, isPaused, scrollToTop])
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8
|
const atTop = el.scrollTop < 8
|
||||||
setIsPaused(!atBottom)
|
setIsPaused(!atTop)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFilter(severity: SeverityFilter) {
|
function toggleFilter(severity: SeverityFilter) {
|
||||||
@@ -136,7 +137,7 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
onClick={() => setSearch('')}
|
onClick={() => setSearch('')}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
×
|
<XIcon size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -196,10 +197,10 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
className={styles.resumeBtn}
|
className={styles.resumeBtn}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsPaused(false)
|
setIsPaused(false)
|
||||||
scrollToBottom()
|
scrollToTop()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
↓ Resume auto-scroll
|
↑ Scroll to latest
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.clearAll {
|
.clearAll {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, type ChangeEvent } from 'react'
|
import { useState, type ChangeEvent } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
import styles from './FilterBar.module.css'
|
import styles from './FilterBar.module.css'
|
||||||
import { Input } from '../../primitives/Input/Input'
|
import { Input } from '../../primitives/Input/Input'
|
||||||
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
||||||
@@ -77,12 +78,7 @@ export function FilterBar({
|
|||||||
if (onSearchChange) onSearchChange('')
|
if (onSearchChange) onSearchChange('')
|
||||||
else setInternalSearch('')
|
else setInternalSearch('')
|
||||||
} : undefined}
|
} : undefined}
|
||||||
icon={
|
icon={<Search size={13} />}
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/design-system/composites/KpiStrip/KpiStrip.module.css
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
.kpiStrip {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiCard {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 16px 18px 12px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiCard:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiCard::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, var(--kpi-border-color), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valueRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trendSuccess { color: var(--success); }
|
||||||
|
.trendWarning { color: var(--warning); }
|
||||||
|
.trendError { color: var(--error); }
|
||||||
|
.trendMuted { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkline {
|
||||||
|
margin-top: 8px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
86
src/design-system/composites/KpiStrip/KpiStrip.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { KpiStrip } from './KpiStrip'
|
||||||
|
import type { KpiItem } from './KpiStrip'
|
||||||
|
|
||||||
|
const sampleItems: KpiItem[] = [
|
||||||
|
{ label: 'Total', value: 42 },
|
||||||
|
{ label: 'Active', value: '18', trend: { label: '+3', variant: 'success' } },
|
||||||
|
{ label: 'Errors', value: 5, subtitle: 'last 24h', sparkline: [1, 3, 2, 5, 4] },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('KpiStrip', () => {
|
||||||
|
it('renders all items', () => {
|
||||||
|
const { container } = render(<KpiStrip items={sampleItems} />)
|
||||||
|
const cards = container.querySelectorAll('[class*="kpiCard"]')
|
||||||
|
expect(cards).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders labels and values', () => {
|
||||||
|
render(<KpiStrip items={sampleItems} />)
|
||||||
|
expect(screen.getByText('Total')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('42')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Active')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('18')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders trend with correct text', () => {
|
||||||
|
render(<KpiStrip items={sampleItems} />)
|
||||||
|
expect(screen.getByText('+3')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies variant class to trend (trendSuccess)', () => {
|
||||||
|
render(<KpiStrip items={sampleItems} />)
|
||||||
|
const trend = screen.getByText('+3')
|
||||||
|
expect(trend.className).toContain('trendSuccess')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides trend when omitted', () => {
|
||||||
|
render(<KpiStrip items={[{ label: 'No Trend', value: 10 }]} />)
|
||||||
|
const { container } = render(<KpiStrip items={[{ label: 'No Trend2', value: 10 }]} />)
|
||||||
|
const trends = container.querySelectorAll('[class*="trend"]')
|
||||||
|
expect(trends).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders subtitle', () => {
|
||||||
|
render(<KpiStrip items={sampleItems} />)
|
||||||
|
expect(screen.getByText('last 24h')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders sparkline when data provided', () => {
|
||||||
|
const { container } = render(<KpiStrip items={sampleItems} />)
|
||||||
|
const svgs = container.querySelectorAll('svg')
|
||||||
|
expect(svgs.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts className prop', () => {
|
||||||
|
const { container } = render(<KpiStrip items={sampleItems} className="custom" />)
|
||||||
|
expect(container.firstChild).toHaveClass('custom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty items array', () => {
|
||||||
|
const { container } = render(<KpiStrip items={[]} />)
|
||||||
|
const cards = container.querySelectorAll('[class*="kpiCard"]')
|
||||||
|
expect(cards).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses default border color (--amber) when borderColor omitted', () => {
|
||||||
|
const { container } = render(<KpiStrip items={[{ label: 'Default', value: 1 }]} />)
|
||||||
|
const card = container.querySelector('[class*="kpiCard"]') as HTMLElement
|
||||||
|
expect(card.style.getPropertyValue('--kpi-border-color')).toBe('var(--amber)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies custom borderColor', () => {
|
||||||
|
const items: KpiItem[] = [{ label: 'Custom', value: 1, borderColor: 'var(--teal)' }]
|
||||||
|
const { container } = render(<KpiStrip items={items} />)
|
||||||
|
const card = container.querySelector('[class*="kpiCard"]') as HTMLElement
|
||||||
|
expect(card.style.getPropertyValue('--kpi-border-color')).toBe('var(--teal)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders trend with muted variant by default', () => {
|
||||||
|
const items: KpiItem[] = [{ label: 'Muted', value: 1, trend: { label: '0%' } }]
|
||||||
|
render(<KpiStrip items={items} />)
|
||||||
|
const trend = screen.getByText('0%')
|
||||||
|
expect(trend.className).toContain('trendMuted')
|
||||||
|
})
|
||||||
|
})
|
||||||
71
src/design-system/composites/KpiStrip/KpiStrip.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import styles from './KpiStrip.module.css'
|
||||||
|
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
|
||||||
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface KpiItem {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
trend?: { label: ReactNode; variant?: 'success' | 'warning' | 'error' | 'muted' }
|
||||||
|
subtitle?: string
|
||||||
|
sparkline?: number[]
|
||||||
|
borderColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KpiStripProps {
|
||||||
|
items: KpiItem[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendClassMap: Record<string, string> = {
|
||||||
|
success: styles.trendSuccess,
|
||||||
|
warning: styles.trendWarning,
|
||||||
|
error: styles.trendError,
|
||||||
|
muted: styles.trendMuted,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KpiStrip({ items, className }: KpiStripProps) {
|
||||||
|
const stripClasses = [styles.kpiStrip, className ?? ''].filter(Boolean).join(' ')
|
||||||
|
const gridStyle: CSSProperties = {
|
||||||
|
gridTemplateColumns: items.length > 0 ? `repeat(${items.length}, 1fr)` : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={stripClasses} style={gridStyle}>
|
||||||
|
{items.map((item) => {
|
||||||
|
const borderColor = item.borderColor ?? 'var(--amber)'
|
||||||
|
const cardStyle: CSSProperties & Record<string, string> = {
|
||||||
|
'--kpi-border-color': borderColor,
|
||||||
|
}
|
||||||
|
const trendVariant = item.trend?.variant ?? 'muted'
|
||||||
|
const trendClass = trendClassMap[trendVariant] ?? styles.trendMuted
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.label} className={styles.kpiCard} style={cardStyle}>
|
||||||
|
<div className={styles.label}>{item.label}</div>
|
||||||
|
<div className={styles.valueRow}>
|
||||||
|
<span className={styles.value}>{item.value}</span>
|
||||||
|
{item.trend && (
|
||||||
|
<span className={`${styles.trend} ${trendClass}`}>
|
||||||
|
{item.trend.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.subtitle && (
|
||||||
|
<div className={styles.subtitle}>{item.subtitle}</div>
|
||||||
|
)}
|
||||||
|
{item.sparkline && item.sparkline.length >= 2 && (
|
||||||
|
<div className={styles.sparkline}>
|
||||||
|
<Sparkline
|
||||||
|
data={item.sparkline}
|
||||||
|
color={borderColor}
|
||||||
|
width={200}
|
||||||
|
height={32}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg {
|
|
||||||
display: block;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gridLine {
|
|
||||||
stroke: var(--border-subtle);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.axisLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdLine {
|
|
||||||
stroke: var(--error);
|
|
||||||
stroke-width: 1.5;
|
|
||||||
stroke-dasharray: 5 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.line {
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crosshair {
|
|
||||||
stroke: var(--text-faint);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipLabel {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipValue {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendDot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yLabel {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
writing-mode: vertical-lr;
|
|
||||||
transform: rotate(180deg);
|
|
||||||
text-align: center;
|
|
||||||
position: absolute;
|
|
||||||
left: 4px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xLabel {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
@@ -1,233 +1,101 @@
|
|||||||
import { useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import styles from './LineChart.module.css'
|
import { Line, ReferenceLine } from 'recharts'
|
||||||
import {
|
import { ThemedChart } from '../ThemedChart/ThemedChart'
|
||||||
computeYScale,
|
import { CHART_COLORS } from '../../utils/rechartsTheme'
|
||||||
computeXScale,
|
|
||||||
seriesPoints,
|
|
||||||
formatAxisLabel,
|
|
||||||
CHART_COLORS,
|
|
||||||
type ChartSeries,
|
|
||||||
} from '../_chart-utils'
|
|
||||||
|
|
||||||
interface Threshold {
|
export interface DataPoint {
|
||||||
value: number
|
x: any
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartSeries {
|
||||||
label: string
|
label: string
|
||||||
|
data: DataPoint[]
|
||||||
|
color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LineChartProps {
|
interface LineChartProps {
|
||||||
series: ChartSeries[]
|
series: ChartSeries[]
|
||||||
xLabel?: string
|
|
||||||
yLabel?: string
|
|
||||||
threshold?: Threshold
|
|
||||||
height?: number
|
height?: number
|
||||||
width?: number
|
width?: number
|
||||||
|
yLabel?: string
|
||||||
|
xLabel?: string
|
||||||
|
threshold?: { value: number; label: string }
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Y_TICK_COUNT = 4
|
function formatTime(d: Date): string {
|
||||||
const DIMS = {
|
const h = String(d.getHours()).padStart(2, '0')
|
||||||
paddingTop: 12,
|
const m = String(d.getMinutes()).padStart(2, '0')
|
||||||
paddingRight: 16,
|
return `${h}:${m}`
|
||||||
paddingBottom: 28,
|
|
||||||
paddingLeft: 48,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LineChart({
|
export function LineChart({
|
||||||
series,
|
series,
|
||||||
xLabel,
|
height,
|
||||||
|
width,
|
||||||
yLabel,
|
yLabel,
|
||||||
|
xLabel,
|
||||||
threshold,
|
threshold,
|
||||||
height = 200,
|
|
||||||
width = 400,
|
|
||||||
className,
|
className,
|
||||||
}: LineChartProps) {
|
}: LineChartProps) {
|
||||||
const [tooltip, setTooltip] = useState<{
|
const { data, hasDateX } = useMemo(() => {
|
||||||
x: number
|
const map = new Map<string, Record<string, any>>()
|
||||||
y: number
|
let dateDetected = false
|
||||||
values: { label: string; value: number; color: string }[]
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
const dims = { ...DIMS, width, height }
|
for (const s of series) {
|
||||||
const allData = series.flatMap((s) => s.data)
|
for (const pt of s.data) {
|
||||||
|
const isDate = pt.x instanceof Date
|
||||||
if (allData.length === 0) {
|
if (isDate) dateDetected = true
|
||||||
return <div className={`${styles.empty} ${className ?? ''}`}>No data</div>
|
const key = isDate ? pt.x.getTime().toString() : String(pt.x)
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, { _x: isDate ? formatTime(pt.x) : pt.x })
|
||||||
|
}
|
||||||
|
map.get(key)![s.label] = pt.y
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { max, toY } = computeYScale(series, dims, threshold?.value)
|
return { data: Array.from(map.values()), hasDateX: dateDetected }
|
||||||
const { toX } = computeXScale(series, dims)
|
}, [series])
|
||||||
const plotH = height - dims.paddingTop - dims.paddingBottom
|
|
||||||
const plotW = width - dims.paddingLeft - dims.paddingRight
|
|
||||||
const bottomY = dims.paddingTop + plotH
|
|
||||||
|
|
||||||
const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) =>
|
const chart = (
|
||||||
Math.round((max / Y_TICK_COUNT) * i),
|
<ThemedChart
|
||||||
)
|
data={data}
|
||||||
|
|
||||||
const firstSeries = series[0]
|
|
||||||
const xSamples =
|
|
||||||
firstSeries && firstSeries.data.length > 0
|
|
||||||
? [
|
|
||||||
firstSeries.data[0].x,
|
|
||||||
firstSeries.data[Math.floor(firstSeries.data.length / 2)]?.x,
|
|
||||||
firstSeries.data[firstSeries.data.length - 1].x,
|
|
||||||
].filter(Boolean)
|
|
||||||
: []
|
|
||||||
|
|
||||||
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
|
||||||
const mx = e.clientX - rect.left
|
|
||||||
const my = e.clientY - rect.top
|
|
||||||
const pctX = (mx - dims.paddingLeft) / plotW
|
|
||||||
|
|
||||||
const values = series.map((s, i) => {
|
|
||||||
const idx = Math.round(pctX * (s.data.length - 1))
|
|
||||||
const clamped = Math.max(0, Math.min(s.data.length - 1, idx))
|
|
||||||
const pt = s.data[clamped]
|
|
||||||
return {
|
|
||||||
label: s.label,
|
|
||||||
value: pt?.y ?? 0,
|
|
||||||
color: s.color ?? CHART_COLORS[i % CHART_COLORS.length],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setTooltip({ x: mx, y: my, values })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
|
||||||
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
|
||||||
<svg
|
|
||||||
width={width}
|
|
||||||
height={height}
|
height={height}
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
xDataKey="_x"
|
||||||
className={styles.svg}
|
xType={hasDateX ? 'category' : 'category'}
|
||||||
onMouseMove={handleMouseMove}
|
xTickFormatter={hasDateX ? (v: any) => String(v) : undefined}
|
||||||
onMouseLeave={() => setTooltip(null)}
|
yLabel={yLabel}
|
||||||
aria-label="Line chart"
|
className={className}
|
||||||
role="img"
|
|
||||||
>
|
>
|
||||||
{/* Grid lines */}
|
|
||||||
{yTicks.map((val) => {
|
|
||||||
const y = toY(val)
|
|
||||||
return (
|
|
||||||
<g key={val}>
|
|
||||||
<line
|
|
||||||
x1={dims.paddingLeft}
|
|
||||||
y1={y}
|
|
||||||
x2={width - dims.paddingRight}
|
|
||||||
y2={y}
|
|
||||||
className={styles.gridLine}
|
|
||||||
/>
|
|
||||||
<text x={dims.paddingLeft - 6} y={y + 4} className={styles.axisLabel} textAnchor="end">
|
|
||||||
{formatAxisLabel(val)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* X-axis labels */}
|
|
||||||
{xSamples.map((xVal, i) => {
|
|
||||||
const xPos = toX(xVal)
|
|
||||||
const xv = xVal instanceof Date ? xVal : new Date(xVal as number)
|
|
||||||
const label =
|
|
||||||
xVal instanceof Date || (typeof xVal === 'number' && xVal > 1e10)
|
|
||||||
? xv.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
: formatAxisLabel(xVal as number)
|
|
||||||
const anchor = i === 0 ? 'start' : i === xSamples.length - 1 ? 'end' : 'middle'
|
|
||||||
return (
|
|
||||||
<text
|
|
||||||
key={i}
|
|
||||||
x={xPos}
|
|
||||||
y={height - dims.paddingBottom + 16}
|
|
||||||
className={styles.axisLabel}
|
|
||||||
textAnchor={anchor}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Threshold line */}
|
|
||||||
{threshold && (
|
|
||||||
<g>
|
|
||||||
<line
|
|
||||||
x1={dims.paddingLeft}
|
|
||||||
y1={toY(threshold.value)}
|
|
||||||
x2={width - dims.paddingRight}
|
|
||||||
y2={toY(threshold.value)}
|
|
||||||
className={styles.thresholdLine}
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={width - dims.paddingRight - 4}
|
|
||||||
y={toY(threshold.value) - 4}
|
|
||||||
className={styles.thresholdLabel}
|
|
||||||
textAnchor="end"
|
|
||||||
>
|
|
||||||
{threshold.label}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Lines only (no area fill) */}
|
|
||||||
{series.map((s, i) => {
|
|
||||||
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
|
|
||||||
const pts = seriesPoints(s, toX, toY)
|
|
||||||
return (
|
|
||||||
<polyline
|
|
||||||
key={`line-${i}`}
|
|
||||||
points={pts}
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
|
||||||
className={styles.line}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Crosshair */}
|
|
||||||
{tooltip && (
|
|
||||||
<line
|
|
||||||
x1={tooltip.x}
|
|
||||||
y1={dims.paddingTop}
|
|
||||||
x2={tooltip.x}
|
|
||||||
y2={bottomY}
|
|
||||||
className={styles.crosshair}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
{tooltip && (
|
|
||||||
<div
|
|
||||||
className={styles.tooltip}
|
|
||||||
style={{ left: tooltip.x + 12, top: tooltip.y }}
|
|
||||||
>
|
|
||||||
{tooltip.values.map((v) => (
|
|
||||||
<div key={v.label} className={styles.tooltipRow}>
|
|
||||||
<span className={styles.tooltipDot} style={{ background: v.color }} />
|
|
||||||
<span className={styles.tooltipLabel}>{v.label}:</span>
|
|
||||||
<span className={styles.tooltipValue}>{formatAxisLabel(v.value)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
{series.length > 1 && (
|
|
||||||
<div className={styles.legend}>
|
|
||||||
{series.map((s, i) => (
|
{series.map((s, i) => (
|
||||||
<div key={s.label} className={styles.legendItem}>
|
<Line
|
||||||
<span
|
key={s.label}
|
||||||
className={styles.legendDot}
|
type="monotone"
|
||||||
style={{ background: s.color ?? CHART_COLORS[i % CHART_COLORS.length] }}
|
dataKey={s.label}
|
||||||
|
stroke={s.color ?? CHART_COLORS[i % CHART_COLORS.length]}
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
<span className={styles.legendLabel}>{s.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
{threshold && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={threshold.value}
|
||||||
|
stroke="var(--text-muted)"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
label={{
|
||||||
|
value: threshold.label,
|
||||||
|
position: 'insideTopRight',
|
||||||
|
style: { fontSize: 10, fill: 'var(--text-muted)' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
</ThemedChart>
|
||||||
{xLabel && <div className={styles.xLabel}>{xLabel}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (width) {
|
||||||
|
return <div style={{ width }}>{chart}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return chart
|
||||||
}
|
}
|
||||||
|
|||||||
110
src/design-system/composites/LogViewer/LogViewer.module.css
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
.container {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 3px 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelBadge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelInfo {
|
||||||
|
color: var(--running);
|
||||||
|
background: color-mix(in srgb, var(--running) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelWarn {
|
||||||
|
color: var(--warning);
|
||||||
|
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelError {
|
||||||
|
color: var(--error);
|
||||||
|
background: color-mix(in srgb, var(--error) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelDebug {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelTrace {
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: color-mix(in srgb, var(--text-faint) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceBadge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 48px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceContainer {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceApp {
|
||||||
|
color: var(--running);
|
||||||
|
background: color-mix(in srgb, var(--running) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceAgent {
|
||||||
|
color: var(--warning);
|
||||||
|
background: color-mix(in srgb, var(--warning) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceDefault {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: color-mix(in srgb, var(--text-muted) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
76
src/design-system/composites/LogViewer/LogViewer.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { LogViewer, type LogEntry } from './LogViewer'
|
||||||
|
|
||||||
|
const entries: LogEntry[] = [
|
||||||
|
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'Server started', source: 'app' },
|
||||||
|
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage', source: 'container' },
|
||||||
|
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed', source: 'agent' },
|
||||||
|
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
|
||||||
|
{ timestamp: '2024-01-15T10:30:20Z', level: 'trace', message: 'Entering handleRequest()' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('LogViewer', () => {
|
||||||
|
it('renders entries with timestamps and messages', () => {
|
||||||
|
render(<LogViewer entries={entries} />)
|
||||||
|
expect(screen.getByText('Server started')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('High memory usage')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Connection failed')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Entering handleRequest()')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG, TRACE)', () => {
|
||||||
|
render(<LogViewer entries={entries} />)
|
||||||
|
expect(screen.getByText('INFO')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('WARN')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('ERROR')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('DEBUG')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('TRACE')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with custom maxHeight (number)', () => {
|
||||||
|
const { container } = render(<LogViewer entries={entries} maxHeight={300} />)
|
||||||
|
const el = container.firstElementChild as HTMLElement
|
||||||
|
expect(el.style.maxHeight).toBe('300px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with string maxHeight', () => {
|
||||||
|
const { container } = render(<LogViewer entries={entries} maxHeight="50vh" />)
|
||||||
|
const el = container.firstElementChild as HTMLElement
|
||||||
|
expect(el.style.maxHeight).toBe('50vh')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty entries', () => {
|
||||||
|
render(<LogViewer entries={[]} />)
|
||||||
|
expect(screen.getByText('No log entries.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts className prop', () => {
|
||||||
|
const { container } = render(<LogViewer entries={entries} className="custom-class" />)
|
||||||
|
const el = container.firstElementChild as HTMLElement
|
||||||
|
expect(el.classList.contains('custom-class')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders source badges when source is provided', () => {
|
||||||
|
render(<LogViewer entries={entries} />)
|
||||||
|
expect(screen.getByText('app')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('container')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('agent')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits source badge when source is not provided', () => {
|
||||||
|
const noSourceEntries: LogEntry[] = [
|
||||||
|
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'No source here' },
|
||||||
|
]
|
||||||
|
render(<LogViewer entries={noSourceEntries} />)
|
||||||
|
expect(screen.getByText('No source here')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('app')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('container')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has role="log" for accessibility', () => {
|
||||||
|
render(<LogViewer entries={entries} />)
|
||||||
|
expect(screen.getByRole('log')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
90
src/design-system/composites/LogViewer/LogViewer.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useRef, useEffect, useCallback } from 'react'
|
||||||
|
import styles from './LogViewer.module.css'
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: string
|
||||||
|
level: 'info' | 'warn' | 'error' | 'debug' | 'trace'
|
||||||
|
message: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogViewerProps {
|
||||||
|
entries: LogEntry[]
|
||||||
|
maxHeight?: number | string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_CLASS: Record<LogEntry['level'], string> = {
|
||||||
|
info: styles.levelInfo,
|
||||||
|
warn: styles.levelWarn,
|
||||||
|
error: styles.levelError,
|
||||||
|
debug: styles.levelDebug,
|
||||||
|
trace: styles.levelTrace,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_CLASS: Record<string, string> = {
|
||||||
|
container: styles.sourceContainer,
|
||||||
|
app: styles.sourceApp,
|
||||||
|
agent: styles.sourceAgent,
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isAtTopRef = useRef(true)
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
isAtTopRef.current = el.scrollTop < 20
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (el && isAtTopRef.current) {
|
||||||
|
el.scrollTop = 0
|
||||||
|
}
|
||||||
|
}, [entries])
|
||||||
|
|
||||||
|
const heightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className={[styles.container, className].filter(Boolean).join(' ')}
|
||||||
|
style={{ maxHeight: heightStyle }}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
role="log"
|
||||||
|
>
|
||||||
|
{entries.map((entry, i) => (
|
||||||
|
<div key={i} className={styles.line}>
|
||||||
|
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
|
||||||
|
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
|
||||||
|
{entry.level.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{entry.source && (
|
||||||
|
<span className={[styles.sourceBadge, SOURCE_CLASS[entry.source] ?? styles.sourceDefault].join(' ')}>
|
||||||
|
{entry.source}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={styles.message}>{entry.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<div className={styles.empty}>No log entries.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
src/design-system/composites/LoginForm/LoginDialog.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { LoginDialog } from './LoginDialog'
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onSubmit: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LoginDialog', () => {
|
||||||
|
it('renders Modal with LoginForm when open', () => {
|
||||||
|
render(<LoginDialog {...defaultProps} />)
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
render(<LoginDialog {...defaultProps} open={false} />)
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose on Esc', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginDialog {...defaultProps} onClose={onClose} />)
|
||||||
|
await user.keyboard('{Escape}')
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose on backdrop click', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginDialog {...defaultProps} onClose={onClose} />)
|
||||||
|
await user.click(screen.getByTestId('modal-backdrop'))
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes LoginForm props through', () => {
|
||||||
|
render(
|
||||||
|
<LoginDialog
|
||||||
|
{...defaultProps}
|
||||||
|
title="Welcome"
|
||||||
|
socialProviders={[{ label: 'Continue with Google', onClick: vi.fn() }]}
|
||||||
|
error="Bad credentials"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Welcome')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Bad credentials')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
15
src/design-system/composites/LoginForm/LoginDialog.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Modal } from '../Modal/Modal'
|
||||||
|
import { LoginForm, type LoginFormProps } from './LoginForm'
|
||||||
|
|
||||||
|
export interface LoginDialogProps extends LoginFormProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginDialog({ open, onClose, className, ...formProps }: LoginDialogProps) {
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} size="sm" className={className}>
|
||||||
|
<LoginForm {...formProps} />
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
src/design-system/composites/LoginForm/LoginForm.module.css
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
.loginForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socialSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socialButton {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dividerLine {
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dividerText {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rememberRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgotLink {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--amber);
|
||||||
|
font-weight: 500;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgotLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitButton {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signUpText {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signUpLink {
|
||||||
|
color: var(--amber);
|
||||||
|
font-weight: 500;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signUpLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
193
src/design-system/composites/LoginForm/LoginForm.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { LoginForm } from './LoginForm'
|
||||||
|
|
||||||
|
const socialProviders = [
|
||||||
|
{ label: 'Continue with Google', onClick: vi.fn() },
|
||||||
|
{ label: 'Continue with GitHub', onClick: vi.fn() },
|
||||||
|
]
|
||||||
|
|
||||||
|
const allProps = {
|
||||||
|
logo: <div data-testid="logo">Logo</div>,
|
||||||
|
title: 'Welcome back',
|
||||||
|
socialProviders,
|
||||||
|
onSubmit: vi.fn(),
|
||||||
|
onForgotPassword: vi.fn(),
|
||||||
|
onSignUp: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LoginForm', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders all elements when all props provided', () => {
|
||||||
|
render(<LoginForm {...allProps} />)
|
||||||
|
expect(screen.getByTestId('logo')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Welcome back')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Continue with GitHub')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('or')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/sign up/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders default title when title prop omitted', () => {
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides social section when socialProviders is empty', () => {
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} socialProviders={[]} />)
|
||||||
|
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides social section when socialProviders is omitted', () => {
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides forgot password link when onForgotPassword omitted', () => {
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.queryByText(/forgot password/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides sign up link when onSignUp omitted', () => {
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.queryByText(/sign up/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides credentials section when onSubmit omitted (social only)', () => {
|
||||||
|
render(<LoginForm socialProviders={socialProviders} />)
|
||||||
|
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||||
|
// Social buttons should still render
|
||||||
|
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows server error Alert when error prop set', () => {
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} error="Invalid credentials" />)
|
||||||
|
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validation', () => {
|
||||||
|
it('validates required email', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(screen.getByText('Email is required')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates email format', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
await user.type(screen.getByLabelText(/email/i), 'notanemail')
|
||||||
|
await user.type(screen.getByLabelText(/password/i), 'password123')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates required password', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(screen.getByText('Password is required')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates password minimum length', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
||||||
|
await user.type(screen.getByLabelText(/password/i), 'short')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears field errors on typing', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(screen.getByText('Email is required')).toBeInTheDocument()
|
||||||
|
await user.type(screen.getByLabelText(/email/i), 't')
|
||||||
|
expect(screen.queryByText('Email is required')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSubmit with credentials when valid', async () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={onSubmit} />)
|
||||||
|
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
||||||
|
await user.type(screen.getByLabelText(/password/i), 'password123')
|
||||||
|
await user.click(screen.getByLabelText(/remember me/i))
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
remember: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call onSubmit when validation fails', async () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={onSubmit} />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loading state', () => {
|
||||||
|
it('disables form inputs when loading', () => {
|
||||||
|
render(<LoginForm {...allProps} loading />)
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText(/password/i)).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText(/remember me/i)).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows spinner on submit button when loading', () => {
|
||||||
|
render(<LoginForm {...allProps} loading />)
|
||||||
|
const submitBtn = screen.getByRole('button', { name: /sign in/i })
|
||||||
|
expect(submitBtn).toBeDisabled()
|
||||||
|
// Button component renders Spinner when loading=true
|
||||||
|
expect(submitBtn.querySelector('[class*="spinner"]')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables social buttons when loading', () => {
|
||||||
|
render(<LoginForm {...allProps} loading />)
|
||||||
|
expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeDisabled()
|
||||||
|
expect(screen.getByRole('button', { name: 'Continue with GitHub' })).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('callbacks', () => {
|
||||||
|
it('calls social provider onClick when clicked', async () => {
|
||||||
|
const onClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm socialProviders={[{ label: 'Continue with Google', onClick }]} onSubmit={vi.fn()} />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Continue with Google' }))
|
||||||
|
expect(onClick).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onForgotPassword when link clicked', async () => {
|
||||||
|
const onForgotPassword = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} onForgotPassword={onForgotPassword} />)
|
||||||
|
await user.click(screen.getByText(/forgot password/i))
|
||||||
|
expect(onForgotPassword).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSignUp when link clicked', async () => {
|
||||||
|
const onSignUp = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} onSignUp={onSignUp} />)
|
||||||
|
await user.click(screen.getByText(/sign up/i))
|
||||||
|
expect(onSignUp).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
223
src/design-system/composites/LoginForm/LoginForm.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { useEffect, useRef, useState, type ReactNode, type FormEvent } from 'react'
|
||||||
|
import { Button } from '../../primitives/Button/Button'
|
||||||
|
import { Input } from '../../primitives/Input/Input'
|
||||||
|
import { Checkbox } from '../../primitives/Checkbox/Checkbox'
|
||||||
|
import { FormField } from '../../primitives/FormField/FormField'
|
||||||
|
import { Alert } from '../../primitives/Alert/Alert'
|
||||||
|
import styles from './LoginForm.module.css'
|
||||||
|
|
||||||
|
export interface SocialProvider {
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginFormProps {
|
||||||
|
logo?: ReactNode
|
||||||
|
title?: string
|
||||||
|
socialProviders?: SocialProvider[]
|
||||||
|
onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void
|
||||||
|
onForgotPassword?: () => void
|
||||||
|
onSignUp?: () => void
|
||||||
|
error?: string
|
||||||
|
loading?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldErrors {
|
||||||
|
email?: string
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
function validate(email: string, password: string): FieldErrors {
|
||||||
|
const errors: FieldErrors = {}
|
||||||
|
if (!email) {
|
||||||
|
errors.email = 'Email is required'
|
||||||
|
} else if (!EMAIL_REGEX.test(email)) {
|
||||||
|
errors.email = 'Please enter a valid email address'
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
errors.password = 'Password is required'
|
||||||
|
} else if (password.length < 8) {
|
||||||
|
errors.password = 'Password must be at least 8 characters'
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm({
|
||||||
|
logo,
|
||||||
|
title = 'Sign in',
|
||||||
|
socialProviders,
|
||||||
|
onSubmit,
|
||||||
|
onForgotPassword,
|
||||||
|
onSignUp,
|
||||||
|
error,
|
||||||
|
loading = false,
|
||||||
|
className,
|
||||||
|
}: LoginFormProps) {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [remember, setRemember] = useState(false)
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const emailRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Auto-focus first input on mount
|
||||||
|
useEffect(() => {
|
||||||
|
emailRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Reset submitted flag when error prop changes (new server error from re-attempt)
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) setSubmitted(false)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
// Server error is shown from prop, hidden after next submit attempt
|
||||||
|
const showServerError = error && !submitted
|
||||||
|
|
||||||
|
const hasSocial = socialProviders && socialProviders.length > 0
|
||||||
|
const hasCredentials = !!onSubmit
|
||||||
|
const showDivider = hasSocial && hasCredentials
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitted(true)
|
||||||
|
const errors = validate(email, password)
|
||||||
|
setFieldErrors(errors)
|
||||||
|
if (Object.keys(errors).length === 0) {
|
||||||
|
onSubmit?.({ email, password, remember })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.loginForm} ${className ?? ''}`}>
|
||||||
|
{logo && <div className={styles.logo}>{logo}</div>}
|
||||||
|
<h2 className={styles.title}>{title}</h2>
|
||||||
|
|
||||||
|
{showServerError && (
|
||||||
|
<div className={styles.error}>
|
||||||
|
<Alert variant="error">{error}</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSocial && (
|
||||||
|
<div className={styles.socialSection}>
|
||||||
|
{socialProviders.map((provider) => (
|
||||||
|
<Button
|
||||||
|
key={provider.label}
|
||||||
|
variant="secondary"
|
||||||
|
className={styles.socialButton}
|
||||||
|
onClick={provider.onClick}
|
||||||
|
disabled={loading}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{provider.icon}
|
||||||
|
{provider.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDivider && (
|
||||||
|
<div className={styles.divider}>
|
||||||
|
<div className={styles.dividerLine} />
|
||||||
|
<span className={styles.dividerText}>or</span>
|
||||||
|
<div className={styles.dividerLine} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasCredentials && (
|
||||||
|
<form
|
||||||
|
className={styles.fields}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
aria-label="Sign in"
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<FormField label="Email" htmlFor="login-email" required error={fieldErrors.email}>
|
||||||
|
<Input
|
||||||
|
ref={emailRef}
|
||||||
|
id="login-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value)
|
||||||
|
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }))
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Password" htmlFor="login-password" required error={fieldErrors.password}>
|
||||||
|
<Input
|
||||||
|
id="login-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPassword(e.target.value)
|
||||||
|
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }))
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className={styles.rememberRow}>
|
||||||
|
<Checkbox
|
||||||
|
label="Remember me"
|
||||||
|
checked={remember}
|
||||||
|
onChange={(e) => setRemember(e.target.checked)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{onForgotPassword && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.forgotLink}
|
||||||
|
onClick={onForgotPassword}
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{onSignUp && (
|
||||||
|
<div className={styles.signUpText}>
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.signUpLink}
|
||||||
|
onClick={onSignUp}
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasCredentials && onSignUp && (
|
||||||
|
<div className={styles.signUpText}>
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.signUpLink}
|
||||||
|
onClick={onSignUp}
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--sidebar-muted);
|
color: var(--sidebar-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
|
|
||||||
.count {
|
.count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--sidebar-muted);
|
color: var(--sidebar-muted);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row.clickable {
|
.row.clickable {
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
.dur {
|
.dur {
|
||||||
width: 42px;
|
width: 42px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -96,6 +96,58 @@
|
|||||||
padding: 2px 0 2px 4px;
|
padding: 2px 0 2px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Action trigger — hidden by default, shown on hover/selected */
|
||||||
|
.actionsTrigger {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover .actionsTrigger,
|
||||||
|
.actionsVisible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBtn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all 0.1s;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBtn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 7px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeInfo { background: var(--running); }
|
||||||
|
.badgeSuccess { background: var(--success); }
|
||||||
|
.badgeWarning { background: var(--amber); }
|
||||||
|
.badgeError { background: var(--error); }
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { ProcessorTimeline } from './ProcessorTimeline'
|
||||||
|
|
||||||
|
const processors = [
|
||||||
|
{ name: 'Validate', type: 'validator', durationMs: 12, status: 'ok' as const, startMs: 0 },
|
||||||
|
{ name: 'Enrich', type: 'enricher', durationMs: 35, status: 'slow' as const, startMs: 12 },
|
||||||
|
{ name: 'Route', type: 'router', durationMs: 8, status: 'fail' as const, startMs: 47 },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('ProcessorTimeline', () => {
|
||||||
|
it('renders processor names', () => {
|
||||||
|
render(<ProcessorTimeline processors={processors} totalMs={55} />)
|
||||||
|
expect(screen.getByText('Validate')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Enrich')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Route')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render action trigger when no actions provided', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline processors={processors} totalMs={55} />,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('[aria-label*="Actions for"]')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders action trigger when actions provided', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
actions={[{ label: 'Change Log Level', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking action trigger does not fire onProcessorClick', async () => {
|
||||||
|
const onProcessorClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
onProcessorClick={onProcessorClick}
|
||||||
|
actions={[{ label: 'Test Action', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const trigger = container.querySelector('[aria-label="Actions for Validate"]')!
|
||||||
|
await user.click(trigger)
|
||||||
|
expect(onProcessorClick).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls action onClick when menu item clicked', async () => {
|
||||||
|
const actionClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
actions={[{ label: 'Change Log Level', onClick: actionClick }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const trigger = container.querySelector('[aria-label="Actions for Validate"]')!
|
||||||
|
await user.click(trigger)
|
||||||
|
await user.click(screen.getByText('Change Log Level'))
|
||||||
|
expect(actionClick).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports dynamic getActions per processor', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
getActions={(proc) =>
|
||||||
|
proc.status === 'fail'
|
||||||
|
? [{ label: 'View Error', onClick: () => {} }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
// Only the failing processor should have an action trigger
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(1)
|
||||||
|
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for Route')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { EllipsisVertical } from 'lucide-react'
|
||||||
import styles from './ProcessorTimeline.module.css'
|
import styles from './ProcessorTimeline.module.css'
|
||||||
|
import { Dropdown } from '../Dropdown/Dropdown'
|
||||||
|
import type { NodeBadge } from '../RouteFlow/RouteFlow'
|
||||||
|
|
||||||
export interface ProcessorStep {
|
export interface ProcessorStep {
|
||||||
name: string
|
name: string
|
||||||
@@ -6,6 +10,15 @@ export interface ProcessorStep {
|
|||||||
durationMs: number
|
durationMs: number
|
||||||
status: 'ok' | 'slow' | 'fail'
|
status: 'ok' | 'slow' | 'fail'
|
||||||
startMs: number
|
startMs: number
|
||||||
|
badges?: NodeBadge[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessorAction {
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
divider?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessorTimelineProps {
|
interface ProcessorTimelineProps {
|
||||||
@@ -13,6 +26,8 @@ interface ProcessorTimelineProps {
|
|||||||
totalMs: number
|
totalMs: number
|
||||||
onProcessorClick?: (processor: ProcessorStep, index: number) => void
|
onProcessorClick?: (processor: ProcessorStep, index: number) => void
|
||||||
selectedIndex?: number
|
selectedIndex?: number
|
||||||
|
actions?: ProcessorAction[]
|
||||||
|
getActions?: (processor: ProcessorStep, index: number) => ProcessorAction[]
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +41,8 @@ export function ProcessorTimeline({
|
|||||||
totalMs,
|
totalMs,
|
||||||
onProcessorClick,
|
onProcessorClick,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
|
actions,
|
||||||
|
getActions,
|
||||||
className,
|
className,
|
||||||
}: ProcessorTimelineProps) {
|
}: ProcessorTimelineProps) {
|
||||||
const safeTotal = totalMs || 1
|
const safeTotal = totalMs || 1
|
||||||
@@ -70,6 +87,16 @@ export function ProcessorTimeline({
|
|||||||
>
|
>
|
||||||
<div className={styles.name} title={proc.name}>
|
<div className={styles.name} title={proc.name}>
|
||||||
{proc.name}
|
{proc.name}
|
||||||
|
{proc.badges?.map((badge, bi) => (
|
||||||
|
<span
|
||||||
|
key={bi}
|
||||||
|
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||||
|
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||||
|
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.barBg}>
|
<div className={styles.barBg}>
|
||||||
<div
|
<div
|
||||||
@@ -82,6 +109,30 @@ export function ProcessorTimeline({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.dur}>{formatDuration(proc.durationMs)}</div>
|
<div className={styles.dur}>{formatDuration(proc.durationMs)}</div>
|
||||||
|
{(() => {
|
||||||
|
const resolvedActions = getActions ? getActions(proc, i) : (actions ?? [])
|
||||||
|
if (resolvedActions.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
className={styles.actionsBtn}
|
||||||
|
aria-label={`Actions for ${proc.name}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<EllipsisVertical size={14} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={resolvedActions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
|
|
||||||
.duration {
|
.duration {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,17 +188,100 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottleneck badge */
|
/* Action trigger — hidden by default, shown on hover/selected */
|
||||||
.bottleneckBadge {
|
.actionsTrigger {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node:hover .actionsTrigger,
|
||||||
|
.actionsVisible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBtn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all 0.1s;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBtn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badgeRow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -7px;
|
top: -7px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--error);
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badgeInfo { background: var(--running); }
|
||||||
|
.badgeSuccess { background: var(--success); }
|
||||||
|
.badgeWarning { background: var(--amber); }
|
||||||
|
.badgeError { background: var(--error); }
|
||||||
|
|
||||||
|
/* Node wrapper (replaces inline style) */
|
||||||
|
.nodeWrapper {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-flow sections */
|
||||||
|
.flowSection {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flowSectionSeparated {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flowLabel {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-left: 2px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flowLabelDefault { color: var(--text-muted); }
|
||||||
|
.flowLabelError { color: var(--error); }
|
||||||
|
.flowLabelWarning { color: var(--warning); }
|
||||||
|
.flowLabelInfo { color: var(--running); }
|
||||||
|
|||||||
160
src/design-system/composites/RouteFlow/RouteFlow.test.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { RouteFlow } from './RouteFlow'
|
||||||
|
|
||||||
|
const nodes = [
|
||||||
|
{ name: 'jms:orders', type: 'from' as const, durationMs: 4, status: 'ok' as const },
|
||||||
|
{ name: 'OrderValidator', type: 'process' as const, durationMs: 8, status: 'ok' as const },
|
||||||
|
{ name: 'http:payment-api', type: 'to' as const, durationMs: 187, status: 'slow' as const },
|
||||||
|
{ name: 'dead-letter:failed', type: 'error-handler' as const, durationMs: 14, status: 'fail' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('RouteFlow', () => {
|
||||||
|
it('renders node names', () => {
|
||||||
|
render(<RouteFlow nodes={nodes} />)
|
||||||
|
expect(screen.getByText('jms:orders')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('OrderValidator')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('http:payment-api')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('dead-letter:failed')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render action trigger when no actions provided', () => {
|
||||||
|
const { container } = render(<RouteFlow nodes={nodes} />)
|
||||||
|
expect(container.querySelector('[aria-label*="Actions for"]')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders action trigger on all nodes including error handlers when actions provided', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
actions={[{ label: 'View Config', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(4) // 3 main + 1 error handler
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking action trigger does not fire onNodeClick', async () => {
|
||||||
|
const onNodeClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
actions={[{ label: 'Test Action', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const trigger = container.querySelector('[aria-label="Actions for jms:orders"]')!
|
||||||
|
await user.click(trigger)
|
||||||
|
expect(onNodeClick).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls action onClick when menu item clicked', async () => {
|
||||||
|
const actionClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
actions={[{ label: 'Change Log Level', onClick: actionClick }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const trigger = container.querySelector('[aria-label="Actions for jms:orders"]')!
|
||||||
|
await user.click(trigger)
|
||||||
|
await user.click(screen.getByText('Change Log Level'))
|
||||||
|
expect(actionClick).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports dynamic getActions per node', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
getActions={(node) =>
|
||||||
|
node.type === 'process'
|
||||||
|
? [{ label: 'Edit Processor', onClick: () => {} }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(1)
|
||||||
|
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for OrderValidator')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const multiFlows = [
|
||||||
|
{
|
||||||
|
label: 'Main Route',
|
||||||
|
nodes: [
|
||||||
|
{ name: 'timer:tick', type: 'from' as const, durationMs: 0, status: 'ok' as const },
|
||||||
|
{ name: 'Processor1', type: 'process' as const, durationMs: 8, status: 'ok' as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'onException',
|
||||||
|
variant: 'error' as const,
|
||||||
|
nodes: [
|
||||||
|
{ name: 'LogHandler', type: 'process' as const, durationMs: 3, status: 'ok' as const },
|
||||||
|
{ name: 'dead-letter:errors', type: 'to' as const, durationMs: 8, status: 'fail' as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('RouteFlow (multi-flow)', () => {
|
||||||
|
it('renders all segment labels', () => {
|
||||||
|
render(<RouteFlow flows={multiFlows} />)
|
||||||
|
expect(screen.getByText('Main Route')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('onException')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all nodes across segments', () => {
|
||||||
|
render(<RouteFlow flows={multiFlows} />)
|
||||||
|
expect(screen.getByText('timer:tick')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Processor1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('LogHandler')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('dead-letter:errors')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses global flat indexing for onNodeClick', async () => {
|
||||||
|
const onNodeClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<RouteFlow flows={multiFlows} onNodeClick={onNodeClick} />)
|
||||||
|
// Click the first node of the second flow (global index = 2)
|
||||||
|
await user.click(screen.getByText('LogHandler'))
|
||||||
|
expect(onNodeClick).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: 'LogHandler' }),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selectedIndex highlights correct node across flows', () => {
|
||||||
|
const { container } = render(<RouteFlow flows={multiFlows} selectedIndex={3} />)
|
||||||
|
// Index 3 = dead-letter:errors (2nd node of 2nd flow)
|
||||||
|
const selectedNodes = container.querySelectorAll('[class*="nodeSelected"]')
|
||||||
|
expect(selectedNodes.length).toBe(1)
|
||||||
|
expect(selectedNodes[0]).toHaveTextContent('dead-letter:errors')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('actions work in multi-flow mode', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
flows={multiFlows}
|
||||||
|
actions={[{ label: 'Test Action', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flows takes precedence over nodes', () => {
|
||||||
|
render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
flows={multiFlows}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
// Should render flow content, not nodes content
|
||||||
|
expect(screen.getByText('Main Route')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('jms:orders')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,14 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-react'
|
||||||
import styles from './RouteFlow.module.css'
|
import styles from './RouteFlow.module.css'
|
||||||
|
import { Dropdown } from '../Dropdown/Dropdown'
|
||||||
|
import { formatDuration } from '../../../utils/format-utils'
|
||||||
|
|
||||||
|
export interface NodeBadge {
|
||||||
|
label: string
|
||||||
|
variant?: 'info' | 'success' | 'warning' | 'error'
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export interface RouteNode {
|
export interface RouteNode {
|
||||||
name: string
|
name: string
|
||||||
@@ -6,21 +16,33 @@ export interface RouteNode {
|
|||||||
durationMs: number
|
durationMs: number
|
||||||
status: 'ok' | 'slow' | 'fail'
|
status: 'ok' | 'slow' | 'fail'
|
||||||
isBottleneck?: boolean
|
isBottleneck?: boolean
|
||||||
|
badges?: NodeBadge[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeAction {
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
divider?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowSegment {
|
||||||
|
label: string
|
||||||
|
nodes: RouteNode[]
|
||||||
|
variant?: 'default' | 'error' | 'warning' | 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RouteFlowProps {
|
interface RouteFlowProps {
|
||||||
nodes: RouteNode[]
|
nodes?: RouteNode[]
|
||||||
|
flows?: FlowSegment[]
|
||||||
onNodeClick?: (node: RouteNode, index: number) => void
|
onNodeClick?: (node: RouteNode, index: number) => void
|
||||||
selectedIndex?: number
|
selectedIndex?: number
|
||||||
|
actions?: NodeAction[]
|
||||||
|
getActions?: (node: RouteNode, index: number) => NodeAction[]
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(ms: number): string {
|
|
||||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
|
||||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
|
||||||
return `${ms}ms`
|
|
||||||
}
|
|
||||||
|
|
||||||
function durationClass(ms: number, status: string): string {
|
function durationClass(ms: number, status: string): string {
|
||||||
if (status === 'fail') return styles.durBreach
|
if (status === 'fail') return styles.durBreach
|
||||||
if (ms < 50) return styles.durFast
|
if (ms < 50) return styles.durFast
|
||||||
@@ -29,12 +51,12 @@ function durationClass(ms: number, status: string): string {
|
|||||||
return styles.durBreach
|
return styles.durBreach
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_ICONS: Record<string, string> = {
|
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||||
'from': '\u25B6',
|
'from': <Play size={14} />,
|
||||||
'process': '\u2699',
|
'process': <Cog size={14} />,
|
||||||
'to': '\u25A2',
|
'to': <Square size={14} />,
|
||||||
'choice': '\u25C6',
|
'choice': <Diamond size={14} />,
|
||||||
'error-handler': '\u26A0',
|
'error-handler': <AlertTriangle size={14} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICON_CLASSES: Record<string, string> = {
|
const ICON_CLASSES: Record<string, string> = {
|
||||||
@@ -52,12 +74,141 @@ function nodeStatusClass(node: RouteNode): string {
|
|||||||
return styles.nodeHealthy
|
return styles.nodeHealthy
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: RouteFlowProps) {
|
function renderActionTrigger(
|
||||||
const mainNodes = nodes.filter((n) => n.type !== 'error-handler')
|
node: RouteNode,
|
||||||
const errorHandlers = nodes.filter((n) => n.type === 'error-handler')
|
index: number,
|
||||||
|
isSelected: boolean,
|
||||||
|
actions?: NodeAction[],
|
||||||
|
getActions?: (node: RouteNode, index: number) => NodeAction[],
|
||||||
|
) {
|
||||||
|
const resolvedActions = getActions ? getActions(node, index) : (actions ?? [])
|
||||||
|
if (resolvedActions.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
className={styles.actionsBtn}
|
||||||
|
aria-label={`Actions for ${node.name}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<EllipsisVertical size={14} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={resolvedActions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLOW_LABEL_CLASSES: Record<string, string> = {
|
||||||
|
'default': styles.flowLabelDefault,
|
||||||
|
'error': styles.flowLabelError,
|
||||||
|
'warning': styles.flowLabelWarning,
|
||||||
|
'info': styles.flowLabelInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodeChain(
|
||||||
|
nodes: RouteNode[],
|
||||||
|
globalIndexOffset: number,
|
||||||
|
onNodeClick?: RouteFlowProps['onNodeClick'],
|
||||||
|
selectedIndex?: number,
|
||||||
|
actions?: NodeAction[],
|
||||||
|
getActions?: (node: RouteNode, index: number) => NodeAction[],
|
||||||
|
) {
|
||||||
|
const isClickable = !!onNodeClick
|
||||||
|
|
||||||
|
return nodes.map((node, i) => {
|
||||||
|
const globalIndex = globalIndexOffset + i
|
||||||
|
const isSelected = selectedIndex === globalIndex
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className={styles.nodeWrapper}>
|
||||||
|
{i > 0 && (
|
||||||
|
<div className={styles.connector}>
|
||||||
|
<div className={styles.connectorLine} />
|
||||||
|
<div className={styles.connectorArrow} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`${styles.node} ${nodeStatusClass(node)} ${isSelected ? styles.nodeSelected : ''} ${isClickable ? styles.nodeClickable : ''}`}
|
||||||
|
onClick={() => onNodeClick?.(node, globalIndex)}
|
||||||
|
role={isClickable ? 'button' : undefined}
|
||||||
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault()
|
||||||
|
onNodeClick?.(node, globalIndex)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(node.isBottleneck || node.badges?.length) ? (
|
||||||
|
<span className={styles.badgeRow}>
|
||||||
|
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
|
||||||
|
{node.badges?.map((badge, bi) => (
|
||||||
|
<span
|
||||||
|
key={bi}
|
||||||
|
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||||
|
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||||
|
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||||
|
{TYPE_ICONS[node.type] ?? <Square size={14} />}
|
||||||
|
</div>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.type}>{node.type}</div>
|
||||||
|
<div className={styles.label} title={node.name}>{node.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.stats}>
|
||||||
|
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
||||||
|
{formatDuration(node.durationMs)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderActionTrigger(node, globalIndex, isSelected, actions, getActions)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteFlow({ nodes, flows, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) {
|
||||||
|
// Multi-flow mode
|
||||||
|
if (flows && flows.length > 0) {
|
||||||
|
let globalOffset = 0
|
||||||
|
return (
|
||||||
|
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||||
|
{flows.map((flow, fi) => {
|
||||||
|
const sectionOffset = globalOffset
|
||||||
|
globalOffset += flow.nodes.length
|
||||||
|
const variant = flow.variant ?? 'default'
|
||||||
|
const labelClass = FLOW_LABEL_CLASSES[variant] ?? styles.flowLabelDefault
|
||||||
|
return (
|
||||||
|
<div key={fi} className={`${styles.flowSection} ${fi > 0 ? styles.flowSectionSeparated : ''}`}>
|
||||||
|
<div className={`${styles.flowLabel} ${labelClass}`}>{flow.label}</div>
|
||||||
|
{renderNodeChain(flow.nodes, sectionOffset, onNodeClick, selectedIndex, actions, getActions)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy mode (single nodes array with automatic error-handler separation)
|
||||||
|
const allNodes = nodes ?? []
|
||||||
|
const mainNodes = allNodes.filter((n) => n.type !== 'error-handler')
|
||||||
|
const errorHandlers = allNodes.filter((n) => n.type === 'error-handler')
|
||||||
|
|
||||||
// Map from mainNodes index back to original nodes index
|
// Map from mainNodes index back to original nodes index
|
||||||
const mainNodeOriginalIndices = nodes.reduce<number[]>((acc, n, idx) => {
|
const mainNodeOriginalIndices = allNodes.reduce<number[]>((acc, n, idx) => {
|
||||||
if (n.type !== 'error-handler') acc.push(idx)
|
if (n.type !== 'error-handler') acc.push(idx)
|
||||||
return acc
|
return acc
|
||||||
}, [])
|
}, [])
|
||||||
@@ -70,7 +221,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
const isClickable = !!onNodeClick
|
const isClickable = !!onNodeClick
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
<div key={i} className={styles.nodeWrapper}>
|
||||||
{i > 0 && (
|
{i > 0 && (
|
||||||
<div className={styles.connector}>
|
<div className={styles.connector}>
|
||||||
<div className={styles.connectorLine} />
|
<div className={styles.connectorLine} />
|
||||||
@@ -89,9 +240,23 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{node.isBottleneck && <span className={styles.bottleneckBadge}>BOTTLENECK</span>}
|
{(node.isBottleneck || node.badges?.length) ? (
|
||||||
|
<span className={styles.badgeRow}>
|
||||||
|
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
|
||||||
|
{node.badges?.map((badge, bi) => (
|
||||||
|
<span
|
||||||
|
key={bi}
|
||||||
|
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||||
|
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||||
|
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||||
{TYPE_ICONS[node.type] ?? '\u25A2'}
|
{TYPE_ICONS[node.type] ?? <Square size={14} />}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.info}>
|
<div className={styles.info}>
|
||||||
<div className={styles.type}>{node.type}</div>
|
<div className={styles.type}>{node.type}</div>
|
||||||
@@ -102,6 +267,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
{formatDuration(node.durationMs)}
|
{formatDuration(node.durationMs)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderActionTrigger(node, originalIndex, isSelected, actions, getActions)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -110,7 +276,9 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
{errorHandlers.length > 0 && (
|
{errorHandlers.length > 0 && (
|
||||||
<div className={styles.errorSection}>
|
<div className={styles.errorSection}>
|
||||||
<div className={styles.errorLabel}>Error Handler</div>
|
<div className={styles.errorLabel}>Error Handler</div>
|
||||||
{errorHandlers.map((node, i) => (
|
{errorHandlers.map((node, i) => {
|
||||||
|
const errOriginalIndex = allNodes.indexOf(node)
|
||||||
|
return (
|
||||||
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
||||||
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
||||||
{TYPE_ICONS['error-handler']}
|
{TYPE_ICONS['error-handler']}
|
||||||
@@ -124,8 +292,10 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
{formatDuration(node.durationMs)}
|
{formatDuration(node.durationMs)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderActionTrigger(node, errOriginalIndex, false, actions, getActions)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
.count {
|
.count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
|
|||||||
37
src/design-system/composites/SplitPane/SplitPane.module.css
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
.splitPane {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--split-columns, 1fr 2fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listPane {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPane {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyDetail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
69
src/design-system/composites/SplitPane/SplitPane.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { SplitPane } from './SplitPane'
|
||||||
|
|
||||||
|
describe('SplitPane', () => {
|
||||||
|
it('renders list and detail content', () => {
|
||||||
|
render(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>List items</div>}
|
||||||
|
detail={<div>Detail content</div>}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('List items')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Detail content')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows default empty message when detail is null', () => {
|
||||||
|
render(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>List items</div>}
|
||||||
|
detail={null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Select an item to view details')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows custom empty message', () => {
|
||||||
|
render(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>List items</div>}
|
||||||
|
detail={null}
|
||||||
|
emptyMessage="Pick something"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Pick something')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with different ratios (checks --split-columns CSS property)', () => {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>List</div>}
|
||||||
|
detail={<div>Detail</div>}
|
||||||
|
ratio="1:1"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const root = container.firstChild as HTMLElement
|
||||||
|
expect(root.style.getPropertyValue('--split-columns')).toBe('1fr 1fr')
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>List</div>}
|
||||||
|
detail={<div>Detail</div>}
|
||||||
|
ratio="2:3"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(root.style.getPropertyValue('--split-columns')).toBe('2fr 3fr')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts className', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>List</div>}
|
||||||
|
detail={<div>Detail</div>}
|
||||||
|
className="custom-class"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(container.firstChild).toHaveClass('custom-class')
|
||||||
|
})
|
||||||
|
})
|
||||||
38
src/design-system/composites/SplitPane/SplitPane.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import styles from './SplitPane.module.css'
|
||||||
|
|
||||||
|
interface SplitPaneProps {
|
||||||
|
list: ReactNode
|
||||||
|
detail: ReactNode | null
|
||||||
|
emptyMessage?: string
|
||||||
|
ratio?: '1:1' | '1:2' | '2:3'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratioMap: Record<string, string> = {
|
||||||
|
'1:1': '1fr 1fr',
|
||||||
|
'1:2': '1fr 2fr',
|
||||||
|
'2:3': '2fr 3fr',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SplitPane({
|
||||||
|
list,
|
||||||
|
detail,
|
||||||
|
emptyMessage = 'Select an item to view details',
|
||||||
|
ratio = '1:2',
|
||||||
|
className,
|
||||||
|
}: SplitPaneProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.splitPane} ${className ?? ''}`}
|
||||||
|
style={{ '--split-columns': ratioMap[ratio] } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<div className={styles.listPane}>{list}</div>
|
||||||
|
<div className={styles.detailPane}>
|
||||||
|
{detail !== null ? detail : (
|
||||||
|
<div className={styles.emptyDetail}>{emptyMessage}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
.count {
|
.count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
.tooltip {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
41
src/design-system/composites/ThemedChart/ChartTooltip.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { TooltipProps } from 'recharts'
|
||||||
|
import styles from './ChartTooltip.module.css'
|
||||||
|
|
||||||
|
function formatValue(val: number): string {
|
||||||
|
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
|
||||||
|
if (val >= 1000) return `${(val / 1000).toFixed(1)}k`
|
||||||
|
if (Number.isInteger(val)) return String(val)
|
||||||
|
return val.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(val: unknown): string | null {
|
||||||
|
if (val == null) return null
|
||||||
|
const str = String(val)
|
||||||
|
const ms = typeof val === 'number' && val > 1e12 ? val
|
||||||
|
: typeof val === 'number' && val > 1e9 ? val * 1000
|
||||||
|
: Date.parse(str)
|
||||||
|
if (isNaN(ms)) return str
|
||||||
|
return new Date(ms).toLocaleString([], {
|
||||||
|
month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartTooltip({ active, payload, label }: TooltipProps<number, string>) {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
|
||||||
|
const timeLabel = formatTimestamp(label)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tooltip}>
|
||||||
|
{timeLabel && <div className={styles.time}>{timeLabel}</div>}
|
||||||
|
{payload.map((entry) => (
|
||||||
|
<div key={entry.dataKey as string} className={styles.row}>
|
||||||
|
<span className={styles.dot} style={{ background: entry.color }} />
|
||||||
|
<span className={styles.label}>{entry.name}:</span>
|
||||||
|
<span className={styles.value}>{formatValue(entry.value as number)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ThemedChart } from './ThemedChart'
|
||||||
|
import { Line } from 'recharts'
|
||||||
|
|
||||||
|
// Recharts uses ResizeObserver internally
|
||||||
|
class ResizeObserverMock {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver = ResizeObserverMock as any
|
||||||
|
|
||||||
|
describe('ThemedChart', () => {
|
||||||
|
it('renders nothing when data is empty', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={[]}>
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.innerHTML).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a chart container when data is provided', () => {
|
||||||
|
const data = [
|
||||||
|
{ time: '10:00', value: 10 },
|
||||||
|
{ time: '10:01', value: 20 },
|
||||||
|
]
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={data} height={160}>
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('.recharts-responsive-container')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
const data = [{ time: '10:00', value: 5 }]
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={data} className="my-chart">
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('.my-chart')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
74
src/design-system/composites/ThemedChart/ThemedChart.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
ComposedChart,
|
||||||
|
CartesianGrid,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
} from 'recharts'
|
||||||
|
import { rechartsTheme } from '../../utils/rechartsTheme'
|
||||||
|
import { ChartTooltip } from './ChartTooltip'
|
||||||
|
|
||||||
|
interface ThemedChartProps {
|
||||||
|
data: Record<string, any>[]
|
||||||
|
height?: number
|
||||||
|
xDataKey?: string
|
||||||
|
xType?: 'number' | 'category'
|
||||||
|
xTickFormatter?: (value: any) => string
|
||||||
|
yTickFormatter?: (value: any) => string
|
||||||
|
yLabel?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemedChart({
|
||||||
|
data,
|
||||||
|
height = 200,
|
||||||
|
xDataKey = 'time',
|
||||||
|
xType = 'category',
|
||||||
|
xTickFormatter,
|
||||||
|
yTickFormatter,
|
||||||
|
yLabel,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: ThemedChartProps) {
|
||||||
|
if (!data.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show ~5-6 ticks max to avoid label overlap
|
||||||
|
const maxTicks = 6
|
||||||
|
const tickInterval = data.length > maxTicks
|
||||||
|
? Math.ceil(data.length / maxTicks) - 1
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ width: '100%', height }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid {...rechartsTheme.cartesianGrid} />
|
||||||
|
<XAxis
|
||||||
|
dataKey={xDataKey}
|
||||||
|
type={xType}
|
||||||
|
{...rechartsTheme.xAxis}
|
||||||
|
tickFormatter={xTickFormatter}
|
||||||
|
interval={tickInterval}
|
||||||
|
minTickGap={40}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
{...rechartsTheme.yAxis}
|
||||||
|
tickFormatter={yTickFormatter}
|
||||||
|
label={yLabel ? {
|
||||||
|
value: yLabel,
|
||||||
|
angle: -90,
|
||||||
|
position: 'insideLeft',
|
||||||
|
style: { fontSize: 11, fill: 'var(--text-muted)' },
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<ChartTooltip />} cursor={rechartsTheme.tooltip.cursor} />
|
||||||
|
{children}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'Info', variant: 'info' }) })
|
act(() => { getApi().toast({ title: 'Info', variant: 'info' }) })
|
||||||
|
|
||||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows correct icon for success variant', () => {
|
it('shows correct icon for success variant', () => {
|
||||||
@@ -97,7 +97,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'OK', variant: 'success' }) })
|
act(() => { getApi().toast({ title: 'OK', variant: 'success' }) })
|
||||||
|
|
||||||
expect(screen.getByText('✓')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows correct icon for warning variant', () => {
|
it('shows correct icon for warning variant', () => {
|
||||||
@@ -105,7 +105,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) })
|
act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) })
|
||||||
|
|
||||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows correct icon for error variant', () => {
|
it('shows correct icon for error variant', () => {
|
||||||
@@ -113,7 +113,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'Err', variant: 'error' }) })
|
act(() => { getApi().toast({ title: 'Err', variant: 'error' }) })
|
||||||
|
|
||||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dismisses toast when close button is clicked', () => {
|
it('dismisses toast when close button is clicked', () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
|
||||||
import styles from './Toast.module.css'
|
import styles from './Toast.module.css'
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
@@ -39,11 +40,11 @@ const MAX_TOASTS = 5
|
|||||||
const DEFAULT_DURATION = 5000
|
const DEFAULT_DURATION = 5000
|
||||||
const EXIT_ANIMATION_MS = 300
|
const EXIT_ANIMATION_MS = 300
|
||||||
|
|
||||||
const ICONS: Record<ToastVariant, string> = {
|
const ICONS: Record<ToastVariant, ReactNode> = {
|
||||||
info: 'ℹ',
|
info: <Info size={16} />,
|
||||||
success: '✓',
|
success: <CheckCircle size={16} />,
|
||||||
warning: '⚠',
|
warning: <AlertTriangle size={16} />,
|
||||||
error: '✕',
|
error: <XCircle size={16} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Context ────────────────────────────────────────────────────────────────
|
// ── Context ────────────────────────────────────────────────────────────────
|
||||||
@@ -56,6 +57,10 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const dismiss = useCallback((id: string) => {
|
const dismiss = useCallback((id: string) => {
|
||||||
// Clear auto-dismiss timer if running
|
// Clear auto-dismiss timer if running
|
||||||
const timer = timersRef.current.get(id)
|
const timer = timersRef.current.get(id)
|
||||||
@@ -70,16 +75,15 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Remove after animation completes
|
// Remove after animation completes
|
||||||
setTimeout(() => {
|
setTimeout(() => removeToast(id), EXIT_ANIMATION_MS)
|
||||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
}, [removeToast])
|
||||||
}, EXIT_ANIMATION_MS)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const toast = useCallback(
|
const toast = useCallback(
|
||||||
(options: ToastOptions): string => {
|
(options: ToastOptions): string => {
|
||||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||||
const duration = options.duration ?? DEFAULT_DURATION
|
|
||||||
const variant = options.variant ?? 'info'
|
const variant = options.variant ?? 'info'
|
||||||
|
// Error toasts persist until manually dismissed; others auto-close after DEFAULT_DURATION
|
||||||
|
const duration = options.duration ?? (variant === 'error' ? 0 : DEFAULT_DURATION)
|
||||||
|
|
||||||
const newToast: ToastItem = {
|
const newToast: ToastItem = {
|
||||||
id,
|
id,
|
||||||
@@ -96,11 +100,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
return next.slice(-MAX_TOASTS)
|
return next.slice(-MAX_TOASTS)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Schedule auto-dismiss
|
// Schedule auto-dismiss (duration 0 = persist until manual dismiss)
|
||||||
|
if (duration > 0) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
dismiss(id)
|
dismiss(id)
|
||||||
}, duration)
|
}, duration)
|
||||||
timersRef.current.set(id, timer)
|
timersRef.current.set(id, timer)
|
||||||
|
}
|
||||||
|
|
||||||
return id
|
return id
|
||||||
},
|
},
|
||||||
@@ -183,7 +189,7 @@ function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) {
|
|||||||
aria-label="Dismiss notification"
|
aria-label="Dismiss notification"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
×
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
|
|||||||
@@ -31,6 +31,52 @@ function flattenVisibleNodes(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Keyboard nav helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleArrowDown(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
|
||||||
|
const next = visibleNodes[currentIndex + 1]
|
||||||
|
if (next) focusNode(next.node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleArrowUp(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
|
||||||
|
const prev = visibleNodes[currentIndex - 1]
|
||||||
|
if (prev) focusNode(prev.node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleArrowRight(
|
||||||
|
current: FlatNode | undefined,
|
||||||
|
currentIndex: number,
|
||||||
|
expandedSet: Set<string>,
|
||||||
|
visibleNodes: FlatNode[],
|
||||||
|
handleToggle: (id: string) => void,
|
||||||
|
focusNode: (id: string) => void,
|
||||||
|
) {
|
||||||
|
if (!current) return
|
||||||
|
const hasChildren = current.node.children && current.node.children.length > 0
|
||||||
|
if (!hasChildren) return
|
||||||
|
if (!expandedSet.has(current.node.id)) {
|
||||||
|
handleToggle(current.node.id)
|
||||||
|
} else {
|
||||||
|
const next = visibleNodes[currentIndex + 1]
|
||||||
|
if (next) focusNode(next.node.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleArrowLeft(
|
||||||
|
current: FlatNode | undefined,
|
||||||
|
expandedSet: Set<string>,
|
||||||
|
handleToggle: (id: string) => void,
|
||||||
|
focusNode: (id: string) => void,
|
||||||
|
) {
|
||||||
|
if (!current) return
|
||||||
|
const hasChildren = current.node.children && current.node.children.length > 0
|
||||||
|
if (hasChildren && expandedSet.has(current.node.id)) {
|
||||||
|
handleToggle(current.node.id)
|
||||||
|
} else if (current.parentId !== null) {
|
||||||
|
focusNode(current.parentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface TreeViewProps {
|
interface TreeViewProps {
|
||||||
nodes: TreeNode[]
|
nodes: TreeNode[]
|
||||||
onSelect?: (id: string) => void
|
onSelect?: (id: string) => void
|
||||||
@@ -105,68 +151,13 @@ export function TreeView({
|
|||||||
const current = visibleNodes[currentIndex]
|
const current = visibleNodes[currentIndex]
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowDown': {
|
case 'ArrowDown': { e.preventDefault(); handleArrowDown(visibleNodes, currentIndex, focusNode); break }
|
||||||
e.preventDefault()
|
case 'ArrowUp': { e.preventDefault(); handleArrowUp(visibleNodes, currentIndex, focusNode); break }
|
||||||
const next = visibleNodes[currentIndex + 1]
|
case 'ArrowRight': { e.preventDefault(); handleArrowRight(current, currentIndex, expandedSet, visibleNodes, handleToggle, focusNode); break }
|
||||||
if (next) focusNode(next.node.id)
|
case 'ArrowLeft': { e.preventDefault(); handleArrowLeft(current, expandedSet, handleToggle, focusNode); break }
|
||||||
break
|
case 'Enter': { e.preventDefault(); if (current) onSelect?.(current.node.id); break }
|
||||||
}
|
case 'Home': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[0].node.id); break }
|
||||||
case 'ArrowUp': {
|
case 'End': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[visibleNodes.length - 1].node.id); break }
|
||||||
e.preventDefault()
|
|
||||||
const prev = visibleNodes[currentIndex - 1]
|
|
||||||
if (prev) focusNode(prev.node.id)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'ArrowRight': {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!current) break
|
|
||||||
const hasChildren = current.node.children && current.node.children.length > 0
|
|
||||||
if (hasChildren) {
|
|
||||||
if (!expandedSet.has(current.node.id)) {
|
|
||||||
// Expand it
|
|
||||||
handleToggle(current.node.id)
|
|
||||||
} else {
|
|
||||||
// Move to first child (it will be the next visible node)
|
|
||||||
const next = visibleNodes[currentIndex + 1]
|
|
||||||
if (next) focusNode(next.node.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'ArrowLeft': {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!current) break
|
|
||||||
const hasChildren = current.node.children && current.node.children.length > 0
|
|
||||||
if (hasChildren && expandedSet.has(current.node.id)) {
|
|
||||||
// Collapse
|
|
||||||
handleToggle(current.node.id)
|
|
||||||
} else if (current.parentId !== null) {
|
|
||||||
// Move to parent
|
|
||||||
focusNode(current.parentId)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'Enter': {
|
|
||||||
e.preventDefault()
|
|
||||||
if (current) {
|
|
||||||
onSelect?.(current.node.id)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'Home': {
|
|
||||||
e.preventDefault()
|
|
||||||
if (visibleNodes.length > 0) {
|
|
||||||
focusNode(visibleNodes[0].node.id)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'End': {
|
|
||||||
e.preventDefault()
|
|
||||||
if (visibleNodes.length > 0) {
|
|
||||||
focusNode(visibleNodes[visibleNodes.length - 1].node.id)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -239,6 +230,10 @@ function TreeNodeRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li role="none">
|
<li role="none">
|
||||||
|
{/* S1082: No onKeyDown here by design — the parent <ul role="tree"> carries
|
||||||
|
onKeyDown={handleKeyDown} which handles Enter (select) and all arrow keys
|
||||||
|
per the WAI-ARIA tree widget pattern. Adding a duplicate handler here would
|
||||||
|
fire the action twice. */}
|
||||||
<div
|
<div
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||||
|
|||||||