Compare commits

...

10 Commits

Author SHA1 Message Date
hsiegeln
92ea8673fc docs: add consumer usage guide for @cameleer/design-system package
Some checks failed
Build & Publish / publish (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:34:23 +01:00
hsiegeln
3be4c0a976 feat: configure package.json for @cameleer/design-system publishing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:32:49 +01:00
hsiegeln
d1e5499688 ci: add Gitea Actions workflow for npm publishing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:32:28 +01:00
hsiegeln
8da0363089 feat: add Vite library build config with dts generation
Separate vite.lib.config.ts for library mode builds:
- ES module output (index.es.js) with react/react-dom externalized
- Consolidated type declarations via rollupTypes (index.es.d.ts)
- CSS Modules with debuggable scoped names (cameleer_ prefix)
- Deterministic output filenames (style.css, index.es.js)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:30:45 +01:00
hsiegeln
5c1add8c9e feat: add library entry point for design system package
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:26:11 +01:00
hsiegeln
cebaa2c55c chore: add vite-plugin-dts and ignore dist/
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:24:50 +01:00
hsiegeln
2c427a31a1 docs: add implementation plan for design system packaging
8-task plan covering: git remote, vite-plugin-dts, library entry point,
Vite lib config, package.json, Gitea Actions CI/CD, consumer docs, push.

Fixes from review: deterministic output filename, .npmrc echo instead of
heredoc, dist/ in .gitignore, prerequisite for untracked files, dts
verification, worktree guidance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:19:16 +01:00
hsiegeln
727a5de9dc docs: fix spec issues from review
- Add tokens.css/reset.css imports to entry point (critical: CSS tokens would be missing)
- Add font loading docs for consumers (DM Sans, JetBrains Mono)
- Add publishConfig for Gitea registry (npm publish would target npmjs.org)
- Fix exports map: types condition first
- Add scoped registry line to CI .npmrc
- Note vite-plugin-dts as required devDependency
- Add repository field to package.json spec
- Note tsconfig.node.json update needed
- CSS Module scoping strategy for debuggable class names
- Note CI auth requirements for consuming apps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:12:28 +01:00
hsiegeln
45c35b59fe docs: add design spec for design system packaging
Covers Vite library mode build, Gitea npm registry publishing,
snapshot + tag-based versioning, and consumer setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:07:28 +01:00
hsiegeln
5de97dab14 feat: unified global search & filter system with Cmd-K navigation
Replace per-page filtering with a single GlobalFilterProvider (time range +
status) consumed by a redesigned TopBar across all pages. Lift CommandPalette
to App level so Cmd-K works globally with filtered results that navigate to
exchanges, routes, agents, and applications. Sidebar auto-reveals and selects
the target entry on Cmd-K navigation via location state.

- Extract shared time preset utilities (computePresetRange, DEFAULT_PRESETS)
- Add GlobalFilterProvider (time range + status) and CommandPaletteProvider
- Add TimeRangeDropdown primitive with Popover preset list
- Redesign TopBar: breadcrumb | time dropdown | status pills | search | env
- Add application category to Cmd-K search
- Remove FilterBar and local DateRangePicker from Dashboard/Metrics pages
- Filter AgentHealth EventFeed by global time range
- Remove shift/onSearchClick props from TopBar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:06:25 +01:00
36 changed files with 2599 additions and 260 deletions

View File

@@ -0,0 +1,39 @@
name: Build & Publish
on:
push:
branches: [main]
tags: ['v*']
jobs:
publish:
runs-on: linux-arm64
container:
image: node:22-bookworm-slim
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run tests
run: npx vitest run
- name: Build library
run: npm run build:lib
- name: Publish package
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
npm version "$VERSION" --no-git-tag-version
TAG="latest"
else
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
DATE=$(date +%Y%m%d)
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
TAG="dev"
fi
echo '@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/' > .npmrc
echo '//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${{ secrets.REGISTRY_TOKEN }}' >> .npmrc
npm publish --tag "$TAG"

View File

@@ -42,3 +42,68 @@ import type { Column } from '../design-system/composites'
import { AppShell } from '../design-system/layout/AppShell'
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
```
## Using This Design System in Other Apps
This design system is published as `@cameleer/design-system` to the Gitea npm registry.
### Registry: `https://gitea.siegeln.net/api/packages/cameleer/npm/`
### Setup in a consuming app
1. Add `.npmrc` to the project root:
```
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${GITEA_TOKEN}
```
Note: CI pipelines for consuming apps also need this `.npmrc` and a `GITEA_TOKEN` secret to fetch the package during `npm ci`.
2. Install:
```bash
# Snapshot builds (during development)
npm install @cameleer/design-system@dev
# Stable releases
npm install @cameleer/design-system
```
3. Add fonts to `index.html` (required — the package does not bundle fonts):
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
```
Without these, `var(--font-body)` and `var(--font-mono)` fall back to `system-ui` / `monospace`.
4. Import styles once at app root, then use components:
```tsx
import '@cameleer/design-system/style.css'
import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
```
### Import Paths (Consumer)
```tsx
// All components from single entry
import { Button, Input, Modal, DataTable, AppShell } from '@cameleer/design-system'
// Types
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
// Providers
import { ThemeProvider, useTheme } from '@cameleer/design-system'
import { CommandPaletteProvider, useCommandPalette } from '@cameleer/design-system'
import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
// Utils
import { hashColor } from '@cameleer/design-system'
// Styles (once, at app root)
import '@cameleer/design-system/style.css'
```

View File

@@ -204,23 +204,26 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
## Import Paths
### Within this repo (design system development)
```tsx
// Primitives
import { Button, Input, Badge, ... } from './design-system/primitives'
// Composites
import { DataTable, Modal, Toast, ... } from './design-system/composites'
import type { Column, SearchResult, FeedEvent, ... } from './design-system/composites'
// Layout
import { Button, Input, Badge } from './design-system/primitives'
import { DataTable, Modal, Toast } from './design-system/composites'
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
import { AppShell } from './design-system/layout/AppShell'
import { Sidebar } from './design-system/layout/Sidebar'
import { TopBar } from './design-system/layout/TopBar'
// Theme
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
```
### From consuming apps (via npm package)
```tsx
import '@cameleer/design-system/style.css' // once at app root
import { Button, Input, Modal, DataTable, AppShell, ThemeProvider } from '@cameleer/design-system'
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
```
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
## Styling Rules
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)

View File

@@ -0,0 +1,612 @@
# Design System Packaging 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.
>
> **IMPORTANT: Use an isolated git worktree** — another agent may be working on the main tree. Create a worktree before starting any task. The worktree must be created from the current working state (not a clean `main`) because several provider/util files are uncommitted.
**Goal:** Package the Cameleer3 design system as `@cameleer/design-system` and publish it to Gitea's npm registry via CI/CD.
**Architecture:** Vite library mode builds the design system into an ESM bundle + CSS + TypeScript declarations. A Gitea Actions workflow publishes snapshot versions on every push to main, and stable versions on `v*` tags. Consuming apps install from Gitea's npm registry.
**Tech Stack:** Vite (library mode), vite-plugin-dts, TypeScript, CSS Modules, Gitea Actions
**Spec:** `docs/superpowers/specs/2026-03-18-design-system-packaging-design.md`
---
## Prerequisites
Before starting, ensure these currently-untracked files are committed (they are referenced by the library entry point):
- `src/design-system/providers/CommandPaletteProvider.tsx`
- `src/design-system/providers/GlobalFilterProvider.tsx`
- `src/design-system/utils/timePresets.ts`
The git remote should be added **before** creating a worktree, since remotes are repo-level config shared across all worktrees.
---
## File Map
| Action | File | Responsibility |
|--------|------|----------------|
| Create | `src/design-system/index.ts` | Library entry point — re-exports all components, imports global CSS |
| Create | `vite.lib.config.ts` | Vite library build config (separate from app build) |
| Create | `.gitea/workflows/publish.yml` | CI/CD: test, build, publish on push to main / tag |
| Modify | `.gitignore` | Add `dist/` to prevent build artifacts from being committed |
| Modify | `package.json` | Rename, add exports/peers/publishConfig/files/repository |
| Modify | `tsconfig.node.json` | Include `vite.lib.config.ts` |
| Modify | `CLAUDE.md` | Add consumer usage docs for AI agents |
| Modify | `COMPONENT_GUIDE.md` | Add package import paths for consumers |
---
### Task 1: Add git remote and commit untracked files
**Files:**
- None created (git operations + staging existing untracked files)
- [ ] **Step 1: Add the origin remote**
```bash
git remote add origin https://gitea.siegeln.net/cameleer/design-system.git
```
If `origin` already exists, use `git remote set-url origin https://gitea.siegeln.net/cameleer/design-system.git` instead.
- [ ] **Step 2: Verify remote**
```bash
git remote -v
```
Expected: `origin https://gitea.siegeln.net/cameleer/design-system.git (fetch)` and `(push)`
- [ ] **Step 3: Commit untracked provider/util files**
These files are required by the library entry point (Task 3) but are currently untracked:
```bash
git add src/design-system/providers/CommandPaletteProvider.tsx src/design-system/providers/GlobalFilterProvider.tsx src/design-system/utils/timePresets.ts
git commit -m "feat: add CommandPaletteProvider, GlobalFilterProvider, and timePresets"
```
- [ ] **Step 4: Commit any other pending changes**
Check `git status` — there may be other modified files from a prior agent's work. Stage and commit anything that belongs on main before proceeding:
```bash
git status
```
If there are modified files, commit them with an appropriate message before continuing.
---
### Task 2: Install vite-plugin-dts and add dist/ to .gitignore
**Files:**
- Modify: `package.json` (devDependencies)
- Modify: `.gitignore`
- [ ] **Step 1: Install the plugin**
```bash
npm install -D vite-plugin-dts
```
Note: `vite-plugin-dts` with `rollupTypes: true` requires `@microsoft/api-extractor` as a peer dependency. If the install warns about this, also install it:
```bash
npm install -D @microsoft/api-extractor
```
- [ ] **Step 2: Verify it's in devDependencies**
```bash
node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).devDependencies['vite-plugin-dts'])"
```
Expected: a version string like `^4.x.x`
- [ ] **Step 3: Add `dist/` to `.gitignore`**
Append to `.gitignore`:
```
dist/
```
This prevents build artifacts from being accidentally committed.
- [ ] **Step 4: Commit**
```bash
git add package.json package-lock.json .gitignore
git commit -m "chore: add vite-plugin-dts and ignore dist/"
```
---
### Task 3: Create library entry point
**Files:**
- Create: `src/design-system/index.ts`
- [ ] **Step 1: Create `src/design-system/index.ts`**
```ts
import './tokens.css'
import './reset.css'
export * from './primitives'
export * from './composites'
export * from './layout'
export * from './providers/ThemeProvider'
export * from './providers/CommandPaletteProvider'
export * from './providers/GlobalFilterProvider'
export * from './utils/hashColor'
export * from './utils/timePresets'
```
This file imports `tokens.css` and `reset.css` at the top so Vite includes them in the bundled `style.css`. Without these imports, all `var(--*)` tokens would resolve to nothing in consuming apps.
- [ ] **Step 2: Verify TypeScript is happy**
```bash
npx tsc -b --noEmit
```
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/design-system/index.ts
git commit -m "feat: add library entry point for design system package"
```
---
### Task 4: Create Vite library build config
**Files:**
- Create: `vite.lib.config.ts`
- Modify: `tsconfig.node.json`
- [ ] **Step 1: Create `vite.lib.config.ts`**
Note: `__dirname` works in Vite config files despite this being an ESM project — Vite transpiles config files before executing them.
```ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import dts from 'vite-plugin-dts'
import { resolve } from 'path'
export default defineConfig({
plugins: [
react(),
dts({
include: ['src/design-system'],
outDir: 'dist',
rollupTypes: true,
}),
],
css: {
modules: {
localsConvention: 'camelCase',
generateScopedName: 'cameleer_[name]_[local]_[hash:5]',
},
},
build: {
lib: {
entry: resolve(__dirname, 'src/design-system/index.ts'),
formats: ['es'],
fileName: () => 'index.es.js',
},
rollupOptions: {
external: ['react', 'react-dom', 'react-router-dom', 'react/jsx-runtime'],
},
cssFileName: 'style',
},
})
```
Key choices:
- `rollupTypes: true` consolidates all `.d.ts` into a single `dist/index.d.ts`
- `generateScopedName: 'cameleer_[name]_[local]_[hash:5]'` makes class names debuggable in consumer devtools
- `react/jsx-runtime` is externalized (peer dep of React, not bundled)
- `cssFileName: 'style'` ensures output is `dist/style.css`
- `fileName: () => 'index.es.js'` forces a deterministic output filename — Vite 6 defaults to `.mjs` for ES format which would mismatch `package.json` exports
- [ ] **Step 2: Update `tsconfig.node.json` to include the new config file**
Change the `include` array from:
```json
"include": ["vite.config.ts"]
```
to:
```json
"include": ["vite.config.ts", "vite.lib.config.ts"]
```
- [ ] **Step 3: Test the library build**
The `build:lib` script isn't in `package.json` yet (added in Task 5). Run directly:
```bash
npx vite build --config vite.lib.config.ts
```
Expected: `dist/` directory created with `index.es.js`, `style.css`, and `index.d.ts`
- [ ] **Step 4: Verify the output**
```bash
ls dist/
```
Expected: `index.es.js`, `style.css`, `index.d.ts`
Check that `style.css` contains token variables:
```bash
grep "bg-body" dist/style.css
```
Expected: matches found (proves tokens.css was included)
Check that type declarations contain exported components:
```bash
grep "Button" dist/index.d.ts
```
Expected: matches found (proves types were generated)
If `index.d.ts` is missing or empty, `rollupTypes` may have failed silently. In that case, install `@microsoft/api-extractor` and rebuild, or set `rollupTypes: false` (which produces individual `.d.ts` files — less clean but functional).
- [ ] **Step 5: Clean up and commit**
```bash
rm -rf dist
git add vite.lib.config.ts tsconfig.node.json
git commit -m "feat: add Vite library build config with dts generation"
```
---
### Task 5: Update package.json for library publishing
**Files:**
- Modify: `package.json`
- [ ] **Step 1: Update package.json**
Apply these changes to the existing `package.json`:
1. Change `"name"` from `"cameleer3"` to `"@cameleer/design-system"`
2. Change `"version"` from `"0.0.0"` to `"0.1.0"`
3. Remove `"private": true`
4. Add `"main": "./dist/index.es.js"`
5. Add `"module": "./dist/index.es.js"`
6. Add `"types": "./dist/index.d.ts"`
7. Add `"exports"` block (note: `types` must come first):
```json
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.es.js"
},
"./style.css": "./dist/style.css"
}
```
8. Add `"files": ["dist"]`
9. Add `"sideEffects": ["*.css"]`
10. Add `"publishConfig"`:
```json
"publishConfig": {
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
}
```
11. Add `"repository"`:
```json
"repository": {
"type": "git",
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
}
```
12. Add `"peerDependencies"`:
```json
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
}
```
13. Add to `"scripts"`:
```json
"build:lib": "vite build --config vite.lib.config.ts"
```
The final `package.json` should look like:
```json
{
"name": "@cameleer/design-system",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.es.js"
},
"./style.css": "./dist/style.css"
},
"files": ["dist"],
"sideEffects": ["*.css"],
"publishConfig": {
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
},
"repository": {
"type": "git",
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:lib": "vite build --config vite.lib.config.ts",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"happy-dom": "^20.8.4",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vite-plugin-dts": "<version installed in Task 2>",
"vitest": "^3.0.0"
}
}
```
- [ ] **Step 2: Verify the full library build works end-to-end**
```bash
npm run build:lib
```
Expected: succeeds, `dist/` contains `index.es.js`, `style.css`, `index.d.ts`
- [ ] **Step 3: Clean up and commit**
```bash
rm -rf dist
git add package.json
git commit -m "feat: configure package.json for @cameleer/design-system publishing"
```
---
### Task 6: Create Gitea Actions workflow
**Files:**
- Create: `.gitea/workflows/publish.yml`
- [ ] **Step 1: Create `.gitea/workflows/publish.yml`**
```yaml
name: Build & Publish
on:
push:
branches: [main]
tags: ['v*']
jobs:
publish:
runs-on: linux-arm64
container:
image: node:22-bookworm-slim
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run tests
run: npx vitest run
- name: Build library
run: npm run build:lib
- name: Publish package
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
npm version "$VERSION" --no-git-tag-version
TAG="latest"
else
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
DATE=$(date +%Y%m%d)
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
TAG="dev"
fi
echo '@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/' > .npmrc
echo '//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${{ secrets.REGISTRY_TOKEN }}' >> .npmrc
npm publish --tag "$TAG"
```
Note: The `.npmrc` is written with `echo` commands (not a heredoc) to avoid YAML indentation being included in the file content, which would break npm's parsing.
- [ ] **Step 2: Commit**
```bash
mkdir -p .gitea/workflows
git add .gitea/workflows/publish.yml
git commit -m "ci: add Gitea Actions workflow for npm publishing"
```
---
### Task 7: Update documentation for consumers
**Files:**
- Modify: `CLAUDE.md`
- Modify: `COMPONENT_GUIDE.md`
- [ ] **Step 1: Add consumer section to `CLAUDE.md`**
Add the following section at the end of `CLAUDE.md`:
```markdown
## Using This Design System in Other Apps
This design system is published as `@cameleer/design-system` to the Gitea npm registry.
### Registry: `https://gitea.siegeln.net/api/packages/cameleer/npm/`
### Setup in a consuming app
1. Add `.npmrc` to the project root:
```
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${GITEA_TOKEN}
```
Note: CI pipelines for consuming apps also need this `.npmrc` and a `GITEA_TOKEN` secret to fetch the package during `npm ci`.
2. Install:
```bash
# Snapshot builds (during development)
npm install @cameleer/design-system@dev
# Stable releases
npm install @cameleer/design-system
```
3. Add fonts to `index.html` (required — the package does not bundle fonts):
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
```
Without these, `var(--font-body)` and `var(--font-mono)` fall back to `system-ui` / `monospace`.
4. Import styles once at app root, then use components:
```tsx
import '@cameleer/design-system/style.css'
import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
```
### Import Paths (Consumer)
```tsx
// All components from single entry
import { Button, Input, Modal, DataTable, AppShell } from '@cameleer/design-system'
// Types
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
// Providers
import { ThemeProvider, useTheme } from '@cameleer/design-system'
import { CommandPaletteProvider, useCommandPalette } from '@cameleer/design-system'
import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
// Utils
import { hashColor } from '@cameleer/design-system'
// Styles (once, at app root)
import '@cameleer/design-system/style.css'
```
```
- [ ] **Step 2: Update the `## Import Paths` section in `COMPONENT_GUIDE.md`**
Find the `## Import Paths` section heading and replace everything from that heading down to the next `##` heading (or end of file) with:
```markdown
## Import Paths
### Within this repo (design system development)
```tsx
import { Button, Input, Badge } from './design-system/primitives'
import { DataTable, Modal, Toast } from './design-system/composites'
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
import { AppShell } from './design-system/layout/AppShell'
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
```
### From consuming apps (via npm package)
```tsx
import '@cameleer/design-system/style.css' // once at app root
import { Button, Input, Modal, DataTable, AppShell, ThemeProvider } from '@cameleer/design-system'
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
```
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
```
- [ ] **Step 3: Commit**
```bash
git add CLAUDE.md COMPONENT_GUIDE.md
git commit -m "docs: add consumer usage guide for @cameleer/design-system package"
```
---
### Task 8: Push to Gitea and verify CI
**Files:**
- None (git operations only)
- [ ] **Step 1: Push all commits to Gitea**
```bash
git push -u origin main
```
- [ ] **Step 2: Check CI status**
Use the Gitea MCP server or visit `https://gitea.siegeln.net/cameleer/design-system/actions` to monitor the workflow run. The workflow should:
1. Install deps
2. Run tests
3. Build the library
4. Publish a snapshot version with the `dev` tag
- [ ] **Step 3: Verify the package is published**
Check the Gitea package registry at `https://gitea.siegeln.net/cameleer/-/packages`. The `@cameleer/design-system` package should appear with a `0.0.0-snapshot.*` version tagged as `dev`.
If the workflow fails, check the job logs via the Gitea MCP server's `actions_run_read` tool for diagnostics.

View File

@@ -0,0 +1,229 @@
# Design System Packaging — Design Spec
**Date:** 2026-03-18
**Status:** Approved
**Package:** `@cameleer/design-system`
**Registry:** Gitea npm registry at `gitea.siegeln.net`
**Repository:** `https://gitea.siegeln.net/cameleer/design-system`
## Goal
Package the Cameleer3 design system as a reusable npm library so other React applications in the Cameleer ecosystem can consume it via `npm install`. Publishing is automated via Gitea Actions.
## Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Registry | Gitea built-in npm registry | Already have Gitea infrastructure |
| Package scope | `@cameleer/design-system` | Matches the org |
| Export style | Single package, flat exports | Simple DX, tree-shaking handles unused code |
| What's included | Everything (primitives, composites, layout, providers, utils, tokens) | All consuming apps are Cameleer apps |
| Build tool | Vite library mode | Already using Vite, CSS Modules first-class |
| Output format | ESM only | All consumers are Vite/ESM |
| Versioning | Tag-based releases + snapshot on every main push | Snapshots for dev, tags for milestones |
| Runner arch | ARM64 | Gitea runner is ARM64 |
| Auth secret | `REGISTRY_TOKEN` (org-level) | Existing all-access token |
## 1. Library Entry Point
New file `src/design-system/index.ts` — the single public API. It must import global CSS at the top so that `tokens.css` and `reset.css` are included in the bundled `dist/style.css`:
```ts
import './tokens.css'
import './reset.css'
export * from './primitives'
export * from './composites'
export * from './layout'
export * from './providers/ThemeProvider'
export * from './providers/CommandPaletteProvider'
export * from './providers/GlobalFilterProvider'
export * from './utils/hashColor'
export * from './utils/timePresets'
```
Without the CSS imports, all `var(--*)` tokens used in component CSS Modules would resolve to nothing in consuming apps.
Consumers import the bundled CSS once at their app root:
```ts
import '@cameleer/design-system/style.css'
```
## 2. Vite Library Build Config
A separate `vite.lib.config.ts` to keep library and app builds independent:
- **Entry:** `src/design-system/index.ts`
- **Output:** `dist/index.es.js` (ESM)
- **CSS:** Extracted to `dist/style.css`
- **CSS Modules scoping:** `cameleer_[name]_[local]_[hash:5]` — debuggable in consumer devtools, unique enough to avoid collisions
- **Externals:** `react`, `react-dom`, `react-router-dom` (peer deps, not bundled)
- **Types:** `vite-plugin-dts` generates `dist/index.d.ts` with full TypeScript declarations
- **Build script:** `"build:lib": "vite build --config vite.lib.config.ts"`
- **New devDependency:** `vite-plugin-dts` must be installed
`tsconfig.node.json` must be updated to include `vite.lib.config.ts`.
Output structure:
```
dist/
index.es.js # ESM bundle
style.css # All CSS (tokens + reset + component modules)
index.d.ts # TypeScript declarations
```
## 3. Package Configuration
Updates to `package.json`:
```json
{
"name": "@cameleer/design-system",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.es.js"
},
"./style.css": "./dist/style.css"
},
"files": ["dist"],
"sideEffects": ["*.css"],
"publishConfig": {
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
},
"repository": {
"type": "git",
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
}
}
```
- `"private": true` is removed
- `"types"` comes first in the exports conditions (TypeScript resolution requirement)
- `publishConfig` ensures `npm publish` targets the Gitea registry, not npmjs.org
- Existing `scripts`, `dependencies`, and `devDependencies` remain for the app build
- `peerDependencies` tells consumers what to provide
## 4. Gitea Actions CI/CD Pipeline
Workflow at `.gitea/workflows/publish.yml`:
**Triggers:**
- Push to `main` → publish snapshot (`0.0.0-snapshot.<YYYYMMDD>.<short-sha>`) with `dev` dist-tag
- Push tag `v*` → publish stable release (e.g., `1.0.0`) with `latest` dist-tag
**Steps:**
1. Checkout at ref
2. `npm ci` (install deps)
3. `npx vitest run` (gate: don't publish broken code)
4. `npm run build:lib` (build the library)
5. Determine version from tag or generate snapshot version
6. Configure `.npmrc` with scoped registry + auth token
7. `npm publish --tag <dev|latest>`
**Runner:** ARM64 with `node:22-bookworm-slim` container image.
```yaml
name: Build & Publish
on:
push:
branches: [main]
tags: ['v*']
jobs:
publish:
runs-on: linux-arm64
container:
image: node:22-bookworm-slim
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx vitest run
- run: npm run build:lib
- run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
npm version "$VERSION" --no-git-tag-version
TAG="latest"
else
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
DATE=$(date +%Y%m%d)
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
TAG="dev"
fi
cat > .npmrc << 'NPMRC'
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${{ secrets.REGISTRY_TOKEN }}
NPMRC
npm publish --tag "$TAG"
```
## 5. Consumer Setup
In any consuming app (e.g., `cameleer3-server/ui`):
**1. Add `.npmrc` to project root:**
```
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${GITEA_TOKEN}
```
Note: The consuming app's CI pipeline also needs this `.npmrc` and a `GITEA_TOKEN` secret to fetch the package during `npm ci`.
**2. Install:**
```bash
# During development (snapshot builds)
npm install @cameleer/design-system@dev
# For stable releases (later)
npm install @cameleer/design-system
```
**3. Add fonts to `index.html`:**
The design system uses DM Sans and JetBrains Mono via Google Fonts. These must be loaded by the consuming app since font `<link>` tags are not part of the library output:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
```
Without these, `var(--font-body)` and `var(--font-mono)` will fall back to `system-ui` / `monospace`.
**4. Use:**
```tsx
// Import styles once at app root
import '@cameleer/design-system/style.css'
// Import components
import { Button, Input, Modal, AppShell, ThemeProvider } from '@cameleer/design-system'
```
## 6. Documentation Updates
Update `CLAUDE.md` and `COMPONENT_GUIDE.md` in this repo with:
- The package name and registry URL
- How consuming apps should configure `.npmrc` (including CI)
- Font loading requirement (Google Fonts link)
- Import patterns for consumers (`@cameleer/design-system` instead of relative paths)
- Note that `style.css` must be imported once at the app root
This ensures other AI agents working on consuming Cameleer apps understand how to use the design system.

967
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,30 @@
{
"name": "cameleer3",
"private": true,
"version": "0.0.0",
"name": "@cameleer/design-system",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",
"types": "./dist/index.es.d.ts",
"exports": {
".": {
"types": "./dist/index.es.d.ts",
"import": "./dist/index.es.js"
},
"./style.css": "./dist/style.css"
},
"files": ["dist"],
"sideEffects": ["*.css"],
"publishConfig": {
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
},
"repository": {
"type": "git",
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:lib": "vite build --config vite.lib.config.ts",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest"
@@ -15,6 +34,11 @@
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
@@ -25,6 +49,7 @@
"happy-dom": "^20.8.4",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vite-plugin-dts": "^4.5.4",
"vitest": "^3.0.0"
}
}

View File

@@ -1,4 +1,5 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useMemo, useCallback } from 'react'
import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'
import { Dashboard } from './pages/Dashboard/Dashboard'
import { Metrics } from './pages/Metrics/Metrics'
import { RouteDetail } from './pages/RouteDetail/RouteDetail'
@@ -8,19 +9,92 @@ import { Inventory } from './pages/Inventory/Inventory'
import { Admin } from './pages/Admin/Admin'
import { ApiDocs } from './pages/ApiDocs/ApiDocs'
import { CommandPalette } from './design-system/composites/CommandPalette/CommandPalette'
import type { SearchResult } from './design-system/composites/CommandPalette/types'
import { useCommandPalette } from './design-system/providers/CommandPaletteProvider'
import { useGlobalFilters } from './design-system/providers/GlobalFilterProvider'
import { buildSearchData } from './mocks/searchData'
import { exchanges } from './mocks/exchanges'
import { routes } from './mocks/routes'
import { agents } from './mocks/agents'
import { SIDEBAR_APPS } from './mocks/sidebar'
/** Compute which sidebar path to reveal for a given search result */
function computeSidebarRevealPath(result: SearchResult): string | undefined {
if (!result.path) return undefined
if (result.category === 'application') {
// /apps/:id — already a sidebar node path
return result.path
}
if (result.category === 'route') {
// /routes/:id — already a sidebar node path
return result.path
}
if (result.category === 'agent') {
// /agents/:appId/:agentId — already a sidebar node path
return result.path
}
if (result.category === 'exchange') {
// /exchanges/:id — no sidebar entry; resolve to the parent route
const exchange = exchanges.find((e) => e.id === result.id)
if (exchange) {
return `/routes/${exchange.route}`
}
}
return result.path
}
export default function App() {
const navigate = useNavigate()
const { open: paletteOpen, setOpen } = useCommandPalette()
const { isInTimeRange, statusFilters } = useGlobalFilters()
const filteredSearchData = useMemo(() => {
// Filter exchanges by time range and status
let filteredExchanges = exchanges.filter((e) => isInTimeRange(e.timestamp))
if (statusFilters.size > 0) {
filteredExchanges = filteredExchanges.filter((e) => statusFilters.has(e.status))
}
return buildSearchData(filteredExchanges, routes, agents)
}, [isInTimeRange, statusFilters])
const handleSelect = useCallback(
(result: SearchResult) => {
if (result.path) {
const sidebarReveal = computeSidebarRevealPath(result)
navigate(result.path, { state: sidebarReveal ? { sidebarReveal } : undefined })
}
setOpen(false)
},
[navigate, setOpen],
)
return (
<Routes>
<Route path="/" element={<Navigate to="/apps" replace />} />
<Route path="/apps" element={<Dashboard />} />
<Route path="/apps/:id" element={<Dashboard />} />
<Route path="/metrics" element={<Metrics />} />
<Route path="/routes/:id" element={<RouteDetail />} />
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
<Route path="/agents/*" element={<AgentHealth />} />
<Route path="/admin" element={<Admin />} />
<Route path="/api-docs" element={<ApiDocs />} />
<Route path="/inventory" element={<Inventory />} />
</Routes>
<>
<Routes>
<Route path="/" element={<Navigate to="/apps" replace />} />
<Route path="/apps" element={<Dashboard />} />
<Route path="/apps/:id" element={<Dashboard />} />
<Route path="/metrics" element={<Metrics />} />
<Route path="/routes/:id" element={<RouteDetail />} />
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
<Route path="/agents/*" element={<AgentHealth />} />
<Route path="/admin" element={<Admin />} />
<Route path="/api-docs" element={<ApiDocs />} />
<Route path="/inventory" element={<Inventory />} />
</Routes>
<CommandPalette
open={paletteOpen}
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}
data={filteredSearchData}
onSelect={handleSelect}
/>
</>
)
}

View File

@@ -16,6 +16,7 @@ interface CommandPaletteProps {
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
all: 'All',
application: 'Applications',
exchange: 'Exchanges',
route: 'Routes',
agent: 'Agents',
@@ -23,6 +24,7 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
'all',
'application',
'exchange',
'route',
'agent',

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
export type SearchCategory = 'exchange' | 'route' | 'agent'
export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent'
export interface SearchResult {
id: string
@@ -10,6 +10,7 @@ export interface SearchResult {
meta: string
timestamp?: string
icon?: ReactNode
path?: string
expandedContent?: string
matchRanges?: [number, number][]
}

View File

@@ -0,0 +1,11 @@
import './tokens.css'
import './reset.css'
export * from './primitives'
export * from './composites'
export * from './layout'
export * from './providers/ThemeProvider'
export * from './providers/CommandPaletteProvider'
export * from './providers/GlobalFilterProvider'
export * from './utils/hashColor'
export * from './utils/timePresets'

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import styles from './Sidebar.module.css'
import camelLogoUrl from '../../../assets/camel-logo.svg'
@@ -220,6 +220,31 @@ export function Sidebar({ apps, className }: SidebarProps) {
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
// Sidebar reveal from Cmd-K navigation (passed via location state)
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
useEffect(() => {
if (!sidebarRevealPath) return
// Uncollapse Applications section if reveal path matches an apps tree node
const matchesAppTree = appNodes.some((node) =>
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
)
if (matchesAppTree && appsCollapsed) {
_setAppsCollapsed(false)
localStorage.setItem('cameleer:sidebar:apps-collapsed', 'false')
}
// Uncollapse Agents section if reveal path matches an agents tree node
const matchesAgentTree = agentNodes.some((node) =>
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
)
if (matchesAgentTree && agentsCollapsed) {
_setAgentsCollapsed(false)
localStorage.setItem('cameleer:sidebar:agents-collapsed', 'false')
}
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
// Build starred items
const starredItems = useMemo(
() => collectStarredItems(apps, starredIds),
@@ -231,6 +256,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
const starredAgents = starredItems.filter((i) => i.type === 'agent')
const hasStarred = starredItems.length > 0
// For exchange detail pages, use the reveal path for sidebar selection so
// the parent route is highlighted (exchanges have no sidebar entry of their own)
const effectiveSelectedPath = location.pathname.startsWith('/exchanges/') && sidebarRevealPath
? sidebarRevealPath
: location.pathname
return (
<aside className={`${styles.sidebar} ${className ?? ''}`}>
{/* Logo */}
@@ -299,11 +330,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
{!appsCollapsed && (
<SidebarTree
nodes={appNodes}
selectedPath={location.pathname}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:apps"
autoRevealPath={sidebarRevealPath}
/>
)}
</div>
@@ -332,11 +364,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
{!agentsCollapsed && (
<SidebarTree
nodes={agentNodes}
selectedPath={location.pathname}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:agents"
autoRevealPath={sidebarRevealPath}
/>
)}
</div>

View File

@@ -2,6 +2,7 @@ import {
useState,
useRef,
useCallback,
useEffect,
useMemo,
type ReactNode,
type KeyboardEvent,
@@ -31,6 +32,7 @@ export interface SidebarTreeProps {
className?: string
filterQuery?: string
persistKey?: string // sessionStorage key to persist expand state across remounts
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
}
// ── Star icon SVGs ───────────────────────────────────────────────────────────
@@ -138,6 +140,7 @@ export function SidebarTree({
className,
filterQuery,
persistKey,
autoRevealPath,
}: SidebarTreeProps) {
const navigate = useNavigate()
@@ -146,6 +149,27 @@ export function SidebarTree({
() => persistKey ? readExpandState(persistKey) : new Set(),
)
// Auto-expand parent when autoRevealPath changes (e.g. from Cmd-K navigation)
useEffect(() => {
if (!autoRevealPath) return
for (const node of nodes) {
// Check if a child of this node matches the reveal path
if (node.children?.some((child) => child.path === autoRevealPath)) {
if (!userExpandedIds.has(node.id)) {
setUserExpandedIds((prev) => {
const next = new Set(prev)
next.add(node.id)
if (persistKey) writeExpandState(persistKey, next)
return next
})
}
break
}
// Also check if the node itself matches (top-level node, no parent to expand)
if (node.path === autoRevealPath) break
}
}, [autoRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
// Filter
const { filtered, matchedParentIds } = useMemo(
() => filterNodes(nodes, filterQuery ?? ''),

View File

@@ -14,6 +14,14 @@
flex-shrink: 0;
}
/* Filters group: time range + status pills */
.filters {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* Center search trigger */
.search {
display: flex;
@@ -28,9 +36,9 @@
font-family: var(--font-body);
cursor: pointer;
transition: border-color 0.15s;
min-width: 280px;
min-width: 180px;
flex: 1;
max-width: 400px;
max-width: 280px;
text-align: left;
}
@@ -86,16 +94,6 @@
letter-spacing: 0.5px;
}
.shift {
font-family: var(--font-mono);
font-size: 10px;
padding: 3px 10px;
border-radius: 10px;
background: var(--running-bg);
color: var(--running);
border: 1px solid var(--running-border);
}
.user {
display: flex;
align-items: center;

View File

@@ -1,6 +1,10 @@
import styles from './TopBar.module.css'
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
import { Avatar } from '../../primitives/Avatar/Avatar'
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
import { useGlobalFilters, type ExchangeStatus } from '../../providers/GlobalFilterProvider'
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
interface BreadcrumbItem {
label: string
@@ -10,29 +14,51 @@ interface BreadcrumbItem {
interface TopBarProps {
breadcrumb: BreadcrumbItem[]
environment?: string
shift?: string
user?: { name: string }
onSearchClick?: () => void
className?: string
}
const STATUS_PILLS: { status: ExchangeStatus; label: string }[] = [
{ status: 'completed', label: 'OK' },
{ status: 'warning', label: 'Warn' },
{ status: 'failed', label: 'Error' },
{ status: 'running', label: 'Running' },
]
export function TopBar({
breadcrumb,
environment,
shift,
user,
onSearchClick,
className,
}: TopBarProps) {
const globalFilters = useGlobalFilters()
const commandPalette = useCommandPalette()
return (
<header className={`${styles.topbar} ${className ?? ''}`}>
{/* Left: Breadcrumb */}
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
{/* Filters: time range + status pills */}
<div className={styles.filters}>
<TimeRangeDropdown
value={globalFilters.timeRange}
onChange={globalFilters.setTimeRange}
/>
{STATUS_PILLS.map(({ status, label }) => (
<FilterPill
key={status}
label={label}
active={globalFilters.statusFilters.has(status)}
onClick={() => globalFilters.toggleStatus(status)}
/>
))}
</div>
{/* Center: Search trigger */}
<button
className={styles.search}
onClick={onSearchClick}
onClick={() => commandPalette.setOpen(true)}
type="button"
aria-label="Open search"
>
@@ -42,18 +68,15 @@ export function TopBar({
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span>
<span className={styles.searchPlaceholder}>Search by Order ID, route, error...</span>
<span className={styles.searchPlaceholder}>Search... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span>
</button>
{/* Right: env badge, shift, user */}
{/* Right: env badge, user */}
<div className={styles.right}>
{environment && (
<span className={styles.env}>{environment}</span>
)}
{shift && (
<span className={styles.shift}>Shift: {shift}</span>
)}
{user && (
<div className={styles.user}>
<span className={styles.userName}>{user.name}</span>

View File

@@ -2,53 +2,7 @@ import { useState } from 'react'
import styles from './DateRangePicker.module.css'
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
import { FilterPill } from '../FilterPill/FilterPill'
interface DateRange {
start: Date
end: Date
}
interface Preset {
label: string
value: string
}
const DEFAULT_PRESETS: Preset[] = [
{ label: 'Last 1h', value: 'last-1h' },
{ label: 'Last 6h', value: 'last-6h' },
{ label: 'Today', value: 'today' },
{ label: 'This shift', value: 'shift' },
{ label: 'Last 24h', value: 'last-24h' },
{ label: 'Last 7d', value: 'last-7d' },
{ label: 'Custom', value: 'custom' },
]
function computePresetRange(preset: string): DateRange {
const now = new Date()
const end = now
switch (preset) {
case 'last-1h':
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
case 'last-6h':
return { start: new Date(now.getTime() - 6 * 60 * 60 * 1000), end }
case 'today': {
const start = new Date(now)
start.setHours(0, 0, 0, 0)
return { start, end }
}
case 'shift': {
// "This shift" = last 8 hours
return { start: new Date(now.getTime() - 8 * 60 * 60 * 1000), end }
}
case 'last-24h':
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
case 'last-7d':
return { start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), end }
default:
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
}
}
import { DEFAULT_PRESETS, computePresetRange, type DateRange, type Preset } from '../../utils/timePresets'
interface DateRangePickerProps {
value: DateRange

View File

@@ -0,0 +1,45 @@
.trigger {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
height: 28px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--amber, var(--warning));
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
white-space: nowrap;
}
.trigger:hover {
border-color: var(--text-faint);
background: var(--bg-surface);
}
.icon {
font-size: 13px;
line-height: 1;
}
.label {
line-height: 1;
}
.caret {
font-size: 9px;
opacity: 0.7;
line-height: 1;
}
.presetList {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
min-width: 100px;
}

View File

@@ -0,0 +1,55 @@
import styles from './TimeRangeDropdown.module.css'
import { Popover } from '../../composites/Popover/Popover'
import { FilterPill } from '../FilterPill/FilterPill'
import { computePresetRange, PRESET_SHORT_LABELS } from '../../utils/timePresets'
import type { TimeRange } from '../../providers/GlobalFilterProvider'
const DROPDOWN_PRESETS = [
{ value: 'last-1h', label: '1h' },
{ value: 'last-3h', label: '3h' },
{ value: 'last-6h', label: '6h' },
{ value: 'today', label: 'Today' },
{ value: 'shift', label: 'Shift' },
{ value: 'last-24h', label: '24h' },
{ value: 'last-7d', label: '7d' },
]
interface TimeRangeDropdownProps {
value: TimeRange
onChange: (range: TimeRange) => void
className?: string
}
export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) {
const activeLabel = value.preset ? (PRESET_SHORT_LABELS[value.preset] ?? value.preset) : 'Custom'
return (
<Popover
className={className}
position="bottom"
align="start"
trigger={
<button className={styles.trigger} type="button" aria-label="Select time range">
<span className={styles.icon} aria-hidden="true">&#9201;</span>
<span className={styles.label}>{activeLabel}</span>
<span className={styles.caret} aria-hidden="true">&#9662;</span>
</button>
}
content={
<div className={styles.presetList}>
{DROPDOWN_PRESETS.map((preset) => (
<FilterPill
key={preset.value}
label={preset.label}
active={value.preset === preset.value}
onClick={() => {
const range = computePresetRange(preset.value)
onChange({ ...range, preset: preset.value })
}}
/>
))}
</div>
}
/>
)
}

View File

@@ -28,5 +28,6 @@ export { StatCard } from './StatCard/StatCard'
export { StatusDot } from './StatusDot/StatusDot'
export { Tag } from './Tag/Tag'
export { Textarea } from './Textarea/Textarea'
export { TimeRangeDropdown } from './TimeRangeDropdown/TimeRangeDropdown'
export { Toggle } from './Toggle/Toggle'
export { Tooltip } from './Tooltip/Tooltip'

View File

@@ -0,0 +1,28 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
interface CommandPaletteContextValue {
open: boolean
setOpen: (open: boolean) => void
}
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(null)
export function CommandPaletteProvider({ children }: { children: ReactNode }) {
const [open, setOpenState] = useState(false)
const setOpen = useCallback((value: boolean) => {
setOpenState(value)
}, [])
return (
<CommandPaletteContext.Provider value={{ open, setOpen }}>
{children}
</CommandPaletteContext.Provider>
)
}
export function useCommandPalette(): CommandPaletteContextValue {
const ctx = useContext(CommandPaletteContext)
if (!ctx) throw new Error('useCommandPalette must be used within CommandPaletteProvider')
return ctx
}

View File

@@ -0,0 +1,79 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
import { computePresetRange } from '../utils/timePresets'
export interface TimeRange {
start: Date
end: Date
preset: string | null
}
export type ExchangeStatus = 'completed' | 'failed' | 'running' | 'warning'
interface GlobalFilterContextValue {
timeRange: TimeRange
setTimeRange: (range: TimeRange) => void
statusFilters: Set<ExchangeStatus>
toggleStatus: (status: ExchangeStatus) => void
clearStatusFilters: () => void
isInTimeRange: (timestamp: Date) => boolean
}
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
const DEFAULT_PRESET = 'last-3h'
function getDefaultTimeRange(): TimeRange {
const { start, end } = computePresetRange(DEFAULT_PRESET)
return { start, end, preset: DEFAULT_PRESET }
}
export function GlobalFilterProvider({ children }: { children: ReactNode }) {
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
const setTimeRange = useCallback((range: TimeRange) => {
setTimeRangeState(range)
}, [])
const toggleStatus = useCallback((status: ExchangeStatus) => {
setStatusFilters((prev) => {
const next = new Set(prev)
if (next.has(status)) {
next.delete(status)
} else {
next.add(status)
}
return next
})
}, [])
const clearStatusFilters = useCallback(() => {
setStatusFilters(new Set())
}, [])
const isInTimeRange = useCallback(
(timestamp: Date) => {
if (timeRange.preset) {
// Recompute from now so the window stays fresh
const { start } = computePresetRange(timeRange.preset)
return timestamp >= start
}
return timestamp >= timeRange.start && timestamp <= timeRange.end
},
[timeRange],
)
return (
<GlobalFilterContext.Provider
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange }}
>
{children}
</GlobalFilterContext.Provider>
)
}
export function useGlobalFilters(): GlobalFilterContextValue {
const ctx = useContext(GlobalFilterContext)
if (!ctx) throw new Error('useGlobalFilters must be used within GlobalFilterProvider')
return ctx
}

View File

@@ -0,0 +1,59 @@
export interface DateRange {
start: Date
end: Date
}
export interface Preset {
label: string
value: string
}
export const DEFAULT_PRESETS: Preset[] = [
{ label: 'Last 1h', value: 'last-1h' },
{ label: 'Last 6h', value: 'last-6h' },
{ label: 'Today', value: 'today' },
{ label: 'This shift', value: 'shift' },
{ label: 'Last 24h', value: 'last-24h' },
{ label: 'Last 7d', value: 'last-7d' },
{ label: 'Custom', value: 'custom' },
]
export const PRESET_SHORT_LABELS: Record<string, string> = {
'last-1h': '1h',
'last-3h': '3h',
'last-6h': '6h',
'today': 'Today',
'shift': 'Shift',
'last-24h': '24h',
'last-7d': '7d',
'custom': 'Custom',
}
export function computePresetRange(preset: string): DateRange {
const now = new Date()
const end = now
switch (preset) {
case 'last-1h':
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
case 'last-3h':
return { start: new Date(now.getTime() - 3 * 60 * 60 * 1000), end }
case 'last-6h':
return { start: new Date(now.getTime() - 6 * 60 * 60 * 1000), end }
case 'today': {
const start = new Date(now)
start.setHours(0, 0, 0, 0)
return { start, end }
}
case 'shift': {
// "This shift" = last 8 hours
return { start: new Date(now.getTime() - 8 * 60 * 60 * 1000), end }
}
case 'last-24h':
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
case 'last-7d':
return { start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), end }
default:
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
}
}

View File

@@ -2,6 +2,8 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider } from './design-system/providers/ThemeProvider'
import { GlobalFilterProvider } from './design-system/providers/GlobalFilterProvider'
import { CommandPaletteProvider } from './design-system/providers/CommandPaletteProvider'
import App from './App'
import './index.css'
@@ -9,7 +11,11 @@ createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<ThemeProvider>
<App />
<GlobalFilterProvider>
<CommandPaletteProvider>
<App />
</CommandPaletteProvider>
</GlobalFilterProvider>
</ThemeProvider>
</BrowserRouter>
</StrictMode>,

98
src/mocks/searchData.tsx Normal file
View File

@@ -0,0 +1,98 @@
import type { SearchResult } from '../design-system/composites/CommandPalette/types'
import { exchanges, type Exchange } from './exchanges'
import { routes } from './routes'
import { agents } from './agents'
import { SIDEBAR_APPS, type SidebarApp } from './sidebar'
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
return `${ms}ms`
}
function statusLabel(status: Exchange['status']): string {
switch (status) {
case 'completed': return 'OK'
case 'failed': return 'ERR'
case 'running': return 'RUN'
case 'warning': return 'WARN'
}
}
function statusToVariant(status: Exchange['status']): string {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'running': return 'running'
case 'warning': return 'warning'
}
}
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function healthToColor(health: SidebarApp['health']): string {
switch (health) {
case 'live': return 'success'
case 'stale': return 'warning'
case 'dead': return 'error'
}
}
export function buildSearchData(
exs: Exchange[] = exchanges,
rts: typeof routes = routes,
ags: typeof agents = agents,
apps: SidebarApp[] = SIDEBAR_APPS,
): SearchResult[] {
const results: SearchResult[] = []
for (const app of apps) {
const liveAgents = app.agents.filter((a) => a.status === 'live').length
results.push({
id: app.id,
category: 'application',
title: app.name,
badges: [{ label: app.health.toUpperCase(), color: healthToColor(app.health) }],
meta: `${app.routes.length} routes · ${app.agents.length} agents (${liveAgents} live) · ${app.exchangeCount.toLocaleString()} exchanges`,
path: `/apps/${app.id}`,
})
}
for (const exec of exs) {
results.push({
id: exec.id,
category: 'exchange',
title: `${exec.orderId}${exec.route}`,
badges: [{ label: statusLabel(exec.status), color: statusToVariant(exec.status) }],
meta: `${exec.correlationId} · ${formatDuration(exec.durationMs)} · ${exec.customer}`,
timestamp: formatTimestamp(exec.timestamp),
path: `/exchanges/${exec.id}`,
})
}
for (const route of rts) {
results.push({
id: route.id,
category: 'route',
title: route.name,
badges: [{ label: route.group }],
meta: `${route.exchangeCount.toLocaleString()} exchanges · ${route.successRate}% success`,
path: `/routes/${route.id}`,
})
}
for (const agent of ags) {
results.push({
id: agent.id,
category: 'agent',
title: agent.name,
badges: [{ label: agent.status }],
meta: `${agent.service} ${agent.version} · ${agent.tps} · ${agent.lastSeen}`,
path: `/agents/${agent.appId}/${agent.id}`,
})
}
return results
}

View File

@@ -10,7 +10,7 @@ export function Admin() {
<TopBar
breadcrumb={[{ label: 'Admin' }]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
<EmptyState

View File

@@ -18,6 +18,9 @@ import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { Badge } from '../../design-system/primitives/Badge/Badge'
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
// Global filters
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
// Mock data
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
@@ -117,6 +120,7 @@ function buildBreadcrumb(scope: Scope) {
export function AgentHealth() {
const scope = useScope()
const navigate = useNavigate()
const { isInTimeRange } = useGlobalFilters()
// Filter agents by scope
const filteredAgents = useMemo(() => {
@@ -135,8 +139,8 @@ export function AgentHealth() {
const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 0)
const totalActiveRoutes = filteredAgents.reduce((s, a) => s + a.activeRoutes, 0)
// Events are a global timeline feed — show all regardless of scope
const filteredEvents = agentEvents
// Filter events by global time range
const filteredEvents = agentEvents.filter((e) => isInTimeRange(e.timestamp))
// Single instance for expanded charts
const singleInstance = scope.level === 'instance' ? filteredAgents[0] : null

View File

@@ -10,7 +10,7 @@ export function ApiDocs() {
<TopBar
breadcrumb={[{ label: 'API Documentation' }]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
<EmptyState

View File

@@ -16,7 +16,7 @@ export function AppDetail() {
{ label: id ?? '' },
]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
<EmptyState

View File

@@ -8,13 +8,9 @@ import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
// Composites
import { FilterBar } from '../../design-system/composites/FilterBar/FilterBar'
import type { ActiveFilter } from '../../design-system/composites/FilterBar/FilterBar'
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
import type { Column } from '../../design-system/composites/DataTable/types'
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
import { CommandPalette } from '../../design-system/composites/CommandPalette/CommandPalette'
import type { SearchResult } from '../../design-system/composites/CommandPalette/types'
import { ShortcutsBar } from '../../design-system/composites/ShortcutsBar/ShortcutsBar'
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
@@ -24,10 +20,11 @@ import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { Badge } from '../../design-system/primitives/Badge/Badge'
// Global filters
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
// Mock data
import { exchanges, type Exchange } from '../../mocks/exchanges'
import { routes } from '../../mocks/routes'
import { agents } from '../../mocks/agents'
import { kpiMetrics } from '../../mocks/metrics'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
@@ -137,58 +134,6 @@ function durationClass(ms: number, status: Exchange['status']): string {
return styles.durBreach
}
// ─── Build CommandPalette search data ────────────────────────────────────────
function buildSearchData(
exs: Exchange[],
rts: typeof routes,
ags: typeof agents,
): SearchResult[] {
const results: SearchResult[] = []
for (const exec of exs) {
results.push({
id: exec.id,
category: 'exchange',
title: `${exec.orderId}${exec.route}`,
badges: [{ label: statusLabel(exec.status), color: statusToVariant(exec.status) }],
meta: `${exec.correlationId} · ${formatDuration(exec.durationMs)} · ${exec.customer}`,
timestamp: formatTimestamp(exec.timestamp),
})
}
for (const route of rts) {
results.push({
id: route.id,
category: 'route',
title: route.name,
badges: [{ label: route.group }],
meta: `${route.exchangeCount.toLocaleString()} exchanges · ${route.successRate}% success`,
})
}
for (const agent of ags) {
results.push({
id: agent.id,
category: 'agent',
title: agent.name,
badges: [{ label: agent.status }],
meta: `${agent.service} ${agent.version} · ${agent.tps} · ${agent.lastSeen}`,
})
}
return results
}
function buildStatusFilters(exs: Exchange[]) {
return [
{ label: 'All', value: 'all', count: exs.length },
{ label: 'OK', value: 'completed', count: exs.filter((e) => e.status === 'completed').length, color: 'success' as const },
{ label: 'Warn', value: 'warning', count: exs.filter((e) => e.status === 'warning').length },
{ label: 'Error', value: 'failed', count: exs.filter((e) => e.status === 'failed').length, color: 'error' as const },
{ label: 'Running', value: 'running', count: exs.filter((e) => e.status === 'running').length, color: 'running' as const },
]
}
const SHORTCUTS = [
{ keys: 'Ctrl+K', label: 'Search' },
{ keys: '↑↓', label: 'Navigate rows' },
@@ -199,12 +144,11 @@ const SHORTCUTS = [
// ─── Dashboard component ──────────────────────────────────────────────────────
export function Dashboard() {
const { id: appId } = useParams<{ id: string }>()
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>([])
const [search, setSearch] = useState('')
const [selectedId, setSelectedId] = useState<string | undefined>()
const [panelOpen, setPanelOpen] = useState(false)
const [selectedExchange, setSelectedExchange] = useState<Exchange | null>(null)
const [paletteOpen, setPaletteOpen] = useState(false)
const { isInTimeRange, statusFilters } = useGlobalFilters()
// Build set of route IDs belonging to the selected app (if any)
const appRouteIds = useMemo(() => {
@@ -214,55 +158,26 @@ export function Dashboard() {
return new Set(app.routes.map((r) => r.id))
}, [appId])
const selectedApp = appId ? SIDEBAR_APPS.find((a) => a.id === appId) : null
// Scope all data to the selected app
const scopedExchanges = useMemo(() => {
if (!appRouteIds) return exchanges
return exchanges.filter((e) => appRouteIds.has(e.route))
}, [appRouteIds])
const scopedRoutes = useMemo(() => {
if (!appRouteIds) return routes
return routes.filter((r) => appRouteIds.has(r.id))
}, [appRouteIds])
const scopedAgents = useMemo(() => {
if (!selectedApp) return agents
const agentIds = new Set(selectedApp.agents.map((a) => a.id))
return agents.filter((a) => agentIds.has(a.id))
}, [selectedApp])
// Filter exchanges (scoped + user filters)
// Filter exchanges (scoped + global filters)
const filteredExchanges = useMemo(() => {
let data = scopedExchanges
const statusFilter = activeFilters.find((f) =>
['completed', 'failed', 'running', 'warning', 'all'].includes(f.value),
)
if (statusFilter && statusFilter.value !== 'all') {
data = data.filter((e) => e.status === statusFilter.value)
}
// Time range filter
data = data.filter((e) => isInTimeRange(e.timestamp))
if (search.trim()) {
const q = search.toLowerCase()
data = data.filter(
(e) =>
e.orderId.toLowerCase().includes(q) ||
e.route.toLowerCase().includes(q) ||
e.customer.toLowerCase().includes(q) ||
e.correlationId.toLowerCase().includes(q) ||
(e.errorMessage?.toLowerCase().includes(q) ?? false),
)
// Status filter
if (statusFilters.size > 0) {
data = data.filter((e) => statusFilters.has(e.status))
}
return data
}, [activeFilters, search, scopedExchanges])
const searchData = useMemo(
() => buildSearchData(scopedExchanges, scopedRoutes, scopedAgents),
[scopedExchanges, scopedRoutes, scopedAgents],
)
}, [scopedExchanges, isInTimeRange, statusFilters])
function handleRowClick(row: Exchange) {
setSelectedId(row.id)
@@ -392,7 +307,6 @@ export function Dashboard() {
}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
onSearchClick={() => setPaletteOpen(true)}
/>
{/* Scrollable content */}
@@ -414,17 +328,6 @@ export function Dashboard() {
))}
</div>
{/* Filter bar */}
<FilterBar
filters={buildStatusFilters(scopedExchanges)}
activeFilters={activeFilters}
onFilterChange={setActiveFilters}
searchPlaceholder="Search by Order ID, correlation ID, error message..."
searchValue={search}
onSearchChange={setSearch}
className={styles.filterBar}
/>
{/* Exchanges table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
@@ -459,15 +362,6 @@ export function Dashboard() {
</div>
</div>
{/* Command palette */}
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onSelect={() => setPaletteOpen(false)}
data={searchData}
onOpen={() => setPaletteOpen(true)}
/>
{/* Shortcuts bar */}
<ShortcutsBar shortcuts={SHORTCUTS} />
</AppShell>

View File

@@ -122,7 +122,7 @@ export function ExchangeDetail() {
{ label: id ?? 'Unknown' },
]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
<div className={styles.content}>
@@ -149,7 +149,6 @@ export function ExchangeDetail() {
{ label: exchange.id },
]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>

View File

@@ -120,9 +120,8 @@ export function LayoutSection() {
{ label: 'order-ingest' },
]}
environment="production"
shift="Morning"
user={{ name: 'Hendrik' }}
onSearchClick={() => undefined}
/>
</div>
</DemoCard>

View File

@@ -7,24 +7,12 @@
background: var(--bg-body);
}
/* Date range picker bar */
.dateRangeBar {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 10px 16px;
margin-bottom: 16px;
box-shadow: var(--shadow-card);
}
.refreshIndicator {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-bottom: 12px;
justify-content: flex-end;
}
.refreshDot {

View File

@@ -1,4 +1,3 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import styles from './Metrics.module.css'
@@ -16,7 +15,6 @@ import type { Column } from '../../design-system/composites/DataTable/types'
// Primitives
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
import { DateRangePicker } from '../../design-system/primitives/DateRangePicker/DateRangePicker'
import { Sparkline } from '../../design-system/primitives/Sparkline/Sparkline'
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { Badge } from '../../design-system/primitives/Badge/Badge'
@@ -192,10 +190,6 @@ function convertSeries(series: typeof throughputSeries) {
// ─── Metrics page ─────────────────────────────────────────────────────────────
export function Metrics() {
const navigate = useNavigate()
const [dateRange, setDateRange] = useState({
start: new Date('2026-03-18T06:00:00'),
end: new Date('2026-03-18T09:15:00'),
})
return (
<AppShell
@@ -210,20 +204,16 @@ export function Metrics() {
{ label: 'Metrics' },
]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
{/* Scrollable content */}
<div className={styles.content}>
{/* Date range picker bar */}
<div className={styles.dateRangeBar}>
<DateRangePicker value={dateRange} onChange={setDateRange} />
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
{/* Auto-refresh indicator */}
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
{/* KPI stat cards (5) */}

View File

@@ -210,7 +210,7 @@ export function RouteDetail() {
{ label: id ?? 'Unknown' },
]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
<div className={styles.content}>
@@ -236,7 +236,7 @@ export function RouteDetail() {
{ label: route.name },
]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>

View File

@@ -20,5 +20,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "vite.lib.config.ts"]
}

34
vite.lib.config.ts Normal file
View File

@@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import dts from 'vite-plugin-dts'
import { resolve } from 'path'
export default defineConfig({
plugins: [
react(),
dts({
include: ['src/design-system'],
exclude: ['**/*.test.tsx', '**/*.test.ts'],
outDir: 'dist',
tsconfigPath: resolve(__dirname, 'tsconfig.app.json'),
rollupTypes: true,
}),
],
css: {
modules: {
localsConvention: 'camelCase',
generateScopedName: 'cameleer_[name]_[local]_[hash:5]',
},
},
build: {
lib: {
entry: resolve(__dirname, 'src/design-system/index.ts'),
formats: ['es'],
fileName: () => 'index.es.js',
cssFileName: 'style',
},
rollupOptions: {
external: ['react', 'react-dom', 'react-router-dom', 'react/jsx-runtime'],
},
},
})