Compare commits
142 Commits
59c4638f85
...
e25969d5dc
| Author | SHA1 | Date | |
|---|---|---|---|
| e25969d5dc | |||
|
|
325dfcae8e | ||
|
|
b061cc47f3 | ||
|
|
f4ea7b4a5b | ||
|
|
484ed05c1f | ||
|
|
4468a69526 | ||
| c0db29014b | |||
| ec055f6948 | |||
| 72fa2d297f | |||
|
|
a8538db774 | ||
|
|
b2ce22d81c | ||
|
|
07c0d304bc | ||
|
|
4df134ec96 | ||
|
|
fb47d939a7 | ||
|
|
32328247ad | ||
|
|
e06b730161 | ||
|
|
e83ab9907e | ||
|
|
da98c9fd3f | ||
|
|
4f9ec3332f | ||
|
|
b65e818f3d | ||
|
|
7930e8b0bd | ||
|
|
6f6e005114 | ||
|
|
33c1d8d5fa | ||
|
|
40fd0e05ad | ||
|
|
873955ac04 | ||
|
|
95dd7aa1d0 | ||
|
|
8eb02c48aa | ||
|
|
8f51570da7 | ||
|
|
fada354df8 | ||
|
|
721d3fcb48 | ||
|
|
1846201364 | ||
| 305d3d7eaa | |||
| fff5d993ba | |||
| fe8338c2c5 | |||
| f91d5d57a1 | |||
|
|
263bee6886 | ||
|
|
f12009f49c | ||
|
|
aa0e67d219 | ||
|
|
89a56739bd | ||
|
|
94ef3f1c20 | ||
|
|
997002e913 | ||
|
|
b4e450d68a | ||
|
|
50704c6e45 | ||
|
|
01f79afaf3 | ||
|
|
a993c5c924 | ||
|
|
df72199cd1 | ||
|
|
b2539f21d0 | ||
|
|
42aaea03e5 | ||
|
|
060daa4883 | ||
|
|
81b5ed0baf | ||
|
|
cbd4274a52 | ||
|
|
bfccd48b37 | ||
|
|
002b55c3c0 | ||
|
|
9ac311f138 | ||
|
|
3fc559d066 | ||
|
|
fd7f12513d | ||
|
|
a7cb866cab | ||
|
|
dfdc9777c4 | ||
|
|
f2dfddf944 | ||
|
|
95084d38b3 | ||
|
|
51c30ed73d | ||
|
|
f612d2ecd1 | ||
|
|
8354595d0f | ||
|
|
aa36671e95 | ||
|
|
c9e65d435c | ||
|
|
a7a6f191ca | ||
|
|
64e6cc11a1 | ||
|
|
3d22c3031f | ||
|
|
3a5196417d | ||
|
|
32dac7d466 | ||
|
|
ddf25cf501 | ||
|
|
328e307212 | ||
|
|
ccee7dfae2 | ||
|
|
3a9621f0c2 | ||
|
|
73d2891d8e | ||
|
|
18a09b25b8 | ||
|
|
3515fbddc5 | ||
|
|
b9bc2163f4 | ||
|
|
772609c919 | ||
|
|
1c231d6659 | ||
|
|
f0911e65ed | ||
|
|
8738d07da7 | ||
|
|
d0707f22a5 | ||
|
|
77b074f3c0 | ||
|
|
6429436935 | ||
|
|
dc01dd0285 | ||
|
|
6381cabe68 | ||
|
|
0e89dafa43 | ||
|
|
5bd2b92493 | ||
|
|
7cef6a918a | ||
|
|
5edc4c92f2 | ||
|
|
0985969377 | ||
|
|
7e1a2ad660 | ||
|
|
d04a303f98 | ||
|
|
fc8502ac0c | ||
|
|
3224199894 | ||
|
|
9970a68a35 | ||
|
|
a55ea16370 | ||
|
|
6cd60e86e8 | ||
|
|
e5061350a5 | ||
|
|
a473a45aae | ||
|
|
8562711f49 | ||
|
|
b2e45380ec | ||
|
|
da8d4ecb19 | ||
|
|
d57ed9b878 | ||
|
|
99e59597d1 | ||
|
|
2affd1aba0 | ||
|
|
4af06f732b | ||
|
|
2eaec32849 | ||
|
|
839f3ff794 | ||
|
|
87e3bc490a | ||
|
|
ec9e11b243 | ||
|
|
57a09c31dd | ||
|
|
d6ba66f27f | ||
|
|
8e98cb1acb | ||
|
|
71334b8941 | ||
|
|
a55b819533 | ||
|
|
0e96f8c295 | ||
|
|
d986db163b | ||
|
|
e72323b8db | ||
|
|
850b9d35a2 | ||
|
|
a94d9b211c | ||
|
|
acb8f02bca | ||
| e29cc58ae7 | |||
| 1c09e4798b | |||
| af035e7d33 | |||
|
|
d082480849 | ||
|
|
e2eea086dc | ||
|
|
c9a7f905af | ||
|
|
ed5fb707ad | ||
|
|
cc0bffea86 | ||
|
|
7312689376 | ||
|
|
fe979fc789 | ||
|
|
c908c6d7bb | ||
|
|
a418a23e56 | ||
|
|
925c081ce3 | ||
|
|
130883a7db | ||
|
|
dd90f709e1 | ||
|
|
cfcfc7bb90 | ||
|
|
678d8df42c | ||
|
|
610a59fdc3 | ||
|
|
d1d88db4dd |
@ -79,6 +79,13 @@ FIELD_ENCRYPT_KEY=
|
||||
# Product-specific MEK name in AKV — only for 'akv' provider
|
||||
FIELD_ENCRYPT_MEK_NAME=lysnr-mek
|
||||
|
||||
# ── Gitea NPM Registry (private @bytelyst packages) ─────────
|
||||
# Token for authenticating with the Gitea npm registry.
|
||||
# Generate at: http://<GITEA_NPM_HOST>:3300/user/settings/applications
|
||||
GITEA_NPM_TOKEN=
|
||||
GITEA_NPM_HOST=localhost
|
||||
GITEA_NPM_OWNER=learning_ai_user
|
||||
|
||||
# ── Product Identity ──────────────────────────────────────────
|
||||
DEFAULT_PRODUCT_ID=lysnrai
|
||||
|
||||
|
||||
@ -48,7 +48,7 @@ jobs:
|
||||
run: |
|
||||
cp /run/secrets/gitea_publish_npmrc /tmp/publish.npmrc
|
||||
chmod 600 /tmp/publish.npmrc
|
||||
npm whoami --userconfig /tmp/publish.npmrc --registry https://gitea.bytelyst.com/api/packages/bytelyst/npm/
|
||||
npm whoami --userconfig /tmp/publish.npmrc --registry https://gitea.bytelyst.com/api/packages/learning_ai_user/npm/
|
||||
|
||||
- name: Install workspace deps
|
||||
run: HUSKY=0 pnpm install --frozen-lockfile
|
||||
|
||||
@ -48,7 +48,7 @@ jobs:
|
||||
run: |
|
||||
cp /run/secrets/gitea_publish_npmrc /tmp/publish.npmrc
|
||||
chmod 600 /tmp/publish.npmrc
|
||||
npm whoami --userconfig /tmp/publish.npmrc --registry https://gitea.bytelyst.com/api/packages/bytelyst/npm/
|
||||
npm whoami --userconfig /tmp/publish.npmrc --registry https://gitea.bytelyst.com/api/packages/learning_ai_user/npm/
|
||||
|
||||
- name: Install workspace deps
|
||||
run: HUSKY=0 pnpm install --frozen-lockfile
|
||||
|
||||
@ -55,7 +55,7 @@ jobs:
|
||||
chmod 600 /tmp/publish.npmrc
|
||||
echo "Configured registry:"
|
||||
sed -E 's#(_auth(Token)?=).*#\1***#; s#(//[^[:space:]]+:)_authToken=.*#\1_authToken=***#' /tmp/publish.npmrc
|
||||
npm whoami --userconfig /tmp/publish.npmrc --registry https://gitea.bytelyst.com/api/packages/bytelyst/npm/
|
||||
npm whoami --userconfig /tmp/publish.npmrc --registry https://gitea.bytelyst.com/api/packages/learning_ai_user/npm/
|
||||
|
||||
- name: Install workspace deps
|
||||
run: HUSKY=0 pnpm install --frozen-lockfile
|
||||
|
||||
77
.gitea/workflows/size-limit.yml
Normal file
77
.gitea/workflows/size-limit.yml
Normal file
@ -0,0 +1,77 @@
|
||||
name: Size limit
|
||||
|
||||
# ROADMAP TODO #6 — enforces bundle-size budgets defined in
|
||||
# .size-limit.cjs on every push to main and every PR. Failures block
|
||||
# merge so that bundle bloat is a visible, deliberate decision.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- '.size-limit.cjs'
|
||||
- '.gitea/workflows/size-limit.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- '.size-limit.cjs'
|
||||
|
||||
concurrency:
|
||||
group: size-limit-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
size:
|
||||
name: size-limit
|
||||
runs-on: [ubuntu-latest, bytelyst, hostinger]
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
github-server-url: https://gitea.bytelyst.com
|
||||
|
||||
- name: Install pinned pnpm
|
||||
run: |
|
||||
npm install -g pnpm@10.6.5
|
||||
pnpm --version
|
||||
|
||||
- name: Install dependencies
|
||||
run: HUSKY=0 pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build measured packages
|
||||
# size-limit measures dist/ — ensure every entry in
|
||||
# .size-limit.cjs has a fresh build before running.
|
||||
run: |
|
||||
pnpm --filter @bytelyst/api-client \
|
||||
--filter @bytelyst/auth-client \
|
||||
--filter @bytelyst/celebrations \
|
||||
--filter @bytelyst/quick-actions \
|
||||
--filter @bytelyst/react-auth \
|
||||
--filter @bytelyst/dashboard-shell \
|
||||
--filter @bytelyst/ai-ui \
|
||||
--filter @bytelyst/command-palette \
|
||||
run build
|
||||
|
||||
- name: Enforce size budgets
|
||||
run: pnpm size
|
||||
|
||||
- name: Upload size report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: size-limit-${{ github.run_id }}
|
||||
path: |
|
||||
.size-limit/
|
||||
retention-days: 14
|
||||
continue-on-error: true
|
||||
@ -23,3 +23,13 @@ echo "🐶 Running pre-commit hooks for common platform..."
|
||||
|
||||
# Run lint-staged on staged files
|
||||
pnpm exec lint-staged
|
||||
|
||||
# CC.8 — Re-run the roadmap progress counter whenever the roadmap is staged.
|
||||
# Updates the §11.2 progress block + per-wave headings + re-stages the file
|
||||
# so the commit captures the refreshed counts. Silent no-op when the file
|
||||
# wasn't touched.
|
||||
if git diff --cached --name-only | grep -q "^docs/UI_ROADMAP_2026_V3_CROSS_REPO\.md$"; then
|
||||
echo "📊 Refreshing roadmap progress counter..."
|
||||
pnpm dlx tsx scripts/count-roadmap-progress.ts docs/UI_ROADMAP_2026_V3_CROSS_REPO.md \
|
||||
&& git add docs/UI_ROADMAP_2026_V3_CROSS_REPO.md
|
||||
fi
|
||||
|
||||
103
.size-limit.cjs
Normal file
103
.size-limit.cjs
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* ROADMAP TODO #6 — Bundle-size budgets for @bytelyst/* packages.
|
||||
*
|
||||
* Each entry measures the gzipped size of a package's built `dist/`
|
||||
* output. The 'limit' field is the budget — PRs that exceed it fail CI.
|
||||
*
|
||||
* Initial budgets per learning_ai_uxui_web/docs/ROADMAP_2026.md §5
|
||||
* "Performance budgets":
|
||||
* - Pure-TS clients → 8 KB
|
||||
* - Feature packs → 6 KB
|
||||
* - UI primitive slices → ~1 KB per primitive (whole pkg < 30 KB)
|
||||
* - Tokens / design-tokens → 12 KB (CSS heavy)
|
||||
*
|
||||
* Pilot scope (this commit): wire up 6 representative packages. Rollout
|
||||
* to the rest of @bytelyst/* lands incrementally as packages stabilise.
|
||||
*
|
||||
* Run locally:
|
||||
* pnpm -w size — full check
|
||||
* pnpm -w size --why <name> — explain what's contributing
|
||||
*
|
||||
* To add a package:
|
||||
* 1. Confirm the package has 'build' in its scripts and emits to dist/
|
||||
* 2. Add an entry below with name, path, and limit
|
||||
* 3. Run `pnpm -w size --update` to record the current baseline if
|
||||
* you're starting under-budget (optional)
|
||||
*/
|
||||
module.exports = [
|
||||
// ── Pure-TS clients (8 KB) ──────────────────────────────────────
|
||||
{
|
||||
name: '@bytelyst/api-client',
|
||||
path: 'packages/api-client/dist/index.js',
|
||||
limit: '8 KB',
|
||||
gzip: true,
|
||||
},
|
||||
{
|
||||
name: '@bytelyst/auth-client',
|
||||
path: 'packages/auth-client/dist/index.js',
|
||||
limit: '8 KB',
|
||||
gzip: true,
|
||||
},
|
||||
// ── Feature packs (6 KB) ────────────────────────────────────────
|
||||
{
|
||||
name: '@bytelyst/celebrations',
|
||||
path: 'packages/celebrations/dist/index.js',
|
||||
limit: '6 KB',
|
||||
gzip: true,
|
||||
},
|
||||
{
|
||||
name: '@bytelyst/quick-actions',
|
||||
path: 'packages/quick-actions/dist/index.js',
|
||||
limit: '6 KB',
|
||||
gzip: true,
|
||||
},
|
||||
// ── React bindings (10 KB — slightly higher for hooks + context) ─
|
||||
{
|
||||
name: '@bytelyst/react-auth',
|
||||
path: 'packages/react-auth/dist/index.js',
|
||||
limit: '10 KB',
|
||||
gzip: true,
|
||||
},
|
||||
// ── Shells / composite UI (30 KB) ───────────────────────────────
|
||||
{
|
||||
name: '@bytelyst/dashboard-shell',
|
||||
path: 'packages/dashboard-shell/dist/index.js',
|
||||
limit: '30 KB',
|
||||
gzip: true,
|
||||
},
|
||||
// ── AI-native UI (35 KB — streaming + parsing is heavy) ─────────
|
||||
{
|
||||
name: '@bytelyst/ai-ui',
|
||||
path: 'packages/ai-ui/dist/index.js',
|
||||
limit: '35 KB',
|
||||
gzip: true,
|
||||
},
|
||||
// ── Command palette (15 KB — fuzzy + dialog + registry) ─────────
|
||||
{
|
||||
name: '@bytelyst/command-palette',
|
||||
path: 'packages/command-palette/dist/index.js',
|
||||
limit: '15 KB',
|
||||
gzip: true,
|
||||
},
|
||||
// ── Motion primitives (8 KB — 5 components, zero deps) ──────────
|
||||
{
|
||||
name: '@bytelyst/motion',
|
||||
path: 'packages/motion/dist/index.js',
|
||||
limit: '8 KB',
|
||||
gzip: true,
|
||||
},
|
||||
// ── Data-viz primitives (10 KB — 5 SVG components) ──────────────
|
||||
{
|
||||
name: '@bytelyst/data-viz',
|
||||
path: 'packages/data-viz/dist/index.js',
|
||||
limit: '10 KB',
|
||||
gzip: true,
|
||||
},
|
||||
// ── Notifications UI (10 KB — center + banner + announcement) ───
|
||||
{
|
||||
name: '@bytelyst/notifications-ui',
|
||||
path: 'packages/notifications-ui/dist/index.js',
|
||||
limit: '10 KB',
|
||||
gzip: true,
|
||||
},
|
||||
];
|
||||
@ -1,6 +1,7 @@
|
||||
# AGENTS.md — AI Coding Agent Instructions
|
||||
|
||||
<!-- BEGIN: canonical-behavior-pointer (auto-managed) -->
|
||||
|
||||
> **Read first (ecosystem-wide agent behavior):**
|
||||
> [`AI.dev/SKILLS/agent-behavior-guidelines.md`](AI.dev/SKILLS/agent-behavior-guidelines.md)
|
||||
>
|
||||
@ -10,6 +11,7 @@
|
||||
> Cosmos doc, conventional commits, style preservation).
|
||||
>
|
||||
> The per-repo content below extends — never duplicates — the canonical rules.
|
||||
|
||||
<!-- END: canonical-behavior-pointer -->
|
||||
|
||||
> **For:** Claude Code, OpenAI Codex, Cursor, GitHub Copilot, Windsurf Cascade, and any AI coding agent.
|
||||
@ -158,7 +160,7 @@ learning_ai_common_plat/
|
||||
- **Template:** `scripts/npmrc.template` in this repo (learning_ai_common_plat)
|
||||
- **Sync:** `bash scripts/sync-npmrc.sh` — copies template to all 13 product repos
|
||||
- **Audit:** `bash scripts/check-npmrc-drift.sh` — detects drift (CI-ready, exits 1 on mismatch)
|
||||
- **Key config:** `@bytelyst:registry` uses `${GITEA_NPM_HOST:-localhost}:3300` SSH tunnel — never hardcode `gitea.bytelyst.com` (unreachable on corp network)
|
||||
- **Key config:** `@bytelyst:registry` uses `${GITEA_NPM_HOST:-localhost}` — never hardcode `gitea.bytelyst.com` (unreachable on corp network)
|
||||
- **If adding a new repo:** add it to the `REPOS` array in both `sync-npmrc.sh` and `check-npmrc-drift.sh`, then run sync
|
||||
|
||||
### MUST NOT do
|
||||
@ -166,7 +168,7 @@ learning_ai_common_plat/
|
||||
- Never use `console.log` in production code — use `req.log` or `app.log` in Fastify
|
||||
- Never use `any` type — use Zod inference or explicit types
|
||||
- Never hardcode secrets or API keys
|
||||
- Never hardcode `gitea.bytelyst.com` in `.npmrc` — use `${GITEA_NPM_HOST:-localhost}:3300` via the canonical template
|
||||
- Never hardcode `gitea.bytelyst.com` in `.npmrc` — use `${GITEA_NPM_HOST:-localhost}` via the canonical template
|
||||
- Secret guardrails: Husky runs `scripts/secret-scan-staged.sh` (pre-commit) and `scripts/secret-scan-repo.sh` (pre-push). See `docs/WINDSURF/CODEX_SESSION_SUMMARY_AND_PLAYBOOK.md`.
|
||||
- Never commit real emulator keys or blob account keys in tracked files; keep placeholders in `.env.example`
|
||||
- Never modify tests to make them pass — fix the actual code
|
||||
|
||||
98
AI.dev/CHEATSHEETS/README.md
Normal file
98
AI.dev/CHEATSHEETS/README.md
Normal file
@ -0,0 +1,98 @@
|
||||
# AI Coding CLI Cheat Sheets
|
||||
|
||||
> **Location:** `AI.dev/CHEATSHEETS/`
|
||||
> **Audience:** Every developer in the ByteLyst ecosystem who delegates work to a
|
||||
> terminal-based AI coding agent.
|
||||
> **Companion docs:** [`../SKILLS/`](../SKILLS/) (how-to skills), [`../PROMPTS/`](../PROMPTS/)
|
||||
> (reusable copy-paste prompts), and the canonical
|
||||
> [`../SKILLS/agent-behavior-guidelines.md`](../SKILLS/agent-behavior-guidelines.md)
|
||||
> (the rules every agent must follow, regardless of which CLI runs it).
|
||||
|
||||
---
|
||||
|
||||
## What's here
|
||||
|
||||
Quick, dense reference cards for the three terminal AI agents we use to delegate
|
||||
coding work. Each sheet is **task-oriented** — commands, modes, session management,
|
||||
config, and the ByteLyst-specific guardrails — not a marketing overview.
|
||||
|
||||
| CLI | Cheat sheet | Best for |
|
||||
| ------------------ | -------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| 🤖 **Devin** | [`devin-cli.md`](./devin-cli.md) | Long-running autonomous sessions; delegate a scoped roadmap and walk away |
|
||||
| 🟣 **Claude Code** | [`claude-code-cli.md`](./claude-code-cli.md) | Interactive pair-programming in the terminal; deep multi-file edits with review |
|
||||
| 🟢 **Codex CLI** | [`codex-cli.md`](./codex-cli.md) | Fast local edits + scriptable `exec` runs in CI / one-shot automation |
|
||||
|
||||
---
|
||||
|
||||
## The 30-second mental model
|
||||
|
||||
| | Devin | Claude Code | Codex CLI |
|
||||
| ----------------------- | ----------------------------- | -------------------------------- | --------------------------------------------------------------- |
|
||||
| **Interaction** | Mostly fire-and-forget | Interactive REPL | Interactive **or** `exec` one-shot |
|
||||
| **Strength** | Autonomy over many steps | Reasoning + careful edits | Speed + scripting |
|
||||
| **Auto-approve flag** | `--permission-mode dangerous` | `--dangerously-skip-permissions` | `--dangerously-bypass-approvals-and-sandbox` (or `--full-auto`) |
|
||||
| **Isolation** | `--sandbox` | OS sandbox / devcontainer | `--sandbox <mode>` (built-in) |
|
||||
| **Per-repo rules file** | `AGENTS.md` | `AGENTS.md` (+ `CLAUDE.md`) | `AGENTS.md` |
|
||||
| **Resume a session** | `devin -r [id]` | `claude --resume` / `-c` | `codex resume` |
|
||||
|
||||
> ⚠️ Exact flags drift between releases. **Always confirm with `<cli> --help`.** The
|
||||
> durable value of these sheets is the ByteLyst workflow, not the flag spelling.
|
||||
|
||||
---
|
||||
|
||||
## Which CLI should I use?
|
||||
|
||||
- **Delegating a scoped, multi-step job and want to walk away?** → **Devin**. Point it at
|
||||
a self-contained roadmap (see [`../PROMPTS/`](../PROMPTS/)) and let it run.
|
||||
- **Working through a hard problem and want to review each move?** → **Claude Code** in
|
||||
**plan mode** — it proposes, you approve, it executes.
|
||||
- **Need a one-shot, scriptable run inside CI / Gitea Actions?** → **Codex CLI**
|
||||
`codex exec` — non-interactive, exits with a status you can gate on.
|
||||
- **Just making fast local edits?** → whichever you have open; **Codex** or **Claude Code**
|
||||
interactive are both quick.
|
||||
|
||||
| Official docs | |
|
||||
| ------------- | ----------------------------------------------------------------------------- |
|
||||
| Devin | <https://docs.devin.ai> |
|
||||
| Claude Code | <https://docs.anthropic.com/en/docs/claude-code> |
|
||||
| Codex CLI | <https://developers.openai.com/codex/cli> · <https://github.com/openai/codex> |
|
||||
|
||||
---
|
||||
|
||||
## Rules that apply no matter which CLI you run
|
||||
|
||||
These come from [`../SKILLS/agent-behavior-guidelines.md`](../SKILLS/agent-behavior-guidelines.md)
|
||||
and the per-repo `AGENTS.md`. Put them in the agent's opening prompt every time:
|
||||
|
||||
1. **Tests are sacred** — never delete/weaken a test to go green; fix the code.
|
||||
2. **Verify before "done"** — only claim success after the real gate
|
||||
(`typecheck` + `test` + `build`) passes. No fabricated results.
|
||||
3. **Scope lock** — never hand-edit shared infra (`.npmrc`, `docker-prep.sh`),
|
||||
and don't touch repos outside the task.
|
||||
4. **No `console.log` / `print`** in product code.
|
||||
5. **`productId` on every Cosmos document.**
|
||||
6. **Conventional commits** — `type(scope): description`.
|
||||
7. **Style preservation** — match the file's existing style; don't add emojis to code.
|
||||
|
||||
## ByteLyst environment facts every agent needs
|
||||
|
||||
- **Package manager:** `pnpm` workspace. Shared packages link via `workspace:*` /
|
||||
`"*"` — no registry needed for local builds.
|
||||
- **Next.js apps build with `next build --webpack`** (not plain/Turbopack).
|
||||
- **Corporate network:** commands run behind a TLS-intercepting proxy
|
||||
(`NETWORK=corp`). The Gitea npm registry is reached over an SSH tunnel at
|
||||
`localhost:3300`. Gradle needs the custom truststore (`$GRADLE_OPTS`).
|
||||
- **Monorepo root:** `learning_ai_common_plat` holds `packages/*`, `services/*`,
|
||||
and `dashboards/*`. Product repos are siblings under the same parent.
|
||||
|
||||
---
|
||||
|
||||
## Adding / updating a cheat sheet
|
||||
|
||||
1. Edit or create `AI.dev/CHEATSHEETS/<cli>-cli.md`.
|
||||
2. Keep the section order consistent across sheets (Install → Auth → Core commands
|
||||
→ Modes → Sessions → Config → ByteLyst workflow → Troubleshooting → Quick card).
|
||||
3. Add it to the table above.
|
||||
4. Commit: `docs(cheatsheets): update <cli> CLI cheat sheet`.
|
||||
|
||||
_Last updated: 2026-05-28_
|
||||
127
AI.dev/CHEATSHEETS/claude-code-cli.md
Normal file
127
AI.dev/CHEATSHEETS/claude-code-cli.md
Normal file
@ -0,0 +1,127 @@
|
||||
# 🟣 Claude Code CLI — Cheat Sheet
|
||||
|
||||
> **What it is:** Anthropic's **Claude Code** — an agentic coding tool that runs in your
|
||||
> terminal, reads/edits files, runs commands, and pair-programs interactively.
|
||||
> **Best for:** Deep, multi-file changes where you want to stay in the loop — reasoning
|
||||
> through a refactor, debugging across modules, or building a feature with review at each step.
|
||||
> **Per-repo rules:** reads `AGENTS.md` (canonical) and, if present, `CLAUDE.md`.
|
||||
|
||||
> ⚠️ **Flags/commands drift between versions.** Confirm with `claude --help` and
|
||||
> `/help` inside a session.
|
||||
>
|
||||
> **Official docs:** <https://docs.anthropic.com/en/docs/claude-code>
|
||||
|
||||
---
|
||||
|
||||
## Install & auth
|
||||
|
||||
```bash
|
||||
npm install -g @anthropic-ai/claude-code # or the documented installer
|
||||
claude --version
|
||||
claude # first run walks you through login
|
||||
```
|
||||
|
||||
- Auth via Anthropic account (Pro/Max) or an `ANTHROPIC_API_KEY`.
|
||||
- Config & history live under `~/.claude/`.
|
||||
|
||||
## Launching
|
||||
|
||||
```bash
|
||||
claude # interactive REPL in the current dir
|
||||
claude "explain src/server.ts and propose a refactor" # seed a first prompt
|
||||
claude -p "list the failing tests" # print mode: one-shot, non-interactive
|
||||
cat error.log | claude -p "what's the root cause?" # pipe context in
|
||||
```
|
||||
|
||||
## Session management
|
||||
|
||||
```bash
|
||||
claude -c # continue the most recent conversation
|
||||
claude --resume # pick a past session to resume
|
||||
claude --resume <id> "<prompt>" # resume a specific session with a new instruction
|
||||
```
|
||||
|
||||
Inside a session, history is preserved; use `/clear` to reset context when switching tasks.
|
||||
|
||||
## Permission modes
|
||||
|
||||
| How | Effect |
|
||||
| -------------------------------- | ------------------------------------------------------- |
|
||||
| _(default)_ | Prompts before edits / commands |
|
||||
| `--permission-mode acceptEdits` | Auto-accepts file edits, still gates commands |
|
||||
| `--permission-mode plan` | **Plan mode** — proposes a plan, makes no changes |
|
||||
| `--dangerously-skip-permissions` | Auto-approves **everything** (use only in a sandbox/VM) |
|
||||
|
||||
```bash
|
||||
claude --permission-mode plan # safe: get a plan first, no edits
|
||||
claude --dangerously-skip-permissions # full auto — only in throwaway/sandboxed env
|
||||
```
|
||||
|
||||
> 🧠 Prefer **plan mode** for anything non-trivial: review the plan, then let it execute.
|
||||
> Reserve `--dangerously-skip-permissions` for isolated environments (devcontainer/VM).
|
||||
|
||||
## Useful in-session slash commands
|
||||
|
||||
| Command | Purpose |
|
||||
| --------------- | ------------------------------------------- |
|
||||
| `/help` | List commands |
|
||||
| `/clear` | Reset conversation context |
|
||||
| `/init` | Generate/refresh a `CLAUDE.md` for the repo |
|
||||
| `/review` | Review recent changes |
|
||||
| `/model` | Switch model |
|
||||
| `/agents` | Manage subagents |
|
||||
| `/mcp` | Inspect/connect MCP servers |
|
||||
| `#` (prefix) | Add a durable memory to `CLAUDE.md` |
|
||||
| `@path/to/file` | Pull a file into context |
|
||||
|
||||
## Config files & memory
|
||||
|
||||
- **`AGENTS.md`** (repo root) — canonical agent rules; Claude reads it.
|
||||
- **`CLAUDE.md`** — optional Claude-specific memory; `/init` scaffolds it, `#` appends to it.
|
||||
- **`.claude/settings.json`** (repo or `~/.claude/`) — permissions, hooks, tools, MCP.
|
||||
- **MCP servers** — connect tools/data via `claude mcp add ...` or `.mcp.json`.
|
||||
|
||||
## ByteLyst workflow
|
||||
|
||||
Open every delegation with the shared guardrails — Claude honors `AGENTS.md`, but
|
||||
re-stating scope keeps it tight:
|
||||
|
||||
```text
|
||||
Follow AGENTS.md + AI.dev/SKILLS/agent-behavior-guidelines.md.
|
||||
Scope: <paths>. Tests are sacred; fix code, not tests.
|
||||
Verify: pnpm --filter <pkg> typecheck && ... test && ... build (next build --webpack).
|
||||
pnpm workspace; @bytelyst/* link via workspace:*. Conventional commits, one per logical change.
|
||||
Use plan mode first for multi-file work; show the plan before editing.
|
||||
```
|
||||
|
||||
- Use `/review` before committing a cluster of edits.
|
||||
- For repo onboarding context, run `/init` once and commit the resulting `CLAUDE.md`
|
||||
(keep it thin — it should point at `AGENTS.md`, not duplicate it).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| Edits applied without asking | You're in `acceptEdits`/`--dangerously-skip-permissions`; switch to default or `plan` |
|
||||
| Context bloated / confused | `/clear` and re-seed with `@` file references |
|
||||
| Can't reach Gitea/registry | Corp proxy must be active (`NETWORK=corp`); workspace deps avoid the registry |
|
||||
| Build fails on Turbopack | Use `next build --webpack` |
|
||||
| Wants a tool it can't access | Wire it via MCP (`/mcp`, `claude mcp add`) |
|
||||
|
||||
## Quick-reference card
|
||||
|
||||
```text
|
||||
claude # interactive
|
||||
claude -p "..." # one-shot print mode
|
||||
claude -c # continue last session
|
||||
claude --resume # pick a session
|
||||
claude --permission-mode plan # plan first, no edits
|
||||
/init /clear /review /model /mcp #memory @file
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related:** [`devin-cli.md`](./devin-cli.md) · [`codex-cli.md`](./codex-cli.md) ·
|
||||
[`../PROMPTS/`](../PROMPTS/) · [`../SKILLS/agent-behavior-guidelines.md`](../SKILLS/agent-behavior-guidelines.md)
|
||||
|
||||
_Last updated: 2026-05-28 · verify flags against your installed version (`claude --help`)._
|
||||
128
AI.dev/CHEATSHEETS/codex-cli.md
Normal file
128
AI.dev/CHEATSHEETS/codex-cli.md
Normal file
@ -0,0 +1,128 @@
|
||||
# 🟢 Codex CLI — Cheat Sheet
|
||||
|
||||
> **What it is:** OpenAI's **Codex CLI** — an open-source terminal coding agent that
|
||||
> edits files and runs commands inside a built-in sandbox, interactively or as a
|
||||
> scriptable one-shot (`codex exec`).
|
||||
> **Best for:** Fast local edits, and **automation** — `codex exec` slots cleanly into
|
||||
> CI / Gitea Actions and one-shot scripts.
|
||||
> **Per-repo rules:** reads `AGENTS.md` (merged with `~/.codex/AGENTS.md` and any
|
||||
> project-local `AGENTS.md`).
|
||||
> **In this repo:** delegation examples live under
|
||||
> [`docs/ecosystem/delegation/codex/`](../../docs/ecosystem/delegation/codex/) and
|
||||
> [`docs/CODEX_RESUME_PROMPT.md`](../../docs/CODEX_RESUME_PROMPT.md).
|
||||
|
||||
> ⚠️ **Flags/modes drift between versions.** Confirm with `codex --help`.
|
||||
>
|
||||
> **Official docs:** <https://developers.openai.com/codex/cli> · source: <https://github.com/openai/codex>
|
||||
|
||||
---
|
||||
|
||||
## Install & auth
|
||||
|
||||
```bash
|
||||
npm install -g @openai/codex # or: brew install codex
|
||||
codex --version
|
||||
codex login # ChatGPT sign-in, or set OPENAI_API_KEY
|
||||
```
|
||||
|
||||
- Config & state live under `~/.codex/` (notably `~/.codex/config.toml`).
|
||||
|
||||
## Launching
|
||||
|
||||
```bash
|
||||
codex # interactive TUI in the current dir
|
||||
codex "add a vitest for src/lib/utils.ts" # seed the first instruction
|
||||
codex exec "run the test suite and fix failures" # non-interactive one-shot (scripts/CI)
|
||||
codex resume # resume a previous session
|
||||
```
|
||||
|
||||
## Approval + sandbox modes
|
||||
|
||||
Codex couples **what it can touch** (sandbox) with **when it asks** (approvals).
|
||||
|
||||
| Flag | Meaning |
|
||||
| ------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| `--sandbox read-only` | Can read; no writes, no commands |
|
||||
| `--sandbox workspace-write` | Can edit the working dir + run commands in it (default-ish) |
|
||||
| `--sandbox danger-full-access` | No sandbox restrictions |
|
||||
| `--ask-for-approval untrusted` \| `on-failure` \| `on-request` \| `never` | When to prompt you |
|
||||
| `--full-auto` | Convenience: low-friction auto (workspace-write + minimal prompts) |
|
||||
| `--dangerously-bypass-approvals-and-sandbox` | **No approvals, no sandbox** (CI/throwaway only) |
|
||||
|
||||
```bash
|
||||
codex --full-auto # everyday autonomy, still sandboxed
|
||||
codex --sandbox workspace-write --ask-for-approval on-failure
|
||||
codex exec --dangerously-bypass-approvals-and-sandbox "..." # CI only, isolated runner
|
||||
```
|
||||
|
||||
> 🧠 The sandbox is **built in** (unlike Devin's optional `--sandbox`). For ByteLyst,
|
||||
> `workspace-write` is fine for single-package work, but cross-package `workspace:*`
|
||||
> builds and the corp proxy / Gitea tunnel may need broader access — prefer running from
|
||||
> the monorepo root and, if installs fail, loosen the sandbox rather than fighting it.
|
||||
|
||||
## Config (`~/.codex/config.toml`)
|
||||
|
||||
```toml
|
||||
model = "..." # default model
|
||||
approval_policy = "on-failure"
|
||||
sandbox_mode = "workspace-write"
|
||||
# [mcp_servers.*] # wire external tools via MCP
|
||||
```
|
||||
|
||||
Project-level `AGENTS.md` is layered on top of `~/.codex/AGENTS.md`.
|
||||
|
||||
## `codex exec` for automation (the high-value mode)
|
||||
|
||||
```bash
|
||||
# One-shot, non-interactive — perfect for Gitea Actions / scripts:
|
||||
codex exec "typecheck the repo and fix any TS errors" \
|
||||
--sandbox workspace-write --ask-for-approval never
|
||||
```
|
||||
|
||||
- Deterministic, no TUI; exits with a status you can gate CI on.
|
||||
- Pair with a tight prompt + explicit verify commands (see below).
|
||||
|
||||
## ByteLyst workflow
|
||||
|
||||
Lead with the shared guardrails (Codex reads `AGENTS.md`, but restate scope):
|
||||
|
||||
```text
|
||||
Follow AGENTS.md + AI.dev/SKILLS/agent-behavior-guidelines.md.
|
||||
Scope: <paths> only. Tests are sacred. No console.log. productId on Cosmos docs.
|
||||
Verify: pnpm --filter <pkg> typecheck && ... test && ... build (next build --webpack).
|
||||
pnpm workspace; @bytelyst/* via workspace:*. Conventional commits, one per change.
|
||||
Only mark done after verify passes; never fabricate results.
|
||||
```
|
||||
|
||||
- Start in `read-only` for a plan, then switch to `workspace-write` to execute.
|
||||
- Reserve `--dangerously-bypass-approvals-and-sandbox` for the isolated CI runner.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| "permission denied" writing files | Sandbox is `read-only`; use `--sandbox workspace-write` |
|
||||
| Can't build `@bytelyst/*` deps | Sandbox can't see sibling `packages/*` — run from monorepo root, loosen sandbox |
|
||||
| Network/registry errors | Corp proxy must be active; workspace deps avoid the registry |
|
||||
| `next build` fails (Turbopack) | Use `next build --webpack` |
|
||||
| Non-interactive run hangs on a prompt | Add `--ask-for-approval never` to `codex exec` |
|
||||
|
||||
## Quick-reference card
|
||||
|
||||
```text
|
||||
codex # interactive
|
||||
codex "..." # seed an instruction
|
||||
codex exec "..." # one-shot (CI/scripts)
|
||||
codex resume # resume session
|
||||
--sandbox read-only|workspace-write|danger-full-access
|
||||
--ask-for-approval untrusted|on-failure|on-request|never
|
||||
--full-auto # convenient autonomy (still sandboxed)
|
||||
~/.codex/config.toml # defaults: model, approval_policy, sandbox_mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related:** [`devin-cli.md`](./devin-cli.md) · [`claude-code-cli.md`](./claude-code-cli.md) ·
|
||||
[`../PROMPTS/`](../PROMPTS/) · [`../SKILLS/agent-behavior-guidelines.md`](../SKILLS/agent-behavior-guidelines.md)
|
||||
|
||||
_Last updated: 2026-05-28 · verify flags against your installed version (`codex --help`)._
|
||||
126
AI.dev/CHEATSHEETS/devin-cli.md
Normal file
126
AI.dev/CHEATSHEETS/devin-cli.md
Normal file
@ -0,0 +1,126 @@
|
||||
# 🤖 Devin CLI — Cheat Sheet
|
||||
|
||||
> **What it is:** Cognition's **Devin** as a terminal agent. You hand it a scoped task
|
||||
> (ideally a self-contained roadmap), and it plans → edits → runs → verifies across many
|
||||
> steps with minimal babysitting.
|
||||
> **Best for:** Long-running, well-scoped delegations where you want to walk away — e.g.
|
||||
> "validate and harden `tracker-web`'s test suite," or "execute this 5-task roadmap."
|
||||
> **In this repo:** session config lives in `.devin/config.local.json` (**secret — never
|
||||
> commit or print it**). A worked example is
|
||||
> [`dashboards/tracker-web/docs/roadmaps/DEVIN_CLI_EXPERIMENT.md`](../../dashboards/tracker-web/docs/roadmaps/DEVIN_CLI_EXPERIMENT.md).
|
||||
|
||||
> ⚠️ **Flags drift between versions.** Run `devin --help` to confirm. This sheet captures
|
||||
> the workflow + ByteLyst conventions, which are stable.
|
||||
>
|
||||
> **Official docs:** <https://docs.devin.ai>
|
||||
|
||||
---
|
||||
|
||||
## Install & auth
|
||||
|
||||
```bash
|
||||
# Install per the official docs (https://docs.devin.ai), then verify + authenticate.
|
||||
# (Confirm the exact subcommands for your version with `devin --help`.)
|
||||
devin --help # list available commands/flags
|
||||
devin login # authenticate (or provide the API key Devin expects)
|
||||
```
|
||||
|
||||
- Auth/state for this workspace is cached in `.devin/config.local.json` (mode `600`).
|
||||
- That file may contain an **API token** → it is git-ignored; keep it that way.
|
||||
|
||||
## Launching a session
|
||||
|
||||
```bash
|
||||
devin # interactive TUI in the current dir
|
||||
devin "validate the tracker-web tests" # seed the first instruction inline
|
||||
```
|
||||
|
||||
A Devin session shows a live **todo list** and streams its tool calls (reads, edits,
|
||||
shell commands). You can interrupt at any time (Ctrl-C / Esc) — that shows up in the
|
||||
log as `Canceled due to user interrupt` (this is **you**, not a permission block).
|
||||
|
||||
## Permission & sandbox modes
|
||||
|
||||
| Flag | Effect |
|
||||
| ----------------------------- | ------------------------------------------------------------- |
|
||||
| _(default)_ | Asks before potentially destructive commands |
|
||||
| `--permission-mode dangerous` | **Auto-approves** commands without prompting |
|
||||
| `--sandbox` | Runs in an **isolated environment** (restricted fs / network) |
|
||||
|
||||
```bash
|
||||
# Full autonomy, isolated:
|
||||
devin --permission-mode dangerous --sandbox
|
||||
|
||||
# Full autonomy, direct access to the real pnpm workspace + corp proxy:
|
||||
devin --permission-mode dangerous
|
||||
```
|
||||
|
||||
> 🧠 **Gotcha (learned the hard way):** `--sandbox` can wall off the filesystem and
|
||||
> network even when command _approval_ is automatic. ByteLyst work needs to (a) read
|
||||
> sibling `packages/*` for `workspace:*` builds and (b) reach the corp proxy / Gitea
|
||||
> tunnel for `pnpm`. If a sandboxed run stalls on install/build, **drop `--sandbox`**.
|
||||
> `dangerous` mode never blocks; a stall is almost always sandbox scope.
|
||||
|
||||
## Session management
|
||||
|
||||
```bash
|
||||
devin -r # list recent sessions
|
||||
devin -r <id> # resume a specific session (e.g. `devin -r adaptable-comma`)
|
||||
/exit # leave the interactive session
|
||||
```
|
||||
|
||||
Sessions are named (e.g. `adaptable-comma`); the CLI prints the resume command when you
|
||||
exit. Resuming keeps the prior context + todo state.
|
||||
|
||||
## How to delegate well (the ByteLyst pattern)
|
||||
|
||||
Devin shines with a **self-contained roadmap file**. Don't free-form a vague goal —
|
||||
point it at a doc that encodes scope, verify commands, and a done-definition. See
|
||||
[`AI.dev/PROMPTS/`](../PROMPTS/) for reusable templates and the tracker-web experiment
|
||||
doc for the gold-standard shape:
|
||||
|
||||
1. **Scope lock** — name the exact folder; forbid edits elsewhere (incl. shared packages).
|
||||
2. **Verify commands** — give the literal `pnpm typecheck && pnpm lint && pnpm test && pnpm build`.
|
||||
3. **Commit discipline** — one task = one conventional commit; flip the checkbox + paste
|
||||
the short-SHA in the same commit.
|
||||
4. **No fabrication** — only check a box after its Verify passes; otherwise leave `- [ ]`
|
||||
with a one-line blocker.
|
||||
5. **Done-definition** — full suite green from a clean state + a summary of SHAs.
|
||||
|
||||
Paste this preamble into the first instruction every time:
|
||||
|
||||
```text
|
||||
Read AGENTS.md and AI.dev/SKILLS/agent-behavior-guidelines.md first.
|
||||
Scope: ONLY <path>. Don't edit shared packages/.npmrc/docker-prep.sh.
|
||||
Tests are sacred. Verify with: <exact commands>. Conventional commits.
|
||||
Builds use `next build --webpack`. pnpm workspace; workspace:* deps link locally.
|
||||
Only mark a task done after its verify passes — never fabricate. Commit + push per task.
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
| ----------------------------------------------- | ---------------------------------------------------- | ------------------------------------------ |
|
||||
| `Canceled due to user interrupt` | You pressed Ctrl-C/Esc or `/exit` | Resume: `devin -r <id>` |
|
||||
| Stalls on `pnpm install` / build in `--sandbox` | Sandbox blocks fs/network | Re-run **without** `--sandbox` |
|
||||
| Can't find `@bytelyst/*` packages | Sandbox can't see sibling `packages/*` | Run from the monorepo; drop `--sandbox` |
|
||||
| `pnpm` network errors | Corp proxy / Gitea tunnel not reachable from sandbox | Run on host (proxy active) without sandbox |
|
||||
| Asks for approval despite `dangerous` | Flag typo / older binary | `devin --help`; update the CLI |
|
||||
|
||||
## Quick-reference card
|
||||
|
||||
```text
|
||||
devin # start interactive session here
|
||||
devin --permission-mode dangerous # auto-approve commands (recommended for delegations)
|
||||
add --sandbox # ...but only if the task needs no workspace/network
|
||||
devin -r # list sessions
|
||||
devin -r <name> # resume a session
|
||||
/exit # end session (prints resume command)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related:** [`claude-code-cli.md`](./claude-code-cli.md) · [`codex-cli.md`](./codex-cli.md) ·
|
||||
[`../PROMPTS/`](../PROMPTS/) · [`../SKILLS/agent-behavior-guidelines.md`](../SKILLS/agent-behavior-guidelines.md)
|
||||
|
||||
_Last updated: 2026-05-28 · verify flags against your installed version (`devin --help`)._
|
||||
69
AI.dev/SKILLS/docker-doctor.md
Normal file
69
AI.dev/SKILLS/docker-doctor.md
Normal file
@ -0,0 +1,69 @@
|
||||
# docker-doctor — Static linter for Docker build hygiene
|
||||
|
||||
Sibling to `gitea-doctor` (`scripts/gitea/doctor.sh`). Detects Dockerfile, compose, and
|
||||
`.npmrc.docker` drift from the invariants established by
|
||||
[`docker-build-optimization-roadmap.md`](../../../learning_ai_devops_tools/docs/docker-build-optimization-roadmap.md)
|
||||
Phase A.
|
||||
|
||||
## When to run
|
||||
|
||||
- **Before every Docker build** (alongside `gitea-doctor`)
|
||||
- **In CI** on PRs touching `Dockerfile`, `docker-compose*.yml`, `.dockerignore`, `.npmrc.docker`
|
||||
- **In pre-commit hook** (warning-only at first, error after stabilization)
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Canonical (run from common-plat)
|
||||
bash scripts/docker-doctor.sh --repo /path/to/repo
|
||||
|
||||
# Per-repo wrapper (preferred)
|
||||
bash scripts/docker-doctor.sh
|
||||
|
||||
# CI / scripting
|
||||
bash scripts/docker-doctor.sh --quiet # only print failures
|
||||
bash scripts/docker-doctor.sh --warn-only # always exit 0
|
||||
```
|
||||
|
||||
## Checks performed
|
||||
|
||||
| # | Check | Severity | Roadmap ref |
|
||||
| --- | --------------------------------------------------------------------------------------------- | -------- | ----------------- |
|
||||
| 1 | `.npmrc.docker` uses `${GITEA_NPM_HOST}` placeholder | Error | F4 |
|
||||
| 2 | `.npmrc.docker` uses `${GITEA_NPM_OWNER}` placeholder | Error | F14 |
|
||||
| 3 | `.npmrc.docker` uses `${GITEA_NPM_TOKEN}` for `_authToken` | Error | F4 |
|
||||
| 4 | `.gitignore` covers `.docker-deps/*` | Error | B3 |
|
||||
| 5 | `.gitignore` covers `*.bak` | Warn | B3 |
|
||||
| 6 | Dockerfile has `# syntax=docker/dockerfile:1.7` directive | Warn | A2 |
|
||||
| 7 | Dockerfile base image is approved (`node:22-alpine`/`-slim` or `${BASE_IMAGE}`) | Error | canonical |
|
||||
| 8 | Dockerfile uses corepack (no `npm install -g pnpm`) | Error | A1 |
|
||||
| 9 | If Dockerfile COPYs `.npmrc.docker`, it declares `ARG GITEA_NPM_OWNER` + `ARG GITEA_NPM_HOST` | Error | F14 |
|
||||
| 10 | `.docker-deps/` COPY uses wildcard `COPY .docker-deps* ...` | Error | A5-2, B3 |
|
||||
| 11 | Web Dockerfile uses glob `COPY web/*.{json,ts,...}` (not enumerated configs) | Error | F11, F13 |
|
||||
| 12 | Compose healthcheck uses `127.0.0.1`, not `localhost` | Error | F12 |
|
||||
| 13 | Compose healthcheck has `start_period` | Warn | A9-3 |
|
||||
| 14 | Compose passes `GITEA_NPM_OWNER` build arg | Warn | F14 |
|
||||
| 15 | `.dockerignore` does NOT exclude `pnpm-lock.yaml` | Warn | F1 (per ADR-0001) |
|
||||
|
||||
## Exit codes
|
||||
|
||||
- `0` — all checks pass (warnings allowed)
|
||||
- `1` — one or more error-level checks failed
|
||||
- `2` — bad invocation / repo not found
|
||||
|
||||
## Sibling: gitea-doctor
|
||||
|
||||
`docker-doctor` and `gitea-doctor` are intentionally separate:
|
||||
|
||||
| Tool | Scope | When to run |
|
||||
| --------------- | -------------------------------------------- | -------------------------------- |
|
||||
| `gitea-doctor` | runtime env, token, registry HTTP 200 | Before every build / deploy |
|
||||
| `docker-doctor` | static analysis of Dockerfile + compose YAML | On every PR touching those files |
|
||||
|
||||
Run both via `make doctor` in repos that have it wired up.
|
||||
|
||||
## Related
|
||||
|
||||
- Roadmap: [`docker-build-optimization-roadmap.md`](../../../learning_ai_devops_tools/docs/docker-build-optimization-roadmap.md) §Phase E
|
||||
- ADR-0001: [`0001-docker-build-lockfile-policy.md`](../../../learning_ai_devops_tools/docs/adr/0001-docker-build-lockfile-policy.md)
|
||||
- Sibling script: `learning_ai_common_plat/scripts/gitea/doctor.sh`
|
||||
@ -1,9 +1,9 @@
|
||||
Last refresh: 2026-05-23T06:00:12Z (2026-05-22 23:00:12 PDT)
|
||||
Cascade conversations: 50 (438M)
|
||||
Memories: 137
|
||||
Last refresh: 2026-05-29T17:09:25Z (2026-05-29 10:09:25 PDT)
|
||||
Cascade conversations: 50 (502M)
|
||||
Memories: 138
|
||||
Implicit context: 20
|
||||
Code tracker dirs: 14
|
||||
File edit history: 5093 entries
|
||||
Workspace storage: 49 workspaces
|
||||
Code tracker dirs: 43
|
||||
File edit history: 5395 entries
|
||||
Workspace storage: 52 workspaces
|
||||
Repo docs: 7 files across 2 repos
|
||||
Repo workflows: 56 files across 13 repos
|
||||
|
||||
@ -21,8 +21,7 @@ Auto-discovers new repos, updates symlinks, and re-copies docs + workflows.
|
||||
| `learning_ai_notes` | NoteLett | ✅ | — |
|
||||
| `learning_ai_flowmonk` | FlowMonk | ✅ | — |
|
||||
| `learning_ai_trails` | ActionTrail | ✅ | — |
|
||||
| `learning_ai_smart_auth` | SmartAuth | ✅ | — |
|
||||
| `learning_ai_auth_app` | ByteLyst Auth | ✅ | — |
|
||||
| `learning_ai_auth_app` | ByteLyst SmartAuth | ✅ | — |
|
||||
| `learning_ai_productivity_web` | Productivity Tools | ✅ | — |
|
||||
|
||||
## Steps
|
||||
|
||||
@ -1,54 +1,116 @@
|
||||
---
|
||||
description: Regenerate AI agent docs (AGENTS.md, CLAUDE.md, .cursorrules, etc.) across all repos
|
||||
description: Regenerate AI agent docs across all repos (single source of truth pattern)
|
||||
---
|
||||
|
||||
# Update Agent Docs Across Workspace
|
||||
|
||||
Regenerates all 8 AI agent configuration files across all repos in the workspace.
|
||||
Maintains AI agent docs across the ByteLyst workspace using a
|
||||
**single source of truth** pattern with **zero duplication**.
|
||||
|
||||
## Files Generated Per Repo
|
||||
## Architecture
|
||||
|
||||
| File | Tool |
|
||||
| --------------------------------- | ----------------------------------------------- |
|
||||
| `AGENTS.md` | Universal (OpenAI Codex, Claude, Copilot, etc.) |
|
||||
| `CLAUDE.md` | Claude Code |
|
||||
| `.cursorrules` | Cursor AI |
|
||||
| `.github/copilot-instructions.md` | GitHub Copilot |
|
||||
| `.windsurfrules` | Windsurf / Cascade |
|
||||
| `.clinerules` | Cline / Roo Code |
|
||||
| `.aider.conf.yml` | Aider |
|
||||
| `.editorconfig` | All editors |
|
||||
```
|
||||
learning_ai_common_plat/AI.dev/SKILLS/
|
||||
├── agent-behavior-guidelines.md ← SINGLE source of truth for behavior rules
|
||||
└── agent-onboarding.md ← Read-order index for agents
|
||||
|
||||
<each repo>/
|
||||
├── AGENTS.md ← Repo-specific only. Auto-prepended with
|
||||
│ "Read first" pointer to canonical file.
|
||||
├── .github/copilot-instructions.md ← Thin pointer (no rules). Auto-generated.
|
||||
├── .aider.conf.yml ← Aider config pointing to AGENTS.md. Auto-generated.
|
||||
└── .editorconfig ← Editor config. Auto-generated.
|
||||
|
||||
DELETED across all repos (content was duplicated AGENTS.md):
|
||||
├── CLAUDE.md
|
||||
├── .cursorrules
|
||||
├── .windsurfrules
|
||||
└── .clinerules
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run the update script:
|
||||
1. **Edit the canonical sources** when behavior rules change:
|
||||
|
||||
```bash
|
||||
cd /Users/sd9235/code/mygh/learning_ai_common_plat
|
||||
./scripts/update-agent-docs.sh
|
||||
```
|
||||
```bash
|
||||
# ecosystem-wide agent behavior (Karpathy + ByteLyst)
|
||||
open learning_ai_common_plat/AI.dev/SKILLS/agent-behavior-guidelines.md
|
||||
|
||||
The script reads `learning_ai_common_plat/.windsurf/workflows/repos.txt` as the canonical list of managed workspace repositories.
|
||||
# read-order index
|
||||
open learning_ai_common_plat/AI.dev/SKILLS/agent-onboarding.md
|
||||
```
|
||||
|
||||
2. Review changes per repo:
|
||||
These are referenced (not copied) by every repo's AGENTS.md, so changes
|
||||
take effect immediately — no regeneration needed when only behavior rules change.
|
||||
|
||||
```bash
|
||||
while IFS= read -r repo; do
|
||||
[[ -z "$repo" || "$repo" =~ ^# ]] && continue
|
||||
cd /Users/sd9235/code/mygh/$repo && git diff --stat
|
||||
done < /Users/sd9235/code/mygh/learning_ai_common_plat/.windsurf/workflows/repos.txt
|
||||
```
|
||||
2. **Run the generator** to delete legacy files / refresh pointers / sync configs:
|
||||
|
||||
3. Commit changes (if any):
|
||||
```bash
|
||||
cd /Users/sd9235/code/mygh/learning_ai_common_plat
|
||||
./scripts/update-agent-docs.sh --dry-run # preview
|
||||
./scripts/update-agent-docs.sh # apply + commit per repo
|
||||
./scripts/update-agent-docs.sh --no-commit # apply without committing
|
||||
```
|
||||
|
||||
```bash
|
||||
cd /Users/sd9235/code/mygh/learning_voice_ai_agent
|
||||
[ -n "$(git status --porcelain)" ] && git add -A && git commit -m "chore(docs): update agent configuration files"
|
||||
```
|
||||
Reads `learning_ai_common_plat/.windsurf/workflows/repos.txt` as the
|
||||
canonical list of managed repos.
|
||||
|
||||
3. **Verify no drift**:
|
||||
|
||||
```bash
|
||||
bash scripts/check-agent-docs-drift.sh
|
||||
```
|
||||
|
||||
Exits 1 if any repo has drifted from the canonical generator output.
|
||||
Suitable for CI.
|
||||
|
||||
4. **Review changes per repo**:
|
||||
|
||||
```bash
|
||||
while IFS= read -r repo; do
|
||||
[[ -z "$repo" || "$repo" =~ ^# ]] && continue
|
||||
echo "── $repo ──"
|
||||
git -C /Users/sd9235/code/mygh/"$repo" log -1 --oneline
|
||||
done < /Users/sd9235/code/mygh/learning_ai_common_plat/.windsurf/workflows/repos.txt
|
||||
```
|
||||
|
||||
5. **Push (when ready)**:
|
||||
|
||||
```bash
|
||||
while IFS= read -r repo; do
|
||||
[[ -z "$repo" || "$repo" =~ ^# ]] && continue
|
||||
git -C /Users/sd9235/code/mygh/"$repo" push
|
||||
done < /Users/sd9235/code/mygh/learning_ai_common_plat/.windsurf/workflows/repos.txt
|
||||
```
|
||||
|
||||
## Files generated per repo
|
||||
|
||||
| File | Type | Tool(s) |
|
||||
| --------------------------------- | ------- | -------------------------------------- |
|
||||
| `AGENTS.md` | Hybrid | Codex, Claude, Cursor, Windsurf, Cline |
|
||||
| `.github/copilot-instructions.md` | Pointer | GitHub Copilot |
|
||||
| `.aider.conf.yml` | Config | Aider |
|
||||
| `.editorconfig` | Config | All editors |
|
||||
|
||||
`AGENTS.md` is hybrid: the body is hand-maintained (repo-specific content),
|
||||
but the generator idempotently prepends a `<!-- BEGIN: canonical-behavior-pointer -->`
|
||||
block at the top. The block is the only auto-managed region; everything else
|
||||
is left alone.
|
||||
|
||||
## Files deleted by the generator
|
||||
|
||||
| File | Reason |
|
||||
| ---------------- | ------------------------------------------------------------- |
|
||||
| `CLAUDE.md` | Duplicated AGENTS.md. Claude Code reads AGENTS.md by default. |
|
||||
| `.cursorrules` | Duplicated AGENTS.md. Cursor reads AGENTS.md. |
|
||||
| `.windsurfrules` | Duplicated AGENTS.md. Windsurf reads AGENTS.md. |
|
||||
| `.clinerules` | Duplicated AGENTS.md. Cline reads AGENTS.md. |
|
||||
|
||||
## Notes
|
||||
|
||||
- The script scans each repo's structure and regenerates docs based on current state
|
||||
- Only commits if there are actual changes
|
||||
- Safe to run repeatedly (idempotent)
|
||||
- Requires `learning_ai_common_plat` to be the source of truth for templates
|
||||
- Safe to run repeatedly (idempotent).
|
||||
- Only commits when actual changes exist in the repo.
|
||||
- The script never touches the body of AGENTS.md outside the marker block.
|
||||
- For workspaces that include `learning_ai_common_plat` as a sibling
|
||||
(default for Windsurf/Cascade), the canonical guidelines file resolves
|
||||
via the relative path written into each AGENTS.md pointer.
|
||||
|
||||
@ -19,16 +19,21 @@ learning_ai_trails
|
||||
learning_ai_local_memory_gpt
|
||||
learning_ai_efforise
|
||||
learning_ai_local_llms
|
||||
learning_ai_talk2obsidian
|
||||
|
||||
# --- Auth & identity ---
|
||||
learning_ai_smart_auth
|
||||
learning_ai_auth_app
|
||||
|
||||
# --- Web & misc ---
|
||||
learning_ai_productivity_web
|
||||
|
||||
# --- OSS (subdirectory repos under oss/) ---
|
||||
oss/learning_ai_claw-code-oss
|
||||
# NOTE: oss/learning_ai_claw-code-oss is intentionally OMITTED. It is an
|
||||
# upstream Anthropic Claude Code OSS clone with its own CLAUDE.md convention
|
||||
# (auto-generated by Claude Code's bootstrap, not the deprecated ByteLyst
|
||||
# per-product CLAUDE.md pattern). We do not manage its agent docs; deleting
|
||||
# CLAUDE.md or forcing the ByteLyst canonical pointer block would diverge
|
||||
# from upstream alignment for no benefit.
|
||||
oss/learning_ai_claw-cowork
|
||||
|
||||
# -- tooling --
|
||||
|
||||
@ -46,8 +46,7 @@ All code across the ByteLyst workspace repos:
|
||||
- learning_ai_peakpulse (PeakPulse)
|
||||
- learning_ai_notes (NoteLett)
|
||||
- learning_ai_trails (ActionTrail)
|
||||
- learning_ai_smart_auth (SmartAuth)
|
||||
- learning_ai_auth_app (ByteLyst Auth)
|
||||
- learning_ai_auth_app (ByteLyst SmartAuth — companion app + PRD/roadmap)
|
||||
- learning_ai_productivity_web (Productivity Tools)
|
||||
|
||||
## Domain Context
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Window 1: Phase 0 scaffolding + Manus cleanup (RUN FIRST — other windows depend on this)"
|
||||
description: 'Window 1: Phase 0 scaffolding + Manus cleanup (RUN FIRST — other windows depend on this)'
|
||||
---
|
||||
|
||||
# Window 1: Phase 0 Scaffolding + Manus Cleanup
|
||||
@ -38,18 +38,20 @@ Create `shared/product.json` following the ecosystem pattern. Use NoteLett (`../
|
||||
}
|
||||
```
|
||||
|
||||
## Step 2: Create Agent Config Files (8 files)
|
||||
## Step 2: Create Agent Config Files (single-source-of-truth pattern)
|
||||
|
||||
Create all 8 agent config files matching ecosystem standard. Copy structure from `../learning_ai_notes/` or `../learning_ai_trails/`:
|
||||
Hand-write `AGENTS.md` only. All other agent files are generated by the
|
||||
canonical script. Legacy files (`CLAUDE.md`, `.cursorrules`, `.windsurfrules`,
|
||||
`.clinerules`) are **deprecated** and must NOT be created — they used to
|
||||
duplicate AGENTS.md content and drifted.
|
||||
|
||||
1. `AGENTS.md` — AI agent onboarding guide (customize for efforise: Vite SPA + Fastify backend, productId efforise, port 4020, --er-* tokens)
|
||||
2. `CLAUDE.md` — Claude Code instructions (short version pointing to AGENTS.md)
|
||||
3. `.windsurfrules` — Windsurf rules (short version pointing to AGENTS.md)
|
||||
4. `.cursorrules` — Cursor rules (short version pointing to AGENTS.md)
|
||||
5. `.clinerules` — Cline rules (short version pointing to AGENTS.md)
|
||||
6. `.aider.conf.yml` — Aider config
|
||||
7. `.editorconfig` — Copy from any ecosystem repo
|
||||
8. `.github/copilot-instructions.md` — GitHub Copilot rules
|
||||
1. `AGENTS.md` — AI agent onboarding guide (customize for efforise: Vite SPA + Fastify backend, productId efforise, port 4020, --er-\* tokens). Copy structure from `../learning_ai_notes/AGENTS.md` or `../learning_ai_trails/AGENTS.md`.
|
||||
2. Add this repo to `../learning_ai_common_plat/.windsurf/workflows/repos.txt` if not already present.
|
||||
3. Run `bash ../learning_ai_common_plat/scripts/update-agent-docs.sh`. This will:
|
||||
- Prepend the canonical-behavior-pointer block to `AGENTS.md`
|
||||
- Generate `.editorconfig`, `.aider.conf.yml`, `.github/copilot-instructions.md`
|
||||
- Verify no legacy files exist (deletes them if present)
|
||||
4. Verify with `bash ../learning_ai_common_plat/scripts/check-agent-docs-drift.sh`.
|
||||
|
||||
## Step 3: Create Root Config Files
|
||||
|
||||
@ -70,12 +72,14 @@ Create all 8 agent config files matching ecosystem standard. Copy structure from
|
||||
## Step 4: Manus Artifact Cleanup
|
||||
|
||||
### 4a. Clean `vite.config.ts`
|
||||
|
||||
- Remove `vite-plugin-manus-runtime` plugin
|
||||
- Remove `vite-plugin-manus-debug-collector` plugin + all LOG_DIR code
|
||||
- Remove `@builder.io/vite-plugin-jsx-loc` plugin
|
||||
- Replace `allowedHosts: [".manuspre.computer", ...]` with `allowedHosts: ["localhost"]`
|
||||
|
||||
### 4b. Delete Manus Files
|
||||
|
||||
- Delete `client/src/components/ManusDialog.tsx`
|
||||
- Delete `client/src/components/Map.tsx` (Google Maps boilerplate, 156 lines)
|
||||
- Delete `client/public/__manus__/` directory (contains `debug-collector.js`)
|
||||
@ -86,21 +90,25 @@ Create all 8 agent config files matching ecosystem standard. Copy structure from
|
||||
- Delete `patches/wouter@3.7.1.patch` (evaluate first — remove if not critical)
|
||||
|
||||
### 4c. Clean `client/index.html`
|
||||
|
||||
- Remove `VITE_ANALYTICS_ENDPOINT` / `VITE_ANALYTICS_WEBSITE_ID` script references
|
||||
|
||||
### 4d. Dependency Cleanup in `package.json`
|
||||
- **Downgrade** `zod` from `^4.1.12` → `^3.24.2` (CRITICAL — Zod 4 breaks @bytelyst/* integration)
|
||||
|
||||
- **Downgrade** `zod` from `^4.1.12` → `^3.24.2` (CRITICAL — Zod 4 breaks @bytelyst/\* integration)
|
||||
- **Upgrade** `typescript` from `5.6.3` → `^5.7.3`
|
||||
- **Remove:** `streamdown`, `cmdk`, `add` (devDep), `@types/google.maps` (devDep), `next-themes`
|
||||
- **Remove:** `express`, `@types/express` (server/ is deleted)
|
||||
- **Remove** Manus vite plugins from devDeps: `vite-plugin-manus-runtime`, `@builder.io/vite-plugin-jsx-loc`
|
||||
|
||||
### 4e. Move Files
|
||||
|
||||
- Move `ideas.md` → `docs/ideas.md`
|
||||
|
||||
## Step 5: Create README.md
|
||||
|
||||
Write a proper README.md with:
|
||||
|
||||
- Product name + description
|
||||
- Tech stack (Vite + React 19 SPA, Fastify 5 backend planned)
|
||||
- Setup instructions (`pnpm install`, `pnpm dev`)
|
||||
|
||||
@ -13,7 +13,7 @@ COPY dashboards/admin-web/package.json dashboards/admin-web/
|
||||
|
||||
RUN --mount=type=secret,id=gitea_npm_token \
|
||||
TOKEN=$(cat /run/secrets/gitea_npm_token) && \
|
||||
printf '@bytelyst:registry=http://localhost:3300/api/packages/bytelyst/npm/\n//localhost:3300/api/packages/bytelyst/npm/:_authToken=%s\n' "$TOKEN" > .npmrc && \
|
||||
printf '@bytelyst:registry=http://localhost:3300/api/packages/learning_ai_user/npm/\n//localhost:3300/api/packages/learning_ai_user/npm/:_authToken=%s\n' "$TOKEN" > .npmrc && \
|
||||
pnpm install --ignore-scripts --legacy-peer-deps
|
||||
|
||||
COPY dashboards/admin-web/ dashboards/admin-web/
|
||||
|
||||
272
dashboards/admin-web/docs/roadmaps/UX_INTEGRATION_ADMIN.md
Normal file
272
dashboards/admin-web/docs/roadmaps/UX_INTEGRATION_ADMIN.md
Normal file
@ -0,0 +1,272 @@
|
||||
# admin-web × ByteLyst UX Integration Roadmap
|
||||
|
||||
> **Purpose:** Additively adopt the latest shared `@bytelyst/*` UX into the platform admin console —
|
||||
> a token bridge, charts migration (`recharts` → `@bytelyst/charts`), command palette, page chrome,
|
||||
> motion, and system banners — **without** ripping out the mature local shadcn `src/components/ui/*`
|
||||
> layer (that wholesale replacement is explicitly out of scope for this phase).
|
||||
> **Delegation target:** Devin CLI (`devin --prompt-file docs/roadmaps/UX_INTEGRATION_ADMIN.md`).
|
||||
>
|
||||
> **Repo:** `learning_ai_common_plat/dashboards/admin-web` (`@bytelyst/admin-web`)
|
||||
> **Run dir:** this package · **Stack:** Next.js 16 · React 19 · TS 5 · Tailwind 4 · Vitest · Playwright
|
||||
> **Showcase reference (read-only, do not edit):** `../../../copilot/learning_ai_uxui_web`.
|
||||
|
||||
---
|
||||
|
||||
## Current-state review (verified 2026-05-29)
|
||||
|
||||
admin-web is large (~45 dashboard surfaces) and has **two** UI layers: a mature local shadcn
|
||||
`src/components/ui/*` (radix-based: button/dialog/select/tabs/…) **and** a `Primitives.tsx` adapter.
|
||||
This roadmap is **additive** — it brings in shared capabilities the app lacks; it does **not**
|
||||
rewrite the shadcn primitive layer.
|
||||
|
||||
| Area | Today | Gap |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| Token system | shadcn OKLCH vars (`--background`/`--primary`/`--card`) + `.dark` | `@bytelyst/ui` expects `--bl-*` → **need a bridge** (wave 1) |
|
||||
| UI-drift guard | `eslint.config.mjs` has **no** `no-restricted-imports` | add ratchet: direct `@bytelyst/ui` only via `Primitives.tsx` |
|
||||
| Charts | **`recharts` directly in 5 files** (`(dashboard)/page`, `usage`, `users/[id]`, `ops/client-logs`, `docs`, `extraction/entity-chart`) | `@bytelyst/charts` + `@bytelyst/data-viz` |
|
||||
| Command / nav | none (45 surfaces!) | `@bytelyst/command-palette` (⌘K) — high value |
|
||||
| Page chrome | mixed | `@bytelyst/dashboard-components` (already a dep) |
|
||||
| Motion | none | `@bytelyst/motion` (reduced-motion aware) |
|
||||
| System messages | `broadcasts`/`maintenance`/`notifications` surfaces exist | `@bytelyst/notifications-ui` banners (if a real feed is reachable) |
|
||||
|
||||
**Explicitly OUT of scope this phase:** replacing the local shadcn `src/components/ui/*` components
|
||||
with `@bytelyst/ui` equivalents. Leave them as-is; only NEW UI and the waves below use shared packages.
|
||||
|
||||
---
|
||||
|
||||
## Baseline audit (verified by Devin run — 2026-05-29)
|
||||
|
||||
> Hard facts captured from the working tree **before** any wave changes. This corrects/confirms the
|
||||
> hand-written current-state table above and pins the green/red gates each wave must hold.
|
||||
|
||||
**UI layers (confirmed).** Two coexisting layers: mature local shadcn `src/components/ui/*` (radix-based)
|
||||
|
||||
- a `src/components/ui/Primitives.tsx` adapter re-exporting `@bytelyst/ui` (`Button`/`Badge`/`Input`/
|
||||
`Select`/`Textarea`/`DataTable`/`Modal`/… ). `Primitives.tsx` already consumes `--bl-input` /
|
||||
`--bl-surface-muted`. **Additive contract holds:** UX-1→6 do not rewrite shadcn `ui/*`.
|
||||
|
||||
**Charts (corrected to 5 files, not 6).** `recharts@^3.7.0` is imported directly in exactly **5** files
|
||||
(roadmap prose listed `docs` — verified it does **not** import recharts):
|
||||
|
||||
1. `src/app/(dashboard)/page.tsx` — `AreaChart` (DAU) + `BarChart` (revenue)
|
||||
2. `src/app/(dashboard)/usage/page.tsx` — `AreaChart` + `BarChart` + `PieChart`
|
||||
3. `src/app/(dashboard)/users/[id]/page.tsx` — `AreaChart` (per-user daily usage)
|
||||
4. `src/app/(dashboard)/ops/client-logs/page.tsx` — `BarChart` (telemetry)
|
||||
5. `src/components/extraction/entity-chart.tsx` — `BarChart` + `PieChart` (+ non-recharts `EntityTimeline`)
|
||||
|
||||
**Shared viz packages (built, NOT yet deps).** `@bytelyst/charts@0.1.1` (`LineChart`/`BarChart`/
|
||||
`AreaChart`/`Donut`/`Gauge`/`RadarChart`, pure SVG, `--bl-*`-themed) and `@bytelyst/data-viz@0.1.0`
|
||||
(`Sparkline`/`KpiCard`/`ProgressRing`/`BarSparkline`/`Heatmap`) are built (`dist/` present) but are
|
||||
**not** in `admin-web` deps yet → UX-2 adds them as `workspace:*`. `@bytelyst/dashboard-components`
|
||||
**is** already a dep (`PageHeader` re-exported via `Primitives.tsx`). `@bytelyst/command-palette`,
|
||||
`@bytelyst/motion`, `@bytelyst/notifications-ui` are **not** yet deps.
|
||||
|
||||
**Tokens.** `--bl-*` tokens are already supplied by `@bytelyst/design-tokens/css` (imported in
|
||||
`globals.css`), but they carry the _default_ design-token palette, not admin's shadcn OKLCH ramp →
|
||||
UX-1 adds an in-`globals.css` **bridge** remapping the `--bl-*` shared contract onto admin's
|
||||
`--background`/`--card`/`--primary`/… (light **and** `.dark`), so shared components theme correctly.
|
||||
|
||||
**ESLint.** `eslint.config.mjs` has **no** `no-restricted-imports` → UX-1 adds the ratchet
|
||||
(forbid direct `@bytelyst/ui` outside `Primitives.tsx`).
|
||||
|
||||
**Green/red gates at baseline (run dir = this package):**
|
||||
|
||||
| Gate | Baseline result |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `typecheck` (`tsc --noEmit`) | ✅ clean |
|
||||
| `lint` (`eslint`) | ✅ clean |
|
||||
| `test` (`vitest run`) | ✅ **16 files / 111 tests** pass (all `.test.ts`, node env, logic-level) |
|
||||
| `format:check` (`prettier --check`) | ❌ **RED — 29 pre-existing files** (none in any wave's edit scope; see below) |
|
||||
| `build` (`next build --webpack`) | ✅ compiles, 123 routes (pre-existing multi-lockfile warning only) |
|
||||
| `test:e2e` (Playwright, 91 tests) | captured in **E2e baseline** section below |
|
||||
|
||||
**Pre-existing `format:check` debt (NOT caused by this run).** 29 files already fail Prettier at
|
||||
baseline — none overlap the files UX-1→6 edit. Sweeping-reformatting 29 unrelated files would be
|
||||
scope-creep in a shared monorepo, so this gate is treated like e2e: **no NEW format failures vs this
|
||||
baseline**; every file this run creates/edits is kept Prettier-clean. (CSS is not in the prettier glob.)
|
||||
|
||||
---
|
||||
|
||||
## Ground rules (non-negotiable)
|
||||
|
||||
1. **Scope lock:** edit only files under `dashboards/admin-web/`. Never edit shared
|
||||
`packages/@bytelyst/*`, other dashboards, services, the showcase, or any sibling repo. Never
|
||||
`git push` outside `admin-web/`.
|
||||
2. **Additive only:** do NOT rewrite the local shadcn `src/components/ui/*` layer. Route only NEW
|
||||
shared-`@bytelyst/ui` usage through `src/components/ui/Primitives.tsx`.
|
||||
3. **Deps are `workspace:*`** (common-plat convention) — match that, NOT `"*"`. Keep lockfile
|
||||
changes minimal (importer link entries only); recover with `pnpm install --frozen-lockfile` if
|
||||
within-repo links corrupt. Do NOT commit a full monorepo lockfile re-normalization or relink
|
||||
`packages/ui/node_modules/@radix-ui/*` to a sibling store (this broke Turbopack/e2e on tracker-web).
|
||||
4. **Tokens:** keep the shadcn OKLCH system; add a `--bl-*` → admin-token **bridge** (wave 1) so
|
||||
shared components theme correctly in light + dark. Zero new hardcoded color literals.
|
||||
5. **Tests are sacred** — never weaken/skip/delete; add a test per wave.
|
||||
6. **Commit cadence:** verify → commit → push **per item** (`feat(admin-web): … (UX-N)`); never
|
||||
leave >1 wave uncommitted; immediately tick the checkbox with **SHA + test counts**. Not done
|
||||
until committed **and** pushed.
|
||||
7. **Deferrals explicit** (`[~]` + reason + table row); **no stray files**; env blockers recorded here.
|
||||
|
||||
### Verify (after EVERY wave + final sweep)
|
||||
|
||||
```bash
|
||||
pnpm --filter @bytelyst/admin-web typecheck
|
||||
pnpm --filter @bytelyst/admin-web lint
|
||||
pnpm --filter @bytelyst/admin-web test
|
||||
pnpm --filter @bytelyst/admin-web format:check
|
||||
pnpm --filter @bytelyst/admin-web build
|
||||
pnpm --filter @bytelyst/admin-web test:e2e # Playwright + @axe-core (no new failures vs wave-1 baseline)
|
||||
# size:check (bundlesize) where it runs in this env; else record gzipped sizes here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Waves
|
||||
|
||||
- [x] **UX-1 — Token bridge + UI-drift ratchet (FIRST):** add a `--bl-*` → admin OKLCH bridge in
|
||||
`globals.css` (light + dark); add ESLint `no-restricted-imports` forbidding direct
|
||||
`@bytelyst/ui` imports outside `Primitives.tsx`; add a Primitives export-presence test. Capture
|
||||
the e2e baseline below. — **DONE** `df72199c` · test **17 files / 140 tests** (+1 file / +29 vs
|
||||
baseline 16 / 111); typecheck+lint+build green; e2e 11✅/80❌ (unchanged baseline); format:check
|
||||
no new failures (29 pre-existing).
|
||||
- [x] **UX-2 — Charts:** migrate the `recharts` usages to `@bytelyst/charts` (+ `@bytelyst/data-viz`),
|
||||
lazy-loaded; render tests (no NaN in SVG). Drop `recharts` if fully unused afterward.
|
||||
— **DONE** `01f79afa` · Migrated all **5** recharts files (page, usage, users/[id],
|
||||
ops/client-logs, extraction/entity-chart) → `AreaChart`/`BarChart`/`Donut` via a lazy
|
||||
`src/components/charts` seam (`next/dynamic`, own chunk; dynamic-imports a local static
|
||||
re-export `primitives.tsx` because the packages declare only an `import` export condition).
|
||||
Added pure finite-safe mappers `src/lib/chart-data.ts`. **`recharts` dropped** from
|
||||
`package.json` + lockfile importer (now fully unused; 33 orphaned pkgs pruned). Stacked
|
||||
severity chart → single-series bars colored by dominant severity; pie charts → `Donut`;
|
||||
horizontal bars → vertical (charts are vertical/single-series; StackedBar deferred to charts
|
||||
0.2.x). Lockfile change = **+6/−3 importer lines only** (no re-normalization; `workspace:*`;
|
||||
`--frozen-lockfile` clean). Vitest config: inline + `dedupe` react for SSR render tests.
|
||||
test **18 files / 159 tests** (+1 file / +19); typecheck+lint+build green (123 routes);
|
||||
format:check no new failures; e2e unchanged (see below).
|
||||
- [x] **UX-3 — Command palette:** add `@bytelyst/command-palette`; mount `CommandRegistryProvider` +
|
||||
`CommandPalette` (⌘K, lazy) in `(dashboard)/layout.tsx`; register navigate commands for the major
|
||||
surfaces (Users, Billing, Flags, Broadcasts, Audit, Experiments, Subscriptions, Licenses, Ops, …) + theme toggle + sign out; Vitest test palette opens on ⌘K.
|
||||
— **DONE** `b4e450d6` · `CommandRegistryProvider` wraps the dashboard; `CommandMenu`
|
||||
(`useCommandPalette` ⌘K/Ctrl-K hotkey + lazy `next/dynamic` dialog via local
|
||||
`command-palette-dialog.tsx` re-export) registers **21** navigate commands (`src/lib/admin-commands.ts`) + theme-toggle + sign-out actions; `onNavigate`→`router.push`. Dep added `workspace:*`
|
||||
(importer-only lockfile change, `--frozen-lockfile` clean). Vitest: pure command-set tests +
|
||||
happy-dom ⌘K/Ctrl-K interaction test (`react-dom/client` + `act`, no new deps; react deduped).
|
||||
test **19 files / 165 tests** (+6); typecheck+lint+build green (123 routes); format:check no new
|
||||
failures; e2e unchanged.
|
||||
- [x] **UX-4 — Page chrome:** use `@bytelyst/dashboard-components` (`PageHeader`/`ErrorPage`/
|
||||
`NotFoundPage`/`LoadingSpinner`) on `error.tsx`/`not-found.tsx`/`loading.tsx` + a few high-traffic
|
||||
surfaces where chrome is bespoke. Keep it additive.
|
||||
— **DONE** `94ef3f1c` · `error.tsx`→`ErrorPage` (telemetry kept; retry→`reset`);
|
||||
`loading.tsx`→`LoadingSpinner` inside the existing skeleton; `not-found.tsx` already used
|
||||
`NotFoundPage` (confirmed); dashboard overview `page.tsx` header→`PageHeader` (Refresh as
|
||||
`actions`, subtitle preserved below). Rich detail headers (users/[id] back-button + badges)
|
||||
intentionally left bespoke — `PageHeader` has no subtitle/badge slot, so forcing it would
|
||||
regress them (additive rule). dashboard-components reads `--color-*` (admin `@theme inline`),
|
||||
so themes correctly. test **20 files / 168 tests** (+3, happy-dom render of error/not-found/
|
||||
loading chrome); typecheck+lint+build green; format:check no new failures; e2e unchanged.
|
||||
- [x] **UX-5 — Motion:** add `@bytelyst/motion`; subtle `Reveal`/`Stagger` on the overview dashboard
|
||||
cards + key tables; respect `prefers-reduced-motion`. (Note tracker-web's lesson: do NOT apply
|
||||
motion to surfaces an offline axe gate scans synchronously if transient opacity trips contrast.)
|
||||
— **DONE** `aa0e67d2` · `@bytelyst/motion` added `workspace:*` (importer-only lockfile change,
|
||||
`--frozen-lockfile` clean). Dashboard overview only: KPI cards grid → `StaggerList`
|
||||
(from="up", 50ms), bottom Model-Usage/Recent-Users tables → `Reveal`. Primitives honor
|
||||
`prefers-reduced-motion` and resolve to **opacity 1** (no element stranded transparent → no
|
||||
contrast/a11y regression; SSR-safe `prefersReducedMotion`). Applied to the auth-gated dashboard
|
||||
only (not scanned by the public e2e set), per the tracker-web axe/opacity caution. test
|
||||
**21 files / 170 tests** (+2, happy-dom asserts primitives end visible + render all children);
|
||||
typecheck+lint+build green; format:check no new failures; e2e unchanged.
|
||||
- [~] **UX-6 — System banners (conditional):** if a real broadcasts/maintenance feed is reachable,
|
||||
add `@bytelyst/notifications-ui` `BannerStack`/`Announcement` in `(dashboard)/layout.tsx`.
|
||||
`NotificationCenter` only with a real feed; else **defer**.
|
||||
— **DEFERRED** · No real broadcasts/maintenance feed is reachable in this environment:
|
||||
`platform-service` (`:4003`) refuses connections (the same backend gap that makes 80/91 e2e
|
||||
fail), so the admin `/api/maintenance` + `/api/broadcasts` proxies have nothing to surface.
|
||||
Per the wave's explicit condition (and the run brief), banners are **not** added against an
|
||||
empty/unreachable feed — adding `BannerStack`/`NotificationCenter` here would be unverifiable
|
||||
and could render a permanent empty/erroring banner. Follow-up recorded in the Deferrals table.
|
||||
|
||||
## Cross-cutting
|
||||
|
||||
- [x] **CC.1** Full suite + build green every wave. — `typecheck`+`lint`+`build` green and `vitest`
|
||||
passing after each wave; final **22 files / 183 tests**.
|
||||
- [x] **CC.2** Dark-mode parity (bridge works in `.dark`). — Bridge maps every `--bl-*` to an admin
|
||||
`--*` var (or `color-mix` of one) that flips between `:root` and `.dark`, so parity is inherited;
|
||||
guarded by `src/__tests__/token-bridge.test.ts`.
|
||||
- [x] **CC.3** No new color literals. — All new color values are `var(--*)` tokens (incl. the
|
||||
`var(--chart-*)` categorical palette); bridge introduces only `var()`/`color-mix` (asserted by
|
||||
the token-bridge test); grep of every touched file finds zero new hex/oklch/hsl/rgb literals.
|
||||
- [x] **CC.4** No new a11y violations; labels on all controls. — Charts render `role="img"`+`<title>`
|
||||
(`ariaLabel`); palette dialog has `ariaLabel="Admin command palette"`; `LoadingSpinner` is
|
||||
`role="status"`; motion primitives resolve to opacity 1 (no contrast trap). No new unlabeled
|
||||
controls introduced. (Full `@axe-core` gate needs the backend — deferred; see Deferrals.)
|
||||
- [x] **CC.5** Bundle: dynamic `import()` for charts/palette; record gzipped sizes. — Charts are
|
||||
code-split via `next/dynamic` into their own async chunk (**~11.0 KB raw / ~3.8 KB gzip**);
|
||||
the command-palette dialog and `@bytelyst/motion` are also `next/dynamic`/deferred. `size:check`
|
||||
(`bundlesize`) has **no config in this repo** (`Config not found`) → recorded gzip sizes here per
|
||||
the roadmap fallback; adding a `bundlesize` budget config is left as a follow-up.
|
||||
- [x] **CC.6** Final tracker + Deferrals table complete. — see below.
|
||||
|
||||
## Progress tracker
|
||||
|
||||
```
|
||||
Setup : UX-1 ✅
|
||||
Adopt : UX-2 ✅ UX-3 ✅ UX-4 ✅ UX-5 ✅ UX-6 ⏭️ deferred (no feed)
|
||||
Cross : CC.1 ✅ CC.2 ✅ CC.3 ✅ CC.4 ✅ CC.5 ✅ CC.6 ✅
|
||||
```
|
||||
|
||||
## E2e baseline (captured on UX-1 — 2026-05-29)
|
||||
|
||||
`pnpm --filter @bytelyst/admin-web test:e2e` (Playwright, chromium, 91 tests, dev server auto-booted on :3001):
|
||||
|
||||
```
|
||||
11 passed
|
||||
80 failed (5.0m)
|
||||
```
|
||||
|
||||
**The 80 failures are environmental, NOT regressions.** They all fail in a `loginAsAdmin` /
|
||||
`beforeEach` step (`getByLabel('Email').fill(...)` times out, or post-login `waitForURL('**/dashboard')`
|
||||
never resolves) because the `platform-service` backend (`:4003`) and emulator stack are not running in
|
||||
this sandbox, so authenticated surfaces never render. The **11 passing** are the public/unauthenticated
|
||||
specs (`login.spec.ts`, `navigation.spec.ts`, `smartauth-login.spec.ts` public-page assertions).
|
||||
|
||||
**Gate for every subsequent wave:** keep these **11 passing** and add **no new failures** — i.e. the
|
||||
count must stay ≥ 11 passed / ≤ 80 failed. (A full green run requires the backend; out of scope here.)
|
||||
|
||||
## Deferrals (fill in as encountered)
|
||||
|
||||
| Item | Reason (surface/data gate) | Follow-up |
|
||||
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **UX-6 — System banners** | No real broadcasts/maintenance feed: `platform-service` (`:4003`) unreachable in this env, so `/api/maintenance` + `/api/broadcasts` return nothing. | When the backend stack runs, add `@bytelyst/notifications-ui` `BannerStack`/`Announcement` (+`NotificationCenter`) in `(dashboard)/layout.tsx` wired to the live feed, and verify against real data. |
|
||||
| `@bytelyst/charts` `StackedBar` | Charts 0.1.1 ships only single-series/vertical bars (StackedBar deferred to charts 0.2.x), so the client-logs stacked severity chart was rendered as single bars colored by dominant severity. | Restore a true stacked severity chart once `@bytelyst/charts` 0.2.x lands `StackedBar`. |
|
||||
| e2e full-green / `@axe-core` gate | 80/91 Playwright specs need the auth backend (`:4003`) which is down here; no `@axe-core` harness exists in `e2e/` yet. | Run the full e2e + add an `@axe-core/playwright` a11y gate once the backend/emulator stack is reachable in CI. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 (FUTURE — do NOT start in this run)
|
||||
|
||||
> Tracked here so it isn't lost. **Out of scope for the phase-1 run above.** Do not begin any of
|
||||
> this without an explicit, separate delegation — it is large and high-risk.
|
||||
|
||||
**Goal:** migrate the local shadcn `src/components/ui/*` layer (~16 radix-based components: `button`,
|
||||
`badge`, `dialog`, `dropdown-menu`, `select`, `tabs`, `tooltip`, `switch`, `slider`, `progress`,
|
||||
`avatar`, `label`, `separator`, `sheet`, …) onto shared `@bytelyst/ui` equivalents via the
|
||||
`Primitives.tsx` adapter, retiring the bespoke radix wrappers.
|
||||
|
||||
**Why it's deferred:**
|
||||
|
||||
- Touches nearly all ~45 surfaces (every page imports shadcn primitives) → enormous blast radius.
|
||||
- Behavioral parity risk: shadcn variants, `class-variance-authority` styling, and radix a11y
|
||||
semantics must be matched 1:1 or admin workflows regress.
|
||||
- Should be done component-by-component behind the wave-1 token bridge, each with snapshot/interaction
|
||||
tests, not as a big-bang swap.
|
||||
|
||||
**Suggested sequencing (when scheduled):**
|
||||
|
||||
1. Bridge first (done in phase-1 UX-1) so shared components already theme correctly.
|
||||
2. Migrate leaf primitives with no children first (`badge`, `label`, `separator`, `avatar`).
|
||||
3. Then form controls (`input`, `select`, `switch`, `slider`) with submit/interaction tests.
|
||||
4. Then overlays (`dialog`, `dropdown-menu`, `tooltip`, `sheet`) — verify focus-trap/Esc/scroll-lock.
|
||||
5. Delete each shadcn wrapper only once all its importers are migrated and the drift ratchet passes.
|
||||
6. One component per commit; keep `test:coverage` and `size:check` green throughout.
|
||||
|
||||
**Acceptance:** local `src/components/ui/*` shadcn wrappers removed (or reduced to thin re-exports of
|
||||
the adapter), all surfaces visually + behaviorally unchanged, coverage and bundle budgets held.
|
||||
@ -1,6 +1,6 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
import nextTs from 'eslint-config-next/typescript';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
// Ignores MUST come first so they apply to every subsequent
|
||||
@ -11,32 +11,67 @@ const eslintConfig = defineConfig([
|
||||
// at index 0 is the documented way to make eslint v9 skip files
|
||||
// entirely before any config rules apply.
|
||||
{
|
||||
ignores: [
|
||||
"**/.pnpmfile.cjs",
|
||||
"**/*.cjs",
|
||||
],
|
||||
ignores: ['**/.pnpmfile.cjs', '**/*.cjs'],
|
||||
},
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
rules: {
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }],
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||
],
|
||||
},
|
||||
},
|
||||
// UX-drift ratchet (UX-1): shared `@bytelyst/ui` primitives must be adopted
|
||||
// through the local adapter `src/components/ui/Primitives.tsx`, never imported
|
||||
// directly into pages/components. This keeps product-specific variants, the
|
||||
// `--bl-*` token bridge, and a single migration seam in one place (the
|
||||
// exception below). Mirrors tracker-web's convention.
|
||||
{
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: '@bytelyst/ui',
|
||||
message:
|
||||
"Import shared UI primitives via '@/components/ui/Primitives' instead of '@bytelyst/ui' directly (UX-drift ratchet — see docs/roadmaps/UX_INTEGRATION_ADMIN.md).",
|
||||
},
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
group: ['@bytelyst/ui/*'],
|
||||
message:
|
||||
"Import shared UI primitives via '@/components/ui/Primitives' instead of '@bytelyst/ui/*' directly (UX-drift ratchet — see docs/roadmaps/UX_INTEGRATION_ADMIN.md).",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// The Primitives adapter is the ONE sanctioned seam for `@bytelyst/ui`.
|
||||
{
|
||||
files: ['src/components/ui/Primitives.tsx'],
|
||||
rules: {
|
||||
'no-restricted-imports': 'off',
|
||||
},
|
||||
},
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts',
|
||||
// .pnpmfile.cjs is a pnpm install hook (CommonJS by design).
|
||||
// The TypeScript no-require-imports rule would otherwise flag
|
||||
// every require() call in this file. eslint-config-next does NOT
|
||||
// ignore .cjs by default.
|
||||
".pnpmfile.cjs",
|
||||
"**/*.cjs",
|
||||
'.pnpmfile.cjs',
|
||||
'**/*.cjs',
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@ -26,15 +26,19 @@
|
||||
"@azure/keyvault-secrets": "^4.10.0",
|
||||
"@bytelyst/api-client": "workspace:*",
|
||||
"@bytelyst/auth": "workspace:*",
|
||||
"@bytelyst/charts": "workspace:*",
|
||||
"@bytelyst/command-palette": "workspace:*",
|
||||
"@bytelyst/config": "workspace:*",
|
||||
"@bytelyst/dashboard-components": "workspace:*",
|
||||
"@bytelyst/cosmos": "workspace:*",
|
||||
"@bytelyst/dashboard-components": "workspace:*",
|
||||
"@bytelyst/data-viz": "workspace:*",
|
||||
"@bytelyst/datastore": "workspace:*",
|
||||
"@bytelyst/design-tokens": "workspace:*",
|
||||
"@bytelyst/devops": "workspace:*",
|
||||
"@bytelyst/errors": "workspace:*",
|
||||
"@bytelyst/extraction": "workspace:*",
|
||||
"@bytelyst/logger": "workspace:*",
|
||||
"@bytelyst/motion": "workspace:*",
|
||||
"@bytelyst/react-auth": "workspace:*",
|
||||
"@bytelyst/telemetry-client": "workspace:*",
|
||||
"@bytelyst/ui": "workspace:*",
|
||||
@ -52,7 +56,6 @@
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"redis": "^4.7.0",
|
||||
"recharts": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
|
||||
89
dashboards/admin-web/src/__tests__/charts-migration.test.tsx
Normal file
89
dashboards/admin-web/src/__tests__/charts-migration.test.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { createElement } from 'react';
|
||||
import { AreaChart, BarChart, Donut } from '@bytelyst/charts';
|
||||
import { finite, seriesValues, dateBars, donutSlices } from '@/lib/chart-data';
|
||||
|
||||
/**
|
||||
* UX-2 — charts migration guard.
|
||||
*
|
||||
* Two layers:
|
||||
* 1. the pure data-shaping helpers always produce finite output, and
|
||||
* 2. the migrated `@bytelyst/charts` primitives render valid SVG with no
|
||||
* literal `NaN` in any coordinate — including the degenerate inputs that
|
||||
* used to silently break recharts (empty series, single point, all-zero,
|
||||
* and non-finite values).
|
||||
*/
|
||||
|
||||
describe('chart-data helpers', () => {
|
||||
it('coerces non-finite values to 0', () => {
|
||||
expect(finite(5)).toBe(5);
|
||||
expect(finite('7')).toBe(7);
|
||||
expect(finite(NaN)).toBe(0);
|
||||
expect(finite(Infinity)).toBe(0);
|
||||
expect(finite(null)).toBe(0);
|
||||
expect(finite(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('maps rows to a finite numeric series', () => {
|
||||
const rows = [{ v: 1 }, { v: 2 }, { v: Number.NaN }];
|
||||
expect(seriesValues(rows, 'v')).toEqual([1, 2, 0]);
|
||||
});
|
||||
|
||||
it('labels only every Nth dated bar but keeps every bar', () => {
|
||||
const rows = Array.from({ length: 7 }, (_, i) => ({ date: `2026-05-0${i + 1}`, n: i }));
|
||||
const bars = dateBars(rows, 'n', 5);
|
||||
expect(bars).toHaveLength(7);
|
||||
expect(bars[0].label).toBe('05-01');
|
||||
expect(bars[1].label).toBe('');
|
||||
expect(bars[5].label).toBe('05-06');
|
||||
expect(bars.every(b => Number.isFinite(b.value))).toBe(true);
|
||||
});
|
||||
|
||||
it('drops non-positive donut slices', () => {
|
||||
const rows = [
|
||||
{ k: 'a', v: 3 },
|
||||
{ k: 'b', v: 0 },
|
||||
{ k: 'c', v: -1 },
|
||||
];
|
||||
const slices = donutSlices(rows, 'k', 'v');
|
||||
expect(slices.map(s => s.id)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrated charts render finite SVG', () => {
|
||||
const series: number[][] = [
|
||||
[10, 20, 15, 30, 25],
|
||||
[], // empty
|
||||
[42], // single point
|
||||
[0, 0, 0], // all-zero
|
||||
[Number.NaN, 5, Number.POSITIVE_INFINITY, 8], // non-finite mixed
|
||||
];
|
||||
|
||||
it.each(series.map((s, i) => [i, s] as const))('AreaChart case %i renders no NaN', (_i, s) => {
|
||||
const html = renderToStaticMarkup(createElement(AreaChart, { values: s, ariaLabel: 'a' }));
|
||||
expect(html).toContain('<svg');
|
||||
expect(html).not.toContain('NaN');
|
||||
});
|
||||
|
||||
it.each(series.map((s, i) => [i, s] as const))('BarChart case %i renders no NaN', (_i, s) => {
|
||||
// Mirror the admin data path: bar values are always sanitized via `finite`.
|
||||
const data = s.map((v, idx) => ({ id: `d${idx}`, value: finite(v), label: `d${idx}` }));
|
||||
const html = renderToStaticMarkup(createElement(BarChart, { data, ariaLabel: 'b' }));
|
||||
expect(html).toContain('<svg');
|
||||
expect(html).not.toContain('NaN');
|
||||
});
|
||||
|
||||
it.each(series.map((s, i) => [i, s] as const))('Donut case %i renders no NaN', (_i, s) => {
|
||||
// Mirror the admin data path: slices always come from `donutSlices`, which
|
||||
// drops non-positive/non-finite values.
|
||||
const slices = donutSlices(
|
||||
s.map((v, idx) => ({ k: `s${idx}`, v })),
|
||||
'k',
|
||||
'v'
|
||||
);
|
||||
const html = renderToStaticMarkup(createElement(Donut, { slices, ariaLabel: 'd' }));
|
||||
expect(html).toContain('<svg');
|
||||
expect(html).not.toContain('NaN');
|
||||
});
|
||||
});
|
||||
117
dashboards/admin-web/src/__tests__/command-palette.test.tsx
Normal file
117
dashboards/admin-web/src/__tests__/command-palette.test.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { useCommandPalette } from '@bytelyst/command-palette';
|
||||
import { buildAdminCommands, NAV_COMMANDS } from '@/lib/admin-commands';
|
||||
|
||||
/**
|
||||
* UX-3 — command palette guard.
|
||||
*
|
||||
* 1. `buildAdminCommands` produces the expected navigate + action set, and the
|
||||
* action callbacks are correctly wired.
|
||||
* 2. The ⌘K / Ctrl-K hotkey (via `useCommandPalette`) toggles the palette open.
|
||||
*/
|
||||
|
||||
describe('buildAdminCommands', () => {
|
||||
const base = () => buildAdminCommands({ resolvedTheme: 'light', toggleTheme() {}, signOut() {} });
|
||||
|
||||
it('includes navigate commands for the major surfaces', () => {
|
||||
const cmds = base();
|
||||
const navHrefs = cmds.filter(c => c.mode === 'navigate').map(c => c.href);
|
||||
for (const href of [
|
||||
'/users',
|
||||
'/billing',
|
||||
'/flags',
|
||||
'/broadcasts',
|
||||
'/audit',
|
||||
'/experiments',
|
||||
'/subscriptions',
|
||||
'/licenses',
|
||||
'/ops',
|
||||
]) {
|
||||
expect(navHrefs).toContain(href);
|
||||
}
|
||||
expect(cmds.filter(c => c.mode === 'navigate')).toHaveLength(NAV_COMMANDS.length);
|
||||
});
|
||||
|
||||
it('exposes theme-toggle + sign-out actions whose run callbacks fire', () => {
|
||||
const toggleTheme = vi.fn();
|
||||
const signOut = vi.fn();
|
||||
const cmds = buildAdminCommands({ resolvedTheme: 'dark', toggleTheme, signOut });
|
||||
|
||||
const theme = cmds.find(c => c.id === 'action-toggle-theme');
|
||||
const out = cmds.find(c => c.id === 'action-sign-out');
|
||||
expect(theme?.label).toBe('Switch to light mode'); // dark -> offer light
|
||||
expect(out?.mode).toBe('actions');
|
||||
|
||||
theme?.run?.();
|
||||
out?.run?.();
|
||||
expect(toggleTheme).toHaveBeenCalledTimes(1);
|
||||
expect(signOut).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('flips the theme label based on the resolved theme', () => {
|
||||
const light = buildAdminCommands({ resolvedTheme: 'light', toggleTheme() {}, signOut() {} });
|
||||
expect(light.find(c => c.id === 'action-toggle-theme')?.label).toBe('Switch to dark mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('⌘K hotkey opens the palette', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function Harness() {
|
||||
const cmdk = useCommandPalette();
|
||||
return <span data-testid="state">{cmdk.open ? 'open' : 'closed'}</span>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function press(init: KeyboardEventInit) {
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ...init }));
|
||||
});
|
||||
}
|
||||
|
||||
it('toggles open on Cmd-K and closed again', () => {
|
||||
act(() => {
|
||||
root.render(<Harness />);
|
||||
});
|
||||
expect(container.textContent).toBe('closed');
|
||||
|
||||
press({ key: 'k', metaKey: true });
|
||||
expect(container.textContent).toBe('open');
|
||||
|
||||
press({ key: 'k', metaKey: true });
|
||||
expect(container.textContent).toBe('closed');
|
||||
});
|
||||
|
||||
it('also opens on Ctrl-K', () => {
|
||||
act(() => {
|
||||
root.render(<Harness />);
|
||||
});
|
||||
press({ key: 'k', ctrlKey: true });
|
||||
expect(container.textContent).toBe('open');
|
||||
});
|
||||
|
||||
it('ignores unmodified k and other keys', () => {
|
||||
act(() => {
|
||||
root.render(<Harness />);
|
||||
});
|
||||
press({ key: 'k' });
|
||||
press({ key: 'j', metaKey: true });
|
||||
expect(container.textContent).toBe('closed');
|
||||
});
|
||||
});
|
||||
62
dashboards/admin-web/src/__tests__/motion.test.tsx
Normal file
62
dashboards/admin-web/src/__tests__/motion.test.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Reveal, StaggerList } from '@bytelyst/motion';
|
||||
|
||||
/**
|
||||
* UX-5 — motion guard. The shared motion primitives must end fully visible
|
||||
* (no element stranded at opacity:0, which would trip contrast / a11y) and
|
||||
* must render all their content. We assert the reduced-motion / disabled path
|
||||
* (what `prefers-reduced-motion` users and SSR-stable snapshots get).
|
||||
*/
|
||||
describe('motion primitives stay visible + render content', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('Reveal renders its child and resolves to visible (opacity 1)', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<Reveal disableMotion from="up">
|
||||
<span>Revealed content</span>
|
||||
</Reveal>
|
||||
);
|
||||
});
|
||||
const el = container.querySelector('[data-testid="bl-reveal"]') as HTMLElement | null;
|
||||
expect(el).toBeTruthy();
|
||||
expect(el!.getAttribute('data-visible')).toBe('true');
|
||||
expect(el!.style.opacity).toBe('1');
|
||||
expect(container.textContent).toContain('Revealed content');
|
||||
});
|
||||
|
||||
it('StaggerList renders every child, each visible', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<StaggerList disableMotion from="up">
|
||||
<div>alpha</div>
|
||||
<div>beta</div>
|
||||
<div>gamma</div>
|
||||
</StaggerList>
|
||||
);
|
||||
});
|
||||
const reveals = container.querySelectorAll('[data-testid="bl-reveal"]');
|
||||
expect(reveals.length).toBe(3);
|
||||
reveals.forEach(r => expect(r.getAttribute('data-visible')).toBe('true'));
|
||||
expect(container.textContent).toContain('alpha');
|
||||
expect(container.textContent).toContain('beta');
|
||||
expect(container.textContent).toContain('gamma');
|
||||
});
|
||||
});
|
||||
66
dashboards/admin-web/src/__tests__/page-chrome.test.tsx
Normal file
66
dashboards/admin-web/src/__tests__/page-chrome.test.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
// error.tsx fires telemetry on mount — stub it so the chrome can render in isolation.
|
||||
vi.mock('@/lib/telemetry', () => ({ trackEvent: vi.fn() }));
|
||||
|
||||
import GlobalError from '@/app/error';
|
||||
import NotFound from '@/app/not-found';
|
||||
import DashboardLoading from '@/app/(dashboard)/loading';
|
||||
|
||||
/**
|
||||
* UX-4 — page-chrome adoption guard. Verifies admin's error / not-found /
|
||||
* loading surfaces render the shared `@bytelyst/dashboard-components` chrome
|
||||
* and that the error retry handler is wired to Next's `reset`.
|
||||
*/
|
||||
describe('page chrome (dashboard-components)', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('error.tsx renders ErrorPage with the message and a working retry', () => {
|
||||
const reset = vi.fn();
|
||||
act(() => {
|
||||
root.render(
|
||||
<GlobalError error={Object.assign(new Error('kaboom'), { digest: 'd1' })} reset={reset} />
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toContain('Something went wrong');
|
||||
expect(container.textContent).toContain('kaboom');
|
||||
|
||||
const retry = Array.from(container.querySelectorAll('button')).find(b =>
|
||||
/try again|retry/i.test(b.textContent ?? '')
|
||||
);
|
||||
expect(retry).toBeTruthy();
|
||||
act(() => retry!.dispatchEvent(new MouseEvent('click', { bubbles: true })));
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('not-found.tsx renders the NotFoundPage chrome', () => {
|
||||
act(() => {
|
||||
root.render(<NotFound />);
|
||||
});
|
||||
expect(container.querySelector('a[href="/"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('loading.tsx renders an accessible LoadingSpinner', () => {
|
||||
act(() => {
|
||||
root.render(<DashboardLoading />);
|
||||
});
|
||||
expect(container.querySelector('[role="status"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as Primitives from '@/components/ui/Primitives';
|
||||
|
||||
/**
|
||||
* UX-1 — Primitives export-presence guard.
|
||||
*
|
||||
* `src/components/ui/Primitives.tsx` is the single sanctioned adapter for the
|
||||
* shared `@bytelyst/ui` layer (enforced by the `no-restricted-imports` ratchet
|
||||
* in `eslint.config.mjs`). If a re-export silently disappears, every consumer
|
||||
* breaks at build time — this test fails fast instead, and documents the
|
||||
* surface the rest of admin-web is allowed to rely on.
|
||||
*/
|
||||
|
||||
// Product-specific component implementations + helpers defined locally.
|
||||
const LOCAL_EXPORTS = [
|
||||
'Button',
|
||||
'IconButton',
|
||||
'Input',
|
||||
'Select',
|
||||
'Textarea',
|
||||
'Badge',
|
||||
'ProductStatusBadge',
|
||||
'statusToneFor',
|
||||
] as const;
|
||||
|
||||
// Shared `@bytelyst/ui` primitives re-exported through the adapter.
|
||||
const SHARED_REEXPORTS = [
|
||||
'ActionMenu',
|
||||
'AlertBanner',
|
||||
'DataList',
|
||||
'DataTable',
|
||||
'Drawer',
|
||||
'EmptyState',
|
||||
'EntityCard',
|
||||
'Field',
|
||||
'FieldLabel',
|
||||
'FilterBar',
|
||||
'FormSection',
|
||||
'MetricCard',
|
||||
'Modal',
|
||||
'PageHeader',
|
||||
'Panel',
|
||||
'PanelHeader',
|
||||
'PanelTitle',
|
||||
'Skeleton',
|
||||
'Timeline',
|
||||
'Toolbar',
|
||||
] as const;
|
||||
|
||||
describe('Primitives adapter exports', () => {
|
||||
it.each(LOCAL_EXPORTS)('exposes local export %s', name => {
|
||||
expect(Primitives[name as keyof typeof Primitives]).toBeDefined();
|
||||
});
|
||||
|
||||
it.each(SHARED_REEXPORTS)('re-exports shared @bytelyst/ui primitive %s', name => {
|
||||
expect(Primitives[name as keyof typeof Primitives]).toBeDefined();
|
||||
});
|
||||
|
||||
it('maps product statuses to a known tone (helper is wired)', () => {
|
||||
expect(Primitives.statusToneFor('active')).toBe('success');
|
||||
expect(Primitives.statusToneFor('failed')).toBe('error');
|
||||
expect(Primitives.statusToneFor(null)).toBe('neutral');
|
||||
expect(Primitives.statusToneFor('totally-unknown')).toBe('neutral');
|
||||
});
|
||||
});
|
||||
61
dashboards/admin-web/src/__tests__/token-bridge.test.ts
Normal file
61
dashboards/admin-web/src/__tests__/token-bridge.test.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* UX-1 / CC.2 / CC.3 guard for the `--bl-*` → admin-OKLCH token bridge.
|
||||
*
|
||||
* Dark-mode parity is *inherited*: every bridged `--bl-*` token must resolve to
|
||||
* an admin shadcn `--*` var (or a `color-mix` of one), which already flips
|
||||
* between `:root` and `.dark`. This test pins that contract so a future edit
|
||||
* can't silently hardcode a one-mode literal (which would break `.dark` parity
|
||||
* and violate the zero-new-color-literals rule).
|
||||
*/
|
||||
const css = readFileSync(join(__dirname, '..', 'app', 'globals.css'), 'utf8');
|
||||
|
||||
// Isolate the UX-1 bridge block.
|
||||
const bridge = css.slice(css.indexOf('@bytelyst/ui token bridge'));
|
||||
|
||||
const REQUIRED_MAPPINGS: Array<[string, string]> = [
|
||||
['--bl-bg-canvas', 'var(--background)'],
|
||||
['--bl-surface-card', 'var(--card)'],
|
||||
['--bl-surface-muted', 'var(--muted)'],
|
||||
['--bl-text-primary', 'var(--foreground)'],
|
||||
['--bl-text-secondary', 'var(--muted-foreground)'],
|
||||
['--bl-border', 'var(--border)'],
|
||||
['--bl-input', 'var(--input)'],
|
||||
['--bl-accent', 'var(--primary)'],
|
||||
['--bl-accent-foreground', 'var(--primary-foreground)'],
|
||||
['--bl-danger', 'var(--destructive)'],
|
||||
['--bl-focus-ring', 'var(--ring)'],
|
||||
];
|
||||
|
||||
describe('--bl-* token bridge (dark-mode parity)', () => {
|
||||
it.each(REQUIRED_MAPPINGS)(
|
||||
'maps %s to %s (an admin var that flips under .dark)',
|
||||
(token, target) => {
|
||||
const re = new RegExp(`${token}\\s*:\\s*${target.replace(/[()]/g, '\\$&')}\\s*;`);
|
||||
expect(re.test(bridge)).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('introduces no raw color literals in the bridge (only var()/color-mix tokens)', () => {
|
||||
// Strip CSS comments, then look for hex / oklch / hsl / rgb literals.
|
||||
const code = bridge.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
expect(/#[0-9a-fA-F]{3,8}\b/.test(code)).toBe(false);
|
||||
expect(/\boklch\(/.test(code)).toBe(false);
|
||||
expect(/\bhsl\(/.test(code)).toBe(false);
|
||||
expect(/\brgb\(/.test(code)).toBe(false);
|
||||
});
|
||||
|
||||
it('every bridged --bl-* value references an admin var', () => {
|
||||
// Each `--bl-foo: <value>;` line inside the bridge must contain `var(--`.
|
||||
const declRe = /(--bl-[a-z0-9-]+)\s*:\s*([^;]+);/g;
|
||||
let m: RegExpExecArray | null;
|
||||
const offenders: string[] = [];
|
||||
while ((m = declRe.exec(bridge))) {
|
||||
if (!m[2].includes('var(--')) offenders.push(`${m[1]}: ${m[2].trim()}`);
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { CommandRegistryProvider } from '@bytelyst/command-palette';
|
||||
import { SidebarNav } from '@/components/sidebar-nav';
|
||||
import { AuthGuard } from '@/components/auth-guard';
|
||||
import { CommandMenu } from '@/components/command-menu';
|
||||
import { ErrorBoundary } from '@/components/error-boundary';
|
||||
import { useStripeConfig } from '@/lib/stripe-context';
|
||||
import { FlaskConical, ShieldCheck } from 'lucide-react';
|
||||
@ -32,13 +34,16 @@ function StripeModeBanner() {
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<SidebarNav />
|
||||
<main className="ml-64 min-h-screen bg-background max-md:ml-0">
|
||||
<StripeModeBanner />
|
||||
<div className="p-8 max-md:p-4">
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</div>
|
||||
</main>
|
||||
<CommandRegistryProvider>
|
||||
<SidebarNav />
|
||||
<main className="ml-64 min-h-screen bg-background max-md:ml-0">
|
||||
<StripeModeBanner />
|
||||
<div className="p-8 max-md:p-4">
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</div>
|
||||
</main>
|
||||
<CommandMenu />
|
||||
</CommandRegistryProvider>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { LoadingSpinner } from '@bytelyst/dashboard-components';
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
@ -29,7 +29,7 @@ export default function DashboardLoading() {
|
||||
<div key={i} className="rounded-xl border bg-card p-6">
|
||||
<div className="mb-4 h-5 w-36 animate-pulse rounded bg-muted" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -1,16 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { BarChart } from '@/components/charts';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bug,
|
||||
@ -207,34 +198,29 @@ function ClusterTimelineChart({ clusters }: { clusters: TelemetryCluster[] }) {
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Cluster Occurrence Timeline</CardTitle>
|
||||
<CardDescription>Error counts by severity over the last 14 days</CardDescription>
|
||||
<CardDescription>
|
||||
Total events per day over the last 14 days, colored by the day's most severe level
|
||||
(fatal / error / warn)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
|
||||
<YAxis tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '8px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Bar dataKey="fatal" stackId="a" fill="hsl(var(--destructive))" name="Fatal" />
|
||||
<Bar dataKey="error" stackId="a" fill="hsl(var(--chart-5))" name="Error" />
|
||||
<Bar
|
||||
dataKey="warn"
|
||||
stackId="a"
|
||||
fill="hsl(var(--chart-4))"
|
||||
name="Warn"
|
||||
radius={[2, 2, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<BarChart
|
||||
data={chartData.map((d, i) => ({
|
||||
id: d.date,
|
||||
value: d.fatal + d.error + d.warn,
|
||||
label: i % 3 === 0 ? d.date.slice(5) : '',
|
||||
color:
|
||||
d.fatal > 0
|
||||
? 'var(--bl-danger)'
|
||||
: d.error > 0
|
||||
? 'var(--chart-5)'
|
||||
: 'var(--bl-warning)',
|
||||
}))}
|
||||
width={720}
|
||||
height={200}
|
||||
className="h-auto w-full"
|
||||
ariaLabel="Total telemetry cluster occurrences per day over the last 14 days"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@ -986,36 +972,17 @@ export default function ClientLogsPage() {
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<ResponsiveContainer width="100%" height={Math.max(200, geoData.length * 32)}>
|
||||
<BarChart data={geoData} layout="vertical" margin={{ left: 40, right: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="countryCode"
|
||||
tick={{ fontSize: 12 }}
|
||||
width={50}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '8px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value: number | undefined) => [
|
||||
(value ?? 0).toLocaleString(),
|
||||
'Events',
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<BarChart
|
||||
data={geoData.map(g => ({
|
||||
id: g.countryCode,
|
||||
value: g.count,
|
||||
label: g.countryCode,
|
||||
}))}
|
||||
width={720}
|
||||
height={240}
|
||||
className="h-auto w-full"
|
||||
ariaLabel="Telemetry events by country over the last 7 days"
|
||||
/>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
||||
@ -34,17 +34,10 @@ import {
|
||||
type ApiUsageRecord,
|
||||
type RevenueAnalytics,
|
||||
} from '@/lib/api';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
} from 'recharts';
|
||||
import { PageHeader } from '@bytelyst/dashboard-components';
|
||||
import { Reveal, StaggerList } from '@bytelyst/motion';
|
||||
import { AreaChart, BarChart } from '@/components/charts';
|
||||
import { seriesValues, dateBars } from '@/lib/chart-data';
|
||||
|
||||
function buildKpiCards(stats: typeof mockSummaryStats, revenue?: RevenueAnalytics | null) {
|
||||
const fmt = (n: number) => (n >= 0 ? `+${n}%` : `${n}%`);
|
||||
@ -284,22 +277,30 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Platform overview and key metrics
|
||||
{lastUpdated && (
|
||||
<span className="ml-2 text-xs">
|
||||
· Updated {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => fetchData(true)} disabled={refreshing}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
className="!mb-2"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fetchData(true)}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground">
|
||||
Platform overview and key metrics
|
||||
{lastUpdated && (
|
||||
<span className="ml-2 text-xs">
|
||||
· Updated {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
@ -310,7 +311,12 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<StaggerList
|
||||
as="div"
|
||||
from="up"
|
||||
stagger={50}
|
||||
className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{kpiCards.map(card => (
|
||||
<Card key={card.title} className="transition-shadow hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
@ -344,7 +350,7 @@ export default function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</StaggerList>
|
||||
)}
|
||||
|
||||
{/* Charts Row */}
|
||||
@ -361,32 +367,13 @@ export default function DashboardPage() {
|
||||
<CardTitle className="text-base">Daily Active Users (30 days)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<AreaChart data={dailyMetrics}>
|
||||
<defs>
|
||||
<linearGradient id="colorUsers" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--chart-1))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--chart-1))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="date" tickFormatter={v => v.slice(5)} className="text-xs" />
|
||||
<YAxis className="text-xs" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="activeUsers"
|
||||
stroke="hsl(var(--chart-1))"
|
||||
fill="url(#colorUsers)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<AreaChart
|
||||
values={seriesValues(dailyMetrics, 'activeUsers')}
|
||||
width={640}
|
||||
height={280}
|
||||
className="h-auto w-full"
|
||||
ariaLabel="Daily active users over the last 30 days"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -396,28 +383,20 @@ export default function DashboardPage() {
|
||||
<CardTitle className="text-base">Daily Revenue (30 days)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={dailyMetrics}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="date" tickFormatter={v => v.slice(5)} className="text-xs" />
|
||||
<YAxis className="text-xs" tickFormatter={v => `$${v}`} />
|
||||
<Tooltip
|
||||
formatter={value => formatCurrency(Number(value))}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="revenue" fill="hsl(var(--chart-2))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<BarChart
|
||||
data={dateBars(dailyMetrics, 'revenue')}
|
||||
width={640}
|
||||
height={280}
|
||||
className="h-auto w-full"
|
||||
ariaLabel="Daily revenue over the last 30 days"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Row: Model Usage + Recent Users */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Reveal from="up" className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Model Usage */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@ -503,7 +482,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -40,27 +40,11 @@ import {
|
||||
type RetentionCohort,
|
||||
} from '@/lib/api';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import { AreaChart, BarChart, Donut } from '@/components/charts';
|
||||
import { seriesValues, dateBars, donutSlices } from '@/lib/chart-data';
|
||||
|
||||
const COLORS = [
|
||||
'hsl(var(--chart-1))',
|
||||
'hsl(var(--chart-2))',
|
||||
'hsl(var(--chart-4))',
|
||||
'hsl(var(--chart-5))',
|
||||
];
|
||||
// Categorical palette drawn from admin's shadcn chart tokens (no literals).
|
||||
const COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-4)', 'var(--chart-5)'];
|
||||
|
||||
function usageRecordsToDailyMetrics(records: ApiUsageRecord[]): DailyMetric[] {
|
||||
const byDate = new Map<string, DailyMetric>();
|
||||
@ -311,34 +295,13 @@ export default function UsagePage() {
|
||||
<CardTitle className="text-base">Token Consumption (30 days)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={dailyMetrics}>
|
||||
<defs>
|
||||
<linearGradient id="colorTokens" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--chart-1))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--chart-1))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="date" tickFormatter={v => v.slice(5)} className="text-xs" />
|
||||
<YAxis className="text-xs" tickFormatter={v => formatNumber(v)} />
|
||||
<Tooltip
|
||||
formatter={value => formatNumber(Number(value))}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="totalTokens"
|
||||
stroke="hsl(var(--chart-1))"
|
||||
fill="url(#colorTokens)"
|
||||
strokeWidth={2}
|
||||
name="Tokens"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<AreaChart
|
||||
values={seriesValues(dailyMetrics, 'totalTokens')}
|
||||
width={640}
|
||||
height={300}
|
||||
className="h-auto w-full"
|
||||
ariaLabel="Token consumption over the last 30 days"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -347,26 +310,13 @@ export default function UsagePage() {
|
||||
<CardTitle className="text-base">API Requests (30 days)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={dailyMetrics}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="date" tickFormatter={v => v.slice(5)} className="text-xs" />
|
||||
<YAxis className="text-xs" tickFormatter={v => formatNumber(v)} />
|
||||
<Tooltip
|
||||
formatter={value => formatNumber(Number(value))}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="totalRequests"
|
||||
fill="hsl(var(--chart-2))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
name="Requests"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<BarChart
|
||||
data={dateBars(dailyMetrics, 'totalRequests')}
|
||||
width={640}
|
||||
height={300}
|
||||
className="h-auto w-full"
|
||||
ariaLabel="API requests over the last 30 days"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@ -378,33 +328,15 @@ export default function UsagePage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Model Distribution by Cost</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={modelUsage}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={70}
|
||||
outerRadius={110}
|
||||
paddingAngle={4}
|
||||
dataKey="cost"
|
||||
nameKey="model"
|
||||
label={({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
|
||||
>
|
||||
{modelUsage.map((_, idx) => (
|
||||
<Cell key={idx} fill={COLORS[idx % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={value => formatCurrency(Number(value))}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<CardContent className="flex justify-center">
|
||||
<Donut
|
||||
slices={donutSlices(modelUsage, 'model', 'cost').map((s, idx) => ({
|
||||
...s,
|
||||
color: COLORS[idx % COLORS.length],
|
||||
}))}
|
||||
size={260}
|
||||
ariaLabel="Model distribution by cost"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -543,33 +475,15 @@ export default function UsagePage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Usage by Platform</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={sourceUsage}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={4}
|
||||
dataKey="tokens"
|
||||
nameKey="source"
|
||||
label={({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
|
||||
>
|
||||
{sourceUsage.map((_, idx) => (
|
||||
<Cell key={idx} fill={COLORS[idx % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={value => formatNumber(Number(value))}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<CardContent className="flex justify-center">
|
||||
<Donut
|
||||
slices={donutSlices(sourceUsage, 'source', 'tokens').map((s, idx) => ({
|
||||
...s,
|
||||
color: COLORS[idx % COLORS.length],
|
||||
}))}
|
||||
size={240}
|
||||
ariaLabel="Token usage by platform"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@ -643,25 +557,18 @@ export default function UsagePage() {
|
||||
<CardTitle className="text-base">Tokens by Product</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={productUsage} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis type="number" tickFormatter={v => formatNumber(v)} className="text-xs" />
|
||||
<YAxis type="category" dataKey="productId" width={100} className="text-xs" />
|
||||
<Tooltip
|
||||
formatter={value => formatNumber(Number(value))}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="tokens" name="Tokens" radius={[0, 4, 4, 0]}>
|
||||
{productUsage.map((_, idx) => (
|
||||
<Cell key={idx} fill={COLORS[idx % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<BarChart
|
||||
data={productUsage.map((p, idx) => ({
|
||||
id: p.productId,
|
||||
value: p.tokens,
|
||||
label: p.productId,
|
||||
color: COLORS[idx % COLORS.length],
|
||||
}))}
|
||||
width={640}
|
||||
height={280}
|
||||
className="h-auto w-full"
|
||||
ariaLabel="Tokens by product"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
|
||||
@ -29,15 +29,8 @@ import {
|
||||
} from '@/components/ui/table';
|
||||
import { apiGetUserView, type UserViewResponse, type ApiUsageRecord } from '@/lib/api';
|
||||
import { formatNumber, formatCurrency, formatDate } from '@/lib/mock-data';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { AreaChart } from '@/components/charts';
|
||||
import { seriesValues } from '@/lib/chart-data';
|
||||
|
||||
const planColors: Record<string, string> = {
|
||||
free: '',
|
||||
@ -272,33 +265,13 @@ export default function UserDetailPage() {
|
||||
<CardTitle className="text-base">Daily Dictation Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<AreaChart data={dailyUsage}>
|
||||
<defs>
|
||||
<linearGradient id="colorDict" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--chart-1))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--chart-1))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="date" tickFormatter={v => v.slice(5)} className="text-xs" />
|
||||
<YAxis className="text-xs" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="dictations"
|
||||
stroke="hsl(var(--chart-1))"
|
||||
fill="url(#colorDict)"
|
||||
strokeWidth={2}
|
||||
name="Dictations"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<AreaChart
|
||||
values={seriesValues(dailyUsage, 'dictations')}
|
||||
width={720}
|
||||
height={250}
|
||||
className="h-auto w-full"
|
||||
ariaLabel="Daily dictation activity"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { ErrorPage } from '@bytelyst/dashboard-components';
|
||||
import { trackEvent } from '@/lib/telemetry';
|
||||
|
||||
export default function GlobalError({
|
||||
@ -19,19 +20,11 @@ export default function GlobalError({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
<div className="mb-4 text-5xl">⚠</div>
|
||||
<h2 className="mb-2 text-xl font-semibold">Something went wrong</h2>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{error.message || 'An unexpected error occurred.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
<ErrorPage
|
||||
title="Something went wrong"
|
||||
message={error.message || 'An unexpected error occurred.'}
|
||||
onRetry={reset}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -118,6 +118,58 @@
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
/* ── @bytelyst/ui token bridge (UX-1) ─────────────────────────────────────
|
||||
* Shared `@bytelyst/*` components read a `--bl-*` contract. By default that
|
||||
* contract is supplied by `@bytelyst/design-tokens/css` (imported above), but
|
||||
* those defaults live under `:root, [data-theme="dark"]` — i.e. they are the
|
||||
* DARK palette, and they switch via a `[data-theme]` attribute that admin-web
|
||||
* does NOT use (admin toggles a `.dark` *class*). Without this bridge, shared
|
||||
* components would render with dark token values in admin's light mode.
|
||||
*
|
||||
* This block re-points the `--bl-*` contract onto admin's shadcn OKLCH ramp.
|
||||
* Every mapping references an admin `--*` var that already flips between
|
||||
* `:root` and `.dark`, so light + dark parity is inherited automatically with
|
||||
* ZERO new color literals (declared last in this file, so it wins the cascade
|
||||
* over the design-tokens import).
|
||||
*
|
||||
* Status hues (`--bl-success` / `--bl-warning` / `--bl-info` + their -muted /
|
||||
* -border variants) intentionally inherit the shared design-tokens palette:
|
||||
* admin's shadcn ramp has no semantic success/warning/info token to map to,
|
||||
* and authoring color literals here is disallowed.
|
||||
* ------------------------------------------------------------------------- */
|
||||
:root {
|
||||
/* surfaces */
|
||||
--bl-bg-canvas: var(--background);
|
||||
--bl-bg-elevated: var(--card);
|
||||
--bl-surface-card: var(--card);
|
||||
--bl-surface-muted: var(--muted);
|
||||
--bl-surface-highlight: var(--accent);
|
||||
--bl-surface-overlay: var(--popover);
|
||||
--bl-surface-sidebar: var(--sidebar);
|
||||
--bl-input: var(--input);
|
||||
/* borders */
|
||||
--bl-border: var(--border);
|
||||
--bl-border-strong: var(--ring);
|
||||
--bl-border-subtle: color-mix(in oklab, var(--border) 60%, transparent);
|
||||
/* text */
|
||||
--bl-text-primary: var(--foreground);
|
||||
--bl-text-secondary: var(--muted-foreground);
|
||||
--bl-text-tertiary: var(--muted-foreground);
|
||||
--bl-text-quiet: var(--muted-foreground);
|
||||
/* accent / primary */
|
||||
--bl-accent: var(--primary);
|
||||
--bl-accent-foreground: var(--primary-foreground);
|
||||
--bl-accent-muted: color-mix(in oklab, var(--primary) 16%, transparent);
|
||||
/* danger ← shadcn destructive */
|
||||
--bl-danger: var(--destructive);
|
||||
--bl-danger-foreground: var(--primary-foreground);
|
||||
--bl-danger-muted: color-mix(in oklab, var(--destructive) 16%, transparent);
|
||||
--bl-danger-border: color-mix(in oklab, var(--destructive) 40%, transparent);
|
||||
/* focus */
|
||||
--bl-focus-ring: var(--ring);
|
||||
--bl-focus-ring-muted: color-mix(in oklab, var(--ring) 40%, transparent);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
53
dashboards/admin-web/src/components/charts/index.tsx
Normal file
53
dashboards/admin-web/src/components/charts/index.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Lazy, code-split wrappers around the shared `@bytelyst/charts` /
|
||||
* `@bytelyst/data-viz` SVG primitives (UX-2 / CC.5). The chart code is pulled
|
||||
* into its own async chunk via `next/dynamic` so it never weighs down the
|
||||
* initial bundle of the (heavy) dashboard surfaces that embed it. The
|
||||
* primitives are pure SVG and token-themed (`--bl-*`, bridged in `globals.css`),
|
||||
* so they inherit admin's light/dark palette automatically.
|
||||
*
|
||||
* Replaces the previous direct `recharts` usage on the dashboard, usage,
|
||||
* per-user, client-logs and extraction-entity surfaces.
|
||||
*/
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
function ChartFallback() {
|
||||
return (
|
||||
<div
|
||||
className="h-full min-h-[200px] w-full animate-pulse rounded-md bg-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const AreaChart = dynamic(
|
||||
() => import('./primitives').then(m => ({ default: m.AreaChart })),
|
||||
{ ssr: false, loading: ChartFallback }
|
||||
);
|
||||
|
||||
export const BarChart = dynamic(() => import('./primitives').then(m => ({ default: m.BarChart })), {
|
||||
ssr: false,
|
||||
loading: ChartFallback,
|
||||
});
|
||||
|
||||
export const LineChart = dynamic(
|
||||
() => import('./primitives').then(m => ({ default: m.LineChart })),
|
||||
{ ssr: false, loading: ChartFallback }
|
||||
);
|
||||
|
||||
export const Donut = dynamic(() => import('./primitives').then(m => ({ default: m.Donut })), {
|
||||
ssr: false,
|
||||
loading: ChartFallback,
|
||||
});
|
||||
|
||||
export const Sparkline = dynamic(
|
||||
() => import('./primitives').then(m => ({ default: m.Sparkline })),
|
||||
{ ssr: false, loading: ChartFallback }
|
||||
);
|
||||
|
||||
export const KpiCard = dynamic(() => import('./primitives').then(m => ({ default: m.KpiCard })), {
|
||||
ssr: false,
|
||||
loading: ChartFallback,
|
||||
});
|
||||
13
dashboards/admin-web/src/components/charts/primitives.tsx
Normal file
13
dashboards/admin-web/src/components/charts/primitives.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Static re-export seam for the shared SVG-chart packages. This module is the
|
||||
* dynamic-import target of `./index.tsx` — `next/dynamic` splits it into its
|
||||
* own chunk, while the *static* `export ... from` lines below resolve the
|
||||
* `@bytelyst/charts` / `@bytelyst/data-viz` package paths exactly like the
|
||||
* other shared `@bytelyst/*` packages do (a direct `import('@bytelyst/charts')`
|
||||
* trips Next's package-`exports` resolver because those packages only declare
|
||||
* an `import` condition).
|
||||
*/
|
||||
export { AreaChart, BarChart, LineChart, Donut } from '@bytelyst/charts';
|
||||
export { Sparkline, KpiCard } from '@bytelyst/data-viz';
|
||||
58
dashboards/admin-web/src/components/command-menu.tsx
Normal file
58
dashboards/admin-web/src/components/command-menu.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCommandPalette, useRegisterCommands } from '@bytelyst/command-palette';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { buildAdminCommands } from '@/lib/admin-commands';
|
||||
|
||||
// Lazy-load the dialog itself (own chunk, client only) — see
|
||||
// command-palette-dialog.tsx for why the dynamic target is a local re-export.
|
||||
const CommandPalette = dynamic(
|
||||
() => import('./command-palette-dialog').then(m => ({ default: m.CommandPalette })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
/**
|
||||
* Mounts the ⌘K command palette for the dashboard (UX-3). Registers
|
||||
* navigate-mode commands for the major surfaces plus theme-toggle / sign-out
|
||||
* actions, and binds the global ⌘K / Ctrl-K hotkey via `useCommandPalette`.
|
||||
*
|
||||
* Must render inside a `<CommandRegistryProvider>` (mounted in the dashboard
|
||||
* layout) so `useRegisterCommands` and the dialog share one registry.
|
||||
*/
|
||||
export function CommandMenu() {
|
||||
const router = useRouter();
|
||||
const { logout } = useAuth();
|
||||
const { resolved, setTheme } = useTheme();
|
||||
const cmdk = useCommandPalette();
|
||||
|
||||
const commands = useMemo(
|
||||
() =>
|
||||
buildAdminCommands({
|
||||
resolvedTheme: resolved,
|
||||
toggleTheme: () => setTheme(resolved === 'dark' ? 'light' : 'dark'),
|
||||
signOut: () => {
|
||||
logout();
|
||||
router.replace('/login');
|
||||
},
|
||||
}),
|
||||
[resolved, setTheme, logout, router]
|
||||
);
|
||||
|
||||
useRegisterCommands(commands);
|
||||
|
||||
return (
|
||||
<CommandPalette
|
||||
open={cmdk.open}
|
||||
onClose={cmdk.hide}
|
||||
ariaLabel="Admin command palette"
|
||||
onNavigate={href => {
|
||||
cmdk.hide();
|
||||
router.push(href);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Static re-export seam for the heavy `<CommandPalette>` dialog so it can be
|
||||
* code-split via `next/dynamic` from `command-menu.tsx`. As with the charts
|
||||
* seam, the dynamic-import target must be a *local* module: a direct
|
||||
* `import('@bytelyst/command-palette')` trips Next's package-`exports`
|
||||
* resolver (the package declares only an `import` condition).
|
||||
*/
|
||||
export { CommandPalette } from '@bytelyst/command-palette';
|
||||
@ -1,18 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { BarChart, Donut } from '@/components/charts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface ExtractionEntity {
|
||||
@ -27,11 +15,11 @@ interface EntityChartProps {
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'hsl(var(--chart-1))',
|
||||
'hsl(var(--chart-2))',
|
||||
'hsl(var(--chart-3))',
|
||||
'hsl(var(--chart-4))',
|
||||
'hsl(var(--chart-5))',
|
||||
'var(--chart-1)',
|
||||
'var(--chart-2)',
|
||||
'var(--chart-3)',
|
||||
'var(--chart-4)',
|
||||
'var(--chart-5)',
|
||||
];
|
||||
|
||||
export function EntityBarChart({ extractions, title = 'Entities by Class' }: EntityChartProps) {
|
||||
@ -55,28 +43,13 @@ export function EntityBarChart({ extractions, title = 'Entities by Class' }: Ent
|
||||
<CardTitle className="text-sm">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={data} layout="vertical" margin={{ left: 20, right: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis type="number" stroke="hsl(var(--muted-foreground))" fontSize={12} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={11}
|
||||
width={120}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" fill="hsl(var(--chart-1))" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<BarChart
|
||||
data={data.map(d => ({ id: d.name, value: d.count, label: d.name }))}
|
||||
width={640}
|
||||
height={250}
|
||||
className="h-auto w-full"
|
||||
ariaLabel={title}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@ -102,36 +75,17 @@ export function EntityPieChart({ extractions, title = 'Class Distribution' }: En
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={90}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
|
||||
labelLine={false}
|
||||
fontSize={11}
|
||||
>
|
||||
{data.map((_, idx) => (
|
||||
<Cell key={idx} fill={COLORS[idx % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11, color: 'hsl(var(--muted-foreground))' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<CardContent className="flex justify-center">
|
||||
<Donut
|
||||
slices={data.map((d, idx) => ({
|
||||
id: d.name,
|
||||
label: d.name,
|
||||
value: d.value,
|
||||
color: COLORS[idx % COLORS.length],
|
||||
}))}
|
||||
size={220}
|
||||
ariaLabel={title}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
102
dashboards/admin-web/src/lib/admin-commands.ts
Normal file
102
dashboards/admin-web/src/lib/admin-commands.ts
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Command registry contributions for the admin ⌘K palette (UX-3).
|
||||
*
|
||||
* Kept framework-free (pure data + callbacks) so the command set is
|
||||
* unit-testable in the node vitest env without rendering anything.
|
||||
*/
|
||||
import type { Command } from '@bytelyst/command-palette';
|
||||
|
||||
/** Curated navigate targets — the major admin surfaces. */
|
||||
export const NAV_COMMANDS: ReadonlyArray<{
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
keywords?: string[];
|
||||
}> = [
|
||||
{ id: 'nav-dashboard', label: 'Dashboard', href: '/', keywords: ['home', 'overview'] },
|
||||
{ id: 'nav-users', label: 'Users', href: '/users', keywords: ['accounts', 'people'] },
|
||||
{
|
||||
id: 'nav-subscriptions',
|
||||
label: 'Subscriptions',
|
||||
href: '/subscriptions',
|
||||
keywords: ['plans', 'billing'],
|
||||
},
|
||||
{ id: 'nav-licenses', label: 'Licenses', href: '/licenses', keywords: ['seats'] },
|
||||
{ id: 'nav-billing', label: 'Billing', href: '/billing', keywords: ['invoices', 'payments'] },
|
||||
{ id: 'nav-usage', label: 'Usage Analytics', href: '/usage', keywords: ['metrics', 'tokens'] },
|
||||
{ id: 'nav-broadcasts', label: 'Broadcasts', href: '/broadcasts', keywords: ['announcements'] },
|
||||
{ id: 'nav-surveys', label: 'Surveys', href: '/surveys', keywords: ['feedback', 'nps'] },
|
||||
{ id: 'nav-flags', label: 'Feature Flags', href: '/flags', keywords: ['toggles', 'rollout'] },
|
||||
{
|
||||
id: 'nav-experiments',
|
||||
label: 'Experiments',
|
||||
href: '/experiments',
|
||||
keywords: ['ab', 'a/b', 'tests'],
|
||||
},
|
||||
{ id: 'nav-audit', label: 'Audit Log', href: '/audit', keywords: ['history', 'events'] },
|
||||
{ id: 'nav-products', label: 'Products', href: '/products' },
|
||||
{ id: 'nav-invitations', label: 'Invitations', href: '/invitations', keywords: ['invites'] },
|
||||
{ id: 'nav-promos', label: 'Promo Codes', href: '/promos', keywords: ['coupons', 'discounts'] },
|
||||
{ id: 'nav-referrals', label: 'Referrals', href: '/referrals' },
|
||||
{
|
||||
id: 'nav-notifications',
|
||||
label: 'Notifications',
|
||||
href: '/notifications',
|
||||
keywords: ['alerts'],
|
||||
},
|
||||
{ id: 'nav-organizations', label: 'Organizations', href: '/organizations', keywords: ['orgs'] },
|
||||
{ id: 'nav-ops', label: 'Mission Control', href: '/ops', keywords: ['ops', 'operations'] },
|
||||
{
|
||||
id: 'nav-client-logs',
|
||||
label: 'Client Logs',
|
||||
href: '/ops/client-logs',
|
||||
keywords: ['telemetry', 'errors'],
|
||||
},
|
||||
{ id: 'nav-extraction', label: 'Extraction', href: '/extraction' },
|
||||
{ id: 'nav-settings', label: 'Settings', href: '/settings', keywords: ['config', 'preferences'] },
|
||||
];
|
||||
|
||||
export interface AdminCommandActions {
|
||||
/** Theme-aware label uses this to read the current mode. */
|
||||
resolvedTheme?: 'light' | 'dark';
|
||||
/** Toggle light/dark theme. */
|
||||
toggleTheme: () => void;
|
||||
/** Sign the admin out and return to /login. */
|
||||
signOut: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full admin command set: navigate-mode entries for the major
|
||||
* surfaces plus the app-chrome actions (theme toggle, sign out).
|
||||
*/
|
||||
export function buildAdminCommands(actions: AdminCommandActions): Command[] {
|
||||
const navigate: Command[] = NAV_COMMANDS.map(n => ({
|
||||
id: n.id,
|
||||
label: n.label,
|
||||
mode: 'navigate',
|
||||
href: n.href,
|
||||
section: 'Go to',
|
||||
keywords: n.keywords,
|
||||
}));
|
||||
|
||||
const chrome: Command[] = [
|
||||
{
|
||||
id: 'action-toggle-theme',
|
||||
label: actions.resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode',
|
||||
mode: 'actions',
|
||||
section: 'Preferences',
|
||||
keywords: ['theme', 'dark', 'light', 'appearance'],
|
||||
run: actions.toggleTheme,
|
||||
},
|
||||
{
|
||||
id: 'action-sign-out',
|
||||
label: 'Sign out',
|
||||
mode: 'actions',
|
||||
section: 'Account',
|
||||
keywords: ['logout', 'log out', 'exit'],
|
||||
run: actions.signOut,
|
||||
},
|
||||
];
|
||||
|
||||
return [...navigate, ...chrome];
|
||||
}
|
||||
50
dashboards/admin-web/src/lib/chart-data.ts
Normal file
50
dashboards/admin-web/src/lib/chart-data.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Pure data-shaping helpers for the `@bytelyst/charts` / `@bytelyst/data-viz`
|
||||
* migration (UX-2). Kept framework-free so they are unit-testable in the node
|
||||
* vitest env and guarantee finite, NaN-free output before it ever reaches an
|
||||
* SVG chart (the chart primitives also filter non-finite values defensively).
|
||||
*/
|
||||
import type { BarDatum } from '@bytelyst/charts';
|
||||
import type { DonutSlice } from '@bytelyst/charts';
|
||||
|
||||
/** Coerce to a finite number, falling back to 0 for null/NaN/Infinity. */
|
||||
export function finite(n: unknown): number {
|
||||
const v = typeof n === 'number' ? n : Number(n);
|
||||
return Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
/** Map a list of rows to a finite numeric series (X = array index). */
|
||||
export function seriesValues<T>(rows: readonly T[], key: keyof T): number[] {
|
||||
return rows.map(r => finite(r[key]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map dated rows to `BarDatum[]`, showing an X label only every `labelEvery`
|
||||
* bars (date `MM-DD`) so dense 30/90-day series don't overlap. Empty string
|
||||
* suppresses a label without dropping the bar.
|
||||
*/
|
||||
export function dateBars<T extends { date: string }>(
|
||||
rows: readonly T[],
|
||||
valueKey: keyof T,
|
||||
labelEvery = 5
|
||||
): BarDatum[] {
|
||||
return rows.map((r, i) => ({
|
||||
id: r.date,
|
||||
value: finite(r[valueKey]),
|
||||
label: i % labelEvery === 0 ? String(r.date).slice(5) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map breakdown rows to `DonutSlice[]`, dropping non-positive slices (a Donut
|
||||
* of all-zero data renders an empty muted ring rather than NaN arcs).
|
||||
*/
|
||||
export function donutSlices<T>(
|
||||
rows: readonly T[],
|
||||
idKey: keyof T,
|
||||
valueKey: keyof T
|
||||
): DonutSlice[] {
|
||||
return rows
|
||||
.map(r => ({ id: String(r[idKey]), label: String(r[idKey]), value: finite(r[valueKey]) }))
|
||||
.filter(s => s.value > 0);
|
||||
}
|
||||
@ -5,7 +5,17 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
exclude: ['e2e/**', 'node_modules/**', '.next/**'],
|
||||
// Inline the workspace SVG-chart packages so Vitest transforms them and
|
||||
// resolves their `react` import through the dedupe below. Without this the
|
||||
// chart dist (linked from a sibling repo's pnpm store) loads a second
|
||||
// physical React copy and `renderToStaticMarkup` throws "Invalid hook call".
|
||||
// The real Next/webpack build already dedupes these to admin-web's React.
|
||||
server: {
|
||||
deps: {
|
||||
inline: [/@bytelyst\/(charts|data-viz|command-palette|dashboard-components|motion)/],
|
||||
},
|
||||
},
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
@ -32,5 +42,7 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
// Force a single physical React/React-DOM copy for SSR chart render tests.
|
||||
dedupe: ['react', 'react-dom'],
|
||||
},
|
||||
});
|
||||
|
||||
6
dashboards/tracker-web/.gitignore
vendored
6
dashboards/tracker-web/.gitignore
vendored
@ -11,6 +11,12 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# playwright e2e artifacts
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
19
dashboards/tracker-web/.prettierignore
Normal file
19
dashboards/tracker-web/.prettierignore
Normal file
@ -0,0 +1,19 @@
|
||||
# Build output
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Coverage + test artifacts
|
||||
coverage/
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
|
||||
# Generated / vendored
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
@ -36,6 +36,7 @@ npm run dev # starts on port 3003
|
||||
## Environment Variables
|
||||
|
||||
See `.env.local.example` for required variables:
|
||||
|
||||
- `PLATFORM_API_URL` — Platform service URL (default `http://localhost:4003`)
|
||||
- `JWT_SECRET` — Shared JWT secret
|
||||
- `PRODUCT_ID` — Product scope (e.g., `lysnrai`, `chronomind`, `nomgap`)
|
||||
|
||||
142
dashboards/tracker-web/docs/TEST_VALIDATION_LOG.md
Normal file
142
dashboards/tracker-web/docs/TEST_VALIDATION_LOG.md
Normal file
@ -0,0 +1,142 @@
|
||||
# Test Validation Log — `@bytelyst/tracker-web`
|
||||
|
||||
This log tracks the validation/hardening effort for the tracker-web dashboard's test
|
||||
suite. Every gate is run from the repo root, filtered to this package. A gate is only
|
||||
marked green if it was actually executed and passed.
|
||||
|
||||
All commands are run as:
|
||||
|
||||
```bash
|
||||
cd /Users/sd9235/code/mygh/learning_ai_common_plat
|
||||
pnpm --filter @bytelyst/tracker-web <script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Baseline (before any changes)
|
||||
|
||||
Recorded at repo HEAD `77b074f3`.
|
||||
|
||||
| Gate | Command | Result | Notes |
|
||||
| ------------- | --------------------------------------------------- | ------ | -------------------------------------------------------------------------------------- |
|
||||
| typecheck | `pnpm --filter @bytelyst/tracker-web typecheck` | PASS | `tsc --noEmit`, exit 0 |
|
||||
| lint | `pnpm --filter @bytelyst/tracker-web lint` | PASS | `eslint`, exit 0 |
|
||||
| test (unit) | `pnpm --filter @bytelyst/tracker-web test` | PASS | 8 files, 55 tests |
|
||||
| test:coverage | `pnpm --filter @bytelyst/tracker-web test:coverage` | RUNS | 90.78% stmts / 84.78% branch (only files imported by tests are measured) |
|
||||
| build | `pnpm --filter @bytelyst/tracker-web build` | PASS | `next build --webpack`, 14 routes |
|
||||
| format:check | `pnpm --filter @bytelyst/tracker-web format:check` | FAIL | `README.md`, `docs/roadmaps/UX_INTEGRATION_BYTELYST.md` not Prettier-formatted |
|
||||
| test:e2e | `pnpm --filter @bytelyst/tracker-web test:e2e` | FAIL | 9 passed / 3 failed (see below) |
|
||||
| size:check | `pnpm --filter @bytelyst/tracker-web size:check` | FAIL | `bundlesize` cannot load — `iltorb` native module won't build on Node 25 (env blocker) |
|
||||
|
||||
### Baseline e2e failures (3)
|
||||
|
||||
1. `Tracker Login Page › shows error for invalid credentials` — depends on a live
|
||||
platform-service. With no backend, the login proxy returns 502
|
||||
"Platform service unavailable", which does not match `/failed|error|invalid/i`.
|
||||
2. `Tracker — Public Roadmap › roadmap page can toggle between board and list view` —
|
||||
list view renders "No items found" (no backend data), so `table, [role='list']`
|
||||
never appears.
|
||||
3. `Tracker — Health › GET /api/health returns ok` — the dev server has no
|
||||
`PLATFORM_API_URL` / `JWT_SECRET` / `DEFAULT_PRODUCT_ID` set, so `/api/health`
|
||||
correctly returns `degraded` (503), not `ok`.
|
||||
|
||||
Root cause for all three: the e2e suite depends on a live platform-service and ambient
|
||||
env, which violates the determinism requirement ("mock network at the route/proxy
|
||||
boundary; don't depend on a live platform-service").
|
||||
|
||||
### Out-of-scope / environment blockers
|
||||
|
||||
- **`size:check` (bundlesize)**: `bundlesize@0.18.2` (latest, unmaintained) transitively
|
||||
requires `brotli-size@0.1.0` → `iltorb@2.4.5`, a deprecated native addon that does not
|
||||
compile on Node 25 (`Cannot find module './build/bindings/iltorb.node'`). `pnpm rebuild
|
||||
iltorb` produces no `build/` output. The broken dependency lives in the root
|
||||
`node_modules` (shared infra, out of scope to patch), and no newer `bundlesize` exists.
|
||||
Bundle budgets are instead verified manually against the gzipped build output.
|
||||
|
||||
---
|
||||
|
||||
## Changes
|
||||
|
||||
Work proceeded as small, independently-pushed units. Commits (newest last):
|
||||
|
||||
| SHA | Commit |
|
||||
| ---------- | ------------------------------------------------------------------------------- |
|
||||
| `d0707f22` | docs(tracker-web): add TEST_VALIDATION_LOG with baseline gate results |
|
||||
| `8738d07d` | fix(tracker-web): format markdown + ignore e2e artifacts in prettier/git |
|
||||
| `1c231d66` | test(tracker-web): make e2e deterministic + add axe a11y and console checks |
|
||||
| `772609c9` | test(tracker-web): cover untested API routes + tracker-client, enforce coverage |
|
||||
|
||||
### What changed
|
||||
|
||||
1. **format:check** — Ran Prettier over `README.md` and
|
||||
`docs/roadmaps/UX_INTEGRATION_BYTELYST.md`. Added `.prettierignore` and
|
||||
`.gitignore` entries for `coverage/`, `test-results/`, `playwright-report/` so
|
||||
generated artifacts no longer break the format/lint gates. (`8738d07d`)
|
||||
|
||||
2. **e2e** — Rewrote `e2e/tracker.spec.ts` to be deterministic: all platform-service
|
||||
calls are mocked at the Next.js proxy boundary (`/api/tracker`, `/api/auth`), and
|
||||
`/api/health`'s required env vars are supplied via `playwright.config.ts`
|
||||
`webServer.env`. Added a login→dashboard happy path, board/list toggle, submit-modal
|
||||
and vote-prompt flows, axe-core accessibility assertions (serious/critical), and a
|
||||
no-unexpected-console-errors check. axe-core is resolved from the workspace (it is a
|
||||
transitive dep of `eslint-plugin-jsx-a11y`) so **no new dependency / lockfile change**
|
||||
was introduced. (`1c231d66`)
|
||||
- **Real bug fixed (in scope):** the axe gate flagged `select-name` (critical) —
|
||||
the roadmap type-filter and submit-modal `<select>` had no accessible name. Added
|
||||
`aria-label`s in `src/app/roadmap/page.tsx`.
|
||||
|
||||
3. **coverage** — Added unit tests for the untested proxy routes
|
||||
(`auth/mfa/verify`, `auth/oauth/[provider]`, `telemetry/ingest`), the
|
||||
`tracker-client` API surface (path/method/body + `x-product-id` injection), and
|
||||
`product-config`. Overall coverage rose from ~90% to **94.36% stmts / 86.58% branch
|
||||
/ 94.28% funcs / 96.99% lines** (55 → 91 unit tests). Also fixed the vitest threshold
|
||||
config: the legacy `thresholds.global` nesting is ignored by the v8 provider, so the
|
||||
80% gate was silently disabled; it is now enforced and passing. (`772609c9`)
|
||||
|
||||
### Final gate results (all run from repo root, filtered to this package)
|
||||
|
||||
| Gate | Command | Result |
|
||||
| ------------- | --------------------------------------------------- | --------------------------------------------------------- |
|
||||
| typecheck | `pnpm --filter @bytelyst/tracker-web typecheck` | PASS (exit 0) |
|
||||
| lint | `pnpm --filter @bytelyst/tracker-web lint` | PASS (exit 0, 0 warnings) |
|
||||
| test (unit) | `pnpm --filter @bytelyst/tracker-web test` | PASS — 13 files, 91 tests |
|
||||
| test:coverage | `pnpm --filter @bytelyst/tracker-web test:coverage` | PASS — 94.36 / 86.58 / 94.28 / 96.99, thresholds enforced |
|
||||
| build | `pnpm --filter @bytelyst/tracker-web build` | PASS — `next build --webpack`, 14 routes |
|
||||
| format:check | `pnpm --filter @bytelyst/tracker-web format:check` | PASS |
|
||||
| test:e2e | `pnpm --filter @bytelyst/tracker-web test:e2e` | PASS — 18 tests (chromium) |
|
||||
| size:check | `pnpm --filter @bytelyst/tracker-web size:check` | BLOCKED (env) — verified manually, see below |
|
||||
|
||||
### Coverage by file group (final)
|
||||
|
||||
| Group | % Stmts | Notes |
|
||||
| ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `app/api/**` routes | 89–100 | All proxy handlers covered incl. error + 502 paths |
|
||||
| `lib/` | ~92 | `tracker-client`, `telemetry`, `utils`, `product-config` covered |
|
||||
| `components/ui` | 0 | `Primitives.tsx` not rendered — component render tests would need jsdom + @testing-library, which were not added to avoid a lockfile-churning dependency change (see blockers). |
|
||||
|
||||
### bundlesize budgets — manual verification
|
||||
|
||||
`size:check` (bundlesize) cannot execute in this environment (see blocker above), so the
|
||||
gzipped sizes of the budgeted chunks were measured directly after `build`. All are within
|
||||
budget with comfortable margin, e.g.:
|
||||
|
||||
- `framework-*.js`: 58.3 kB gzip (budget 100 kB)
|
||||
- `main-*.js`: 37.3 kB gzip (budget 50 kB)
|
||||
- `app/layout-*.js`: 3.6 kB gzip (budget 150 kB)
|
||||
- largest route `app/dashboard/items/page-*.js`: 6.0 kB gzip (budget 30 kB)
|
||||
|
||||
No budget was raised; no regression observed.
|
||||
|
||||
### Out-of-scope / environment blockers (final)
|
||||
|
||||
- **`size:check`**: `bundlesize@0.18.2` → `brotli-size@0.1.0` → `iltorb@2.4.5` (deprecated
|
||||
native addon) will not compile on Node 25. The broken package lives in the root
|
||||
`node_modules` (shared infra), and no newer `bundlesize` exists. Verified budgets
|
||||
manually instead (above). No in-scope fix is possible without replacing the tool or
|
||||
patching shared infra.
|
||||
- **Component render tests** (`components/ui/Primitives.tsx`): would require adding
|
||||
`@testing-library/react` + a jsdom/happy-dom environment. A `pnpm add` in this
|
||||
environment re-normalises the entire monorepo lockfile (observed: ~5.8k net line
|
||||
churn under `--offline`), which is an unacceptable shared-infra change for this task,
|
||||
so component-render coverage was intentionally left out. Component _logic_ is exercised
|
||||
end-to-end via the deterministic Playwright suite instead.
|
||||
87
dashboards/tracker-web/docs/roadmaps/BACKEND_ENABLERS.md
Normal file
87
dashboards/tracker-web/docs/roadmaps/BACKEND_ENABLERS.md
Normal file
@ -0,0 +1,87 @@
|
||||
# tracker-web — Backend Enablers
|
||||
|
||||
> Follow-ups that **cannot ship from `dashboards/tracker-web` alone** because they require a change
|
||||
> to the shared `services/platform-service`. They are the only remaining items on the
|
||||
> [UX integration roadmap](UX_INTEGRATION_BYTELYST.md) and are **excluded from its ✅ count** until
|
||||
> the backend enabler lands.
|
||||
>
|
||||
> **Hard constraint for every item below:** `platform-service` is **shared by 9 products**
|
||||
> (LysnrAI, MindLyst, ChronoMind, JarvisJr, NomGap, PeakPulse, FlowMonk, NoteLett, ActionTrail,
|
||||
> EffoRise, LocalMemGPT — see `AGENTS.md`). Every change here **must be additive and
|
||||
> backward-compatible**: no behavioural change for products that do not opt in, existing rows/reads
|
||||
> keep working, and every persisted document keeps its `productId`.
|
||||
|
||||
| ID | Title | Blocks | Target module | Status |
|
||||
| ---- | ----------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------- | -------------- |
|
||||
| BE-1 | Server-side HTML sanitization for item/comment bodies | UX-12.3 (rich-text) | `services/platform-service` — items + comments write paths | 🔒 Not started |
|
||||
| BE-2 | Tracker-event notifications feed | UX-13.1 (`NotificationCenter`) | `services/platform-service` — notifications module + `/api/tracker` proxy | 🔒 Not started |
|
||||
|
||||
---
|
||||
|
||||
## BE-1 — Server-side HTML sanitization for `items.description` + `comments.body`
|
||||
|
||||
- **Title:** Sanitize rich HTML on the item-description and comment-body write paths.
|
||||
- **Blocking roadmap item:** [UX-12.3](UX_INTEGRATION_BYTELYST.md#ux-12--detail--board-richness-tabs--tooltip--drawer--timeline--rich-text)
|
||||
— adopt `@bytelyst/rich-text` `RichTextEditor` / `RichTextViewer` in tracker-web.
|
||||
- **Target module:** `services/platform-service` — the **items** module (`items.description`) and the
|
||||
**comments** module (`comments.body`), applied **server-side before persist** (create + update).
|
||||
- **Why it's blocked:** Today `TrackerItem.description` and `Comment.body` are plain `string`s
|
||||
rendered with `whitespace-pre-wrap`; the `/api/tracker/*` proxy neither stores nor sanitizes rich
|
||||
HTML. Adopting a rich-text editor client-side would persist attacker-controlled HTML with no
|
||||
server-side sanitization (stored-XSS), so it must not be done until the backend guarantees safety.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- HTML is sanitized **on the server** (never trust the client) on every write to `items.description`
|
||||
and `comments.body` (create and update).
|
||||
- **Allowlist** of formatting tags only — e.g. `p`, `br`, `strong`, `em`, `u`, `s`, `a`,
|
||||
`ul`/`ol`/`li`, `blockquote`, `code`, `pre`, `h1`–`h3`. Everything else is stripped/escaped.
|
||||
- **Attribute allowlist:** only safe attributes survive; `a[href]` is restricted to
|
||||
`http:` / `https:` / `mailto:` schemes (and gets `rel="noopener noreferrer"`).
|
||||
- **Stripped unconditionally:** `<script>` / `<style>` / `<iframe>` / `<object>`, all inline
|
||||
event-handler attributes (`on*`), and `javascript:` / `data:` URLs.
|
||||
- Output is idempotent (sanitizing already-sanitized content is a no-op) and length-bounded as today.
|
||||
- **Backward-compatible:** existing plain-text rows still read correctly; products that send plain
|
||||
text are unaffected; the response shape is unchanged. **No behavioural change for the other 8
|
||||
products** sharing `platform-service`.
|
||||
- Unit tests cover the XSS vectors above (script injection, `onerror=`, `javascript:` href,
|
||||
`data:` URI, mismatched/oversized tags).
|
||||
|
||||
### Unblocks (tracker-web side, once shipped)
|
||||
|
||||
- Swap the description/comment `<textarea>` for `RichTextEditor` (compose) and render saved content
|
||||
with `RichTextViewer` — no client-side `dangerouslySetInnerHTML` of unsanitized content.
|
||||
|
||||
---
|
||||
|
||||
## BE-2 — Tracker-event notifications feed
|
||||
|
||||
- **Title:** Emit + expose a per-user notifications feed for tracker events.
|
||||
- **Blocking roadmap item:** [UX-13.1](UX_INTEGRATION_BYTELYST.md#ux-13--notifications-surface-via-bytelystnotifications-ui-stretch--data-gated)
|
||||
— mount `NotificationCenter` (bell + `InboxItem` rows) in tracker-web.
|
||||
- **Target module:** `services/platform-service` — its **existing notifications module** (event
|
||||
emission) + the `/api/tracker` proxy (read surface).
|
||||
- **Why it's blocked:** tracker exposes **no notifications feed** — the `/api/tracker/*` proxy
|
||||
surfaces only items / comments / votes / roadmap. `NotificationCenter`/`InboxItem` have nothing to
|
||||
bind to, so only the client-side `BannerStack`/`Announcement` (UX-13.2) shipped.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tracker events emit notifications into the **existing** notifications module — at minimum:
|
||||
**new comment**, **status change**, and **vote milestone** (e.g. crossing 10/25/50 votes).
|
||||
- Each notification is **fanned out to the item author and any subscribers/watchers** of the item,
|
||||
and is **stamped with `productId`** so the feed is product-scoped.
|
||||
- A read API (list with pagination, unread count, mark-as-read / mark-all-read) is **exposed through
|
||||
the `/api/tracker` proxy** so the dashboard reads it with the existing auth token.
|
||||
- Payload shape is compatible with `@bytelyst/notifications-ui` `InboxItem` (id, title/body, type,
|
||||
timestamp, read flag, deep-link to the item).
|
||||
- **Additive / backward-compatible:** emitting these notifications causes **no behavioural change for
|
||||
the 9 products that do not subscribe**; the notifications module contract is extended, not broken;
|
||||
every notification document carries `productId`.
|
||||
- Tests cover emission on each event type, author/subscriber targeting, product scoping, and the
|
||||
unread-count / mark-as-read transitions.
|
||||
|
||||
### Unblocks (tracker-web side, once shipped)
|
||||
|
||||
- Bind `NotificationCenter` to the feed (bell + `InboxItem` rows) in the AppShell sidebar/header,
|
||||
reading via the `/api/tracker` proxy.
|
||||
423
dashboards/tracker-web/docs/roadmaps/UX_INTEGRATION_BYTELYST.md
Normal file
423
dashboards/tracker-web/docs/roadmaps/UX_INTEGRATION_BYTELYST.md
Normal file
@ -0,0 +1,423 @@
|
||||
# tracker-web × ByteLyst UX Integration Roadmap
|
||||
|
||||
> **Purpose:** Adopt the latest shared `@bytelyst/*` UX (proven in the `learning_ai_uxui_web`
|
||||
> showcase) into `tracker-web`, replacing bespoke/raw UI with shared primitives, charts, command
|
||||
> palette, toasts, and motion — while keeping the existing OKLCH token system and dark mode.
|
||||
> **Delegation target:** Devin CLI (`devin --prompt-file docs/roadmaps/UX_INTEGRATION_BYTELYST.md`).
|
||||
>
|
||||
> **Repo:** `learning_ai_common_plat/dashboards/tracker-web` (`@bytelyst/tracker-web`)
|
||||
> **Run dir:** this package · **Stack:** Next.js 16 · React 19 · TS 5 · Tailwind 4 · Vitest · Playwright
|
||||
> **Showcase reference (read-only, do not edit):** `../../../copilot/learning_ai_uxui_web` — live demos
|
||||
> of every package at `http://localhost:3010/showcase/*` and source in `src/catalog/examples/`.
|
||||
|
||||
---
|
||||
|
||||
## Current-state review (verified 2026-05-28)
|
||||
|
||||
| Surface | Today | Gap vs latest UX |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| Token system | shadcn-style OKLCH vars (`--background`, `--primary`, `--card`, `--chart-1..5`) + `.dark` in `globals.css` | `@bytelyst/ui` components expect `--bl-*` tokens → **need a bridge** |
|
||||
| `/dashboard/items` | ✅ already on `@bytelyst/data-table` | good reference pattern |
|
||||
| `/dashboard` overview | badge-pill stats; `--chart-1..5` defined but **unused** | swap to `charts`/`data-viz` (Donut, KpiCard, Sparkline) |
|
||||
| `/dashboard/*` controls | raw `<input>/<select>/<button>`, hand-rolled modals (no focus-trap/Esc/scroll-lock) | `@bytelyst/ui` `Input`/`Button`/`Modal`/`Field` (a11y built-in) |
|
||||
| `/dashboard` layout | hand-rolled sticky top nav | `@bytelyst/ui` `AppShell` |
|
||||
| `/roadmap` (public) | hardcoded `slate-*`/`blue-*` — different visual language | re-skin to tokens + primitives |
|
||||
| Feedback | inline error/success `<div>`s | `@bytelyst/ui` `Toast` |
|
||||
| Command/nav | none | `@bytelyst/command-palette` (⌘K) |
|
||||
| Motion | none | `@bytelyst/motion` (reduced-motion aware) |
|
||||
|
||||
**Available shared components (verified exports):**
|
||||
|
||||
- `@bytelyst/ui`: `Button` `Input` `Field*` `Modal` `ConfirmDialog` `Badge` `StatusBadge`/`StatusDot`
|
||||
`EmptyState` `Skeleton`/`TableSkeleton` `MetricCard` `EntityCard` `Toast`/`ToastProvider`/`useToast`
|
||||
`AppShell*` `Drawer` `ActionMenu` `FilterBar` `PageHeader` `IconButton` `AlertBanner`
|
||||
- `@bytelyst/charts`: `LineChart` `BarChart` `AreaChart` `Donut` `Gauge`
|
||||
- `@bytelyst/data-viz`: `Sparkline` `BarSparkline` `KpiCard` `ProgressRing` `Heatmap`
|
||||
- `@bytelyst/command-palette`: `CommandPalette` `CommandRegistryProvider` `useCommandPalette`
|
||||
- `@bytelyst/motion`: `Reveal` `Stagger` `NumberFlow` `TiltCard` `ScrollProgress` … (see `/showcase/motion/all`)
|
||||
- `@bytelyst/ui` (not yet in the adapter): `Tooltip` `Tabs` `SegmentedControl` `DropdownMenu`
|
||||
`Switch` `Checkbox` `RadioGroup` `Select` `Textarea` `Panel` `Surface` `Card` `Separator`
|
||||
`DataList` `Timeline` `Drawer` `ActionMenu` `IconButton` `AlertBanner`
|
||||
- `@bytelyst/dashboard-components`: `PageHeader` `ErrorPage` `NotFoundPage` `EmptyState`
|
||||
`LoadingSpinner` `LoadingSkeleton`
|
||||
- `@bytelyst/auth-ui`: `LoginForm` `MfaChallenge` `SocialButtons` `PasswordStrengthBar` …
|
||||
- `@bytelyst/notifications-ui`: `NotificationCenter` `InboxItem` `BannerStack` `Announcement`
|
||||
- `@bytelyst/rich-text`: `RichTextEditor` (Tiptap v3, slash + mentions) · `RichTextViewer`
|
||||
|
||||
> **Expansion (2026-05-28):** waves **UX-9 … UX-13** below were added after re-auditing
|
||||
> tracker-web against the full showcase catalog (`src/catalog/routes.ts`, ~60 entries).
|
||||
> They are sequenced **after** the core UX-2 … UX-8 migration and several are explicitly
|
||||
> **stretch / data-gated** — do not start them until the wave they depend on is green.
|
||||
|
||||
> Aligns with master `docs/ROADMAP.md` Phase 2.1 (UI primitives migration) — this roadmap is the
|
||||
> concrete, delegate-ready execution plan and extends it with charts/palette/toast/motion.
|
||||
|
||||
---
|
||||
|
||||
## Ground rules (non-negotiable)
|
||||
|
||||
1. **Scope lock:** edit only files under `dashboards/tracker-web/`. Never edit shared
|
||||
`packages/@bytelyst/*`, the showcase repo, other dashboards/services, or master `docs/ROADMAP.md`.
|
||||
2. **Deps:** add shared packages as `"workspace:*"` in this package's `package.json` (they live in
|
||||
the same monorepo — no publish needed). Run `pnpm install` from the repo root after edits.
|
||||
3. **Preserve the token system + dark mode.** Do NOT rip out the shadcn OKLCH vars. Bridge `--bl-*`
|
||||
onto them (UX-1) so shared components inherit tracker's theme automatically.
|
||||
4. **Tests sacred; no fabrication.** Add tests for new behavior; only check a box after its
|
||||
**Verify** passes. If blocked (e.g. needs live backend), leave `- [ ]` + a one-line note.
|
||||
5. **Style preservation:** match existing conventions. No `console.log`. No hardcoded hex/color
|
||||
literals in new code — use tokens / component props. No emojis in code.
|
||||
6. **Commits:** one task = one conventional commit (`feat(tracker-web): …` / `refactor(tracker-web): …`),
|
||||
flip the checkbox + paste short-SHA in the same commit. **Do not push** — operator reviews.
|
||||
7. **Offline-first verification.** Pages needing auth/live data can't be fully E2E'd here; rely on
|
||||
`typecheck` + `lint` + Vitest render tests + `build`. Mark backend-only checks as deferred.
|
||||
|
||||
## Verify commands (from `dashboards/tracker-web`)
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test
|
||||
pnpm build # final gate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UX-1 — Foundation: token bridge + Primitives adapter ⟵ do first, everything depends on it
|
||||
|
||||
- [x] **1.1** Add `@bytelyst/ui` and `@bytelyst/design-tokens` as `workspace:*` deps; `pnpm install`. (dc01dd02)
|
||||
- [x] **1.2** In `src/app/globals.css`, add a **bridge layer** mapping `--bl-*` → existing tracker
|
||||
vars so shared components theme correctly in light + dark. Minimum set (extend as needed):
|
||||
`css
|
||||
:root, .dark {
|
||||
--bl-accent: var(--primary);
|
||||
--bl-accent-foreground: var(--primary-foreground);
|
||||
--bl-bg-canvas: var(--background);
|
||||
--bl-surface-card: var(--card);
|
||||
--bl-text-primary: var(--foreground);
|
||||
--bl-text-secondary: var(--muted-foreground);
|
||||
--bl-text-tertiary: var(--muted-foreground);
|
||||
--bl-border: var(--border);
|
||||
--bl-danger: var(--destructive);
|
||||
--bl-radius-control: var(--radius-md);
|
||||
--bl-radius-card: var(--radius-lg);
|
||||
/* map chart palette: --bl-chart-1 → --chart-1 … */
|
||||
}
|
||||
`
|
||||
Reference the showcase's `globals.css` fallback layer for the full `--bl-*` surface. (dc01dd02)
|
||||
- [x] **1.3** Create `src/components/ui/Primitives.tsx` re-exporting the `@bytelyst/ui` components
|
||||
this app needs (`Button` `Input` `Field*` `Modal` `ConfirmDialog` `Badge` `StatusBadge`
|
||||
`EmptyState` `Skeleton`/`TableSkeleton` `MetricCard` `Toast`/`ToastProvider`/`useToast`).
|
||||
All app code imports from this adapter, never `@bytelyst/ui` directly (enables the Phase 1.3
|
||||
UI-drift ratchet later). (dc01dd02)
|
||||
- [x] **1.4** Smoke test: a Vitest render test asserting `Button` renders and a `StatusBadge`
|
||||
gets a token-driven class. **Verify:** `pnpm typecheck && pnpm lint && pnpm test && pnpm build`. (dc01dd02)
|
||||
|
||||
## UX-2 — Migrate dashboard controls to primitives
|
||||
|
||||
- [x] **2.1** `src/app/dashboard/items/page.tsx`: replace raw search `<input>`, the 3 filter
|
||||
`<select>`s, the "+ New Item" `<button>`, and the **create modal** with `Input`/`Field`/
|
||||
`Button`/`Modal`. The shared `Modal` fixes the current focus-trap/Esc/scroll-lock a11y gap.
|
||||
Keep `DataTable` as-is; swap the inline type/status/priority cell pills to `StatusBadge`.
|
||||
(UX-2.1 `a7a6f191`: tc/lint/test 165 ✓/format/build/e2e 18 ✓; cell pills → `StatusBadge`
|
||||
tones, errors → `AlertBanner`, all via the Primitives adapter.)
|
||||
- [x] **2.2** `src/app/dashboard/page.tsx`: wrap the `confirm()` delete in `ConfirmDialog`; replace
|
||||
the bespoke `StatCard` chrome with `MetricCard` where it fits (full chart swap is UX-4).
|
||||
(UX-2.2 `c9e65d43`: tc/lint/test 165 ✓/format/build/e2e 18 ✓. Total-count card → `MetricCard`;
|
||||
breakdown `StatCard`s left for the UX-4 chart swap. The `confirm()` delete is the items-list
|
||||
delete action — moved to the accessible `ConfirmDialog`; overview load errors → `AlertBanner`.)
|
||||
- [x] **2.3** `src/app/dashboard/board/page.tsx` + `src/app/dashboard/items/[id]/page.tsx`:
|
||||
migrate buttons/inputs/badges/modals to primitives; comments + status/priority controls.
|
||||
(UX-2.3 `aa36671e`: tc/lint/test 165 ✓/format/build/e2e 18 ✓. Board pills → `StatusDot`/
|
||||
`StatusBadge`/`Badge`, status-move → `Button`; detail editor/selects/vote/comment composer →
|
||||
`Input`/`Textarea`/`Select`/`Button`; errors → `AlertBanner`. ActionMenu/Timeline untouched.)
|
||||
- [x] **2.4** `src/app/login/page.tsx`: migrate form to `Field`/`Input`/`Button` + `AlertBanner`.
|
||||
**Verify per task:** `pnpm typecheck && pnpm lint && pnpm build`.
|
||||
(SUPERSEDED by UX-11 `328e3072`: the login surface was already adopted onto the shared
|
||||
`@bytelyst/auth-ui` `LoginForm`/`MfaChallenge`, which provide the field/input/button +
|
||||
inline error presentation this item asked for. Per the ground rules, the adopted surface is
|
||||
not re-migrated to the lower-level `Field`/`Input`/`Button` primitives. No code change.)
|
||||
|
||||
## UX-3 — Re-skin the public roadmap to the token system
|
||||
|
||||
- [x] **3.1** `src/app/roadmap/page.tsx`: replace hardcoded `slate-*`/`blue-*` utility classes with
|
||||
tokenized equivalents (`bg-card`, `text-foreground`, `text-muted-foreground`, `bg-primary`…)
|
||||
and swap the type/priority/status pills to `Badge`/`StatusBadge`. Keep all behavior + the
|
||||
board/list toggle. Vote buttons keep the a11y attrs added previously.
|
||||
(UX-3.1 `f612d2ec`: tc/lint/test 165 ✓/format/build/e2e 18 ✓. type/priority → `Badge`, status
|
||||
markers → `StatusDot` tones, stats → `MetricCard`, search/filter → `Input`/`Select`, errors →
|
||||
`AlertBanner`; vote buttons keep aria-pressed/aria-label/title; zero new color literals.)
|
||||
- [x] **3.2** Replace the submit + email-prompt modals with the shared `Modal`.
|
||||
**Verify:** `pnpm typecheck && pnpm lint && pnpm build`; existing roadmap tests stay green.
|
||||
(UX-3.2 `51c30ed7`: tc/lint/test 165 ✓/format/build/e2e 18 ✓. Both dialogs now use the shared
|
||||
Radix `Modal`; the bespoke local `Modal` is removed; titles drive the accessible heading.)
|
||||
|
||||
## UX-4 — Data-viz for the overview (use the unused `--chart-*` tokens)
|
||||
|
||||
- [x] **4.1** Add `@bytelyst/charts` + `@bytelyst/data-viz` as `workspace:*` deps.
|
||||
(UX-4 `f2dfddf9`: added with minimal `link:` importer entries in `pnpm-lock.yaml` — no full
|
||||
re-normalisation; recovered/validated with `pnpm install --frozen-lockfile`.)
|
||||
- [x] **4.2** `src/app/dashboard/page.tsx`: replace badge-pill breakdowns with a `Donut` for
|
||||
**By Status** and **By Type**, a `BarChart`/`BarSparkline` for **By Priority**, and `KpiCard`s
|
||||
for `total` + open/in-progress/done with trend where data allows.
|
||||
(UX-4 `f2dfddf9`: KpiCards for total/open/in-progress/done; `Donut` By Status (centered total) + By Type; `BarChart` By Priority, coloured from the bridged `--bl-chart-*` palette. The chart
|
||||
surface is split into `overview-charts.tsx` and loaded via `next/dynamic` (ssr:false) to keep
|
||||
it out of the route bundle. No time-series in `getStats`, so KPI trend is omitted.)
|
||||
- [x] **4.3** Add a Vitest test rendering the overview with mocked `getStats` (no NaN in SVG paths).
|
||||
**Verify:** `pnpm typecheck && pnpm lint && pnpm test && pnpm build`.
|
||||
(UX-4 `f2dfddf9`: tc/lint/test 170 ✓/format/build/e2e 18 ✓. `overview-charts.test.tsx` renders
|
||||
the surface with mocked stats via `react-dom/server` asserting no `NaN`, plus transform unit
|
||||
tests; react deduped in vitest for a single SSR React instance.)
|
||||
|
||||
## UX-5 — Command palette (⌘K)
|
||||
|
||||
- [x] **5.1** Add `@bytelyst/command-palette` dep; mount `CommandRegistryProvider` + `CommandPalette`
|
||||
in `src/app/providers.tsx` (or dashboard layout), opened with ⌘K / Ctrl-K.
|
||||
(UX-5 `a7cb866c`: dep added with a minimal `link:` lockfile entry; `CommandRegistryProvider`
|
||||
wraps the app in `providers.tsx`, the palette shell is loaded via `next/dynamic` (ssr:false).)
|
||||
- [x] **5.2** Register commands: navigate (Overview / Items / Board / Roadmap), **New item**,
|
||||
**Switch product** (wire `ProductSwitcher`), **Toggle theme**, **Sign out**.
|
||||
(UX-5 `a7cb866c`: built in pure `src/lib/command-registry.ts`. New item → `/dashboard/items?new=1`
|
||||
auto-opens the create modal; Switch commands call `setProductId` (same store as ProductSwitcher);
|
||||
Toggle theme flips `setTheme`; Sign out calls `logout()` + routes to `/login`.)
|
||||
- [x] **5.3** Vitest test: palette opens on ⌘K and lists registered commands.
|
||||
**Verify:** `pnpm typecheck && pnpm lint && pnpm test && pnpm build`.
|
||||
(UX-5 `a7cb866c`: tc/lint/test 173 ✓/format/build/e2e 18 ✓. `command-menu.test.tsx` (jsdom)
|
||||
asserts the builder set + that ⌘K opens the palette and lists New item/Toggle theme/Sign out.)
|
||||
|
||||
## UX-6 — Toasts replace inline status divs
|
||||
|
||||
- [x] **6.1** Mount `ToastProvider`; replace inline error/success `<div>`s across items/overview/
|
||||
board/detail/roadmap with `toast()` calls (create, delete, vote, submit, load-error).
|
||||
**Verify:** `pnpm typecheck && pnpm lint && pnpm build`.
|
||||
(UX-6 `3fc559d0`: tc/lint/test 173 ✓/format/build/e2e 18 ✓. `ToastProvider` mounted in
|
||||
`providers.tsx`; the AlertBanner/inline status divs across all five surfaces now emit `toast()`
|
||||
(load errors + create/delete/vote/update/comment/submit). Roadmap submit toasts + closes the
|
||||
dialog; item detail keeps one inline empty-state for the hard item-load failure.)
|
||||
|
||||
## UX-7 — Motion polish (reduced-motion aware)
|
||||
|
||||
- [x] **7.1** Add `@bytelyst/motion`; apply `Reveal`/`Stagger` to dashboard cards, items rows, and
|
||||
roadmap columns; `NumberFlow` on the overview totals. Must no-op under `prefers-reduced-motion`.
|
||||
**Verify:** `pnpm typecheck && pnpm lint && pnpm build`.
|
||||
(UX-7 `002b55c3`: tc/lint/test 173 ✓/format/build/e2e 18 ✓. `@bytelyst/motion` added (minimal
|
||||
`link:` lockfile entry). `Reveal` (staggered) on the overview KPI cards + chart surface and on
|
||||
the items `DataTable`; `NumberFlow` on the overview KPI totals; all no-op under
|
||||
`prefers-reduced-motion`. **Roadmap-column reveal intentionally omitted:** the offline
|
||||
`@axe-core` gate scans `/roadmap` synchronously and `Reveal`'s transient sub-1 opacity trips
|
||||
`color-contrast` (verified — even `from="scale"` at 0.92 fails on the muted card text), which
|
||||
would breach the CC.4 a11y gate. The dashboard/items surfaces are auth-gated (not axe-scanned)
|
||||
so they keep motion; no new a11y violations.)
|
||||
|
||||
## UX-8 — AppShell (stretch)
|
||||
|
||||
- [x] **8.1** Replace the hand-rolled top nav in `src/app/dashboard/layout.tsx` with `@bytelyst/ui`
|
||||
`AppShell` (`AppShellSidebar`/`AppShellNav`/`AppShellPageHeader`/`AppShellSkipLink` + mobile
|
||||
toggle), keeping `ProductSwitcher`, user email, sign-out, and adding the ⌘K trigger + theme
|
||||
toggle. **Verify:** `pnpm typecheck && pnpm lint && pnpm build`; keyboard nav + skip-link work.
|
||||
(UX-8 `cbd4274a`: tc/lint/test 182 ✓/format/build/e2e 18 ✓. `AppShell` + `AppShellSidebar`/
|
||||
`AppShellNav`/`AppShellNavItem` (aria-current active route, client nav) / `AppShellMain`
|
||||
(focusable `#main-content`) / `AppShellSkipLink` + mobile toggle + overlay. Sidebar keeps
|
||||
`ProductSwitcher`, user email, Sign out and adds a ⌘K trigger (replays the hotkey) + theme
|
||||
toggle. AppShell exports go through the Primitives adapter (CC.6) + export-presence test.
|
||||
`AppShellPageHeader` deliberately not used — the per-page `PageHeader` (UX-10) stays the single
|
||||
h1 per route, avoiding duplicate headings.)
|
||||
|
||||
## UX-9 — Complete the Primitives adapter (+ close the coverage gap)
|
||||
|
||||
> The adapter (`src/components/ui/Primitives.tsx`) currently re-exports ~12 components and
|
||||
> sits at **0% test coverage** (flagged in `docs/TEST_VALIDATION_LOG.md`). Broaden it and
|
||||
> lock it with a cheap export-presence test — no `@testing-library/react`/jsdom needed.
|
||||
|
||||
- [x] **9.1** Extend `Primitives.tsx` to also re-export the shared controls the app will need
|
||||
in later waves: `Select` `Textarea` `Checkbox` `Switch` `RadioGroup` `Tooltip` `Tabs`
|
||||
`SegmentedControl` `DropdownMenu` `Drawer` `ActionMenu` `IconButton` `AlertBanner`
|
||||
`Card` `Panel` `Separator` `DataList` `Timeline`. Keep app code importing only from the
|
||||
adapter (preserves the UI-drift ratchet).
|
||||
- [x] **9.2** Add `src/__tests__/primitives-adapter.exports.test.ts` asserting every adapter
|
||||
export is `defined` (a pure import test — raises `Primitives.tsx` off 0% without a DOM dep).
|
||||
**Verify:** `pnpm typecheck && pnpm lint && pnpm test && pnpm build`. (UX-9 `18a09b25`: tc/lint/test 159 ✓/build/e2e 18 ✓)
|
||||
|
||||
## UX-10 — Page chrome via `@bytelyst/dashboard-components`
|
||||
|
||||
- [x] **10.1** `src/app/error.tsx`: render `ErrorPage` from `@bytelyst/dashboard-components`
|
||||
(keep the existing `trackEvent` telemetry side-effect + `reset` wiring). `not-found.tsx`
|
||||
already uses `NotFoundPage` — leave it.
|
||||
- [x] **10.2** Add `PageHeader` (title + breadcrumbs) to the top of `/dashboard`, `/dashboard/items`,
|
||||
`/dashboard/board`, and the item detail page for a consistent header band.
|
||||
- [x] **10.3** Replace ad-hoc loading text with `LoadingSpinner`/`LoadingSkeleton` where a full
|
||||
`SkeletonGroup` (UX-2) is overkill.
|
||||
**Verify:** `pnpm typecheck && pnpm lint && pnpm build`. (UX-10 verified: tc/lint/test 159 ✓/build/e2e 18 ✓; no new color literals)
|
||||
|
||||
## UX-11 — Adopt `@bytelyst/auth-ui` on the login surface
|
||||
|
||||
> `src/app/login/page.tsx` is a hand-rolled `<form>` with a second MFA step. The existing
|
||||
> `/api/auth/*` proxy routes and `auth-context` stay as-is — only the **presentation** changes.
|
||||
|
||||
- [x] **11.1** Add `@bytelyst/auth-ui` as a `workspace:*` dep; `pnpm install` from the root.
|
||||
(Added dep + minimal `link:` lockfile entry; avoided the env-specific full lockfile
|
||||
re-normalisation churn — see TEST_VALIDATION_LOG blocker.)
|
||||
- [x] **11.2** Replace the password form with `LoginForm` and the OTP step with `MfaChallenge`,
|
||||
wiring their submit handlers to the current login/MFA fetch calls. Add `SocialButtons`
|
||||
only for providers the backend actually supports (gate on `/api/auth/oauth/[provider]`).
|
||||
Social gated to Google via `NEXT_PUBLIC_GOOGLE_CLIENT_ID`; an effect adds aria-labels to
|
||||
the placeholder-only inputs so a11y + label queries stay green.
|
||||
- [x] **11.3** Keep all existing auth tests green; add a render test asserting the login form
|
||||
shows email/password fields + submit. (Render assertion is the Playwright "shows login form
|
||||
with correct branding" test; added an auth-ui import smoke test for unit-level wiring.)
|
||||
**Verify:** `pnpm typecheck && pnpm lint && pnpm test && pnpm build`. (UX-11 verified: tc/lint/test 162 ✓/build/e2e 18 ✓)
|
||||
|
||||
## UX-12 — Detail & board richness (Tabs · Tooltip · Drawer · Timeline · rich-text)
|
||||
|
||||
- [x] **12.1** `/dashboard` board↔list: use `SegmentedControl` (or `Tabs`) for the view toggle
|
||||
instead of bespoke buttons; `Tooltip` on truncated titles / status dots.
|
||||
(SegmentedControl now drives the roadmap board/list toggle — the app's only board↔list
|
||||
toggle — replacing the bespoke `blue-600` buttons; `Tooltip` wraps the truncated board
|
||||
card titles. e2e toggle selector updated button→radio. UX-12.1 `ddf25cf5`: tc/lint/test 162 ✓/build/e2e 18 ✓)
|
||||
- [x] **12.2** Item detail: move row/item actions into an `ActionMenu`, and render the item's
|
||||
activity/comment history with `Timeline`.
|
||||
(Edit + Delete now live in an `ActionMenu` in the page header; comments render via
|
||||
`Timeline`. UX-12.2 `32dac7d4`: tc/lint/test 162 ✓/build/e2e 18 ✓.)
|
||||
- [ ] 🔒 **12.3 — BLOCKED ON BACKEND** _(stretch — needs HTML-capable description/comment storage)_
|
||||
Swap the plain description/comment `<textarea>` for `RichTextEditor`, and render saved content
|
||||
with `RichTextViewer`. **Only do this if** the backend stores/returns rich HTML safely.
|
||||
**Verify per task:** `pnpm typecheck && pnpm lint && pnpm build`.
|
||||
DEFERRED (data-gated): `TrackerItem.description` and `Comment.body` are plain `string`s
|
||||
rendered with `whitespace-pre-wrap`; the `/api/tracker/*` proxy does not store or sanitize
|
||||
rich HTML. Adopting `RichTextEditor` would persist HTML with no backend sanitization (XSS
|
||||
risk) and mismatch the plain-text model, so `@bytelyst/rich-text` is intentionally not
|
||||
adopted until the backend supports safe rich HTML. No dep added. **Kept out of the ✅ count.**
|
||||
|
||||
> **Required platform-service change** (see [`BACKEND_ENABLERS.md`](BACKEND_ENABLERS.md) §BE-1):
|
||||
> Add server-side HTML sanitization on the **write** paths for `items.description` and
|
||||
> `comments.body` in `services/platform-service` (the items + comments modules, before persist).
|
||||
> The sanitizer must apply a strict allowlist of formatting tags (e.g. `p`, `br`, `strong`,
|
||||
> `em`, `u`, `s`, `a`, `ul`/`ol`/`li`, `blockquote`, `code`, `pre`, headings) and safe attributes
|
||||
> (`a[href]` restricted to `http`/`https`/`mailto`), and **strip** all `<script>`/`<style>`,
|
||||
> inline event-handler attributes (`on*`), and `javascript:`/`data:` URLs. Sanitization must run
|
||||
> on the server (never trust the client), preserve the existing plain-text reads for old rows,
|
||||
> and be backward-compatible since the field is shared by every product. Once stored HTML is
|
||||
> guaranteed safe, tracker-web can adopt `RichTextEditor` (compose) + `RichTextViewer` (render)
|
||||
> with no client-side `dangerouslySetInnerHTML` of unsanitized content.
|
||||
|
||||
## UX-13 — Notifications surface via `@bytelyst/notifications-ui` (stretch / data-gated)
|
||||
|
||||
> **Gate:** only build this if tracker has (or will get) a notifications data source. If there
|
||||
> is no feed, ship just the `BannerStack` for client-side system messages and leave the rest `- [ ]`.
|
||||
|
||||
- [ ] 🔒 **13.1 — BLOCKED ON BACKEND** Add `@bytelyst/notifications-ui`; mount a `NotificationCenter`
|
||||
bell in the header (or AppShell from UX-8) fed by the notifications API, with `InboxItem` rows.
|
||||
DEFERRED (data-gated): tracker exposes no notifications feed — the `/api/tracker/*` proxy
|
||||
surfaces only items/comments/votes/roadmap, with no notifications endpoint. Per the wave
|
||||
gate, `NotificationCenter`/`InboxItem` are left unbuilt until a feed exists. The dep is
|
||||
added and the imports are smoke-tested so a future feed wiring starts from green.
|
||||
**Kept out of the ✅ count.**
|
||||
|
||||
> **Required platform-service change** (see [`BACKEND_ENABLERS.md`](BACKEND_ENABLERS.md) §BE-2):
|
||||
> Emit notifications into platform-service's **existing notifications module** on tracker
|
||||
> events — at minimum **new comment**, **status change**, and **vote milestones** — fanned out
|
||||
> to the item author and any subscribers/watchers, each stamped with `productId` so the feed
|
||||
> stays product-scoped. Expose the resulting feed (list + unread count + mark-as-read) through
|
||||
> the `/api/tracker` proxy so the dashboard can read it with the existing token, and keep the
|
||||
> payload shape compatible with `@bytelyst/notifications-ui` `InboxItem`. The change must be
|
||||
> additive/backward-compatible (platform-service is shared by 9 products — no behavioural change
|
||||
> for products that do not subscribe). Once the feed exists, tracker-web binds `NotificationCenter`
|
||||
> to it (bell + `InboxItem` rows) in the AppShell sidebar/header.
|
||||
|
||||
- [x] **13.2** Use `BannerStack` for top-of-page system/maintenance messages and `Announcement`
|
||||
for a dismissible "what's new" pill. **Verify:** `pnpm typecheck && pnpm lint && pnpm build`.
|
||||
(Added `@bytelyst/notifications-ui` workspace dep with a minimal `link:` lockfile entry;
|
||||
`SystemBanners` mounts `BannerStack` (env `NEXT_PUBLIC_SYSTEM_NOTICE`, dismissible) +
|
||||
a localStorage-dismissible `Announcement` at the top of the dashboard shell. UX-13.2
|
||||
`3d22c303`: tc/lint/test 165 ✓/build/e2e 18 ✓.)
|
||||
|
||||
## Cross-cutting (run continuously)
|
||||
|
||||
- [x] **CC.1** Full suite green after every wave: `pnpm typecheck && pnpm lint && pnpm test && pnpm build`.
|
||||
(Run after every UX-9…13 commit; final state UX-13.2: typecheck ✓ / lint ✓ / test 165 ✓ /
|
||||
build ✓ / e2e 18 ✓.)
|
||||
- [x] **CC.2** Dark mode parity — every migrated surface looks correct in `.dark`.
|
||||
(Achieved structurally: the extended `--bl-*` bridge maps every adopted token onto tracker's
|
||||
OKLCH vars — which already flip under `.dark` — or `color-mix` of them, plus semantic
|
||||
`--success/--warning/--info` defined per-theme. No adopted component carries a fixed color.)
|
||||
- [x] **CC.3** Zero new hardcoded color literals (grep `slate-|blue-600|#[0-9a-f]{6}` in changed files).
|
||||
(`git diff <UX-base>..HEAD -- src` → no new `slate-N` / `blue-600` / `#hex` / `bg-[#…]`; in fact
|
||||
UX-12.1 removed the roadmap toggle's `blue-600` literals.)
|
||||
- [x] **CC.4** a11y — if `@axe-core/playwright` runs offline, assert no new violations on `/roadmap`
|
||||
and `/login`; otherwise Vitest render assertions for roles/labels.
|
||||
(axe-core runs offline in the Playwright suite asserting no serious/critical violations on
|
||||
`/login` and `/roadmap`; both green. Added aria-labels where the shared `LoginForm` shipped
|
||||
placeholder-only inputs.)
|
||||
- [x] **CC.5** Update master `docs/ROADMAP.md` Phase 2.1 checkboxes **only if** the operator asks —
|
||||
default: leave it, this doc is the source of truth for the integration.
|
||||
(Default applied: master `docs/ROADMAP.md` left untouched — operator did not request it.)
|
||||
- [x] **CC.6** **UI-drift ratchet:** once UX-9 lands, add an ESLint `no-restricted-imports` rule
|
||||
forbidding direct `@bytelyst/ui` imports outside `src/components/ui/Primitives.tsx`, so all
|
||||
future UI goes through the adapter. Fix any existing violations in the same commit.
|
||||
(Done: rule + adapter exemption in `eslint.config.mjs`; no pre-existing violations; probe confirms it fires.)
|
||||
- [x] **CC.7** **Bundle budget:** after chart/motion/rich-text waves, re-check the `bundlesize`
|
||||
budgets in `package.json`. If a route grows, prefer dynamic `import()` for heavy surfaces
|
||||
(charts, rich-text editor) over raising a budget. Note the gzipped sizes in the log.
|
||||
(`bundlesize` itself is env-blocked — `iltorb` won't build on Node 25, see TEST_VALIDATION_LOG —
|
||||
so budgets were verified by gzipping the built chunks: all within budget, no budget raised.
|
||||
Largest: framework 58.3 kB / 100, main 37.3 kB / 50, layout 3.6 kB / 150; heaviest route
|
||||
`/dashboard/items` 6.9 kB / 30, `/roadmap` 3.8 kB / 30, `/login` 3.1 kB / 30. No dynamic
|
||||
import needed — charts/motion/rich-text waves were not adopted in this scope.)
|
||||
- [x] **CC.8** **Coverage ratchet:** keep the enforced Vitest thresholds (80%) green; each wave
|
||||
that adds a component should add the render/export test that keeps `Primitives.tsx` and new
|
||||
surfaces above threshold (no silently-dead gates — see `docs/TEST_VALIDATION_LOG.md`).
|
||||
(Enforced thresholds stay green: 94.36% stmts / 86.58% branch / 94.28% funcs / 96.99% lines,
|
||||
165 tests. Added export/import smoke tests for the Primitives adapter, auth-ui and
|
||||
notifications-ui. Note: v8 reports the pure re-export `Primitives.tsx` barrel as 0% — there
|
||||
are no executable bodies to instrument — but the global gate is green and the export test
|
||||
guards every re-export.)
|
||||
|
||||
## Progress
|
||||
|
||||
```
|
||||
Core : UX-1 ✅ UX-2 ✅ UX-3 ✅ UX-4 ✅ UX-5 ✅ UX-6 ✅ UX-7 ✅ UX-8 ✅
|
||||
Expand : UX-9 ✅ UX-10 ✅ UX-11 ✅ UX-12 ✅ UX-13 ✅
|
||||
Backend-blocked (not in ✅ count) : UX-12.3 🔒 UX-13.1 🔒 (see BACKEND_ENABLERS.md)
|
||||
```
|
||||
|
||||
**All client-only waves are complete.** Every task that can ship from `dashboards/tracker-web`
|
||||
alone (Core UX-2 … UX-8 and Expand UX-9 … UX-13) is done. The only remaining items — **UX-12.3**
|
||||
(rich-text) and **UX-13.1** (notifications feed) — are **🔒 blocked on a shared `platform-service`
|
||||
change**, not on tracker-web. They are tracked as follow-ups in
|
||||
[`BACKEND_ENABLERS.md`](BACKEND_ENABLERS.md) and are intentionally excluded from the ✅ count until
|
||||
the backend enabler lands.
|
||||
|
||||
**UX-1 is done** (token bridge + Primitives adapter, commit `dc01dd02`) — the `--bl-*` bridge is
|
||||
live, so shared components already inherit tracker's theme. **Start with UX-2.** Before editing,
|
||||
read `src/app/globals.css` (the `--bl-*` bridge), `src/components/ui/Primitives.tsx` (the adapter
|
||||
all app code imports from), and `src/app/dashboard/items/page.tsx` (the working `data-table`
|
||||
adoption — the reference pattern). Execute **one task at a time**, verify + commit after each, and
|
||||
do the **Core** waves (UX-2 … UX-8) before the **Expand** waves (UX-9 … UX-13). Treat 12.3 and all
|
||||
of UX-13 as data-gated stretch — leave them `- [ ]` with a note if the backend can't support them.
|
||||
|
||||
---
|
||||
|
||||
## Status — Expand waves complete (2026-05-28)
|
||||
|
||||
The **Expand** waves (UX-9 … UX-13) and **all cross-cutting items (CC.1 … CC.8)** are done,
|
||||
strictly scoped to `dashboards/tracker-web`. Each item was verified
|
||||
(`typecheck` + `lint` + `test` + `build` + Playwright e2e/axe) and shipped as its own
|
||||
conventional commit referencing its roadmap ID:
|
||||
|
||||
| Wave / item | Commit | Summary |
|
||||
| ----------- | ---------- | ---------------------------------------------------------- |
|
||||
| UX-9 | `18a09b25` | Broaden Primitives adapter + export-presence test |
|
||||
| CC.6 | `73d2891d` | `no-restricted-imports` UI-drift ratchet |
|
||||
| UX-10 | `3a9621f0` | `ErrorPage` + `PageHeader` + `LoadingSpinner` page chrome |
|
||||
| UX-1.2 ext | `ccee7dfa` | Full `--bl-*` token bridge (enables CC.2 dark-mode parity) |
|
||||
| UX-11 | `328e3072` | `LoginForm` + `MfaChallenge` on the login surface |
|
||||
| UX-12.1 | `ddf25cf5` | `SegmentedControl` view toggle + board `Tooltip`s |
|
||||
| UX-12.2 | `32dac7d4` | item-detail `ActionMenu` + `Timeline` comments |
|
||||
| UX-13.2 | `3d22c303` | `BannerStack` + `Announcement` system messaging |
|
||||
|
||||
**🔒 Blocked on backend (data-gated, not in the ✅ count):** UX-12.3 (`RichTextEditor` — needs
|
||||
server-side HTML sanitization on `items.description` + `comments.body`) and UX-13.1
|
||||
(`NotificationCenter` — needs a real notifications feed). Both are owned follow-ups tracked in
|
||||
[`BACKEND_ENABLERS.md`](BACKEND_ENABLERS.md); the needed client deps are already wired/smoke-tested
|
||||
so enablement starts from green once the shared `platform-service` change ships.
|
||||
|
||||
**Core waves UX-2 … UX-8 are complete** (see the per-item SHAs above) — every wave that can ship
|
||||
from `dashboards/tracker-web` alone is done. Nothing in this roadmap remains that is unblocked at
|
||||
the tracker-web layer.
|
||||
@ -1,16 +1,167 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { test, expect, type Page, type Route } from '@playwright/test';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* E2E tests for the Tracker dashboard.
|
||||
*
|
||||
* Tests cover login page, authentication redirects, public roadmap,
|
||||
* and dashboard structure.
|
||||
* These tests are deterministic: every call that would hit the backend
|
||||
* platform-service is mocked at the Next.js proxy boundary (`/api/tracker/**`,
|
||||
* `/api/auth/**`). No live platform-service is required. The only real server
|
||||
* handler exercised end-to-end is `/api/health`, whose required env vars are
|
||||
* provided by the Playwright `webServer.env` config.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Locate the axe-core browser source so we can inject it into the page.
|
||||
*
|
||||
* axe-core is already present in this pnpm monorepo as a transitive dependency
|
||||
* of eslint-plugin-jsx-a11y (pulled in by eslint-config-next), so we resolve it
|
||||
* from the store/node_modules rather than declaring a redundant dependency.
|
||||
* Playwright runs this suite with cwd at the package root (dashboards/tracker-web).
|
||||
*/
|
||||
function resolveAxeSource(): string {
|
||||
const candidates = [
|
||||
join(process.cwd(), 'node_modules', 'axe-core', 'axe.js'),
|
||||
join(process.cwd(), '..', '..', 'node_modules', 'axe-core', 'axe.js'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return readFileSync(candidate, 'utf-8');
|
||||
}
|
||||
// Fall back to scanning the pnpm store at the monorepo root.
|
||||
const storeDir = join(process.cwd(), '..', '..', 'node_modules', '.pnpm');
|
||||
const entry = readdirSync(storeDir).find(d => d.startsWith('axe-core@'));
|
||||
if (!entry) {
|
||||
throw new Error('Could not locate axe-core in node_modules or the pnpm store');
|
||||
}
|
||||
return readFileSync(join(storeDir, entry, 'node_modules', 'axe-core', 'axe.js'), 'utf-8');
|
||||
}
|
||||
|
||||
const AXE_SOURCE = resolveAxeSource();
|
||||
|
||||
// ── Fixtures ────────────────────────────────────────────────────────
|
||||
|
||||
type RoadmapItem = {
|
||||
id: string;
|
||||
productId: string;
|
||||
type: 'bug' | 'feature' | 'task';
|
||||
status: 'open' | 'in_progress' | 'done';
|
||||
priority: 'critical' | 'high' | 'medium' | 'low';
|
||||
title: string;
|
||||
description: string;
|
||||
labels: string[];
|
||||
assignee: string | null;
|
||||
reportedBy: string;
|
||||
source: 'internal' | 'user_submitted' | 'auto_detected';
|
||||
visibility: 'internal' | 'public';
|
||||
voteCount: number;
|
||||
commentCount: number;
|
||||
targetRelease: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function makeItem(partial: Partial<RoadmapItem> & { id: string; title: string }): RoadmapItem {
|
||||
return {
|
||||
productId: 'tracker-e2e',
|
||||
type: 'feature',
|
||||
status: 'open',
|
||||
priority: 'medium',
|
||||
description: 'A sample roadmap item used in e2e tests.',
|
||||
labels: [],
|
||||
assignee: null,
|
||||
reportedBy: 'system',
|
||||
source: 'internal',
|
||||
visibility: 'public',
|
||||
voteCount: 0,
|
||||
commentCount: 0,
|
||||
targetRelease: null,
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
const ROADMAP_ITEMS: RoadmapItem[] = [
|
||||
makeItem({ id: '1', title: 'Dark mode toggle', status: 'open', type: 'feature', voteCount: 12 }),
|
||||
makeItem({
|
||||
id: '2',
|
||||
title: 'Faster sync engine',
|
||||
status: 'in_progress',
|
||||
type: 'feature',
|
||||
priority: 'high',
|
||||
voteCount: 8,
|
||||
}),
|
||||
makeItem({ id: '3', title: 'Export to CSV', status: 'done', type: 'task', voteCount: 5 }),
|
||||
];
|
||||
|
||||
const ROADMAP_STATS = {
|
||||
total: 3,
|
||||
byStatus: { open: 1, in_progress: 1, done: 1 },
|
||||
byType: { feature: 2, task: 1 },
|
||||
totalVotes: 25,
|
||||
};
|
||||
|
||||
/** Mock the public roadmap proxy endpoints so the page renders deterministically. */
|
||||
async function mockRoadmap(page: Page): Promise<void> {
|
||||
await page.route('**/api/tracker/**', async (route: Route) => {
|
||||
const url = route.request().url();
|
||||
if (url.includes('/public/roadmap/stats')) {
|
||||
return route.fulfill({ json: ROADMAP_STATS });
|
||||
}
|
||||
if (url.includes('/public/roadmap')) {
|
||||
return route.fulfill({
|
||||
json: { items: ROADMAP_ITEMS, total: ROADMAP_ITEMS.length, limit: 100, offset: 0 },
|
||||
});
|
||||
}
|
||||
// Default: empty success so nothing else hangs on the network.
|
||||
return route.fulfill({ json: {} });
|
||||
});
|
||||
}
|
||||
|
||||
/** Inject axe-core and return only serious/critical accessibility violations. */
|
||||
async function seriousA11yViolations(page: Page): Promise<{ id: string; impact: string }[]> {
|
||||
await page.addScriptTag({ content: AXE_SOURCE });
|
||||
const violations = await page.evaluate(async () => {
|
||||
// axe is injected onto window by the script tag above.
|
||||
const axe = (
|
||||
window as unknown as {
|
||||
axe: {
|
||||
run: (
|
||||
ctx: unknown,
|
||||
opts: unknown
|
||||
) => Promise<{ violations: { id: string; impact: string | null }[] }>;
|
||||
};
|
||||
}
|
||||
).axe;
|
||||
const result = await axe.run(document, {
|
||||
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] },
|
||||
});
|
||||
return result.violations.map(v => ({ id: v.id, impact: v.impact ?? 'unknown' }));
|
||||
});
|
||||
return violations.filter(v => v.impact === 'serious' || v.impact === 'critical');
|
||||
}
|
||||
|
||||
/** Collect uncaught page errors and console.error messages (minus dev-only noise). */
|
||||
function collectPageErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
const benign = ['favicon', 'Download the React DevTools', 'Failed to load resource', 'net::ERR_'];
|
||||
page.on('pageerror', err => errors.push(`pageerror: ${err.message}`));
|
||||
page.on('console', msg => {
|
||||
if (msg.type() !== 'error') return;
|
||||
const text = msg.text();
|
||||
if (benign.some(b => text.includes(b))) return;
|
||||
errors.push(`console.error: ${text}`);
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ── Login page ──────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Tracker Login Page', () => {
|
||||
test('shows login form with correct branding', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await expect(page.getByText('Tracker')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Tracker' })).toBeVisible();
|
||||
await expect(page.getByText('Feature requests, bugs & task management')).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
await expect(page.getByLabel('Password')).toBeVisible();
|
||||
@ -23,20 +174,28 @@ test.describe('Tracker Login Page', () => {
|
||||
});
|
||||
|
||||
test('shows error for invalid credentials', async ({ page }) => {
|
||||
// Mock the login proxy to reject the credentials deterministically.
|
||||
await page.route('**/api/auth/login', (route: Route) =>
|
||||
route.fulfill({ status: 401, json: { error: 'Invalid email or password' } })
|
||||
);
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill('bad@user.com');
|
||||
await page.getByLabel('Password').fill('wrongpassword');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page.getByText(/failed|error|invalid/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText('Invalid email or password')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('shows loading state on submit', async ({ page }) => {
|
||||
// Block API to keep loading state visible
|
||||
// Delay the login response so the loading state stays visible.
|
||||
await page.route(
|
||||
'**/api/auth/**',
|
||||
route => new Promise(resolve => setTimeout(() => resolve(route.abort()), 3000))
|
||||
'**/api/auth/login',
|
||||
route =>
|
||||
new Promise<void>(resolve =>
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
route.fulfill({ status: 401, json: { error: 'nope' } });
|
||||
}, 3000)
|
||||
)
|
||||
);
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill('test@example.com');
|
||||
@ -44,8 +203,17 @@ test.describe('Tracker Login Page', () => {
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page.getByText('Signing in...')).toBeVisible();
|
||||
});
|
||||
|
||||
test('has no serious accessibility violations', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await expect(page.getByRole('heading', { name: 'Tracker' })).toBeVisible();
|
||||
const violations = await seriousA11yViolations(page);
|
||||
expect(violations, JSON.stringify(violations, null, 2)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Protected routes ────────────────────────────────────────────────
|
||||
|
||||
test.describe('Tracker — Protected Routes', () => {
|
||||
test('/ redirects to login when not authenticated', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@ -58,57 +226,134 @@ test.describe('Tracker — Protected Routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Tracker — Public Roadmap', () => {
|
||||
test('roadmap page renders board layout', async ({ page }) => {
|
||||
await page.goto('/roadmap');
|
||||
// The roadmap page is public — no auth required
|
||||
await expect(page.getByText(/roadmap/i).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
// ── Login → dashboard happy path (fully mocked) ─────────────────────
|
||||
|
||||
test.describe('Tracker — Authenticated dashboard', () => {
|
||||
test('login redirects to dashboard and renders stats', async ({ page }) => {
|
||||
await page.route('**/api/auth/login', (route: Route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
accessToken: 'fake-e2e-token',
|
||||
user: { id: 'u1', email: 'admin@example.com', role: 'admin', displayName: 'Admin' },
|
||||
},
|
||||
})
|
||||
);
|
||||
await page.route('**/api/auth/me', (route: Route) =>
|
||||
route.fulfill({
|
||||
json: { id: 'u1', email: 'admin@example.com', role: 'admin', displayName: 'Admin' },
|
||||
})
|
||||
);
|
||||
await page.route('**/api/tracker/**', (route: Route) => {
|
||||
if (route.request().url().includes('/items/stats')) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
productId: 'tracker-e2e',
|
||||
total: 42,
|
||||
byType: { bug: 10, feature: 30, task: 2 },
|
||||
byStatus: { open: 20, in_progress: 12, done: 10 },
|
||||
byPriority: { critical: 2, high: 10, medium: 20, low: 10 },
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.fulfill({ json: {} });
|
||||
});
|
||||
});
|
||||
|
||||
test('roadmap page has search and filter controls', async ({ page }) => {
|
||||
await page.goto('/roadmap');
|
||||
// Should have a search input
|
||||
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill('admin@example.com');
|
||||
await page.getByLabel('Password').fill('correct-password');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
test('roadmap page has submit suggestion button', async ({ page }) => {
|
||||
await page.goto('/roadmap');
|
||||
await expect(page.getByRole('button', { name: /suggest|submit|new/i }).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test('roadmap page shows status columns in board view', async ({ page }) => {
|
||||
await page.goto('/roadmap');
|
||||
// Board view shows Planned, In Progress, Complete columns
|
||||
await expect(page.getByText('Planned').first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText('In Progress').first()).toBeVisible();
|
||||
await expect(page.getByText('Complete').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('roadmap page can toggle between board and list view', async ({ page }) => {
|
||||
await page.goto('/roadmap');
|
||||
// Look for view toggle buttons
|
||||
const listBtn = page.getByRole('button', { name: /list/i });
|
||||
if (await listBtn.isVisible()) {
|
||||
await listBtn.click();
|
||||
// Should now show list view
|
||||
await expect(page.locator("table, [role='list']").first()).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByText('42')).toBeVisible();
|
||||
await expect(page.getByText('admin@example.com')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Public roadmap (mocked) ─────────────────────────────────────────
|
||||
|
||||
test.describe('Tracker — Public Roadmap', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockRoadmap(page);
|
||||
});
|
||||
|
||||
test('renders header and stats from mocked data', async ({ page }) => {
|
||||
await page.goto('/roadmap');
|
||||
await expect(page.getByRole('heading', { name: /product roadmap/i })).toBeVisible();
|
||||
await expect(page.getByText('Total Items')).toBeVisible();
|
||||
// totalVotes stat from ROADMAP_STATS
|
||||
await expect(page.getByText('25')).toBeVisible();
|
||||
});
|
||||
|
||||
test('has search and filter controls', async ({ page }) => {
|
||||
await page.goto('/roadmap');
|
||||
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('has a submit-idea button', async ({ page }) => {
|
||||
await page.goto('/roadmap');
|
||||
await expect(page.getByRole('button', { name: /submit idea/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows status columns in board view with items', async ({ page }) => {
|
||||
await page.goto('/roadmap');
|
||||
await expect(page.getByRole('heading', { name: 'Planned' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'In Progress' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Complete' })).toBeVisible();
|
||||
// Items land in the correct columns
|
||||
await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Faster sync engine' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Export to CSV' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('can toggle between board and list view', async ({ page }) => {
|
||||
await page.goto('/roadmap');
|
||||
await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible();
|
||||
// The view toggle is a shared SegmentedControl (role=radio), UX-12.1.
|
||||
await page.getByRole('radio', { name: 'List', exact: true }).click();
|
||||
// List view still shows the items (rendered as rows, not a <table>)
|
||||
await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Export to CSV' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('opens the submit-idea modal', async ({ page }) => {
|
||||
await page.goto('/roadmap');
|
||||
await page.getByRole('button', { name: /submit idea/i }).click();
|
||||
await expect(page.getByRole('heading', { name: /submit an idea/i })).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Your email')).toBeVisible();
|
||||
});
|
||||
|
||||
test('prompts for email when voting without a saved email', async ({ page }) => {
|
||||
await page.goto('/roadmap');
|
||||
await page.getByRole('button', { name: /upvote dark mode toggle/i }).click();
|
||||
await expect(page.getByRole('heading', { name: /enter your email to vote/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('has no serious accessibility violations', async ({ page }) => {
|
||||
await page.goto('/roadmap');
|
||||
await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible();
|
||||
const violations = await seriousA11yViolations(page);
|
||||
expect(violations, JSON.stringify(violations, null, 2)).toEqual([]);
|
||||
});
|
||||
|
||||
test('logs no unexpected console errors', async ({ page }) => {
|
||||
const errors = collectPageErrors(page);
|
||||
await page.goto('/roadmap');
|
||||
await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible();
|
||||
expect(errors, errors.join('\n')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Health endpoint (real handler, env provided by webServer) ───────
|
||||
|
||||
test.describe('Tracker — Health', () => {
|
||||
test('GET /api/health returns ok', async ({ request }) => {
|
||||
test('GET /api/health returns ok with required env set', async ({ request }) => {
|
||||
const res = await request.get('/api/health');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe('ok');
|
||||
expect(body.service).toBe('tracker-dashboard');
|
||||
expect(Array.isArray(body.checks)).toBe(true);
|
||||
expect(body.checks.every((c: { status: string }) => c.status === 'pass')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,21 +1,55 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
import nextTs from 'eslint-config-next/typescript';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
rules: {
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }],
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||
],
|
||||
// CC.6 UI-drift ratchet: all @bytelyst/ui usage must go through the
|
||||
// Primitives adapter so future UI stays consistent and centrally swappable.
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: '@bytelyst/ui',
|
||||
message:
|
||||
'Import @bytelyst/ui components via the adapter: src/components/ui/Primitives.tsx (UI-drift ratchet, CC.6).',
|
||||
},
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
group: ['@bytelyst/ui/*'],
|
||||
message:
|
||||
'Import @bytelyst/ui components via the adapter: src/components/ui/Primitives.tsx (UI-drift ratchet, CC.6).',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// The adapter itself is the single sanctioned place to import @bytelyst/ui.
|
||||
files: ['src/components/ui/Primitives.tsx'],
|
||||
rules: {
|
||||
'no-restricted-imports': 'off',
|
||||
},
|
||||
},
|
||||
globalIgnores([
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'coverage/**',
|
||||
'test-results/**',
|
||||
'playwright-report/**',
|
||||
'next-env.d.ts',
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@ -25,11 +25,20 @@
|
||||
"@azure/identity": "^4.13.0",
|
||||
"@azure/keyvault-secrets": "^4.10.0",
|
||||
"@bytelyst/api-client": "workspace:*",
|
||||
"@bytelyst/auth-ui": "workspace:*",
|
||||
"@bytelyst/charts": "workspace:*",
|
||||
"@bytelyst/command-palette": "workspace:*",
|
||||
"@bytelyst/config": "workspace:*",
|
||||
"@bytelyst/dashboard-components": "workspace:*",
|
||||
"@bytelyst/data-table": "workspace:*",
|
||||
"@bytelyst/data-viz": "workspace:*",
|
||||
"@bytelyst/design-tokens": "workspace:*",
|
||||
"@bytelyst/errors": "workspace:*",
|
||||
"@bytelyst/notifications-ui": "workspace:*",
|
||||
"@bytelyst/telemetry-client": "workspace:*",
|
||||
"@bytelyst/logger": "workspace:*",
|
||||
"@bytelyst/motion": "workspace:*",
|
||||
"@bytelyst/ui": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"next": "16.1.6",
|
||||
"posthog-js": "^1.345.5",
|
||||
|
||||
@ -22,5 +22,12 @@ export default defineConfig({
|
||||
url: 'http://localhost:3003',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 30_000,
|
||||
// Provide the env vars /api/health requires so the health gate is deterministic.
|
||||
// These are non-secret placeholders only used to exercise the health handler.
|
||||
env: {
|
||||
PLATFORM_API_URL: 'http://localhost:4003',
|
||||
JWT_SECRET: 'e2e-placeholder-not-a-real-secret',
|
||||
DEFAULT_PRODUCT_ID: 'tracker-e2e',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
67
dashboards/tracker-web/src/__tests__/auth-mfa-verify.test.ts
Normal file
67
dashboards/tracker-web/src/__tests__/auth-mfa-verify.test.ts
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Tests for POST /api/auth/mfa/verify (tracker dashboard — proxy to platform-service).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { POST } from '@/app/api/auth/mfa/verify/route';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
function jsonResponse(data: unknown, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(data),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function callVerify(body: object) {
|
||||
return POST({ json: async () => body } as never);
|
||||
}
|
||||
|
||||
describe('POST /api/auth/mfa/verify (tracker)', () => {
|
||||
beforeEach(() => mockFetch.mockReset());
|
||||
|
||||
it('forwards a successful verification and returns tokens', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ accessToken: 'tok', user: { id: 'u1' } }));
|
||||
|
||||
const res = await callVerify({ challengeToken: 'chal', code: '123456', method: 'totp' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.accessToken).toBe('tok');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/auth/mfa/verify'),
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards the platform-service error status and message', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ error: 'Invalid code' }, 401));
|
||||
|
||||
const res = await callVerify({ challengeToken: 'chal', code: '000000', method: 'totp' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()).error).toBe('Invalid code');
|
||||
});
|
||||
|
||||
it('uses a default error message when platform-service omits one', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({}, 403));
|
||||
|
||||
const res = await callVerify({ challengeToken: 'chal', code: '000000' });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect((await res.json()).error).toBe('MFA verification failed');
|
||||
});
|
||||
|
||||
it('returns 502 when the platform-service is unreachable', async () => {
|
||||
mockFetch.mockImplementationOnce(() => Promise.reject(new Error('ECONNREFUSED')));
|
||||
|
||||
const res = await callVerify({ challengeToken: 'chal', code: '123456' });
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect((await res.json()).error).toBe('Platform service unavailable');
|
||||
});
|
||||
});
|
||||
93
dashboards/tracker-web/src/__tests__/auth-oauth.test.ts
Normal file
93
dashboards/tracker-web/src/__tests__/auth-oauth.test.ts
Normal file
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Tests for POST /api/auth/oauth/[provider] (tracker dashboard — proxy to platform-service).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { POST } from '@/app/api/auth/oauth/[provider]/route';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
function jsonResponse(data: unknown, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(data),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function mockRequest(body: object, headers: Record<string, string> = {}): NextRequest {
|
||||
const headerMap = new Map(Object.entries(headers));
|
||||
return {
|
||||
json: async () => body,
|
||||
headers: { get: (k: string) => headerMap.get(k.toLowerCase()) ?? null },
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
function callOAuth(req: NextRequest, provider = 'google') {
|
||||
return POST(req, { params: Promise.resolve({ provider }) });
|
||||
}
|
||||
|
||||
describe('POST /api/auth/oauth/[provider] (tracker)', () => {
|
||||
beforeEach(() => mockFetch.mockReset());
|
||||
|
||||
it('rejects requests without an idToken (400) and never calls the backend', async () => {
|
||||
const res = await callOAuth(mockRequest({}));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toBe('idToken required');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proxies the provider login and returns tokens on success', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ accessToken: 'tok' }));
|
||||
|
||||
const res = await callOAuth(mockRequest({ idToken: 'google-id-token' }), 'google');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).accessToken).toBe('tok');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/auth/oauth/google'),
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('injects the x-product-id header value into the forwarded body', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ accessToken: 'tok' }));
|
||||
|
||||
await callOAuth(mockRequest({ idToken: 'tok' }, { 'x-product-id': 'chronomind' }));
|
||||
|
||||
const forwardedBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(forwardedBody.productId).toBe('chronomind');
|
||||
expect(forwardedBody.idToken).toBe('tok');
|
||||
});
|
||||
|
||||
it('forwards the platform-service error status', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ error: 'unverified' }, 401));
|
||||
|
||||
const res = await callOAuth(mockRequest({ idToken: 'tok' }));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()).error).toBe('unverified');
|
||||
});
|
||||
|
||||
it('uses a provider-specific default error message', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({}, 403));
|
||||
|
||||
const res = await callOAuth(mockRequest({ idToken: 'tok' }), 'github');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect((await res.json()).error).toBe('OAuth github login failed');
|
||||
});
|
||||
|
||||
it('returns 502 when the platform-service is unreachable', async () => {
|
||||
mockFetch.mockImplementationOnce(() => Promise.reject(new Error('ECONNREFUSED')));
|
||||
|
||||
const res = await callOAuth(mockRequest({ idToken: 'tok' }));
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect((await res.json()).error).toBe('Platform service unavailable');
|
||||
});
|
||||
});
|
||||
31
dashboards/tracker-web/src/__tests__/auth-ui-imports.test.ts
Normal file
31
dashboards/tracker-web/src/__tests__/auth-ui-imports.test.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Smoke test for the @bytelyst/auth-ui wiring used by the login surface (UX-11).
|
||||
*
|
||||
* Pure import test (no DOM): guards that the auth-ui components the login page
|
||||
* depends on resolve and build. The full "login form shows email/password +
|
||||
* submit" render assertion lives in the Playwright suite
|
||||
* (e2e/tracker.spec.ts → "shows login form with correct branding"), which runs
|
||||
* the real component in a browser.
|
||||
*
|
||||
* @see docs/roadmaps/UX_INTEGRATION_BYTELYST.md (UX-11.3)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LoginForm, MfaChallenge, SocialButtons } from '@bytelyst/auth-ui';
|
||||
|
||||
describe('auth-ui wiring', () => {
|
||||
it('resolves LoginForm as a component', () => {
|
||||
expect(LoginForm).toBeDefined();
|
||||
expect(typeof LoginForm).toBe('function');
|
||||
});
|
||||
|
||||
it('resolves MfaChallenge as a component', () => {
|
||||
expect(MfaChallenge).toBeDefined();
|
||||
expect(typeof MfaChallenge).toBe('function');
|
||||
});
|
||||
|
||||
it('resolves SocialButtons as a component', () => {
|
||||
expect(SocialButtons).toBeDefined();
|
||||
expect(typeof SocialButtons).toBe('function');
|
||||
});
|
||||
});
|
||||
102
dashboards/tracker-web/src/__tests__/command-menu.test.tsx
Normal file
102
dashboards/tracker-web/src/__tests__/command-menu.test.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* UX-5.3 — command palette behaviour.
|
||||
*
|
||||
* Asserts the registry builder produces the expected command set, and that the
|
||||
* palette opens on ⌘K and lists the registered commands. Uses a jsdom render
|
||||
* harness with react-dom/client (no @testing-library dependency).
|
||||
*
|
||||
* @see docs/roadmaps/UX_INTEGRATION_BYTELYST.md (UX-5)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { act, createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import {
|
||||
CommandRegistryProvider,
|
||||
CommandPalette,
|
||||
useCommandPalette,
|
||||
useRegisterCommands,
|
||||
} from '@bytelyst/command-palette';
|
||||
import { buildCommands, type CommandMenuDeps } from '@/lib/command-registry';
|
||||
|
||||
beforeAll(() => {
|
||||
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
const stubDeps = (over: Partial<CommandMenuDeps> = {}): CommandMenuDeps => ({
|
||||
navigate: vi.fn(),
|
||||
newItem: vi.fn(),
|
||||
toggleTheme: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
setProduct: vi.fn(),
|
||||
products: [{ id: 'p1', name: 'Prod One' }],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('buildCommands', () => {
|
||||
it('includes navigate, action and per-product switch commands', () => {
|
||||
const cmds = buildCommands(stubDeps());
|
||||
const ids = cmds.map(c => c.id);
|
||||
expect(ids).toEqual(
|
||||
expect.arrayContaining([
|
||||
'nav-overview',
|
||||
'nav-items',
|
||||
'nav-board',
|
||||
'nav-roadmap',
|
||||
'new-item',
|
||||
'toggle-theme',
|
||||
'sign-out',
|
||||
'switch-product-p1',
|
||||
])
|
||||
);
|
||||
// Navigate commands carry an href + navigate mode.
|
||||
const overview = cmds.find(c => c.id === 'nav-overview');
|
||||
expect(overview?.mode).toBe('navigate');
|
||||
expect(overview?.href).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('wires action handlers to the supplied callbacks', () => {
|
||||
const deps = stubDeps();
|
||||
const cmds = buildCommands(deps);
|
||||
cmds.find(c => c.id === 'new-item')?.run?.();
|
||||
cmds.find(c => c.id === 'switch-product-p1')?.run?.();
|
||||
expect(deps.newItem).toHaveBeenCalledTimes(1);
|
||||
expect(deps.setProduct).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
});
|
||||
|
||||
function Harness() {
|
||||
const cmdk = useCommandPalette();
|
||||
useRegisterCommands(buildCommands(stubDeps()));
|
||||
return createElement(CommandPalette, { open: cmdk.open, onClose: cmdk.hide });
|
||||
}
|
||||
|
||||
describe('CommandPalette ⌘K', () => {
|
||||
it('opens on Cmd-K and lists registered commands', () => {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
let root!: Root;
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(createElement(CommandRegistryProvider, null, createElement(Harness)));
|
||||
});
|
||||
|
||||
// Closed initially — the dialog is not rendered.
|
||||
expect(container.querySelector('[data-testid="bl-cmdk"]')).toBeNull();
|
||||
|
||||
// ⌘K toggles it open.
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
|
||||
});
|
||||
expect(container.querySelector('[data-testid="bl-cmdk"]')).not.toBeNull();
|
||||
|
||||
// Actions-tab commands are listed.
|
||||
expect(container.textContent).toContain('New item');
|
||||
expect(container.textContent).toContain('Toggle theme');
|
||||
expect(container.textContent).toContain('Sign out');
|
||||
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
171
dashboards/tracker-web/src/__tests__/fleet-client.test.ts
Normal file
171
dashboards/tracker-web/src/__tests__/fleet-client.test.ts
Normal file
@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Fleet client unit tests — verifies correct URL construction,
|
||||
* method usage, and graceful degradation on errors.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const { fetchSpy } = vi.hoisted(() => ({ fetchSpy: vi.fn() }));
|
||||
|
||||
vi.mock('@bytelyst/api-client', () => ({
|
||||
createApiClient: () => ({ fetch: fetchSpy }),
|
||||
}));
|
||||
|
||||
import {
|
||||
listJobs,
|
||||
getJob,
|
||||
patchJob,
|
||||
getJobRuns,
|
||||
getJobEvents,
|
||||
getJobArtifacts,
|
||||
getJobDag,
|
||||
listFactories,
|
||||
getBudget,
|
||||
upsertBudget,
|
||||
pauseBudget,
|
||||
resumeBudget,
|
||||
} from '@/lib/fleet-client';
|
||||
|
||||
describe('fleet-client', () => {
|
||||
beforeEach(() => {
|
||||
fetchSpy.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('listJobs', () => {
|
||||
it('calls /jobs with query params', async () => {
|
||||
fetchSpy.mockResolvedValue({ jobs: [] });
|
||||
const res = await listJobs({ stage: 'queued', limit: 10 });
|
||||
expect(res.jobs).toEqual([]);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining('/jobs'), expect.anything());
|
||||
});
|
||||
|
||||
it('calls /jobs without params when none provided', async () => {
|
||||
fetchSpy.mockResolvedValue({ jobs: [{ id: 'j1' }] });
|
||||
const res = await listJobs();
|
||||
expect(res.jobs).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getJob', () => {
|
||||
it('returns job on success', async () => {
|
||||
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'queued' });
|
||||
const job = await getJob('j1');
|
||||
expect(job?.id).toBe('j1');
|
||||
});
|
||||
|
||||
it('returns null on 404', async () => {
|
||||
fetchSpy.mockRejectedValue(new Error('404 Not Found'));
|
||||
const job = await getJob('missing');
|
||||
expect(job).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('patchJob', () => {
|
||||
it('sends PATCH with correct body', async () => {
|
||||
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'shipped' });
|
||||
const res = await patchJob('j1', { leaseEpoch: 1, stage: 'shipped' });
|
||||
expect(res.stage).toBe('shipped');
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/jobs/j1',
|
||||
expect.objectContaining({ method: 'PATCH' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getJobRuns', () => {
|
||||
it('returns runs array', async () => {
|
||||
fetchSpy.mockResolvedValue({ runs: [{ id: 'r1', attempt: 1 }] });
|
||||
const res = await getJobRuns('j1');
|
||||
expect(res.runs).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getJobEvents', () => {
|
||||
it('returns events array', async () => {
|
||||
fetchSpy.mockResolvedValue({ events: [{ id: 'e1', type: 'submitted' }] });
|
||||
const res = await getJobEvents('j1');
|
||||
expect(res.events).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getJobArtifacts', () => {
|
||||
it('returns artifacts array', async () => {
|
||||
fetchSpy.mockResolvedValue({ artifacts: [] });
|
||||
const res = await getJobArtifacts('j1');
|
||||
expect(res.artifacts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getJobDag', () => {
|
||||
it('returns dag on success', async () => {
|
||||
fetchSpy.mockResolvedValue({ dag: { id: 'j1', children: [] } });
|
||||
const res = await getJobDag('j1');
|
||||
expect(res?.dag.id).toBe('j1');
|
||||
});
|
||||
|
||||
it('returns null on 404 (leaf job with no children)', async () => {
|
||||
fetchSpy.mockRejectedValue(new Error('404 Not Found'));
|
||||
const res = await getJobDag('leaf');
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listFactories', () => {
|
||||
it('returns factories on success', async () => {
|
||||
fetchSpy.mockResolvedValue({ factories: [{ id: 'f1' }] });
|
||||
const res = await listFactories();
|
||||
expect(res.factories).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns empty array on error (graceful degradation)', async () => {
|
||||
fetchSpy.mockRejectedValue(new Error('Network error'));
|
||||
const res = await listFactories();
|
||||
expect(res.factories).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('budget operations', () => {
|
||||
it('getBudget returns budget or null', async () => {
|
||||
fetchSpy.mockResolvedValue({ id: 'lysnrai', ceilingUsd: 100, spentUsd: 25 });
|
||||
const b = await getBudget('lysnrai');
|
||||
expect(b?.ceilingUsd).toBe(100);
|
||||
|
||||
fetchSpy.mockRejectedValue(new Error('404'));
|
||||
const missing = await getBudget('unknown');
|
||||
expect(missing).toBeNull();
|
||||
});
|
||||
|
||||
it('upsertBudget sends PUT', async () => {
|
||||
fetchSpy.mockResolvedValue({ id: 'p1', ceilingUsd: 50 });
|
||||
await upsertBudget('p1', 50, 'monthly');
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/budgets/p1',
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
);
|
||||
});
|
||||
|
||||
it('pauseBudget sends POST to /pause', async () => {
|
||||
fetchSpy.mockResolvedValue({ status: 'paused' });
|
||||
const res = await pauseBudget('p1');
|
||||
expect(res.status).toBe('paused');
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/budgets/p1/pause',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('resumeBudget sends POST to /resume', async () => {
|
||||
fetchSpy.mockResolvedValue({ status: 'active' });
|
||||
const res = await resumeBudget('p1');
|
||||
expect(res.status).toBe('active');
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/budgets/p1/resume',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -2,7 +2,7 @@
|
||||
* Tests for GET /api/health (tracker dashboard)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
|
||||
import { GET } from '@/app/api/health/route';
|
||||
|
||||
@ -11,12 +11,17 @@ describe('GET /api/health', () => {
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns ok when all required env vars are set', async () => {
|
||||
it('returns ok when all required env vars are set and platform-service is healthy', async () => {
|
||||
process.env.PLATFORM_API_URL = 'http://localhost:4003';
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
process.env.DEFAULT_PRODUCT_ID = 'test-product';
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify({ status: 'ok' }), { status: 200 }))
|
||||
);
|
||||
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
@ -30,7 +35,33 @@ describe('GET /api/health', () => {
|
||||
status: 'pass',
|
||||
message: '3 required vars set',
|
||||
},
|
||||
{
|
||||
name: 'platform-service',
|
||||
status: 'pass',
|
||||
message: 'healthy',
|
||||
},
|
||||
]);
|
||||
expect(fetch).toHaveBeenCalledWith('http://localhost:4003/health', expect.any(Object));
|
||||
});
|
||||
|
||||
it('returns degraded when platform-service health check fails', async () => {
|
||||
process.env.PLATFORM_API_URL = 'http://localhost:4003';
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
process.env.DEFAULT_PRODUCT_ID = 'test-product';
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify({ status: 'degraded' }), { status: 503 }))
|
||||
);
|
||||
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(503);
|
||||
const data = await res.json();
|
||||
expect(data.status).toBe('degraded');
|
||||
expect(data.checks).toContainEqual({
|
||||
name: 'platform-service',
|
||||
status: 'fail',
|
||||
message: 'HTTP 503',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns degraded when env vars are missing', async () => {
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Smoke test for the @bytelyst/notifications-ui wiring used by the dashboard
|
||||
* system banners (UX-13.2).
|
||||
*
|
||||
* Pure import test (no DOM): guards that the notifications-ui surfaces the
|
||||
* SystemBanners component depends on resolve and build. NotificationCenter
|
||||
* (UX-13.1) is intentionally not adopted (no notifications feed) but is asserted
|
||||
* here so a future feed wiring starts from a known-good import.
|
||||
*
|
||||
* @see docs/roadmaps/UX_INTEGRATION_BYTELYST.md (UX-13)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
BannerStack,
|
||||
Announcement,
|
||||
InboxItem,
|
||||
NotificationCenter,
|
||||
} from '@bytelyst/notifications-ui';
|
||||
|
||||
describe('notifications-ui wiring', () => {
|
||||
it('resolves BannerStack (shipped in UX-13.2)', () => {
|
||||
expect(BannerStack).toBeDefined();
|
||||
expect(typeof BannerStack).toBe('function');
|
||||
});
|
||||
|
||||
it('resolves Announcement (shipped in UX-13.2)', () => {
|
||||
expect(Announcement).toBeDefined();
|
||||
expect(typeof Announcement).toBe('function');
|
||||
});
|
||||
|
||||
it('resolves InboxItem + NotificationCenter (reserved for a future feed, UX-13.1)', () => {
|
||||
expect(InboxItem).toBeDefined();
|
||||
expect(NotificationCenter).toBeDefined();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* UX-4.3 — overview data-viz transforms + render safety.
|
||||
*
|
||||
* Renders the dashboard overview chart surface with a mocked `getStats`
|
||||
* payload through react-dom/server (no DOM/jsdom needed) and asserts the
|
||||
* produced SVG markup contains no `NaN` path data. Also unit-tests the pure
|
||||
* transforms in `@/lib/overview-charts` for finite, correctly-mapped output.
|
||||
*
|
||||
* @see docs/roadmaps/UX_INTEGRATION_BYTELYST.md (UX-4)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import OverviewCharts from '@/components/overview-charts';
|
||||
import { statusSlices, typeSlices, priorityBars, overviewKpis } from '@/lib/overview-charts';
|
||||
import type { TrackerStats } from '@/lib/tracker-client';
|
||||
|
||||
const STATS: TrackerStats = {
|
||||
productId: 'tracker-test',
|
||||
total: 42,
|
||||
byType: { bug: 10, feature: 30, task: 2 },
|
||||
byStatus: { open: 20, in_progress: 12, done: 10 },
|
||||
byPriority: { critical: 2, high: 10, medium: 20, low: 10 },
|
||||
};
|
||||
|
||||
const EMPTY_STATS: TrackerStats = {
|
||||
productId: 'tracker-test',
|
||||
total: 0,
|
||||
byType: {},
|
||||
byStatus: {},
|
||||
byPriority: {},
|
||||
};
|
||||
|
||||
describe('overview-charts transforms', () => {
|
||||
it('maps status/type entries to donut slices with finite values', () => {
|
||||
const slices = [...statusSlices(STATS), ...typeSlices(STATS)];
|
||||
expect(slices.length).toBe(6);
|
||||
for (const s of slices) {
|
||||
expect(Number.isFinite(s.value)).toBe(true);
|
||||
expect(s.value).toBeGreaterThanOrEqual(0);
|
||||
expect(s.color).toMatch(/var\(--bl-chart-/);
|
||||
}
|
||||
expect(statusSlices(STATS).find(s => s.id === 'open')?.value).toBe(20);
|
||||
});
|
||||
|
||||
it('orders priority bars critical → low with finite values', () => {
|
||||
const bars = priorityBars(STATS);
|
||||
expect(bars.map(b => b.id)).toEqual(['critical', 'high', 'medium', 'low']);
|
||||
for (const b of bars) expect(Number.isFinite(b.value)).toBe(true);
|
||||
});
|
||||
|
||||
it('coerces missing / non-finite KPIs to 0', () => {
|
||||
expect(overviewKpis(STATS)).toEqual({ total: 42, open: 20, inProgress: 12, done: 10 });
|
||||
expect(overviewKpis(EMPTY_STATS)).toEqual({ total: 0, open: 0, inProgress: 0, done: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('OverviewCharts render safety', () => {
|
||||
it('renders mocked stats without NaN in the SVG paths', () => {
|
||||
const html = renderToStaticMarkup(createElement(OverviewCharts, { stats: STATS }));
|
||||
expect(html).toContain('<svg');
|
||||
expect(html).not.toContain('NaN');
|
||||
expect(html).toContain('42');
|
||||
});
|
||||
|
||||
it('renders empty stats without NaN (empty donut ring)', () => {
|
||||
const html = renderToStaticMarkup(createElement(OverviewCharts, { stats: EMPTY_STATS }));
|
||||
expect(html).not.toContain('NaN');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Export-presence test for the Primitives adapter (UX-9.2).
|
||||
*
|
||||
* A pure import test: it asserts every runtime (value) export of
|
||||
* `src/components/ui/Primitives.tsx` is defined. This lifts the adapter off
|
||||
* 0% coverage without needing `@testing-library/react` or a DOM environment,
|
||||
* and guards against an accidental broken/renamed re-export from `@bytelyst/ui`.
|
||||
*
|
||||
* @see docs/roadmaps/UX_INTEGRATION_BYTELYST.md (UX-9)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as Primitives from '@/components/ui/Primitives';
|
||||
|
||||
// Every value (non-type) export the adapter is expected to provide.
|
||||
const EXPECTED_EXPORTS = [
|
||||
// UX-1 baseline
|
||||
'Button',
|
||||
'Input',
|
||||
'Field',
|
||||
'FieldContent',
|
||||
'FieldDescription',
|
||||
'FieldError',
|
||||
'FieldGroup',
|
||||
'FieldLabel',
|
||||
'FieldTitle',
|
||||
'Modal',
|
||||
'ConfirmDialog',
|
||||
'Badge',
|
||||
'StatusBadge',
|
||||
'StatusDot',
|
||||
'EmptyState',
|
||||
'Skeleton',
|
||||
'SkeletonGroup',
|
||||
'TableSkeleton',
|
||||
'MetricCard',
|
||||
'Toast',
|
||||
'ToastProvider',
|
||||
'useToast',
|
||||
'toast',
|
||||
'dismissToast',
|
||||
// UX-9.1 additions
|
||||
'Select',
|
||||
'Textarea',
|
||||
'Checkbox',
|
||||
'Switch',
|
||||
'RadioGroup',
|
||||
'RadioGroupItem',
|
||||
'Tooltip',
|
||||
'TooltipContent',
|
||||
'TooltipProvider',
|
||||
'TooltipTrigger',
|
||||
'Tabs',
|
||||
'TabsContent',
|
||||
'TabsList',
|
||||
'TabsTrigger',
|
||||
'SegmentedControl',
|
||||
'DropdownMenu',
|
||||
'DropdownMenuContent',
|
||||
'DropdownMenuGroup',
|
||||
'DropdownMenuItem',
|
||||
'DropdownMenuLabel',
|
||||
'DropdownMenuRadioGroup',
|
||||
'DropdownMenuSeparator',
|
||||
'DropdownMenuSub',
|
||||
'DropdownMenuSubTrigger',
|
||||
'DropdownMenuTrigger',
|
||||
'Drawer',
|
||||
'ActionMenu',
|
||||
'IconButton',
|
||||
'AlertBanner',
|
||||
'Card',
|
||||
'CardHeader',
|
||||
'CardTitle',
|
||||
'CardDescription',
|
||||
'Panel',
|
||||
'PanelBody',
|
||||
'PanelDescription',
|
||||
'PanelHeader',
|
||||
'PanelTitle',
|
||||
'Separator',
|
||||
'DataList',
|
||||
'DataListItem',
|
||||
'DataListMeta',
|
||||
'Timeline',
|
||||
// UX-8 AppShell additions
|
||||
'AppShell',
|
||||
'AppShellMain',
|
||||
'AppShellMobileToggle',
|
||||
'AppShellNav',
|
||||
'AppShellNavItem',
|
||||
'AppShellOverlay',
|
||||
'AppShellPageHeader',
|
||||
'AppShellSidebar',
|
||||
'AppShellSkipLink',
|
||||
] as const;
|
||||
|
||||
describe('Primitives adapter — export presence', () => {
|
||||
it.each(EXPECTED_EXPORTS)('re-exports %s as a defined value', name => {
|
||||
expect((Primitives as Record<string, unknown>)[name]).toBeDefined();
|
||||
});
|
||||
|
||||
it('exposes the toast helpers as callable functions', () => {
|
||||
expect(typeof Primitives.toast).toBe('function');
|
||||
expect(typeof Primitives.dismissToast).toBe('function');
|
||||
expect(typeof Primitives.useToast).toBe('function');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Smoke test for Primitives adapter (UX-1)
|
||||
*
|
||||
* Verifies that the @bytelyst/ui components are properly re-exported through
|
||||
* the Primitives adapter and can be imported in tracker-web.
|
||||
*
|
||||
* @see docs/roadmaps/UX_INTEGRATION_BYTELYST.md (UX-1, task 1.4)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
Button,
|
||||
StatusBadge,
|
||||
StatusDot,
|
||||
Input,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
FieldDescription,
|
||||
Modal,
|
||||
ConfirmDialog,
|
||||
Badge,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
TableSkeleton,
|
||||
MetricCard,
|
||||
Toast,
|
||||
ToastProvider,
|
||||
useToast,
|
||||
toast,
|
||||
dismissToast,
|
||||
} from '@/components/ui/Primitives';
|
||||
|
||||
describe('Primitives adapter smoke test', () => {
|
||||
it('should export Button component', () => {
|
||||
expect(Button).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export StatusBadge component', () => {
|
||||
expect(StatusBadge).toBeDefined();
|
||||
expect(StatusDot).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export Input component', () => {
|
||||
expect(Input).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export Field components', () => {
|
||||
expect(Field).toBeDefined();
|
||||
expect(FieldLabel).toBeDefined();
|
||||
expect(FieldError).toBeDefined();
|
||||
expect(FieldDescription).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export Modal component', () => {
|
||||
expect(Modal).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export ConfirmDialog component', () => {
|
||||
expect(ConfirmDialog).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export Badge component', () => {
|
||||
expect(Badge).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export EmptyState component', () => {
|
||||
expect(EmptyState).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export Skeleton components', () => {
|
||||
expect(Skeleton).toBeDefined();
|
||||
expect(TableSkeleton).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export MetricCard component', () => {
|
||||
expect(MetricCard).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export Toast components and hooks', () => {
|
||||
expect(Toast).toBeDefined();
|
||||
expect(ToastProvider).toBeDefined();
|
||||
expect(useToast).toBeDefined();
|
||||
expect(typeof useToast).toBe('function');
|
||||
expect(toast).toBeDefined();
|
||||
expect(typeof toast).toBe('function');
|
||||
expect(dismissToast).toBeDefined();
|
||||
expect(typeof dismissToast).toBe('function');
|
||||
});
|
||||
});
|
||||
37
dashboards/tracker-web/src/__tests__/product-config.test.ts
Normal file
37
dashboards/tracker-web/src/__tests__/product-config.test.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Tests for src/lib/product-config — request productId resolution + identity exports.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { getRequestProductId, PRODUCT_ID, KNOWN_PRODUCTS } from '@/lib/product-config';
|
||||
|
||||
function reqWithHeader(value: string | null): NextRequest {
|
||||
return {
|
||||
headers: { get: (k: string) => (k.toLowerCase() === 'x-product-id' ? value : null) },
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
describe('getRequestProductId', () => {
|
||||
it('returns the x-product-id header when present', () => {
|
||||
expect(getRequestProductId(reqWithHeader('chronomind'))).toBe('chronomind');
|
||||
});
|
||||
|
||||
it('falls back to the configured PRODUCT_ID when the header is absent', () => {
|
||||
expect(getRequestProductId(reqWithHeader(null))).toBe(PRODUCT_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('product identity exports', () => {
|
||||
it('exposes a non-empty PRODUCT_ID string', () => {
|
||||
expect(typeof PRODUCT_ID).toBe('string');
|
||||
expect(PRODUCT_ID.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('lists known products with unique ids', () => {
|
||||
const ids = KNOWN_PRODUCTS.map(p => p.id);
|
||||
expect(ids.length).toBeGreaterThan(0);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
147
dashboards/tracker-web/src/__tests__/roadmap-page.test.ts
Normal file
147
dashboards/tracker-web/src/__tests__/roadmap-page.test.ts
Normal file
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Tests for roadmap page behavior (client-side)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the tracker-client functions
|
||||
vi.mock('@/lib/tracker-client', () => ({
|
||||
getRoadmapItems: vi.fn(),
|
||||
getRoadmapStats: vi.fn(),
|
||||
submitPublicItem: vi.fn(),
|
||||
publicVote: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Roadmap page submit behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(global, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should refresh data after successful submit', async () => {
|
||||
const { getRoadmapItems, getRoadmapStats, submitPublicItem } =
|
||||
await import('@/lib/tracker-client');
|
||||
|
||||
// Mock successful API responses
|
||||
vi.mocked(getRoadmapItems).mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(getRoadmapStats).mockResolvedValue({
|
||||
total: 0,
|
||||
byStatus: {},
|
||||
byType: {},
|
||||
totalVotes: 0,
|
||||
});
|
||||
vi.mocked(submitPublicItem).mockResolvedValue({
|
||||
id: 'test-id',
|
||||
title: 'Test Feature',
|
||||
status: 'open',
|
||||
});
|
||||
|
||||
// Simulate the fetchData call pattern from the component
|
||||
let fetchDataCallCount = 0;
|
||||
const mockFetchData = vi.fn(() => {
|
||||
fetchDataCallCount++;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
// Initial load
|
||||
await mockFetchData();
|
||||
expect(fetchDataCallCount).toBe(1);
|
||||
|
||||
// Simulate successful submission (like in handleSubmit)
|
||||
const submitResult = await submitPublicItem({
|
||||
type: 'feature',
|
||||
title: 'Test Feature',
|
||||
description: 'Test description',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
});
|
||||
|
||||
// After successful submit, fetchData should be called
|
||||
if (submitResult) {
|
||||
await mockFetchData();
|
||||
}
|
||||
|
||||
// Verify fetchData was called again after successful submit
|
||||
expect(fetchDataCallCount).toBe(2);
|
||||
expect(submitPublicItem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not refresh data after failed submit', async () => {
|
||||
const { submitPublicItem } = await import('@/lib/tracker-client');
|
||||
|
||||
// Mock failed submission
|
||||
vi.mocked(submitPublicItem).mockRejectedValue(new Error('Submission failed'));
|
||||
|
||||
let fetchDataCallCount = 0;
|
||||
const mockFetchData = vi.fn(() => {
|
||||
fetchDataCallCount++;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
// Initial load
|
||||
await mockFetchData();
|
||||
expect(fetchDataCallCount).toBe(1);
|
||||
|
||||
// Simulate failed submission
|
||||
try {
|
||||
await submitPublicItem({
|
||||
type: 'feature',
|
||||
title: 'Test Feature',
|
||||
description: 'Test description',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
});
|
||||
} catch (_err) {
|
||||
// Error expected - should not call fetchData
|
||||
expect(fetchDataCallCount).toBe(1);
|
||||
}
|
||||
|
||||
// Verify fetchData was NOT called again after failed submit
|
||||
expect(fetchDataCallCount).toBe(1);
|
||||
});
|
||||
|
||||
it('vote buttons should have A11y attributes', () => {
|
||||
// Test that the component logic includes proper A11y attributes
|
||||
// This is a unit test to verify the expected behavior without rendering
|
||||
|
||||
const mockItem = {
|
||||
id: 'test-id',
|
||||
title: 'Test Feature',
|
||||
voteCount: 5,
|
||||
};
|
||||
|
||||
const hasVoted = true;
|
||||
|
||||
// Verify the expected A11y label format
|
||||
const expectedLabel = hasVoted
|
||||
? `Remove vote from ${mockItem.title}`
|
||||
: `Upvote ${mockItem.title}`;
|
||||
expect(expectedLabel).toBe('Remove vote from Test Feature');
|
||||
|
||||
// Verify the expected aria-pressed value
|
||||
expect(hasVoted).toBe(true);
|
||||
|
||||
// Test with hasVoted = false
|
||||
const hasNotVoted = false;
|
||||
const expectedLabelNotVoted = hasNotVoted
|
||||
? `Remove vote from ${mockItem.title}`
|
||||
: `Upvote ${mockItem.title}`;
|
||||
expect(expectedLabelNotVoted).toBe('Upvote Test Feature');
|
||||
expect(hasNotVoted).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Tests for POST /api/telemetry/ingest (tracker dashboard — proxy to platform-service).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { POST } from '@/app/api/telemetry/ingest/route';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
function mockRequest(body: string): NextRequest {
|
||||
return { text: async () => body } as unknown as NextRequest;
|
||||
}
|
||||
|
||||
describe('POST /api/telemetry/ingest (tracker)', () => {
|
||||
beforeEach(() => mockFetch.mockReset());
|
||||
|
||||
it('forwards the raw beacon body to the telemetry events endpoint', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200 } as Response);
|
||||
|
||||
const payload = JSON.stringify({ events: [{ eventName: 'page_view' }] });
|
||||
const res = await POST(mockRequest(payload));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).ok).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/telemetry/events'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('mirrors a non-ok platform-service status', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 429 } as Response);
|
||||
|
||||
const res = await POST(mockRequest('{}'));
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect((await res.json()).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 502 when the platform-service is unreachable', async () => {
|
||||
mockFetch.mockImplementationOnce(() => Promise.reject(new Error('ECONNREFUSED')));
|
||||
|
||||
const res = await POST(mockRequest('{}'));
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect((await res.json()).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
187
dashboards/tracker-web/src/__tests__/tracker-client.test.ts
Normal file
187
dashboards/tracker-web/src/__tests__/tracker-client.test.ts
Normal file
@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Tests for src/lib/tracker-client — request path/header construction.
|
||||
*
|
||||
* The shared @bytelyst/api-client is mocked so we can assert exactly which
|
||||
* path + options the tracker client forwards, without any real network.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const { fetchSpy } = vi.hoisted(() => ({ fetchSpy: vi.fn() }));
|
||||
|
||||
vi.mock('@bytelyst/api-client', () => ({
|
||||
createApiClient: () => ({ fetch: fetchSpy }),
|
||||
}));
|
||||
|
||||
import {
|
||||
listItems,
|
||||
getItem,
|
||||
createItem,
|
||||
updateItem,
|
||||
updateItemStatus,
|
||||
deleteItem,
|
||||
getStats,
|
||||
listComments,
|
||||
addComment,
|
||||
toggleVote,
|
||||
getRoadmapItems,
|
||||
getRoadmapStats,
|
||||
getPublicItem,
|
||||
submitPublicItem,
|
||||
publicVote,
|
||||
} from '@/lib/tracker-client';
|
||||
|
||||
describe('tracker-client (authenticated API)', () => {
|
||||
beforeEach(() => {
|
||||
fetchSpy.mockReset();
|
||||
fetchSpy.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('builds a query string for listItems', async () => {
|
||||
await listItems({ status: 'open', limit: '10' });
|
||||
const [path] = fetchSpy.mock.calls[0];
|
||||
expect(path).toBe('/items?status=open&limit=10');
|
||||
});
|
||||
|
||||
it('omits the query string when no params are passed', async () => {
|
||||
await listItems();
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('/items');
|
||||
});
|
||||
|
||||
it('requests a single item by id', async () => {
|
||||
await getItem('item_42');
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('/items/item_42');
|
||||
});
|
||||
|
||||
it('POSTs a serialized body when creating an item', async () => {
|
||||
await createItem({ title: 'Bug', type: 'bug' });
|
||||
const [path, options] = fetchSpy.mock.calls[0];
|
||||
expect(path).toBe('/items');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ title: 'Bug', type: 'bug' });
|
||||
});
|
||||
|
||||
it('PATCHes the status sub-resource', async () => {
|
||||
await updateItemStatus('item_1', 'done');
|
||||
const [path, options] = fetchSpy.mock.calls[0];
|
||||
expect(path).toBe('/items/item_1/status');
|
||||
expect(options.method).toBe('PATCH');
|
||||
expect(JSON.parse(options.body)).toEqual({ status: 'done' });
|
||||
});
|
||||
|
||||
it('DELETEs an item by id', async () => {
|
||||
await deleteItem('item_9');
|
||||
const [path, options] = fetchSpy.mock.calls[0];
|
||||
expect(path).toBe('/items/item_9');
|
||||
expect(options.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('PUTs an updated item', async () => {
|
||||
await updateItem('item_1', { title: 'Renamed' });
|
||||
const [path, options] = fetchSpy.mock.calls[0];
|
||||
expect(path).toBe('/items/item_1');
|
||||
expect(options.method).toBe('PUT');
|
||||
expect(JSON.parse(options.body)).toEqual({ title: 'Renamed' });
|
||||
});
|
||||
|
||||
it('requests stats with an optional productId query', async () => {
|
||||
await getStats('chronomind');
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('/items/stats?productId=chronomind');
|
||||
});
|
||||
|
||||
it('requests stats without a query when no productId is given', async () => {
|
||||
await getStats();
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('/items/stats');
|
||||
});
|
||||
|
||||
it('lists comments for an item', async () => {
|
||||
await listComments('item_1');
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('/items/item_1/comments');
|
||||
});
|
||||
|
||||
it('POSTs a new comment body', async () => {
|
||||
await addComment('item_1', 'Looks good');
|
||||
const [path, options] = fetchSpy.mock.calls[0];
|
||||
expect(path).toBe('/items/item_1/comments');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ body: 'Looks good' });
|
||||
});
|
||||
|
||||
it('POSTs a vote toggle', async () => {
|
||||
await toggleVote('item_1');
|
||||
const [path, options] = fetchSpy.mock.calls[0];
|
||||
expect(path).toBe('/items/item_1/vote');
|
||||
expect(options.method).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tracker-client (public roadmap API)', () => {
|
||||
beforeEach(() => {
|
||||
fetchSpy.mockReset();
|
||||
fetchSpy.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('builds the public roadmap path with query params', async () => {
|
||||
await getRoadmapItems({ sortBy: 'voteCount', limit: '100' });
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('/public/roadmap?sortBy=voteCount&limit=100');
|
||||
});
|
||||
|
||||
it('POSTs to the public submit endpoint', async () => {
|
||||
await submitPublicItem({ title: 'Idea', email: 'a@b.com', name: 'A' });
|
||||
const [path, options] = fetchSpy.mock.calls[0];
|
||||
expect(path).toBe('/public/submit');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body).title).toBe('Idea');
|
||||
});
|
||||
|
||||
it('requests public roadmap stats with optional productId', async () => {
|
||||
await getRoadmapStats('lysnrai');
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('/public/roadmap/stats?productId=lysnrai');
|
||||
});
|
||||
|
||||
it('requests a single public item by id', async () => {
|
||||
await getPublicItem('item_7');
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('/public/items/item_7');
|
||||
});
|
||||
|
||||
it('POSTs a public vote with the voter email', async () => {
|
||||
await publicVote('item_7', 'voter@example.com');
|
||||
const [path, options] = fetchSpy.mock.calls[0];
|
||||
expect(path).toBe('/public/items/item_7/vote');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ email: 'voter@example.com' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tracker-client product header injection', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy.mockReset();
|
||||
fetchSpy.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('adds x-product-id from localStorage when running in the browser', async () => {
|
||||
vi.stubGlobal('window', {});
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => (key === 'tracker_selected_product' ? 'chronomind' : null),
|
||||
});
|
||||
|
||||
await listItems();
|
||||
|
||||
const options = fetchSpy.mock.calls[0][1];
|
||||
expect(options.headers['x-product-id']).toBe('chronomind');
|
||||
});
|
||||
|
||||
it('does not add x-product-id when no product is selected', async () => {
|
||||
vi.stubGlobal('window', {});
|
||||
vi.stubGlobal('localStorage', { getItem: () => null });
|
||||
|
||||
await listItems();
|
||||
|
||||
const options = fetchSpy.mock.calls[0][1];
|
||||
expect(options.headers['x-product-id']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
52
dashboards/tracker-web/src/__tests__/utils.test.ts
Normal file
52
dashboards/tracker-web/src/__tests__/utils.test.ts
Normal file
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Tests for src/lib/utils.ts utility functions
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
describe('cn utility function', () => {
|
||||
it('should handle empty input', () => {
|
||||
expect(cn()).toBe('');
|
||||
});
|
||||
|
||||
it('should handle single class string', () => {
|
||||
expect(cn('px-4')).toBe('px-4');
|
||||
});
|
||||
|
||||
it('should handle multiple class strings', () => {
|
||||
expect(cn('px-4', 'py-2', 'bg-blue-500')).toBe('px-4 py-2 bg-blue-500');
|
||||
});
|
||||
|
||||
it('should merge conflicting Tailwind classes (last one wins)', () => {
|
||||
expect(cn('px-4', 'px-2')).toBe('px-2');
|
||||
expect(cn('bg-red-500', 'bg-blue-500')).toBe('bg-blue-500');
|
||||
});
|
||||
|
||||
it('should handle conditional classes with null/undefined', () => {
|
||||
expect(cn('px-4', null, 'py-2', undefined)).toBe('px-4 py-2');
|
||||
});
|
||||
|
||||
it('should handle arrays of classes', () => {
|
||||
expect(cn(['px-4', 'py-2'])).toBe('px-4 py-2');
|
||||
});
|
||||
|
||||
it('should handle objects with boolean values', () => {
|
||||
expect(cn({ 'px-4': true, 'py-2': false, 'bg-blue-500': true })).toBe('px-4 bg-blue-500');
|
||||
});
|
||||
|
||||
it('should handle complex mixed inputs', () => {
|
||||
expect(
|
||||
cn('base-class', ['array-class'], { 'conditional-class': true, 'removed-class': false }, null)
|
||||
).toBe('base-class array-class conditional-class');
|
||||
});
|
||||
|
||||
it('should handle empty arrays and objects', () => {
|
||||
expect(cn([])).toBe('');
|
||||
expect(cn({})).toBe('');
|
||||
});
|
||||
|
||||
it('should handle conflicting classes in complex scenarios', () => {
|
||||
expect(cn('text-sm', ['text-lg'], { 'text-xl': false })).toBe('text-lg');
|
||||
});
|
||||
});
|
||||
58
dashboards/tracker-web/src/app/api/fleet/[...path]/route.ts
Normal file
58
dashboards/tracker-web/src/app/api/fleet/[...path]/route.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Catch-all proxy to platform-service fleet endpoints.
|
||||
* Forwards all /api/fleet/* requests to the fleet backend.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const PLATFORM_API = process.env.PLATFORM_API_URL || 'http://localhost:4003';
|
||||
|
||||
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params;
|
||||
const targetPath = `/fleet/${path.join('/')}`;
|
||||
const url = new URL(targetPath, PLATFORM_API);
|
||||
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const auth = req.headers.get('authorization');
|
||||
if (auth) headers['Authorization'] = auth;
|
||||
|
||||
const tokenHeader = req.headers.get('x-tracker-token');
|
||||
if (tokenHeader && !auth) {
|
||||
headers['Authorization'] = `Bearer ${tokenHeader}`;
|
||||
}
|
||||
|
||||
const productId = req.headers.get('x-product-id');
|
||||
if (productId) headers['x-product-id'] = productId;
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: req.method,
|
||||
headers,
|
||||
};
|
||||
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
const body = await req.text();
|
||||
if (body) fetchOptions.body = body;
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), fetchOptions);
|
||||
const data = await res.text();
|
||||
|
||||
return new NextResponse(data, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Fleet service unavailable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = proxy;
|
||||
export const POST = proxy;
|
||||
export const PUT = proxy;
|
||||
export const PATCH = proxy;
|
||||
export const DELETE = proxy;
|
||||
@ -7,6 +7,7 @@ interface Check {
|
||||
}
|
||||
|
||||
const REQUIRED_ENV = ['PLATFORM_API_URL', 'JWT_SECRET', 'DEFAULT_PRODUCT_ID'];
|
||||
const PLATFORM_HEALTH_TIMEOUT_MS = 2_000;
|
||||
|
||||
function checkEnvVars(): Check {
|
||||
const missing = REQUIRED_ENV.filter(key => !process.env[key]);
|
||||
@ -16,10 +17,42 @@ function checkEnvVars(): Check {
|
||||
return { name: 'env', status: 'pass', message: `${REQUIRED_ENV.length} required vars set` };
|
||||
}
|
||||
|
||||
async function checkPlatformService(): Promise<Check> {
|
||||
const baseUrl = process.env.PLATFORM_API_URL;
|
||||
if (!baseUrl) {
|
||||
return { name: 'platform-service', status: 'fail', message: 'PLATFORM_API_URL missing' };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PLATFORM_HEALTH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(new URL('/health', baseUrl).toString(), {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { name: 'platform-service', status: 'fail', message: `HTTP ${res.status}` };
|
||||
}
|
||||
return { name: 'platform-service', status: 'pass', message: 'healthy' };
|
||||
} catch (err) {
|
||||
return {
|
||||
name: 'platform-service',
|
||||
status: 'fail',
|
||||
message: err instanceof Error ? err.message : 'health check failed',
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const checks: Check[] = [];
|
||||
|
||||
checks.push(checkEnvVars());
|
||||
const envCheck = checkEnvVars();
|
||||
checks.push(envCheck);
|
||||
if (envCheck.status === 'pass') {
|
||||
checks.push(await checkPlatformService());
|
||||
}
|
||||
|
||||
const overall = checks.every(c => c.status === 'pass') ? 'ok' : 'degraded';
|
||||
|
||||
|
||||
@ -2,6 +2,19 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { PageHeader } from '@bytelyst/dashboard-components';
|
||||
import {
|
||||
Button,
|
||||
Badge,
|
||||
StatusBadge,
|
||||
StatusDot,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
toast,
|
||||
type StatusTone,
|
||||
} from '@/components/ui/Primitives';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { listItems, updateItemStatus, type TrackerItem } from '@/lib/tracker-client';
|
||||
|
||||
@ -12,30 +25,33 @@ const COLUMNS: { key: string; label: string; color: string }[] = [
|
||||
{ key: 'closed', label: 'Closed', color: 'border-t-gray-500' },
|
||||
];
|
||||
|
||||
const TYPE_DOT: Record<string, string> = {
|
||||
bug: 'bg-red-500',
|
||||
feature: 'bg-blue-500',
|
||||
task: 'bg-amber-500',
|
||||
const TYPE_TONE: Record<string, StatusTone> = {
|
||||
bug: 'danger',
|
||||
feature: 'info',
|
||||
task: 'warning',
|
||||
};
|
||||
|
||||
const PRIORITY_LABEL: Record<string, string> = {
|
||||
critical: 'text-red-600 dark:text-red-400',
|
||||
high: 'text-orange-600 dark:text-orange-400',
|
||||
medium: 'text-yellow-600 dark:text-yellow-400',
|
||||
low: 'text-green-600 dark:text-green-400',
|
||||
const PRIORITY_TONE: Record<string, StatusTone> = {
|
||||
critical: 'danger',
|
||||
high: 'warning',
|
||||
medium: 'neutral',
|
||||
low: 'success',
|
||||
};
|
||||
|
||||
export default function BoardPage() {
|
||||
const { token } = useAuth();
|
||||
const [items, setItems] = useState<TrackerItem[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
try {
|
||||
const res = await listItems({ limit: '200' });
|
||||
setItems(res.items);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Failed to load board',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
@ -50,92 +66,97 @@ export default function BoardPage() {
|
||||
prev.map(i => (i.id === itemId ? { ...i, status: newStatus as TrackerItem['status'] } : i))
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update status');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Failed to update status',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Board</h1>
|
||||
<p className="text-sm text-muted-foreground">Kanban view of all items</p>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Board"
|
||||
breadcrumbs={[{ label: 'Dashboard', href: '/dashboard' }, { label: 'Board' }]}
|
||||
/>
|
||||
<p className="-mt-4 text-sm text-muted-foreground">Kanban view of all items</p>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{COLUMNS.map(col => {
|
||||
const colItems = items.filter(i => i.status === col.key);
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`rounded-xl border border-border border-t-4 ${col.color} bg-card p-3`}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">{col.label}</h3>
|
||||
<Badge variant="neutral">{colItems.length}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{COLUMNS.map(col => {
|
||||
const colItems = items.filter(i => i.status === col.key);
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`rounded-xl border border-border border-t-4 ${col.color} bg-card p-3`}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">{col.label}</h3>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{colItems.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{colItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border border-border bg-background p-3 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${TYPE_DOT[item.type] || 'bg-gray-400'}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{item.type}</span>
|
||||
<span
|
||||
className={`ml-auto text-xs font-medium ${PRIORITY_LABEL[item.priority] || ''}`}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/dashboard/items/${item.id}`}
|
||||
className="text-sm font-medium leading-tight hover:text-primary hover:underline"
|
||||
<div className="space-y-2">
|
||||
{colItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border border-border bg-background p-3 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{item.voteCount > 0 && <span>{item.voteCount} votes</span>}
|
||||
{item.commentCount > 0 && <span>{item.commentCount} comments</span>}
|
||||
</div>
|
||||
|
||||
{/* Quick status move */}
|
||||
<div className="mt-2 flex gap-1">
|
||||
{COLUMNS.filter(c => c.key !== item.status).map(c => (
|
||||
<button
|
||||
key={c.key}
|
||||
onClick={() => handleStatusChange(item.id, c.key)}
|
||||
className="rounded px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
title={`Move to ${c.label}`}
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<StatusDot tone={TYPE_TONE[item.type] ?? 'neutral'} />
|
||||
<span className="text-xs text-muted-foreground">{item.type}</span>
|
||||
<StatusBadge
|
||||
tone={PRIORITY_TONE[item.priority] ?? 'neutral'}
|
||||
className="ml-auto"
|
||||
>
|
||||
→ {c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{item.priority}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
{colItems.length === 0 && (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">No items</div>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={`/dashboard/items/${item.id}`}
|
||||
className="block truncate text-sm font-medium leading-tight hover:text-primary hover:underline"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{item.title}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{item.voteCount > 0 && <span>{item.voteCount} votes</span>}
|
||||
{item.commentCount > 0 && <span>{item.commentCount} comments</span>}
|
||||
</div>
|
||||
|
||||
{/* Quick status move */}
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{COLUMNS.filter(c => c.key !== item.status).map(c => (
|
||||
<Button
|
||||
key={c.key}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange(item.id, c.key)}
|
||||
className="h-auto px-1.5 py-0.5 text-[10px]"
|
||||
title={`Move to ${c.label}`}
|
||||
>
|
||||
→ {c.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{colItems.length === 0 && (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">No items</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
157
dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx
Normal file
157
dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { PageHeader } from '@bytelyst/dashboard-components';
|
||||
import { Button } from '@/components/ui/Primitives';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { getBudget, pauseBudget, resumeBudget, type FleetBudget } from '@/lib/fleet-client';
|
||||
|
||||
export default function FleetBudgetPage() {
|
||||
const { token } = useAuth();
|
||||
const [budget, setBudget] = useState<FleetBudget | null | undefined>(undefined);
|
||||
const [acting, setActing] = useState(false);
|
||||
|
||||
const productId =
|
||||
typeof window !== 'undefined' ? (localStorage.getItem('tracker_selected_product') ?? '') : '';
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!productId) {
|
||||
setBudget(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const b = await getBudget(productId);
|
||||
setBudget(b);
|
||||
} catch {
|
||||
setBudget(null);
|
||||
}
|
||||
}, [productId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
refresh();
|
||||
}, [token, refresh]);
|
||||
|
||||
const handlePause = async () => {
|
||||
if (!productId) return;
|
||||
setActing(true);
|
||||
try {
|
||||
const updated = await pauseBudget(productId);
|
||||
setBudget(updated);
|
||||
} catch {
|
||||
/* degrade */
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = async () => {
|
||||
if (!productId) return;
|
||||
setActing(true);
|
||||
try {
|
||||
const updated = await resumeBudget(productId);
|
||||
setBudget(updated);
|
||||
} catch {
|
||||
/* degrade */
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (budget === undefined) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader title="Fleet Budget" />
|
||||
<p className="text-muted-foreground mt-4">Loading budget...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader title="Fleet Budget" />
|
||||
|
||||
{!productId && (
|
||||
<p className="text-muted-foreground">
|
||||
Select a product from the sidebar to view its budget.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{productId && budget === null && (
|
||||
<div className="rounded-lg border p-6 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No budget configured for <strong>{productId}</strong>.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Use the API (PUT /fleet/budgets/{productId}) to set a ceiling.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{budget && (
|
||||
<div className="rounded-lg border p-6 space-y-4 max-w-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold">{budget.productId}</h2>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
budget.status === 'active'
|
||||
? 'bg-green-500/20 text-green-700 dark:text-green-400'
|
||||
: 'bg-red-500/20 text-red-700 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
{budget.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Spend bar */}
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>Spent</span>
|
||||
<span>
|
||||
${budget.spentUsd.toFixed(2)} / ${budget.ceilingUsd.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2.5" aria-label="Budget usage bar">
|
||||
<div
|
||||
className={`h-2.5 rounded-full ${
|
||||
budget.spentUsd >= budget.ceilingUsd ? 'bg-red-500' : 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, (budget.spentUsd / budget.ceilingUsd) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>Window: {budget.window}</p>
|
||||
<p>Last updated: {new Date(budget.updatedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex gap-3">
|
||||
{budget.status === 'active' ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handlePause}
|
||||
disabled={acting}
|
||||
aria-label="Pause budget"
|
||||
>
|
||||
{acting ? 'Pausing...' : 'Pause'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleResume}
|
||||
disabled={acting}
|
||||
aria-label="Resume budget"
|
||||
>
|
||||
{acting ? 'Resuming...' : 'Resume'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,230 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { PageHeader } from '@bytelyst/dashboard-components';
|
||||
import { Button } from '@/components/ui/Primitives';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import {
|
||||
getJob,
|
||||
getJobRuns,
|
||||
getJobEvents,
|
||||
getJobArtifacts,
|
||||
getJobDag,
|
||||
patchJob,
|
||||
type FleetJob,
|
||||
type FleetRun,
|
||||
type FleetEvent,
|
||||
type FleetArtifact,
|
||||
type DagNode,
|
||||
} from '@/lib/fleet-client';
|
||||
|
||||
export default function FleetJobDetailPage() {
|
||||
const { token } = useAuth();
|
||||
const params = useParams();
|
||||
const jobId = params.id as string;
|
||||
|
||||
const [job, setJob] = useState<FleetJob | null>(null);
|
||||
const [runs, setRuns] = useState<FleetRun[]>([]);
|
||||
const [events, setEvents] = useState<FleetEvent[]>([]);
|
||||
const [artifacts, setArtifacts] = useState<FleetArtifact[]>([]);
|
||||
const [dag, setDag] = useState<DagNode | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [shipping, setShipping] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [j, r, e, a, d] = await Promise.all([
|
||||
getJob(jobId),
|
||||
getJobRuns(jobId),
|
||||
getJobEvents(jobId),
|
||||
getJobArtifacts(jobId),
|
||||
getJobDag(jobId),
|
||||
]);
|
||||
setJob(j);
|
||||
setRuns(r.runs);
|
||||
setEvents(e.events);
|
||||
setArtifacts(a.artifacts);
|
||||
setDag(d?.dag ?? null);
|
||||
} catch {
|
||||
/* degrade */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !jobId) return;
|
||||
refresh();
|
||||
}, [token, jobId, refresh]);
|
||||
|
||||
const handleShip = async () => {
|
||||
if (!job) return;
|
||||
setShipping(true);
|
||||
try {
|
||||
const updated = await patchJob(jobId, { leaseEpoch: job.leaseEpoch, stage: 'shipped' });
|
||||
setJob(updated);
|
||||
} catch {
|
||||
/* show error in production */
|
||||
} finally {
|
||||
setShipping(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader title="Job Detail" />
|
||||
<p className="text-muted-foreground mt-4">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader title="Job Not Found" />
|
||||
<p className="text-muted-foreground mt-4">The requested job does not exist.</p>
|
||||
<Link href="/dashboard/fleet/jobs" className="text-sm underline mt-2 inline-block">
|
||||
← Back to jobs
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<PageHeader title={job.idempotencyKey} />
|
||||
{job.stage !== 'shipped' && job.stage !== 'failed' && (
|
||||
<Button onClick={handleShip} disabled={shipping} aria-label="Ship this job">
|
||||
{shipping ? 'Shipping...' : 'Ship ✓'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Job metadata */}
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetaCard label="Stage" value={job.stage} />
|
||||
<MetaCard label="Priority" value={job.priority} />
|
||||
<MetaCard label="Kind" value={job.kind} />
|
||||
<MetaCard label="Attempts" value={String(job.attempts)} />
|
||||
</section>
|
||||
|
||||
{/* DAG subtree (if present) */}
|
||||
{dag && dag.children.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-2">DAG Subtree</h2>
|
||||
<DagTree node={dag} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Event timeline */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-2">Event Timeline</h2>
|
||||
{events.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No events recorded.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{events.map(e => (
|
||||
<li
|
||||
key={e.id}
|
||||
className="flex items-start gap-3 text-sm border-l-2 border-muted pl-3 py-1"
|
||||
>
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{new Date(e.at).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="font-medium">{e.type}</span>
|
||||
{e.actor && <span className="text-muted-foreground">by {e.actor}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Runs */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-2">Runs</h2>
|
||||
{runs.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No runs yet.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm" aria-label="Job runs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-4">Attempt</th>
|
||||
<th className="pb-2 pr-4">Engine</th>
|
||||
<th className="pb-2 pr-4">Factory</th>
|
||||
<th className="pb-2 pr-4">Result</th>
|
||||
<th className="pb-2">Started</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map(r => (
|
||||
<tr key={r.id} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4">#{r.attempt}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{r.engine}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{r.factoryId ?? '—'}</td>
|
||||
<td className="py-2 pr-4">{r.result ?? 'running'}</td>
|
||||
<td className="py-2 text-xs text-muted-foreground">
|
||||
{new Date(r.startedAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Artifacts */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-2">Artifacts</h2>
|
||||
{artifacts.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No artifacts.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{artifacts.map(a => (
|
||||
<li key={a.id} className="text-sm flex items-center gap-2">
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-xs">{a.kind}</span>
|
||||
<span className="font-mono text-xs">{a.contentType}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
({(a.sizeBytes / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Link href="/dashboard/fleet/jobs" className="text-sm underline inline-block">
|
||||
← Back to jobs
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-sm font-medium capitalize">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DagTree({ node, depth = 0 }: { node: DagNode; depth?: number }) {
|
||||
return (
|
||||
<div className={`${depth > 0 ? 'ml-4 border-l pl-3' : ''}`}>
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<span className="font-mono text-xs">{node.idempotencyKey}</span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-xs">{node.stage}</span>
|
||||
{node.kind === 'composite' && (
|
||||
<span className="text-xs text-muted-foreground">(composite)</span>
|
||||
)}
|
||||
</div>
|
||||
{node.children.map(child => (
|
||||
<DagTree key={child.id} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
dashboards/tracker-web/src/app/dashboard/fleet/jobs/page.tsx
Normal file
122
dashboards/tracker-web/src/app/dashboard/fleet/jobs/page.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { PageHeader } from '@bytelyst/dashboard-components';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { listJobs, type FleetJob } from '@/lib/fleet-client';
|
||||
|
||||
const STAGES = [
|
||||
'',
|
||||
'queued',
|
||||
'blocked',
|
||||
'assigned',
|
||||
'building',
|
||||
'review',
|
||||
'testing',
|
||||
'shipped',
|
||||
'failed',
|
||||
'dead_letter',
|
||||
];
|
||||
const POLL_INTERVAL = 30_000;
|
||||
|
||||
export default function FleetJobsPage() {
|
||||
const { token } = useAuth();
|
||||
const [jobs, setJobs] = useState<FleetJob[]>([]);
|
||||
const [stage, setStage] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const params: Record<string, string> = { limit: '50' };
|
||||
if (stage) params.stage = stage;
|
||||
const res = await listJobs(params as never);
|
||||
setJobs(res.jobs);
|
||||
} catch {
|
||||
/* degrade */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
refresh();
|
||||
const id = setInterval(refresh, POLL_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
}, [token, refresh]);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader title="Fleet Jobs" />
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 items-center">
|
||||
<label htmlFor="stage-filter" className="text-sm font-medium">
|
||||
Stage:
|
||||
</label>
|
||||
<select
|
||||
id="stage-filter"
|
||||
value={stage}
|
||||
onChange={e => setStage(e.target.value)}
|
||||
className="rounded border px-2 py-1 text-sm bg-background"
|
||||
aria-label="Filter by stage"
|
||||
>
|
||||
{STAGES.map(s => (
|
||||
<option key={s} value={s}>
|
||||
{s || 'All'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">Loading jobs...</p>
|
||||
) : jobs.length === 0 ? (
|
||||
<p className="text-muted-foreground">No jobs match the current filter.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" aria-label="Fleet jobs table">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-4">Idempotency Key</th>
|
||||
<th className="pb-2 pr-4">Stage</th>
|
||||
<th className="pb-2 pr-4">Priority</th>
|
||||
<th className="pb-2 pr-4">Kind</th>
|
||||
<th className="pb-2 pr-4">Attempts</th>
|
||||
<th className="pb-2">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map(j => (
|
||||
<tr key={j.id} className="border-b last:border-0 hover:bg-muted/50 cursor-pointer">
|
||||
<td className="py-2 pr-4">
|
||||
<Link
|
||||
href={`/dashboard/fleet/jobs/${j.id}`}
|
||||
className="hover:underline font-mono text-xs"
|
||||
aria-label={`View job ${j.idempotencyKey}`}
|
||||
>
|
||||
{j.idempotencyKey}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-muted">
|
||||
{j.stage}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 capitalize">{j.priority}</td>
|
||||
<td className="py-2 pr-4">{j.kind}</td>
|
||||
<td className="py-2 pr-4">{j.attempts}</td>
|
||||
<td className="py-2 text-xs text-muted-foreground">
|
||||
{new Date(j.createdAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
dashboards/tracker-web/src/app/dashboard/fleet/page.tsx
Normal file
175
dashboards/tracker-web/src/app/dashboard/fleet/page.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { PageHeader } from '@bytelyst/dashboard-components';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { listFactories, listJobs, type FleetFactory, type FleetJob } from '@/lib/fleet-client';
|
||||
|
||||
const POLL_INTERVAL = 30_000;
|
||||
|
||||
function HealthBadge({ health }: { health: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
ok: 'bg-green-500/20 text-green-700 dark:text-green-400',
|
||||
degraded: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-400',
|
||||
down: 'bg-red-500/20 text-red-700 dark:text-red-400',
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colors[health] ?? colors.ok}`}
|
||||
>
|
||||
{health}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StageBadge({ stage }: { stage: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
queued: 'bg-blue-500/20 text-blue-700 dark:text-blue-400',
|
||||
assigned: 'bg-purple-500/20 text-purple-700 dark:text-purple-400',
|
||||
building: 'bg-orange-500/20 text-orange-700 dark:text-orange-400',
|
||||
shipped: 'bg-green-500/20 text-green-700 dark:text-green-400',
|
||||
failed: 'bg-red-500/20 text-red-700 dark:text-red-400',
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colors[stage] ?? 'bg-muted text-muted-foreground'}`}
|
||||
>
|
||||
{stage}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FleetOverviewPage() {
|
||||
const { token } = useAuth();
|
||||
const [factories, setFactories] = useState<FleetFactory[]>([]);
|
||||
const [jobs, setJobs] = useState<FleetJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [facRes, jobRes] = await Promise.all([listFactories(), listJobs({ limit: 10 })]);
|
||||
setFactories(facRes.factories);
|
||||
setJobs(jobRes.jobs);
|
||||
} catch {
|
||||
/* degrade gracefully */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
refresh();
|
||||
const id = setInterval(refresh, POLL_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
}, [token, refresh]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader title="Fleet" />
|
||||
<p className="text-muted-foreground mt-4">Loading fleet data...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8">
|
||||
<PageHeader title="Fleet Control Plane" />
|
||||
|
||||
{/* Factory cards */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-3">Factories</h2>
|
||||
{factories.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No factories registered. Factories appear after their first heartbeat.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{factories.map(f => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="rounded-lg border p-4 space-y-2"
|
||||
aria-label={`Factory ${f.factoryId}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm truncate">{f.factoryId}</span>
|
||||
<HealthBadge health={f.health} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>Capabilities: {f.capabilities.length > 0 ? f.capabilities.join(', ') : '—'}</p>
|
||||
<p>
|
||||
Load: {f.load} / {f.seatLimit} seats
|
||||
</p>
|
||||
<p>Last heartbeat: {new Date(f.lastHeartbeatAt).toLocaleTimeString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recent jobs summary */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Recent Jobs</h2>
|
||||
<Link
|
||||
href="/dashboard/fleet/jobs"
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
aria-label="View all jobs"
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
{jobs.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No jobs found.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" aria-label="Recent fleet jobs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-4">Key</th>
|
||||
<th className="pb-2 pr-4">Stage</th>
|
||||
<th className="pb-2 pr-4">Priority</th>
|
||||
<th className="pb-2">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map(j => (
|
||||
<tr key={j.id} className="border-b last:border-0 hover:bg-muted/50">
|
||||
<td className="py-2 pr-4">
|
||||
<Link
|
||||
href={`/dashboard/fleet/jobs/${j.id}`}
|
||||
className="hover:underline font-mono text-xs"
|
||||
>
|
||||
{j.idempotencyKey}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<StageBadge stage={j.stage} />
|
||||
</td>
|
||||
<td className="py-2 pr-4 capitalize">{j.priority}</td>
|
||||
<td className="py-2 text-muted-foreground text-xs">
|
||||
{new Date(j.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Quick links */}
|
||||
<nav className="flex gap-4" aria-label="Fleet navigation">
|
||||
<Link href="/dashboard/fleet/jobs" className="text-sm underline" aria-label="All jobs page">
|
||||
All Jobs
|
||||
</Link>
|
||||
<Link href="/dashboard/fleet/budget" className="text-sm underline" aria-label="Budget page">
|
||||
Budgets
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,11 +2,22 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { PageHeader, LoadingSpinner } from '@bytelyst/dashboard-components';
|
||||
import {
|
||||
ActionMenu,
|
||||
Timeline,
|
||||
Button,
|
||||
Input,
|
||||
Textarea,
|
||||
Select,
|
||||
toast,
|
||||
} from '@/components/ui/Primitives';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import {
|
||||
getItem,
|
||||
updateItem,
|
||||
updateItemStatus,
|
||||
deleteItem,
|
||||
listComments,
|
||||
addComment,
|
||||
toggleVote,
|
||||
@ -47,7 +58,11 @@ export default function ItemDetailPage() {
|
||||
const updated = await updateItemStatus(id, status);
|
||||
setItem(updated);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update status');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Failed to update status',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -57,7 +72,11 @@ export default function ItemDetailPage() {
|
||||
const updated = await updateItem(id, { priority } as Partial<TrackerItem>);
|
||||
setItem(updated);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update priority');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Failed to update priority',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -67,7 +86,11 @@ export default function ItemDetailPage() {
|
||||
const updated = await updateItem(id, { visibility } as Partial<TrackerItem>);
|
||||
setItem(updated);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update visibility');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Failed to update visibility',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -80,8 +103,13 @@ export default function ItemDetailPage() {
|
||||
} as Partial<TrackerItem>);
|
||||
setItem(updated);
|
||||
setEditing(false);
|
||||
toast({ type: 'success', title: 'Item updated' });
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Failed to update',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -93,7 +121,11 @@ export default function ItemDetailPage() {
|
||||
setComments(prev => [...prev, comment]);
|
||||
setNewComment('');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to add comment');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Failed to add comment',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -103,86 +135,96 @@ export default function ItemDetailPage() {
|
||||
const res = await toggleVote(id);
|
||||
setItem(prev => (prev ? { ...prev, voteCount: res.voteCount } : prev));
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to vote');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Failed to vote',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
if (!confirm('Delete this item? This cannot be undone.')) return;
|
||||
try {
|
||||
await deleteItem(id);
|
||||
toast({ type: 'success', title: 'Item deleted' });
|
||||
router.push('/dashboard/items');
|
||||
} catch (err: unknown) {
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Failed to delete',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
if (!item) return;
|
||||
setEditTitle(item.title);
|
||||
setEditDescription(item.description);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
if (!item) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] items-center justify-center text-muted-foreground">
|
||||
{error || 'Loading...'}
|
||||
<div className="mx-auto flex min-h-[400px] max-w-3xl items-center justify-center">
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : <LoadingSpinner />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{/* Back */}
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<PageHeader
|
||||
title={item.title}
|
||||
breadcrumbs={[
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'Items', href: '/dashboard/items' },
|
||||
]}
|
||||
actions={
|
||||
editing ? undefined : (
|
||||
<ActionMenu
|
||||
label="Item actions"
|
||||
items={[
|
||||
{ id: 'edit', label: 'Edit', onSelect: startEdit },
|
||||
{ id: 'delete', label: 'Delete', destructive: true, onSelect: handleDelete },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
{/* Title/description editor */}
|
||||
<div className="space-y-2">
|
||||
{editing ? (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
aria-label="Title"
|
||||
value={editTitle}
|
||||
onChange={e => setEditTitle(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-lg font-bold outline-none ring-ring focus:ring-2"
|
||||
className="text-lg font-bold"
|
||||
/>
|
||||
<textarea
|
||||
<Textarea
|
||||
aria-label="Description"
|
||||
value={editDescription}
|
||||
onChange={e => setEditDescription(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Button size="sm" onClick={handleSaveEdit}>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditing(false)}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{item.title}</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditTitle(item.title);
|
||||
setEditDescription(item.description);
|
||||
setEditing(true);
|
||||
}}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
item.description && (
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{item.description}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -194,54 +236,45 @@ export default function ItemDetailPage() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">Status</div>
|
||||
<select
|
||||
<Select
|
||||
aria-label="Status"
|
||||
controlSize="sm"
|
||||
className="mt-1"
|
||||
value={item.status}
|
||||
onChange={e => handleStatusChange(e.target.value)}
|
||||
className="mt-1 rounded border border-input bg-background px-2 py-1 text-sm"
|
||||
>
|
||||
{STATUSES.map(s => (
|
||||
<option key={s} value={s}>
|
||||
{s.replace(/_/g, ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={STATUSES.map(s => ({ value: s, label: s.replace(/_/g, ' ') }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">Priority</div>
|
||||
<select
|
||||
<Select
|
||||
aria-label="Priority"
|
||||
controlSize="sm"
|
||||
className="mt-1"
|
||||
value={item.priority}
|
||||
onChange={e => handlePriorityChange(e.target.value)}
|
||||
className="mt-1 rounded border border-input bg-background px-2 py-1 text-sm"
|
||||
>
|
||||
{PRIORITIES.map(p => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={PRIORITIES.map(p => ({ value: p, label: p }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">Visibility</div>
|
||||
<select
|
||||
<Select
|
||||
aria-label="Visibility"
|
||||
controlSize="sm"
|
||||
className="mt-1"
|
||||
value={item.visibility || 'internal'}
|
||||
onChange={e => handleVisibilityChange(e.target.value)}
|
||||
className="mt-1 rounded border border-input bg-background px-2 py-1 text-sm"
|
||||
>
|
||||
{VISIBILITIES.map(v => (
|
||||
<option key={v} value={v}>
|
||||
{v === 'public' ? '🌐 Public' : '🔒 Internal'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={VISIBILITIES.map(v => ({
|
||||
value: v,
|
||||
label: v === 'public' ? '🌐 Public' : '🔒 Internal',
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">Votes</div>
|
||||
<button
|
||||
onClick={handleVote}
|
||||
className="mt-1 flex items-center gap-1 rounded-md border border-input bg-background px-2 py-1 text-sm hover:bg-accent"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="mt-1" onClick={handleVote}>
|
||||
▲ {item.voteCount}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -265,31 +298,27 @@ export default function ItemDetailPage() {
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Comments ({comments.length})</h2>
|
||||
|
||||
{comments.map(c => (
|
||||
<div key={c.id} className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{c.authorEmail || c.authorId}</span>
|
||||
<span>{new Date(c.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm">{c.body}</p>
|
||||
</div>
|
||||
))}
|
||||
<Timeline
|
||||
emptyLabel="No comments yet."
|
||||
items={comments.map(c => ({
|
||||
id: c.id,
|
||||
title: c.authorEmail || c.authorId,
|
||||
meta: new Date(c.createdAt).toLocaleString(),
|
||||
description: <span className="whitespace-pre-wrap">{c.body}</span>,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleAddComment} className="space-y-2">
|
||||
<textarea
|
||||
<Textarea
|
||||
aria-label="Add a comment"
|
||||
value={newComment}
|
||||
onChange={e => setNewComment(e.target.value)}
|
||||
placeholder="Add a comment..."
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newComment.trim()}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
<Button type="submit" disabled={!newComment.trim()}>
|
||||
Comment
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,29 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { DataTable, type ColumnDef } from '@bytelyst/data-table';
|
||||
import { PageHeader, LoadingSpinner } from '@bytelyst/dashboard-components';
|
||||
import { Reveal } from '@bytelyst/motion';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Textarea,
|
||||
Field,
|
||||
FieldLabel,
|
||||
Modal,
|
||||
ConfirmDialog,
|
||||
StatusBadge,
|
||||
toast,
|
||||
type StatusTone,
|
||||
} from '@/components/ui/Primitives';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { listItems, createItem, deleteItem, type TrackerItem } from '@/lib/tracker-client';
|
||||
|
||||
const TYPE_BADGE: Record<string, string> = {
|
||||
bug: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
|
||||
feature: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
task: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
const TYPE_TONE: Record<string, StatusTone> = {
|
||||
bug: 'danger',
|
||||
feature: 'info',
|
||||
task: 'warning',
|
||||
};
|
||||
|
||||
const PRIORITY_BADGE: Record<string, string> = {
|
||||
critical: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
|
||||
high: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
medium: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
low: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
|
||||
const PRIORITY_TONE: Record<string, StatusTone> = {
|
||||
critical: 'danger',
|
||||
high: 'warning',
|
||||
medium: 'neutral',
|
||||
low: 'success',
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
open: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
|
||||
in_progress: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
done: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
closed: 'bg-gray-100 text-gray-800 dark:bg-gray-800/30 dark:text-gray-300',
|
||||
wont_fix: 'bg-gray-100 text-gray-600 dark:bg-gray-800/30 dark:text-gray-400',
|
||||
const STATUS_TONE: Record<string, StatusTone> = {
|
||||
open: 'success',
|
||||
in_progress: 'warning',
|
||||
done: 'success',
|
||||
closed: 'neutral',
|
||||
wont_fix: 'neutral',
|
||||
};
|
||||
|
||||
export default function ItemsListPage() {
|
||||
@ -31,7 +47,6 @@ export default function ItemsListPage() {
|
||||
const [items, setItems] = useState<TrackerItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Filters
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
@ -46,9 +61,11 @@ export default function ItemsListPage() {
|
||||
const [newPriority, setNewPriority] = useState<'critical' | 'high' | 'medium' | 'low'>('medium');
|
||||
const [newDescription, setNewDescription] = useState('');
|
||||
|
||||
// Delete confirmation
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (typeFilter) params.type = typeFilter;
|
||||
@ -59,7 +76,11 @@ export default function ItemsListPage() {
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load items');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Failed to load items',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -69,6 +90,19 @@ export default function ItemsListPage() {
|
||||
if (token) fetchItems();
|
||||
}, [token, fetchItems]);
|
||||
|
||||
// The ⌘K "New item" command navigates here with ?new=1 — open the create
|
||||
// modal and strip the param so a refresh doesn't reopen it (UX-5).
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('new') === '1') {
|
||||
setShowCreate(true);
|
||||
params.delete('new');
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@ -81,245 +115,261 @@ export default function ItemsListPage() {
|
||||
setShowCreate(false);
|
||||
setNewTitle('');
|
||||
setNewDescription('');
|
||||
toast({ type: 'success', title: 'Item created' });
|
||||
fetchItems();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create item');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Failed to create item',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Delete this item?')) return;
|
||||
const handleDelete = useCallback((id: string) => setDeleteId(id), []);
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
if (!deleteId) return;
|
||||
try {
|
||||
await deleteItem(id);
|
||||
await deleteItem(deleteId);
|
||||
setDeleteId(null);
|
||||
toast({ type: 'success', title: 'Item deleted' });
|
||||
fetchItems();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||
setDeleteId(null);
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Failed to delete',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [deleteId, fetchItems]);
|
||||
|
||||
const columns = useMemo<ColumnDef<TrackerItem, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'title',
|
||||
accessorKey: 'title',
|
||||
header: 'Title',
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
href={`/dashboard/items/${item.id}`}
|
||||
className="font-medium text-foreground hover:text-primary hover:underline"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
{item.labels.length > 0 && (
|
||||
<div className="mt-1 flex gap-1">
|
||||
{item.labels.map(l => (
|
||||
<span
|
||||
key={l}
|
||||
className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
accessorKey: 'type',
|
||||
header: 'Type',
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge tone={TYPE_TONE[row.original.type] ?? 'neutral'}>
|
||||
{row.original.type}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge tone={STATUS_TONE[row.original.status] ?? 'neutral'} dot>
|
||||
{row.original.status.replace(/_/g, ' ')}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
accessorKey: 'priority',
|
||||
header: 'Priority',
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge tone={PRIORITY_TONE[row.original.priority] ?? 'neutral'}>
|
||||
{row.original.priority}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{ id: 'voteCount', accessorKey: 'voteCount', header: 'Votes' },
|
||||
{ id: 'commentCount', accessorKey: 'commentCount', header: 'Comments' },
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(row.original.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[handleDelete]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Items</h1>
|
||||
<p className="text-sm text-muted-foreground">{total} items total</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
+ New Item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<PageHeader
|
||||
title="Items"
|
||||
breadcrumbs={[{ label: 'Dashboard', href: '/dashboard' }, { label: 'Items' }]}
|
||||
actions={<Button onClick={() => setShowCreate(true)}>+ New Item</Button>}
|
||||
/>
|
||||
<p className="-mt-4 text-sm text-muted-foreground">{total} items total</p>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
aria-label="Search items"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm outline-none ring-ring focus:ring-2"
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
aria-label="Filter by type"
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">All types</option>
|
||||
<option value="bug">Bug</option>
|
||||
<option value="feature">Feature</option>
|
||||
<option value="task">Task</option>
|
||||
</select>
|
||||
<select
|
||||
options={[
|
||||
{ value: '', label: 'All types' },
|
||||
{ value: 'bug', label: 'Bug' },
|
||||
{ value: 'feature', label: 'Feature' },
|
||||
{ value: 'task', label: 'Task' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
aria-label="Filter by status"
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="done">Done</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="wont_fix">Won't Fix</option>
|
||||
</select>
|
||||
<select
|
||||
options={[
|
||||
{ value: '', label: 'All statuses' },
|
||||
{ value: 'open', label: 'Open' },
|
||||
{ value: 'in_progress', label: 'In Progress' },
|
||||
{ value: 'done', label: 'Done' },
|
||||
{ value: 'closed', label: 'Closed' },
|
||||
{ value: 'wont_fix', label: "Won't Fix" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
aria-label="Filter by priority"
|
||||
value={priorityFilter}
|
||||
onChange={e => setPriorityFilter(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">All priorities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: '', label: 'All priorities' },
|
||||
{ value: 'critical', label: 'Critical' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Items table */}
|
||||
{/* Items table — @bytelyst/data-table (Wave 9.C.9) */}
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-xl border border-border bg-card p-12 text-center text-muted-foreground">
|
||||
No items found. Create one to get started.
|
||||
<div className="flex justify-center py-10">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">Title</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Type</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Priority</th>
|
||||
<th className="px-4 py-3 text-center font-medium">Votes</th>
|
||||
<th className="px-4 py-3 text-center font-medium">Comments</th>
|
||||
<th className="px-4 py-3 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{items.map(item => (
|
||||
<tr key={item.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/dashboard/items/${item.id}`}
|
||||
className="font-medium text-foreground hover:text-primary hover:underline"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
{item.labels.length > 0 && (
|
||||
<div className="mt-1 flex gap-1">
|
||||
{item.labels.map(l => (
|
||||
<span
|
||||
key={l}
|
||||
className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${TYPE_BADGE[item.type] || ''}`}
|
||||
>
|
||||
{item.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_BADGE[item.status] || ''}`}
|
||||
>
|
||||
{item.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${PRIORITY_BADGE[item.priority] || ''}`}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-muted-foreground">{item.voteCount}</td>
|
||||
<td className="px-4 py-3 text-center text-muted-foreground">
|
||||
{item.commentCount}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="text-xs text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Reveal>
|
||||
<DataTable
|
||||
ariaLabel="Tracker items"
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={item => item.id}
|
||||
enableFilter={false}
|
||||
enableSorting
|
||||
enablePagination
|
||||
pageSize={15}
|
||||
emptyState="No items found. Create one to get started."
|
||||
/>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{/* Create modal */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-lg rounded-xl border border-border bg-card p-6 shadow-xl">
|
||||
<h2 className="mb-4 text-lg font-bold">New Item</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={newTitle}
|
||||
onChange={e => setNewTitle(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Type</label>
|
||||
<select
|
||||
value={newType}
|
||||
onChange={e => setNewType(e.target.value as 'bug' | 'feature' | 'task')}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="bug">Bug</option>
|
||||
<option value="feature">Feature</option>
|
||||
<option value="task">Task</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Priority</label>
|
||||
<select
|
||||
value={newPriority}
|
||||
onChange={e =>
|
||||
setNewPriority(e.target.value as 'critical' | 'high' | 'medium' | 'low')
|
||||
}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<textarea
|
||||
value={newDescription}
|
||||
onChange={e => setNewDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<Modal open={showCreate} onOpenChange={setShowCreate} title="New Item">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<Input
|
||||
label="Title"
|
||||
type="text"
|
||||
required
|
||||
value={newTitle}
|
||||
onChange={e => setNewTitle(e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Type"
|
||||
value={newType}
|
||||
onChange={e => setNewType(e.target.value as 'bug' | 'feature' | 'task')}
|
||||
options={[
|
||||
{ value: 'bug', label: 'Bug' },
|
||||
{ value: 'feature', label: 'Feature' },
|
||||
{ value: 'task', label: 'Task' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Priority"
|
||||
value={newPriority}
|
||||
onChange={e =>
|
||||
setNewPriority(e.target.value as 'critical' | 'high' | 'medium' | 'low')
|
||||
}
|
||||
options={[
|
||||
{ value: 'critical', label: 'Critical' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="new-item-description">Description</FieldLabel>
|
||||
<Textarea
|
||||
id="new-item-description"
|
||||
value={newDescription}
|
||||
onChange={e => setNewDescription(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="ghost" onClick={() => setShowCreate(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Create</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<ConfirmDialog
|
||||
open={deleteId !== null}
|
||||
onOpenChange={open => {
|
||||
if (!open) setDeleteId(null);
|
||||
}}
|
||||
title="Delete item"
|
||||
description="Delete this item? This action cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,20 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
AppShell,
|
||||
AppShellSkipLink,
|
||||
AppShellMobileToggle,
|
||||
AppShellOverlay,
|
||||
AppShellSidebar,
|
||||
AppShellNav,
|
||||
AppShellNavItem,
|
||||
AppShellMain,
|
||||
Button,
|
||||
} from '@/components/ui/Primitives';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { ProductSwitcher } from '@/components/product-switcher';
|
||||
import { SystemBanners } from '@/components/system-banners';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/dashboard', label: 'Overview' },
|
||||
{ href: '/dashboard/items', label: 'Items' },
|
||||
{ href: '/dashboard/board', label: 'Board' },
|
||||
{ href: '/dashboard/fleet', label: 'Fleet' },
|
||||
];
|
||||
|
||||
/** Open the ⌘K command palette by replaying the global hotkey. */
|
||||
function openCommandPalette() {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, ctrlKey: true }));
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading, logout } = useAuth();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
@ -30,42 +52,71 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
);
|
||||
}
|
||||
|
||||
const go = (href: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setNavOpen(false);
|
||||
router.push(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Top nav bar */}
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-card/80 backdrop-blur-sm">
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<Link href="/dashboard" className="text-lg font-bold tracking-tight">
|
||||
Tracker
|
||||
</Link>
|
||||
<nav className="flex items-center gap-1">
|
||||
{NAV_ITEMS.map(item => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<AppShell>
|
||||
<AppShellSkipLink />
|
||||
<AppShellMobileToggle open={navOpen} onClick={() => setNavOpen(o => !o)} />
|
||||
<AppShellOverlay open={navOpen} onClick={() => setNavOpen(false)} />
|
||||
|
||||
<AppShellSidebar open={navOpen} label="Primary">
|
||||
<div className="flex h-full flex-col gap-6 p-4">
|
||||
<Link href="/dashboard" className="px-1 text-lg font-bold tracking-tight">
|
||||
Tracker
|
||||
</Link>
|
||||
|
||||
<AppShellNav>
|
||||
{NAV_ITEMS.map(item => (
|
||||
<AppShellNavItem
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
active={
|
||||
item.href === '/dashboard'
|
||||
? pathname === item.href
|
||||
: pathname.startsWith(item.href)
|
||||
}
|
||||
onClick={go(item.href)}
|
||||
>
|
||||
{item.label}
|
||||
</AppShellNavItem>
|
||||
))}
|
||||
</AppShellNav>
|
||||
|
||||
<div className="mt-auto space-y-3">
|
||||
<ProductSwitcher />
|
||||
<span className="text-sm text-muted-foreground">{user.email}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={openCommandPalette}
|
||||
>
|
||||
Search… ⌘K
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
{theme === 'dark' ? 'Light mode' : 'Dark mode'}
|
||||
</Button>
|
||||
<div className="truncate px-1 text-sm text-muted-foreground">{user.email}</div>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start" onClick={logout}>
|
||||
Sign out
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</AppShellSidebar>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="mx-auto max-w-7xl px-4 py-6">{children}</main>
|
||||
</div>
|
||||
<AppShellMain>
|
||||
<SystemBanners />
|
||||
{children}
|
||||
</AppShellMain>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,88 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { PageHeader, LoadingSpinner } from '@bytelyst/dashboard-components';
|
||||
import { KpiCard } from '@bytelyst/data-viz';
|
||||
import { Reveal, NumberFlow } from '@bytelyst/motion';
|
||||
import { Skeleton, toast } from '@/components/ui/Primitives';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { getStats, type TrackerStats } from '@/lib/tracker-client';
|
||||
import { overviewKpis } from '@/lib/overview-charts';
|
||||
|
||||
const STAT_COLORS: Record<string, string> = {
|
||||
bug: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
|
||||
feature: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
task: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
open: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
|
||||
in_progress: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
done: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
closed: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300',
|
||||
wont_fix: 'bg-gray-100 text-gray-600 dark:bg-gray-900/30 dark:text-gray-400',
|
||||
critical: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
|
||||
high: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
medium: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
low: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
|
||||
};
|
||||
|
||||
function StatCard({ title, entries }: { title: string; entries: Record<string, number> }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-5">
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(entries).map(([key, count]) => (
|
||||
<span
|
||||
key={key}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium ${STAT_COLORS[key] || 'bg-muted text-muted-foreground'}`}
|
||||
>
|
||||
{key.replace(/_/g, ' ')}
|
||||
<span className="font-bold">{count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Heavy SVG chart surface — kept out of the initial route bundle (UX-4 / CC.7).
|
||||
const OverviewCharts = dynamic(() => import('@/components/overview-charts'), {
|
||||
ssr: false,
|
||||
loading: () => <Skeleton className="h-64 w-full" />,
|
||||
});
|
||||
|
||||
export default function DashboardOverview() {
|
||||
const { token } = useAuth();
|
||||
const [stats, setStats] = useState<TrackerStats | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
getStats()
|
||||
.then(setStats)
|
||||
.catch(err => setError(err.message));
|
||||
.catch(err =>
|
||||
toast({ type: 'error', title: 'Failed to load stats', description: err.message })
|
||||
);
|
||||
}, [token]);
|
||||
|
||||
const kpis = stats ? overviewKpis(stats) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">Overview of all tracked items</p>
|
||||
</div>
|
||||
<PageHeader title="Dashboard" />
|
||||
<p className="-mt-4 text-sm text-muted-foreground">Overview of all tracked items</p>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
{stats && kpis ? (
|
||||
<div className="space-y-4">
|
||||
{/* KPI row */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
{ label: `Total · ${stats.productId}`, value: kpis.total },
|
||||
{ label: 'Open', value: kpis.open },
|
||||
{ label: 'In Progress', value: kpis.inProgress },
|
||||
{ label: 'Done', value: kpis.done },
|
||||
].map((k, i) => (
|
||||
<Reveal key={k.label} delay={i * 60}>
|
||||
<KpiCard label={k.label} value={<NumberFlow value={k.value} />} />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<Reveal delay={120}>
|
||||
<OverviewCharts stats={stats} />
|
||||
</Reveal>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center py-10">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats ? (
|
||||
<div className="space-y-4">
|
||||
{/* Total count */}
|
||||
<div className="rounded-xl border border-border bg-card p-6">
|
||||
<div className="text-4xl font-bold">{stats.total}</div>
|
||||
<div className="text-sm text-muted-foreground">Total items for {stats.productId}</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdown cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<StatCard title="By Type" entries={stats.byType} />
|
||||
<StatCard title="By Status" entries={stats.byStatus} />
|
||||
<StatCard title="By Priority" entries={stats.byPriority} />
|
||||
</div>
|
||||
</div>
|
||||
) : !error ? (
|
||||
<div className="text-muted-foreground">Loading stats...</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { ErrorPage } from '@bytelyst/dashboard-components';
|
||||
import { trackEvent } from '@/lib/telemetry';
|
||||
|
||||
export default function GlobalError({
|
||||
@ -19,19 +20,7 @@ export default function GlobalError({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
<div className="mb-4 text-5xl">⚠</div>
|
||||
<h2 className="mb-2 text-xl font-semibold">Something went wrong</h2>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{error.message || 'An unexpected error occurred.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
<ErrorPage message={error.message || 'An unexpected error occurred.'} onRetry={reset} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||
--font-sans:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
@ -61,6 +62,10 @@
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
/* Semantic status colors (token layer) — consumed by the --bl-* bridge below */
|
||||
--success: oklch(0.62 0.14 150);
|
||||
--warning: oklch(0.68 0.15 70);
|
||||
--info: oklch(0.55 0.13 240);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@ -87,6 +92,101 @@
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--success: oklch(0.7 0.15 155);
|
||||
--warning: oklch(0.75 0.15 75);
|
||||
--info: oklch(0.65 0.14 240);
|
||||
}
|
||||
|
||||
/*
|
||||
* Token bridge: map the @bytelyst/* `--bl-*` token surface onto tracker's
|
||||
* shadcn-style OKLCH vars so every adopted shared component (ui, auth-ui,
|
||||
* notifications-ui, dashboard-components, charts) inherits the theme in both
|
||||
* light and dark automatically. Extends the UX-1.2 minimum set (UX-1.2 / CC.2).
|
||||
* Everything references tracker vars (or color-mix of them) so dark mode flips
|
||||
* for free; no standalone color literals here.
|
||||
*/
|
||||
:root,
|
||||
.dark {
|
||||
/* Accent / primary / links */
|
||||
--bl-accent: var(--primary);
|
||||
--bl-accent-foreground: var(--primary-foreground);
|
||||
--bl-accent-muted: color-mix(in oklch, var(--primary) 12%, transparent);
|
||||
--bl-primary: var(--primary);
|
||||
--bl-link: var(--primary);
|
||||
|
||||
/* Backgrounds & surfaces */
|
||||
--bl-bg-canvas: var(--background);
|
||||
--bl-page-bg: var(--background);
|
||||
--bl-bg-elevated: var(--card);
|
||||
--bl-surface: var(--card);
|
||||
--bl-surface-card: var(--card);
|
||||
--bl-surface-sidebar: var(--card);
|
||||
--bl-surface-highlight: var(--accent);
|
||||
--bl-surface-hover: var(--accent);
|
||||
--bl-surface-muted: var(--muted);
|
||||
|
||||
/* Text */
|
||||
--bl-text: var(--foreground);
|
||||
--bl-text-primary: var(--foreground);
|
||||
--bl-text-secondary: var(--muted-foreground);
|
||||
--bl-text-tertiary: var(--muted-foreground);
|
||||
--bl-muted: var(--muted-foreground);
|
||||
|
||||
/* Borders, inputs, focus */
|
||||
--bl-border: var(--border);
|
||||
--bl-border-strong: var(--border);
|
||||
--bl-border-subtle: var(--border);
|
||||
--bl-input: var(--input);
|
||||
--bl-focus-ring: var(--ring);
|
||||
--bl-focus-ring-muted: color-mix(in oklch, var(--ring) 40%, transparent);
|
||||
|
||||
/* Danger / error */
|
||||
--bl-danger: var(--destructive);
|
||||
--bl-danger-foreground: var(--primary-foreground);
|
||||
--bl-danger-border: color-mix(in oklch, var(--destructive) 45%, transparent);
|
||||
--bl-danger-muted: color-mix(in oklch, var(--destructive) 12%, transparent);
|
||||
--bl-error: var(--destructive);
|
||||
|
||||
/* Success / warning / info (semantic token layer flips per-theme) */
|
||||
--bl-success: var(--success);
|
||||
--bl-success-border: color-mix(in oklch, var(--success) 45%, transparent);
|
||||
--bl-success-muted: color-mix(in oklch, var(--success) 12%, transparent);
|
||||
--bl-warning: var(--warning);
|
||||
--bl-warning-border: color-mix(in oklch, var(--warning) 45%, transparent);
|
||||
--bl-warning-muted: color-mix(in oklch, var(--warning) 12%, transparent);
|
||||
--bl-info: var(--info);
|
||||
--bl-info-border: color-mix(in oklch, var(--info) 45%, transparent);
|
||||
--bl-info-muted: color-mix(in oklch, var(--info) 12%, transparent);
|
||||
|
||||
/* Overlay scrim */
|
||||
--bl-overlay-scrim: color-mix(in oklch, var(--foreground) 40%, transparent);
|
||||
|
||||
/* Radii */
|
||||
--bl-radius: var(--radius-md);
|
||||
--bl-radius-control: var(--radius-md);
|
||||
--bl-radius-card: var(--radius-lg);
|
||||
--bl-card-radius: var(--radius-lg);
|
||||
--bl-radius-pill: 9999px;
|
||||
|
||||
/* Spacing */
|
||||
--bl-space-2: 0.5rem;
|
||||
--bl-space-3: 0.75rem;
|
||||
--bl-space-4: 1rem;
|
||||
|
||||
/* Typography */
|
||||
--bl-font: var(--font-sans);
|
||||
--bl-font-display: var(--font-sans);
|
||||
|
||||
/* Elevation / layout */
|
||||
--bl-card-shadow: 0 1px 2px 0 color-mix(in oklch, var(--foreground) 8%, transparent);
|
||||
--bl-app-sidebar-width: 16rem;
|
||||
|
||||
/* Chart palette */
|
||||
--bl-chart-1: var(--chart-1);
|
||||
--bl-chart-2: var(--chart-2);
|
||||
--bl-chart-3: var(--chart-3);
|
||||
--bl-chart-4: var(--chart-4);
|
||||
--bl-chart-5: var(--chart-5);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LoginForm, MfaChallenge, type SocialProvider } from '@bytelyst/auth-ui';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '';
|
||||
// Only advertise social providers the backend actually supports. Today that is
|
||||
// Google, and only when a client id is configured (gates /api/auth/oauth/google).
|
||||
const SOCIAL_PROVIDERS: SocialProvider[] = GOOGLE_CLIENT_ID ? ['google'] : [];
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
@ -17,20 +19,23 @@ export default function LoginPage() {
|
||||
// MFA state
|
||||
const [mfaChallenge, setMfaChallenge] = useState<string | null>(null);
|
||||
const [mfaMethods, setMfaMethods] = useState<string[]>([]);
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const [useRecovery, setUseRecovery] = useState(false);
|
||||
|
||||
const completeAuth = useCallback(
|
||||
(data: {
|
||||
accessToken: string;
|
||||
user: { id: string; email: string; role: string; displayName: string };
|
||||
}) => {
|
||||
localStorage.setItem('tracker_token', data.accessToken);
|
||||
// Force full reload so auth-context re-reads token from localStorage
|
||||
window.location.href = '/dashboard';
|
||||
},
|
||||
[]
|
||||
);
|
||||
// The shared LoginForm renders placeholder-only inputs; give them accessible
|
||||
// names so screen readers (and the a11y gate) have a label to announce.
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const root = formRef.current;
|
||||
if (!root) return;
|
||||
root.querySelector('[data-testid="bl-login-email"]')?.setAttribute('aria-label', 'Email');
|
||||
root.querySelector('[data-testid="bl-login-password"]')?.setAttribute('aria-label', 'Password');
|
||||
});
|
||||
|
||||
const completeAuth = useCallback((data: { accessToken: string }) => {
|
||||
localStorage.setItem('tracker_token', data.accessToken);
|
||||
// Force full reload so auth-context re-reads token from localStorage.
|
||||
window.location.href = '/dashboard';
|
||||
}, []);
|
||||
|
||||
const handleLoginResponse = useCallback(
|
||||
(data: Record<string, unknown>) => {
|
||||
@ -40,18 +45,12 @@ export default function LoginPage() {
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
completeAuth(
|
||||
data as {
|
||||
accessToken: string;
|
||||
user: { id: string; email: string; role: string; displayName: string };
|
||||
}
|
||||
);
|
||||
completeAuth(data as { accessToken: string });
|
||||
},
|
||||
[completeAuth]
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handlePasswordLogin = async (email: string, password: string) => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
@ -140,8 +139,11 @@ export default function LoginPage() {
|
||||
}
|
||||
}, [handleLoginResponse]);
|
||||
|
||||
const handleMfaVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleSocialLogin = (provider: SocialProvider) => {
|
||||
if (provider === 'google') void handleGoogleSignIn();
|
||||
};
|
||||
|
||||
const handleMfaVerify = async (code: string) => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
@ -150,7 +152,7 @@ export default function LoginPage() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challengeToken: mfaChallenge,
|
||||
code: mfaCode,
|
||||
code,
|
||||
method: useRecovery ? 'recovery' : 'totp',
|
||||
}),
|
||||
});
|
||||
@ -170,74 +172,43 @@ export default function LoginPage() {
|
||||
// MFA challenge view
|
||||
if (mfaChallenge) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-sm space-y-6 rounded-xl border border-border bg-card p-8 shadow-lg">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Two-Factor Auth</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{useRecovery ? 'Enter a recovery code' : 'Enter your authentication code'}
|
||||
</p>
|
||||
{mfaMethods.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">Methods: {mfaMethods.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={handleMfaVerify} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder={useRecovery ? 'Recovery code' : '000000'}
|
||||
value={mfaCode}
|
||||
onChange={e => setMfaCode(e.target.value)}
|
||||
required
|
||||
maxLength={useRecovery ? 20 : 6}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-center text-lg font-mono tracking-widest outline-none ring-ring focus:ring-2"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || mfaCode.length < 6}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</button>
|
||||
<div className="flex justify-between text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMfaChallenge(null);
|
||||
setMfaMethods([]);
|
||||
setMfaCode('');
|
||||
}}
|
||||
className="text-muted-foreground underline"
|
||||
>
|
||||
Back to login
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUseRecovery(!useRecovery);
|
||||
setMfaCode('');
|
||||
setError('');
|
||||
}}
|
||||
className="text-primary underline"
|
||||
>
|
||||
{useRecovery ? 'Use authenticator' : 'Use recovery code'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<MfaChallenge
|
||||
methods={mfaMethods}
|
||||
isLoading={loading}
|
||||
error={error || null}
|
||||
onSubmit={handleMfaVerify}
|
||||
onUseRecovery={() => {
|
||||
setUseRecovery(true);
|
||||
setError('');
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMfaChallenge(null);
|
||||
setMfaMethods([]);
|
||||
setUseRecovery(false);
|
||||
setError('');
|
||||
}}
|
||||
className="w-full text-center text-xs text-muted-foreground underline"
|
||||
>
|
||||
Back to login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-sm space-y-6 rounded-xl border border-border bg-card p-8 shadow-lg">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Tracker</h1>
|
||||
@ -246,86 +217,15 @@ export default function LoginPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{GOOGLE_CLIENT_ID && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<hr className="flex-1 border-border" />
|
||||
or
|
||||
<hr className="flex-1 border-border" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={loading}
|
||||
className="w-full rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div ref={formRef}>
|
||||
<LoginForm
|
||||
onSubmit={handlePasswordLogin}
|
||||
isLoading={loading}
|
||||
error={error || null}
|
||||
providers={SOCIAL_PROVIDERS}
|
||||
onSocialLogin={handleSocialLogin}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Uses platform-service credentials
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { CommandRegistryProvider } from '@bytelyst/command-palette';
|
||||
import { ToastProvider } from '@/components/ui/Primitives';
|
||||
import { AuthProvider } from '@/lib/auth-context';
|
||||
import { ThemeProvider } from '@/lib/theme-context';
|
||||
import { ProductProvider } from '@/lib/product-context';
|
||||
@ -8,6 +11,9 @@ import { initTelemetry } from '@/lib/telemetry';
|
||||
|
||||
import { CSPostHogProvider } from '@/components/posthog-provider';
|
||||
|
||||
// ⌘K palette — loaded lazily so its code stays out of the initial bundle (UX-5).
|
||||
const CommandMenu = dynamic(() => import('@/components/command-menu'), { ssr: false });
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
initTelemetry();
|
||||
@ -17,7 +23,14 @@ export function Providers({ children }: { children: ReactNode }) {
|
||||
<CSPostHogProvider>
|
||||
<ThemeProvider>
|
||||
<ProductProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<CommandRegistryProvider>
|
||||
{children}
|
||||
<CommandMenu />
|
||||
</CommandRegistryProvider>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</ProductProvider>
|
||||
</ThemeProvider>
|
||||
</CSPostHogProvider>
|
||||
|
||||
@ -1,6 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
SegmentedControl,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Textarea,
|
||||
Modal,
|
||||
Badge,
|
||||
StatusDot,
|
||||
MetricCard,
|
||||
toast,
|
||||
type BadgeProps,
|
||||
type StatusTone,
|
||||
} from '@/components/ui/Primitives';
|
||||
import {
|
||||
getRoadmapItems,
|
||||
getRoadmapStats,
|
||||
@ -10,64 +24,32 @@ import {
|
||||
type PublicRoadmapStats,
|
||||
} from '@/lib/tracker-client';
|
||||
|
||||
type BadgeVariant = NonNullable<BadgeProps['variant']>;
|
||||
|
||||
// ── Status column config ────────────────────────────────────────────
|
||||
const STATUS_COLUMNS = [
|
||||
{
|
||||
key: 'open',
|
||||
label: 'Planned',
|
||||
color: 'bg-blue-500',
|
||||
textColor: 'text-blue-700 dark:text-blue-300',
|
||||
},
|
||||
{
|
||||
key: 'in_progress',
|
||||
label: 'In Progress',
|
||||
color: 'bg-amber-500',
|
||||
textColor: 'text-amber-700 dark:text-amber-300',
|
||||
},
|
||||
{
|
||||
key: 'done',
|
||||
label: 'Complete',
|
||||
color: 'bg-emerald-500',
|
||||
textColor: 'text-emerald-700 dark:text-emerald-300',
|
||||
},
|
||||
{ key: 'open', label: 'Planned', tone: 'info' as StatusTone },
|
||||
{ key: 'in_progress', label: 'In Progress', tone: 'warning' as StatusTone },
|
||||
{ key: 'done', label: 'Complete', tone: 'success' as StatusTone },
|
||||
] as const;
|
||||
|
||||
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
|
||||
feature: {
|
||||
label: 'Feature',
|
||||
className: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||
},
|
||||
bug: { label: 'Bug', className: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300' },
|
||||
task: {
|
||||
label: 'Task',
|
||||
className: 'bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-300',
|
||||
},
|
||||
const TYPE_BADGES: Record<string, { label: string; variant: BadgeVariant }> = {
|
||||
feature: { label: 'Feature', variant: 'info' },
|
||||
bug: { label: 'Bug', variant: 'danger' },
|
||||
task: { label: 'Task', variant: 'neutral' },
|
||||
};
|
||||
|
||||
const PRIORITY_BADGES: Record<string, { label: string; className: string }> = {
|
||||
critical: {
|
||||
label: 'Critical',
|
||||
className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
},
|
||||
high: {
|
||||
label: 'High',
|
||||
className: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
|
||||
},
|
||||
medium: {
|
||||
label: 'Medium',
|
||||
className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
},
|
||||
low: {
|
||||
label: 'Low',
|
||||
className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
},
|
||||
const PRIORITY_BADGES: Record<string, { label: string; variant: BadgeVariant }> = {
|
||||
critical: { label: 'Critical', variant: 'danger' },
|
||||
high: { label: 'High', variant: 'warning' },
|
||||
medium: { label: 'Medium', variant: 'neutral' },
|
||||
low: { label: 'Low', variant: 'success' },
|
||||
};
|
||||
|
||||
export default function RoadmapPage() {
|
||||
const [items, setItems] = useState<TrackerItem[]>([]);
|
||||
const [stats, setStats] = useState<PublicRoadmapStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [view, setView] = useState<'board' | 'list'>('board');
|
||||
@ -82,14 +64,12 @@ export default function RoadmapPage() {
|
||||
name: '',
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitSuccess, setSubmitSuccess] = useState('');
|
||||
|
||||
// Vote email (persisted in localStorage)
|
||||
const [voteEmail, setVoteEmail] = useState('');
|
||||
const [showEmailPrompt, setShowEmailPrompt] = useState(false);
|
||||
const [pendingVoteId, setPendingVoteId] = useState<string | null>(null);
|
||||
const [votedItems, setVotedItems] = useState<Set<string>>(new Set());
|
||||
const [voteError, setVoteError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@ -102,7 +82,6 @@ export default function RoadmapPage() {
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError('');
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
sortBy: 'voteCount',
|
||||
@ -115,7 +94,11 @@ export default function RoadmapPage() {
|
||||
setItems(itemsRes.items);
|
||||
setStats(statsRes);
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : 'Failed to load roadmap');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: "Couldn't load the roadmap",
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -135,7 +118,6 @@ export default function RoadmapPage() {
|
||||
};
|
||||
|
||||
const doVote = async (itemId: string, email: string) => {
|
||||
setVoteError('');
|
||||
try {
|
||||
const res = await publicVote(itemId, email);
|
||||
setItems(prev => prev.map(i => (i.id === itemId ? { ...i, voteCount: res.voteCount } : i)));
|
||||
@ -148,7 +130,11 @@ export default function RoadmapPage() {
|
||||
setVotedItems(newVoted);
|
||||
localStorage.setItem('roadmap_voted', JSON.stringify([...newVoted]));
|
||||
} catch (err) {
|
||||
setVoteError(err instanceof Error ? err.message : 'Vote failed');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Vote failed',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -165,12 +151,14 @@ export default function RoadmapPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setSubmitSuccess('');
|
||||
try {
|
||||
const res = await submitPublicItem(submitForm);
|
||||
setSubmitSuccess(
|
||||
`Thanks! Your ${submitForm.type} request "${res.title}" has been submitted for review.`
|
||||
);
|
||||
toast({
|
||||
type: 'success',
|
||||
title: 'Idea submitted',
|
||||
description: `Your ${submitForm.type} request "${res.title}" has been submitted for review.`,
|
||||
});
|
||||
setShowSubmit(false);
|
||||
setSubmitForm({
|
||||
title: '',
|
||||
description: '',
|
||||
@ -183,8 +171,14 @@ export default function RoadmapPage() {
|
||||
setVoteEmail(submitForm.email);
|
||||
localStorage.setItem('roadmap_email', submitForm.email);
|
||||
}
|
||||
// Refresh data to show new item
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
setSubmitSuccess(`Error: ${err instanceof Error ? err.message : 'Submission failed'}`);
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Submission failed',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@ -193,26 +187,21 @@ export default function RoadmapPage() {
|
||||
const itemsByStatus = (status: string) => items.filter(i => i.status === status);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900">
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm sticky top-0 z-20">
|
||||
<header className="border-b border-border bg-card/80 backdrop-blur-sm sticky top-0 z-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Product Roadmap</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Product Roadmap</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Vote on features, report bugs, and shape what we build next
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowSubmit(true)}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
+ Submit Idea
|
||||
</button>
|
||||
<Button onClick={() => setShowSubmit(true)}>+ Submit Idea</Button>
|
||||
<a
|
||||
href="/login"
|
||||
className="text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Admin →
|
||||
</a>
|
||||
@ -221,78 +210,115 @@ export default function RoadmapPage() {
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
|
||||
{loadError ? (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-900/20 dark:text-red-300">
|
||||
{loadError}
|
||||
</div>
|
||||
) : null}
|
||||
{voteError ? (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-900/20 dark:text-red-300">
|
||||
{voteError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Stats bar */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Total Items" value={stats.total} />
|
||||
<StatCard label="Total Votes" value={stats.totalVotes} />
|
||||
<StatCard label="In Progress" value={stats.byStatus?.in_progress || 0} />
|
||||
<StatCard label="Completed" value={stats.byStatus?.done || 0} />
|
||||
<MetricCard label="Total Items" value={stats.total} />
|
||||
<MetricCard label="Total Votes" value={stats.totalVotes} />
|
||||
<MetricCard label="In Progress" value={stats.byStatus?.in_progress || 0} />
|
||||
<MetricCard label="Completed" value={stats.byStatus?.done || 0} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search items..."
|
||||
aria-label="Search items"
|
||||
className="flex-1"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="feature">Features</option>
|
||||
<option value="bug">Bugs</option>
|
||||
<option value="task">Tasks</option>
|
||||
</select>
|
||||
<div className="flex border border-slate-300 dark:border-slate-600 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setView('board')}
|
||||
className={`px-3 py-2 text-sm ${view === 'board' ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300'}`}
|
||||
>
|
||||
Board
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
className={`px-3 py-2 text-sm ${view === 'list' ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300'}`}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
aria-label="Filter by type"
|
||||
options={[
|
||||
{ value: '', label: 'All Types' },
|
||||
{ value: 'feature', label: 'Features' },
|
||||
{ value: 'bug', label: 'Bugs' },
|
||||
{ value: 'task', label: 'Tasks' },
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
aria-label="Roadmap view"
|
||||
value={view}
|
||||
onValueChange={v => setView(v as 'board' | 'list')}
|
||||
options={[
|
||||
{ value: 'board', label: 'Board' },
|
||||
{ value: 'list', label: 'List' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-20 text-slate-400">Loading roadmap...</div>
|
||||
view === 'board' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{STATUS_COLUMNS.map(col => (
|
||||
<div key={col.key} className="space-y-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<StatusDot tone={col.tone} className="animate-pulse" />
|
||||
<h2 className="h-5 w-24 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="bg-card rounded-xl border border-border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex flex-col items-center min-w-[44px] py-1.5 px-2">
|
||||
<div className="h-4 w-4 bg-muted rounded animate-pulse" />
|
||||
<div className="h-4 w-6 bg-muted rounded mt-1 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="h-4 w-full bg-muted rounded animate-pulse" />
|
||||
<div className="h-3 w-3/4 bg-muted rounded animate-pulse" />
|
||||
<div className="flex gap-2 pt-2">
|
||||
<div className="h-5 w-12 bg-muted rounded-full animate-pulse" />
|
||||
<div className="h-5 w-12 bg-muted rounded-full animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-card rounded-xl border border-border p-4 flex items-center gap-4"
|
||||
>
|
||||
<div className="flex flex-col items-center min-w-[44px] py-1.5 px-2">
|
||||
<div className="h-4 w-4 bg-muted rounded animate-pulse" />
|
||||
<div className="h-4 w-6 bg-muted rounded mt-1 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="h-4 w-full bg-muted rounded animate-pulse" />
|
||||
<div className="h-3 w-1/2 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="h-5 w-12 bg-muted rounded-full animate-pulse" />
|
||||
<div className="h-4 w-16 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : view === 'board' ? (
|
||||
/* Board View */
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{STATUS_COLUMNS.map(col => (
|
||||
<div key={col.key} className="space-y-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`w-3 h-3 rounded-full ${col.color}`} />
|
||||
<h2 className={`font-semibold ${col.textColor}`}>{col.label}</h2>
|
||||
<span className="text-xs text-slate-400 ml-auto">
|
||||
<StatusDot tone={col.tone} />
|
||||
<h2 className="font-semibold text-foreground">{col.label}</h2>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{itemsByStatus(col.key).length}
|
||||
</span>
|
||||
</div>
|
||||
{itemsByStatus(col.key).length === 0 ? (
|
||||
<p className="text-sm text-slate-400 italic py-4 text-center">No items</p>
|
||||
<p className="text-sm text-muted-foreground italic py-4 text-center">No items</p>
|
||||
) : (
|
||||
itemsByStatus(col.key).map(item => (
|
||||
<ItemCard
|
||||
@ -310,7 +336,7 @@ export default function RoadmapPage() {
|
||||
/* List View */
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-center py-10 text-slate-400">No items found</p>
|
||||
<p className="text-center py-10 text-muted-foreground">No items found</p>
|
||||
) : (
|
||||
items.map(item => (
|
||||
<ItemRow
|
||||
@ -326,140 +352,119 @@ export default function RoadmapPage() {
|
||||
</main>
|
||||
|
||||
{/* Email prompt modal */}
|
||||
{showEmailPrompt && (
|
||||
<Modal
|
||||
onClose={() => {
|
||||
<Modal
|
||||
open={showEmailPrompt}
|
||||
onOpenChange={open => {
|
||||
if (!open) {
|
||||
setShowEmailPrompt(false);
|
||||
setPendingVoteId(null);
|
||||
}}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-2">Enter your email to vote</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
We use your email to track your votes. One vote per item.
|
||||
</p>
|
||||
<input
|
||||
}
|
||||
}}
|
||||
title="Enter your email to vote"
|
||||
description="We use your email to track your votes. One vote per item."
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="email"
|
||||
aria-label="Email"
|
||||
placeholder="you@example.com"
|
||||
value={voteEmail}
|
||||
onChange={e => setVoteEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm mb-4"
|
||||
onKeyDown={e => e.key === 'Enter' && handleEmailSubmit()}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowEmailPrompt(false);
|
||||
setPendingVoteId(null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEmailSubmit}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
Vote
|
||||
</button>
|
||||
</Button>
|
||||
<Button onClick={handleEmailSubmit}>Vote</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Submit modal */}
|
||||
{showSubmit && (
|
||||
<Modal
|
||||
onClose={() => {
|
||||
setShowSubmit(false);
|
||||
setSubmitSuccess('');
|
||||
}}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-4">Submit an Idea</h3>
|
||||
{submitSuccess ? (
|
||||
<div
|
||||
className={`p-3 rounded-lg text-sm mb-4 ${submitSuccess.startsWith('Error') ? 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-300' : 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-300'}`}
|
||||
>
|
||||
{submitSuccess}
|
||||
</div>
|
||||
) : null}
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your name"
|
||||
value={submitForm.name}
|
||||
onChange={e => setSubmitForm({ ...submitForm, name: e.target.value })}
|
||||
required
|
||||
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
value={submitForm.email}
|
||||
onChange={e => setSubmitForm({ ...submitForm, email: e.target.value })}
|
||||
required
|
||||
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={submitForm.type}
|
||||
onChange={e => setSubmitForm({ ...submitForm, type: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm"
|
||||
>
|
||||
<option value="feature">Feature Request</option>
|
||||
<option value="bug">Bug Report</option>
|
||||
<option value="task">Task</option>
|
||||
</select>
|
||||
<input
|
||||
<Modal
|
||||
open={showSubmit}
|
||||
onOpenChange={open => {
|
||||
if (!open) setShowSubmit(false);
|
||||
}}
|
||||
title="Submit an Idea"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Title — what would you like to see?"
|
||||
value={submitForm.title}
|
||||
onChange={e => setSubmitForm({ ...submitForm, title: e.target.value })}
|
||||
aria-label="Your name"
|
||||
placeholder="Your name"
|
||||
value={submitForm.name}
|
||||
onChange={e => setSubmitForm({ ...submitForm, name: e.target.value })}
|
||||
required
|
||||
maxLength={500}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Describe your idea or issue in detail (optional)"
|
||||
value={submitForm.description}
|
||||
onChange={e => setSubmitForm({ ...submitForm, description: e.target.value })}
|
||||
rows={4}
|
||||
maxLength={5000}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm resize-none"
|
||||
<Input
|
||||
type="email"
|
||||
aria-label="Your email"
|
||||
placeholder="Your email"
|
||||
value={submitForm.email}
|
||||
onChange={e => setSubmitForm({ ...submitForm, email: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSubmit(false)}
|
||||
className="px-4 py-2 text-sm text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={submitForm.type}
|
||||
onChange={e => setSubmitForm({ ...submitForm, type: e.target.value })}
|
||||
aria-label="Request type"
|
||||
options={[
|
||||
{ value: 'feature', label: 'Feature Request' },
|
||||
{ value: 'bug', label: 'Bug Report' },
|
||||
{ value: 'task', label: 'Task' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
aria-label="Title"
|
||||
placeholder="Title — what would you like to see?"
|
||||
value={submitForm.title}
|
||||
onChange={e => setSubmitForm({ ...submitForm, title: e.target.value })}
|
||||
required
|
||||
maxLength={500}
|
||||
/>
|
||||
<Textarea
|
||||
aria-label="Description"
|
||||
placeholder="Describe your idea or issue in detail (optional)"
|
||||
value={submitForm.description}
|
||||
onChange={e => setSubmitForm({ ...submitForm, description: e.target.value })}
|
||||
rows={4}
|
||||
maxLength={5000}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={() => setShowSubmit(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={submitting}>
|
||||
{submitting ? 'Submitting...' : 'Submit'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-components ──────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-white">{value}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const voteButtonClass = (hasVoted: boolean) =>
|
||||
`flex flex-col items-center min-w-[44px] py-1.5 px-2 rounded-lg border text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||||
hasVoted
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border bg-muted/50 text-muted-foreground hover:border-primary hover:text-primary'
|
||||
}`;
|
||||
|
||||
function ItemCard({
|
||||
item,
|
||||
@ -475,42 +480,29 @@ function ItemCard({
|
||||
const hasVoted = votedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 hover:shadow-md transition-shadow">
|
||||
<div className="bg-card rounded-xl border border-border p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
onClick={() => onVote(item.id)}
|
||||
className={`flex flex-col items-center min-w-[44px] py-1.5 px-2 rounded-lg border text-sm font-semibold transition-colors ${
|
||||
hasVoted
|
||||
? 'bg-blue-50 border-blue-300 text-blue-700 dark:bg-blue-900/30 dark:border-blue-700 dark:text-blue-300'
|
||||
: 'bg-slate-50 border-slate-200 text-slate-500 hover:border-blue-300 hover:text-blue-600 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-300'
|
||||
}`}
|
||||
type="button"
|
||||
aria-pressed={hasVoted}
|
||||
aria-label={hasVoted ? `Remove vote from ${item.title}` : `Upvote ${item.title}`}
|
||||
className={voteButtonClass(hasVoted)}
|
||||
title={hasVoted ? 'Remove vote' : 'Upvote'}
|
||||
>
|
||||
<span className="text-xs">▲</span>
|
||||
<span>{item.voteCount}</span>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-slate-900 dark:text-white text-sm leading-snug">
|
||||
{item.title}
|
||||
</h3>
|
||||
<h3 className="font-medium text-foreground text-sm leading-snug">{item.title}</h3>
|
||||
{item.description && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
<span
|
||||
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${typeBadge.className}`}
|
||||
>
|
||||
{typeBadge.label}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${priorityBadge.className}`}
|
||||
>
|
||||
{priorityBadge.label}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
||||
<Badge variant={typeBadge.variant}>{typeBadge.label}</Badge>
|
||||
<Badge variant={priorityBadge.variant}>{priorityBadge.label}</Badge>
|
||||
{item.commentCount > 0 && (
|
||||
<span className="text-[10px] text-slate-400">💬 {item.commentCount}</span>
|
||||
<span className="text-[10px] text-muted-foreground">💬 {item.commentCount}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -533,53 +525,36 @@ function ItemRow({
|
||||
const hasVoted = votedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 flex items-center gap-4 hover:shadow-md transition-shadow">
|
||||
<div className="bg-card rounded-xl border border-border p-4 flex items-center gap-4 hover:shadow-md transition-shadow">
|
||||
<button
|
||||
onClick={() => onVote(item.id)}
|
||||
className={`flex flex-col items-center min-w-[44px] py-1.5 px-2 rounded-lg border text-sm font-semibold transition-colors ${
|
||||
hasVoted
|
||||
? 'bg-blue-50 border-blue-300 text-blue-700 dark:bg-blue-900/30 dark:border-blue-700 dark:text-blue-300'
|
||||
: 'bg-slate-50 border-slate-200 text-slate-500 hover:border-blue-300 hover:text-blue-600 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-300'
|
||||
}`}
|
||||
type="button"
|
||||
aria-pressed={hasVoted}
|
||||
aria-label={hasVoted ? `Remove vote from ${item.title}` : `Upvote ${item.title}`}
|
||||
title={hasVoted ? 'Remove vote' : 'Upvote'}
|
||||
className={voteButtonClass(hasVoted)}
|
||||
>
|
||||
<span className="text-xs">▲</span>
|
||||
<span>{item.voteCount}</span>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-slate-900 dark:text-white text-sm">{item.title}</h3>
|
||||
<h3 className="font-medium text-foreground text-sm">{item.title}</h3>
|
||||
{item.description && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-1">
|
||||
{item.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${typeBadge.className}`}
|
||||
>
|
||||
{typeBadge.label}
|
||||
</span>
|
||||
<Badge variant={typeBadge.variant}>{typeBadge.label}</Badge>
|
||||
{statusCol && (
|
||||
<span className="flex items-center gap-1 text-xs text-slate-500">
|
||||
<span className={`w-2 h-2 rounded-full ${statusCol.color}`} />
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<StatusDot tone={statusCol.tone} />
|
||||
{statusCol.label}
|
||||
</span>
|
||||
)}
|
||||
{item.commentCount > 0 && (
|
||||
<span className="text-xs text-slate-400">💬 {item.commentCount}</span>
|
||||
<span className="text-xs text-muted-foreground">💬 {item.commentCount}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative bg-white dark:bg-slate-800 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 p-6 w-full max-w-md">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
44
dashboards/tracker-web/src/components/command-menu.tsx
Normal file
44
dashboards/tracker-web/src/components/command-menu.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { CommandPalette, useCommandPalette, useRegisterCommands } from '@bytelyst/command-palette';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { useProduct } from '@/lib/product-context';
|
||||
import { buildCommands } from '@/lib/command-registry';
|
||||
|
||||
/**
|
||||
* ⌘K / Ctrl-K command palette shell (UX-5). Mounted once inside the providers
|
||||
* tree (within CommandRegistryProvider) and loaded via next/dynamic so the
|
||||
* palette code stays out of the initial bundle.
|
||||
*/
|
||||
export default function CommandMenu() {
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { logout } = useAuth();
|
||||
const { products, setProductId } = useProduct();
|
||||
const cmdk = useCommandPalette();
|
||||
|
||||
const commands = useMemo(
|
||||
() =>
|
||||
buildCommands({
|
||||
navigate: href => router.push(href),
|
||||
newItem: () => router.push('/dashboard/items?new=1'),
|
||||
toggleTheme: () => setTheme(theme === 'dark' ? 'light' : 'dark'),
|
||||
signOut: () => {
|
||||
logout();
|
||||
router.push('/login');
|
||||
},
|
||||
setProduct: setProductId,
|
||||
products,
|
||||
}),
|
||||
[router, theme, setTheme, logout, setProductId, products]
|
||||
);
|
||||
|
||||
useRegisterCommands(commands);
|
||||
|
||||
return (
|
||||
<CommandPalette open={cmdk.open} onClose={cmdk.hide} onNavigate={href => router.push(href)} />
|
||||
);
|
||||
}
|
||||
78
dashboards/tracker-web/src/components/overview-charts.tsx
Normal file
78
dashboards/tracker-web/src/components/overview-charts.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { Donut, BarChart } from '@bytelyst/charts';
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/Primitives';
|
||||
import { statusSlices, typeSlices, priorityBars } from '@/lib/overview-charts';
|
||||
import type { TrackerStats } from '@/lib/tracker-client';
|
||||
|
||||
/**
|
||||
* Heavy chart surface for the dashboard overview (UX-4.2). Imported via
|
||||
* next/dynamic from the overview page so the SVG chart code stays out of the
|
||||
* initial route bundle.
|
||||
*/
|
||||
export default function OverviewCharts({ stats }: { stats: TrackerStats }) {
|
||||
const statuses = statusSlices(stats);
|
||||
const types = typeSlices(stats);
|
||||
const priorities = priorityBars(stats);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="flex flex-col items-center p-5">
|
||||
<CardHeader className="w-full">
|
||||
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
By Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<Donut
|
||||
slices={statuses}
|
||||
size={180}
|
||||
ariaLabel="Items by status"
|
||||
centerContent={<div className="text-2xl font-bold text-foreground">{stats.total}</div>}
|
||||
/>
|
||||
<ChartLegend slices={statuses} />
|
||||
</Card>
|
||||
|
||||
<Card className="flex flex-col items-center p-5">
|
||||
<CardHeader className="w-full">
|
||||
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
By Type
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<Donut slices={types} size={180} ariaLabel="Items by type" />
|
||||
<ChartLegend slices={types} />
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
By Priority
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<BarChart
|
||||
data={priorities}
|
||||
width={360}
|
||||
height={200}
|
||||
ariaLabel="Items by priority"
|
||||
className="w-full"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLegend({ slices }: { slices: { id: string; label?: string; color?: string }[] }) {
|
||||
return (
|
||||
<ul className="mt-4 flex w-full flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{slices.map(s => (
|
||||
<li key={s.id} className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ background: s.color }}
|
||||
/>
|
||||
{s.label ?? s.id}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
67
dashboards/tracker-web/src/components/system-banners.tsx
Normal file
67
dashboards/tracker-web/src/components/system-banners.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BannerStack, Announcement, type BannerItem } from '@bytelyst/notifications-ui';
|
||||
|
||||
/**
|
||||
* Top-of-page system messaging for the dashboard (UX-13.2).
|
||||
*
|
||||
* Tracker has no notifications feed yet, so UX-13.1 (NotificationCenter) is
|
||||
* deferred. Per the wave's data-gate we ship the client-side surfaces only:
|
||||
* - `BannerStack` renders dismissible system/maintenance notices sourced from
|
||||
* `NEXT_PUBLIC_SYSTEM_NOTICE` (renders nothing when unset).
|
||||
* - `Announcement` shows a dismissible "what's new" pill, remembered per
|
||||
* version in localStorage.
|
||||
*
|
||||
* All colors come from the bridged `--bl-*` tokens (no hardcoded literals).
|
||||
*/
|
||||
|
||||
const SYSTEM_NOTICE = process.env.NEXT_PUBLIC_SYSTEM_NOTICE || '';
|
||||
const WHATS_NEW_KEY = 'tracker_whatsnew_dismissed';
|
||||
// Bump when the "what's new" copy changes to re-surface it once.
|
||||
const WHATS_NEW_VERSION = '2026-05-ux';
|
||||
|
||||
export function SystemBanners() {
|
||||
const [banners, setBanners] = useState<BannerItem[]>([]);
|
||||
const [showWhatsNew, setShowWhatsNew] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (SYSTEM_NOTICE) {
|
||||
setBanners([
|
||||
{
|
||||
id: 'system-notice',
|
||||
kind: 'warning',
|
||||
title: 'System notice',
|
||||
body: SYSTEM_NOTICE,
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
setShowWhatsNew(localStorage.getItem(WHATS_NEW_KEY) !== WHATS_NEW_VERSION);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissWhatsNew = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(WHATS_NEW_KEY, WHATS_NEW_VERSION);
|
||||
}
|
||||
setShowWhatsNew(false);
|
||||
};
|
||||
|
||||
if (banners.length === 0 && !showWhatsNew) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-6 space-y-3">
|
||||
<BannerStack
|
||||
banners={banners}
|
||||
onDismiss={id => setBanners(prev => prev.filter(b => b.id !== id))}
|
||||
/>
|
||||
{showWhatsNew && (
|
||||
<Announcement tag="NEW" cta={{ label: 'Got it', onSelect: dismissWhatsNew }}>
|
||||
Tracker now runs on the shared ByteLyst design system — consistent header band, accessible
|
||||
controls, and full dark-mode parity.
|
||||
</Announcement>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
dashboards/tracker-web/src/components/ui/Primitives.tsx
Normal file
170
dashboards/tracker-web/src/components/ui/Primitives.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Primitives adapter for @bytelyst/ui components.
|
||||
*
|
||||
* This file re-exports shared UI components from @bytelyst/ui, providing a single
|
||||
* import point for the tracker-web application. All app code should import from this
|
||||
* adapter rather than directly from @bytelyst/ui to enable future UI-drift ratcheting.
|
||||
*
|
||||
* @see docs/roadmaps/UX_INTEGRATION_BYTELYST.md (UX-1)
|
||||
*/
|
||||
|
||||
export { Button, type ButtonProps } from '@bytelyst/ui';
|
||||
|
||||
export { Input, type InputProps } from '@bytelyst/ui';
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldTitle,
|
||||
type FieldContentProps,
|
||||
type FieldDescriptionProps,
|
||||
type FieldErrorProps,
|
||||
type FieldGroupProps,
|
||||
type FieldLabelProps,
|
||||
type FieldProps,
|
||||
type FieldTitleProps,
|
||||
} from '@bytelyst/ui';
|
||||
|
||||
export { Modal, type ModalProps } from '@bytelyst/ui';
|
||||
|
||||
export { ConfirmDialog, type ConfirmDialogProps } from '@bytelyst/ui';
|
||||
|
||||
export { Badge, type BadgeProps } from '@bytelyst/ui';
|
||||
|
||||
export { StatusBadge, StatusDot, type StatusBadgeProps, type StatusTone } from '@bytelyst/ui';
|
||||
|
||||
export { EmptyState, type EmptyStateProps } from '@bytelyst/ui';
|
||||
|
||||
export {
|
||||
Skeleton,
|
||||
SkeletonGroup,
|
||||
TableSkeleton,
|
||||
type SkeletonProps,
|
||||
type SkeletonGroupProps,
|
||||
} from '@bytelyst/ui';
|
||||
|
||||
export { MetricCard, type MetricCardProps } from '@bytelyst/ui';
|
||||
|
||||
export {
|
||||
Toast,
|
||||
ToastProvider,
|
||||
useToast,
|
||||
toast,
|
||||
dismissToast,
|
||||
type ToastMessage,
|
||||
} from '@bytelyst/ui';
|
||||
|
||||
// ── UX-9.1: broaden the adapter with the shared controls later waves need ──
|
||||
// App code imports only from this adapter (preserves the UI-drift ratchet, CC.6).
|
||||
|
||||
export { Select, type SelectProps } from '@bytelyst/ui';
|
||||
|
||||
export { Textarea, type TextareaProps } from '@bytelyst/ui';
|
||||
|
||||
export { Checkbox, type CheckboxProps } from '@bytelyst/ui';
|
||||
|
||||
export { Switch, type SwitchProps } from '@bytelyst/ui';
|
||||
|
||||
export { RadioGroup, RadioGroupItem, type RadioGroupItemProps } from '@bytelyst/ui';
|
||||
|
||||
export {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
type TooltipContentProps,
|
||||
} from '@bytelyst/ui';
|
||||
|
||||
export {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
type TabsContentProps,
|
||||
type TabsListProps,
|
||||
type TabsTriggerProps,
|
||||
} from '@bytelyst/ui';
|
||||
|
||||
export {
|
||||
SegmentedControl,
|
||||
type SegmentedControlOption,
|
||||
type SegmentedControlProps,
|
||||
} from '@bytelyst/ui';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
type DropdownMenuContentProps,
|
||||
type DropdownMenuItemProps,
|
||||
type DropdownMenuLabelProps,
|
||||
} from '@bytelyst/ui';
|
||||
|
||||
export { Drawer, type DrawerProps } from '@bytelyst/ui';
|
||||
|
||||
export { ActionMenu, type ActionMenuItem, type ActionMenuProps } from '@bytelyst/ui';
|
||||
|
||||
export { IconButton, type IconButtonProps } from '@bytelyst/ui';
|
||||
|
||||
export { AlertBanner, type AlertBannerProps, type AlertBannerTone } from '@bytelyst/ui';
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, type CardProps } from '@bytelyst/ui';
|
||||
|
||||
export {
|
||||
Panel,
|
||||
PanelBody,
|
||||
PanelDescription,
|
||||
PanelHeader,
|
||||
PanelTitle,
|
||||
type PanelBodyProps,
|
||||
type PanelDescriptionProps,
|
||||
type PanelHeaderProps,
|
||||
type PanelProps,
|
||||
type PanelTitleProps,
|
||||
} from '@bytelyst/ui';
|
||||
|
||||
export { Separator, type SeparatorProps } from '@bytelyst/ui';
|
||||
|
||||
export {
|
||||
DataList,
|
||||
DataListItem,
|
||||
DataListMeta,
|
||||
type DataListItemProps,
|
||||
type DataListMetaProps,
|
||||
type DataListProps,
|
||||
} from '@bytelyst/ui';
|
||||
|
||||
export { Timeline, type TimelineItem, type TimelineProps } from '@bytelyst/ui';
|
||||
|
||||
// ── UX-8: AppShell nav shell ──────────────────────────────────────────────
|
||||
export {
|
||||
AppShell,
|
||||
AppShellMain,
|
||||
AppShellMobileToggle,
|
||||
AppShellNav,
|
||||
AppShellNavItem,
|
||||
AppShellOverlay,
|
||||
AppShellPageHeader,
|
||||
AppShellSidebar,
|
||||
AppShellSkipLink,
|
||||
type AppShellMainProps,
|
||||
type AppShellMobileToggleProps,
|
||||
type AppShellNavItemProps,
|
||||
type AppShellNavProps,
|
||||
type AppShellOverlayProps,
|
||||
type AppShellPageHeaderProps,
|
||||
type AppShellProps,
|
||||
type AppShellSidebarProps,
|
||||
type AppShellSkipLinkProps,
|
||||
} from '@bytelyst/ui';
|
||||
74
dashboards/tracker-web/src/lib/command-registry.ts
Normal file
74
dashboards/tracker-web/src/lib/command-registry.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Pure command-registry builder for the ⌘K command palette (UX-5).
|
||||
*
|
||||
* Kept free of React/Next imports so it is unit-testable in a node
|
||||
* environment and reusable by the client `CommandMenu` shell.
|
||||
*
|
||||
* @see docs/roadmaps/UX_INTEGRATION_BYTELYST.md (UX-5)
|
||||
*/
|
||||
|
||||
import type { Command } from '@bytelyst/command-palette';
|
||||
|
||||
export interface CommandMenuDeps {
|
||||
/** Navigate to an in-app route. */
|
||||
navigate: (href: string) => void;
|
||||
/** Open the "create item" flow. */
|
||||
newItem: () => void;
|
||||
/** Flip the colour theme. */
|
||||
toggleTheme: () => void;
|
||||
/** Sign the current user out. */
|
||||
signOut: () => void;
|
||||
/** Switch the active product (wires ProductSwitcher). */
|
||||
setProduct: (id: string) => void;
|
||||
/** Known products to expose as switch targets. */
|
||||
products: ReadonlyArray<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
const navCommand = (id: string, label: string, href: string): Command => ({
|
||||
id,
|
||||
label,
|
||||
mode: 'navigate',
|
||||
href,
|
||||
section: 'Navigate',
|
||||
keywords: ['go', 'open', label.toLowerCase()],
|
||||
});
|
||||
|
||||
/** Build the full command list for the palette from the supplied callbacks. */
|
||||
export function buildCommands(deps: CommandMenuDeps): Command[] {
|
||||
return [
|
||||
navCommand('nav-overview', 'Overview', '/dashboard'),
|
||||
navCommand('nav-items', 'Items', '/dashboard/items'),
|
||||
navCommand('nav-board', 'Board', '/dashboard/board'),
|
||||
navCommand('nav-roadmap', 'Roadmap', '/roadmap'),
|
||||
{
|
||||
id: 'new-item',
|
||||
label: 'New item',
|
||||
section: 'Actions',
|
||||
keywords: ['create', 'add', 'item'],
|
||||
run: deps.newItem,
|
||||
},
|
||||
{
|
||||
id: 'toggle-theme',
|
||||
label: 'Toggle theme',
|
||||
section: 'Actions',
|
||||
keywords: ['dark', 'light', 'appearance'],
|
||||
run: deps.toggleTheme,
|
||||
},
|
||||
{
|
||||
id: 'sign-out',
|
||||
label: 'Sign out',
|
||||
section: 'Actions',
|
||||
keywords: ['logout', 'log out'],
|
||||
run: deps.signOut,
|
||||
},
|
||||
...deps.products.map(
|
||||
(p): Command => ({
|
||||
id: `switch-product-${p.id}`,
|
||||
label: `Switch product: ${p.name}`,
|
||||
section: 'Product',
|
||||
keywords: ['product', 'switch', p.name.toLowerCase()],
|
||||
run: () => deps.setProduct(p.id),
|
||||
})
|
||||
),
|
||||
];
|
||||
}
|
||||
193
dashboards/tracker-web/src/lib/fleet-client.ts
Normal file
193
dashboards/tracker-web/src/lib/fleet-client.ts
Normal file
@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Fleet API client — typed wrapper for the fleet coordinator endpoints.
|
||||
* Follows the same pattern as tracker-client.ts.
|
||||
* Degrades gracefully: 404s return null, network errors return defaults.
|
||||
*/
|
||||
|
||||
import { createApiClient } from '@bytelyst/api-client';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FleetJob {
|
||||
id: string;
|
||||
productId: string;
|
||||
stage: string;
|
||||
idempotencyKey: string;
|
||||
bodyMd: string;
|
||||
priority: string;
|
||||
priorityOrder: number;
|
||||
capabilities: string[];
|
||||
kind: string;
|
||||
parentId?: string;
|
||||
attempts: number;
|
||||
leaseEpoch: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FleetFactory {
|
||||
id: string;
|
||||
productId: string;
|
||||
factoryId: string;
|
||||
capabilities: string[];
|
||||
health: 'ok' | 'degraded' | 'down';
|
||||
load: number;
|
||||
seatLimit: number;
|
||||
lastHeartbeatAt: string;
|
||||
}
|
||||
|
||||
export interface FleetRun {
|
||||
id: string;
|
||||
jobId: string;
|
||||
attempt: number;
|
||||
factoryId?: string;
|
||||
engine: string;
|
||||
startedAt: string;
|
||||
endedAt?: string;
|
||||
result?: string;
|
||||
insights: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FleetEvent {
|
||||
id: string;
|
||||
jobId: string;
|
||||
seq: number;
|
||||
type: string;
|
||||
at: string;
|
||||
actor?: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FleetArtifact {
|
||||
id: string;
|
||||
jobId: string;
|
||||
kind: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FleetBudget {
|
||||
id: string;
|
||||
productId: string;
|
||||
ceilingUsd: number;
|
||||
window: string;
|
||||
spentUsd: number;
|
||||
status: 'active' | 'paused';
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DagNode {
|
||||
id: string;
|
||||
idempotencyKey: string;
|
||||
stage: string;
|
||||
priority: string;
|
||||
kind: string;
|
||||
parentId?: string;
|
||||
children: DagNode[];
|
||||
}
|
||||
|
||||
// ── Client ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fleetApi = createApiClient({
|
||||
baseUrl: '/api/fleet',
|
||||
getToken: () => (typeof window !== 'undefined' ? localStorage.getItem('tracker_token') : null),
|
||||
});
|
||||
|
||||
function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const extra: Record<string, string> = {};
|
||||
if (typeof window !== 'undefined') {
|
||||
const pid = localStorage.getItem('tracker_selected_product');
|
||||
if (pid) extra['x-product-id'] = pid;
|
||||
}
|
||||
return fleetApi.fetch<T>(path, {
|
||||
...options,
|
||||
headers: { ...extra, ...(options?.headers as Record<string, string>) },
|
||||
});
|
||||
}
|
||||
|
||||
/** Graceful fetch — returns null on 404 instead of throwing. */
|
||||
async function apiFetchOptional<T>(path: string, options?: RequestInit): Promise<T | null> {
|
||||
try {
|
||||
return await apiFetch<T>(path, options);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes('404')) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Jobs ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ListJobsParams {
|
||||
stage?: string;
|
||||
productId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export async function listJobs(params?: ListJobsParams): Promise<{ jobs: FleetJob[] }> {
|
||||
const qs = params ? `?${new URLSearchParams(params as Record<string, string>).toString()}` : '';
|
||||
return apiFetch(`/jobs${qs}`);
|
||||
}
|
||||
|
||||
export async function getJob(id: string): Promise<FleetJob | null> {
|
||||
return apiFetchOptional(`/jobs/${id}`);
|
||||
}
|
||||
|
||||
export async function patchJob(
|
||||
id: string,
|
||||
body: { leaseEpoch: number; stage: string }
|
||||
): Promise<FleetJob> {
|
||||
return apiFetch(`/jobs/${id}`, { method: 'PATCH', body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export async function getJobRuns(jobId: string): Promise<{ runs: FleetRun[] }> {
|
||||
return apiFetch(`/jobs/${jobId}/runs`);
|
||||
}
|
||||
|
||||
export async function getJobEvents(jobId: string): Promise<{ events: FleetEvent[] }> {
|
||||
return apiFetch(`/jobs/${jobId}/events`);
|
||||
}
|
||||
|
||||
export async function getJobArtifacts(jobId: string): Promise<{ artifacts: FleetArtifact[] }> {
|
||||
return apiFetch(`/jobs/${jobId}/artifacts`);
|
||||
}
|
||||
|
||||
export async function getJobDag(jobId: string): Promise<{ dag: DagNode } | null> {
|
||||
return apiFetchOptional(`/jobs/${jobId}/dag`);
|
||||
}
|
||||
|
||||
// ── Factories ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listFactories(): Promise<{ factories: FleetFactory[] }> {
|
||||
try {
|
||||
return await apiFetch('/factories');
|
||||
} catch {
|
||||
return { factories: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Budgets ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getBudget(productId: string): Promise<FleetBudget | null> {
|
||||
return apiFetchOptional(`/budgets/${productId}`);
|
||||
}
|
||||
|
||||
export async function upsertBudget(
|
||||
productId: string,
|
||||
ceilingUsd: number,
|
||||
window: string
|
||||
): Promise<FleetBudget> {
|
||||
return apiFetch(`/budgets/${productId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ceilingUsd, window }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function pauseBudget(productId: string): Promise<FleetBudget> {
|
||||
return apiFetch(`/budgets/${productId}/pause`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function resumeBudget(productId: string): Promise<FleetBudget> {
|
||||
return apiFetch(`/budgets/${productId}/resume`, { method: 'POST' });
|
||||
}
|
||||
82
dashboards/tracker-web/src/lib/overview-charts.ts
Normal file
82
dashboards/tracker-web/src/lib/overview-charts.ts
Normal file
@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Pure helpers that map TrackerStats into @bytelyst/charts + @bytelyst/data-viz
|
||||
* data shapes for the dashboard overview (UX-4).
|
||||
*
|
||||
* Kept separate from the page component so the transforms are unit-testable
|
||||
* (no DOM needed) and guaranteed to emit only finite numbers — protecting the
|
||||
* SVG charts from NaN path data.
|
||||
*
|
||||
* @see docs/roadmaps/UX_INTEGRATION_BYTELYST.md (UX-4)
|
||||
*/
|
||||
|
||||
import type { DonutSlice, BarDatum } from '@bytelyst/charts';
|
||||
import type { TrackerStats } from './tracker-client';
|
||||
|
||||
/** Cycle the bridged --bl-chart-* palette (maps onto tracker's --chart-1..5). */
|
||||
export const CHART_PALETTE = [
|
||||
'var(--bl-chart-1)',
|
||||
'var(--bl-chart-2)',
|
||||
'var(--bl-chart-3)',
|
||||
'var(--bl-chart-4)',
|
||||
'var(--bl-chart-5)',
|
||||
] as const;
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
open: 'Open',
|
||||
in_progress: 'In Progress',
|
||||
done: 'Done',
|
||||
closed: 'Closed',
|
||||
wont_fix: "Won't Fix",
|
||||
bug: 'Bug',
|
||||
feature: 'Feature',
|
||||
task: 'Task',
|
||||
critical: 'Critical',
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
};
|
||||
|
||||
/** Coerce any value to a finite, non-negative number (no NaN reaches the SVG). */
|
||||
const safe = (v: number | undefined): number =>
|
||||
typeof v === 'number' && Number.isFinite(v) ? Math.max(0, v) : 0;
|
||||
|
||||
function toSlices(entries: Record<string, number>): DonutSlice[] {
|
||||
return Object.entries(entries).map(([key, value], i) => ({
|
||||
id: key,
|
||||
value: safe(value),
|
||||
label: LABELS[key] ?? key,
|
||||
color: CHART_PALETTE[i % CHART_PALETTE.length],
|
||||
}));
|
||||
}
|
||||
|
||||
export const statusSlices = (stats: TrackerStats): DonutSlice[] => toSlices(stats.byStatus);
|
||||
export const typeSlices = (stats: TrackerStats): DonutSlice[] => toSlices(stats.byType);
|
||||
|
||||
/** Priority bars in a fixed, meaningful order (critical → low). */
|
||||
export function priorityBars(stats: TrackerStats): BarDatum[] {
|
||||
const order = ['critical', 'high', 'medium', 'low'];
|
||||
return order
|
||||
.filter(key => key in stats.byPriority)
|
||||
.map((key, i) => ({
|
||||
id: key,
|
||||
value: safe(stats.byPriority[key]),
|
||||
label: LABELS[key] ?? key,
|
||||
color: CHART_PALETTE[i % CHART_PALETTE.length],
|
||||
}));
|
||||
}
|
||||
|
||||
export interface OverviewKpis {
|
||||
total: number;
|
||||
open: number;
|
||||
inProgress: number;
|
||||
done: number;
|
||||
}
|
||||
|
||||
export function overviewKpis(stats: TrackerStats): OverviewKpis {
|
||||
return {
|
||||
total: safe(stats.total),
|
||||
open: safe(stats.byStatus.open),
|
||||
inProgress: safe(stats.byStatus.in_progress),
|
||||
done: safe(stats.byStatus.done),
|
||||
};
|
||||
}
|
||||
@ -5,7 +5,7 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
exclude: ['e2e/**', 'node_modules/**', '.next/**'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
@ -18,13 +18,13 @@ export default defineConfig({
|
||||
'**/*.config.*',
|
||||
'**/e2e/**',
|
||||
],
|
||||
// Vitest reads these keys directly under `thresholds` (the legacy `global`
|
||||
// nesting is ignored by the v8 provider and silently disables enforcement).
|
||||
thresholds: {
|
||||
global: {
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -32,5 +32,9 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
// Workspace packages (e.g. @bytelyst/charts) can resolve their own React
|
||||
// copy via the pnpm store; dedupe so SSR render tests use a single React
|
||||
// instance (avoids the "Invalid hook call" dual-package hazard).
|
||||
dedupe: ['react', 'react-dom'],
|
||||
},
|
||||
});
|
||||
|
||||
@ -48,8 +48,8 @@ services:
|
||||
cosmos-emulator:
|
||||
image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview
|
||||
ports:
|
||||
- '8081:8081'
|
||||
- '1234:1234'
|
||||
- '127.0.0.1:8081:8081'
|
||||
- '127.0.0.1:1234:1234'
|
||||
environment:
|
||||
- PROTOCOL=http
|
||||
- ENABLE_EXPLORER=true
|
||||
@ -61,12 +61,16 @@ services:
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
|
||||
azurite:
|
||||
image: mcr.microsoft.com/azure-storage/azurite:3.35.0
|
||||
command: azurite-blob --blobHost 0.0.0.0 --blobPort 10000 --location /data --skipApiVersionCheck
|
||||
ports:
|
||||
- '10000:10000'
|
||||
- '127.0.0.1:10000:10000'
|
||||
volumes:
|
||||
- azurite-data:/data
|
||||
healthcheck:
|
||||
@ -75,23 +79,31 @@ services:
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256m
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.27.5
|
||||
ports:
|
||||
- '1025:1025'
|
||||
- '8025:8025'
|
||||
- '127.0.0.1:1025:1025'
|
||||
- '127.0.0.1:8025:8025'
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '-q', '--spider', 'http://127.0.0.1:8025']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128m
|
||||
|
||||
loki:
|
||||
image: grafana/loki:3.3.2
|
||||
ports:
|
||||
- '3100:3100'
|
||||
- '127.0.0.1:3100:3100'
|
||||
volumes:
|
||||
- ./services/monitoring/loki/loki-config.yml:/etc/loki/local-config.yaml
|
||||
- loki-data:/loki
|
||||
@ -102,11 +114,15 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384m
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:11.4.0
|
||||
ports:
|
||||
- '3000:3000'
|
||||
- '127.0.0.1:3000:3000'
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=bytelyst
|
||||
@ -124,6 +140,10 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384m
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:v3.5.0
|
||||
@ -147,6 +167,10 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384m
|
||||
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.9.1
|
||||
@ -163,6 +187,10 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128m
|
||||
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:v0.49.1
|
||||
@ -184,6 +212,10 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256m
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8-alpine
|
||||
@ -204,6 +236,10 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128m
|
||||
|
||||
gateway:
|
||||
image: traefik:v3.3
|
||||
@ -218,20 +254,26 @@ services:
|
||||
- '--accesslog.format=json'
|
||||
ports:
|
||||
- '80:80'
|
||||
- '8080:8080'
|
||||
- '127.0.0.1:8080:8080'
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
depends_on:
|
||||
loki:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128m
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: caddy
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
# Bind to public eth0 IP only (not 0.0.0.0) so tailscaled can claim
|
||||
# 100.87.53.10:443 for `tailscale serve` on the tailnet.
|
||||
- '187.124.159.82:80:80'
|
||||
- '187.124.159.82:443:443'
|
||||
volumes:
|
||||
- ../Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
@ -244,6 +286,10 @@ services:
|
||||
mcp-server:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256m
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════
|
||||
# PLATFORM SERVICES (from this repo)
|
||||
@ -279,6 +325,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
extraction-service:
|
||||
build:
|
||||
@ -304,6 +354,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
mcp-server:
|
||||
build:
|
||||
@ -330,6 +384,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384m
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════
|
||||
# PLATFORM DASHBOARDS (from this repo)
|
||||
@ -340,7 +398,7 @@ services:
|
||||
context: .
|
||||
dockerfile: dashboards/admin-web/Dockerfile
|
||||
ports:
|
||||
- '3001:3001'
|
||||
- '127.0.0.1:3001:3001'
|
||||
env_file:
|
||||
- .env.ecosystem
|
||||
environment:
|
||||
@ -359,13 +417,17 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
tracker-web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: dashboards/tracker-web/Dockerfile
|
||||
ports:
|
||||
- '3003:3003'
|
||||
- '127.0.0.1:3003:3003'
|
||||
env_file:
|
||||
- .env.ecosystem
|
||||
environment:
|
||||
@ -381,6 +443,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════
|
||||
# PRODUCT BACKENDS (from sibling repos)
|
||||
@ -406,6 +472,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
chronomind-backend:
|
||||
build:
|
||||
@ -427,6 +497,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
jarvisjr-backend:
|
||||
build:
|
||||
@ -448,6 +522,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
nomgap-backend:
|
||||
build:
|
||||
@ -469,6 +547,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
mindlyst-backend:
|
||||
build:
|
||||
@ -490,6 +572,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
lysnrai-backend:
|
||||
build:
|
||||
@ -511,6 +597,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
notelett-backend:
|
||||
build:
|
||||
@ -533,6 +623,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
flowmonk-backend:
|
||||
build:
|
||||
@ -554,6 +648,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
actiontrail-backend:
|
||||
build:
|
||||
@ -575,6 +673,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
localmemgpt-backend:
|
||||
build:
|
||||
@ -601,6 +703,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
efforise-backend:
|
||||
build:
|
||||
@ -608,7 +714,7 @@ services:
|
||||
context: ../learning_ai_efforise
|
||||
dockerfile: backend/Dockerfile
|
||||
ports:
|
||||
- '4020:4020'
|
||||
- '127.0.0.1:4020:4020'
|
||||
env_file:
|
||||
- .env.ecosystem
|
||||
environment:
|
||||
@ -624,6 +730,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════
|
||||
# PRODUCT WEB APPS (from sibling repos)
|
||||
@ -640,7 +750,7 @@ services:
|
||||
NEXT_PUBLIC_PLATFORM_SERVICE_URL: http://localhost:4003
|
||||
NEXT_PUBLIC_PRODUCT_ID: lysnrai
|
||||
ports:
|
||||
- '3002:3002'
|
||||
- '127.0.0.1:3002:3002'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3002
|
||||
@ -656,6 +766,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
chronomind-web:
|
||||
build:
|
||||
@ -667,7 +781,7 @@ services:
|
||||
NEXT_PUBLIC_BACKEND_URL: http://localhost:4011
|
||||
NEXT_PUBLIC_PLATFORM_SERVICE_URL: http://localhost:4003
|
||||
ports:
|
||||
- '3030:3030'
|
||||
- '127.0.0.1:3030:3030'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3030
|
||||
@ -682,6 +796,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
jarvisjr-web:
|
||||
build:
|
||||
@ -692,10 +810,11 @@ services:
|
||||
GITEA_NPM_HOST: ${GITEA_NPM_HOST:-host.docker.internal}
|
||||
NEXT_PUBLIC_PLATFORM_SERVICE_URL: http://localhost:4003
|
||||
ports:
|
||||
- '3035:3035'
|
||||
- '127.0.0.1:3035:3035'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3035
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PLATFORM_SERVICE_URL=http://platform-service:4003
|
||||
depends_on:
|
||||
jarvisjr-backend:
|
||||
@ -706,6 +825,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
flowmonk-web:
|
||||
build:
|
||||
@ -717,10 +840,11 @@ services:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:4017
|
||||
NEXT_PUBLIC_PLATFORM_URL: http://localhost:4003/api
|
||||
ports:
|
||||
- '3040:3040'
|
||||
- '127.0.0.1:3040:3040'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3040
|
||||
- HOSTNAME=0.0.0.0
|
||||
- API_URL=http://flowmonk-backend:4017
|
||||
- PLATFORM_URL=http://platform-service:4003/api
|
||||
depends_on:
|
||||
@ -732,6 +856,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
notelett-web:
|
||||
build:
|
||||
@ -743,7 +871,7 @@ services:
|
||||
NEXT_PUBLIC_NOTES_API_URL: http://localhost:4016/api
|
||||
NEXT_PUBLIC_PLATFORM_SERVICE_URL: http://localhost:4003/api
|
||||
ports:
|
||||
- '3045:3045'
|
||||
- '127.0.0.1:3045:3045'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3045
|
||||
@ -758,6 +886,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
mindlyst-web:
|
||||
build:
|
||||
@ -768,10 +900,11 @@ services:
|
||||
GITEA_NPM_HOST: ${GITEA_NPM_HOST:-host.docker.internal}
|
||||
NEXT_PUBLIC_PLATFORM_SERVICE_URL: http://localhost:4003
|
||||
ports:
|
||||
- '3050:3050'
|
||||
- '127.0.0.1:3050:3050'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3050
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PLATFORM_SERVICE_URL=http://platform-service:4003
|
||||
depends_on:
|
||||
mindlyst-backend:
|
||||
@ -782,6 +915,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
# TODO(nomgap): Decide whether local Docker smoke tests still need a web
|
||||
# service once the Vercel deployment path is fully documented.
|
||||
@ -797,10 +934,11 @@ services:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:4018
|
||||
NEXT_PUBLIC_PLATFORM_URL: http://localhost:4003
|
||||
ports:
|
||||
- '3060:3060'
|
||||
- '127.0.0.1:3060:3060'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3060
|
||||
- HOSTNAME=0.0.0.0
|
||||
- API_URL=http://actiontrail-backend:4018
|
||||
- PLATFORM_URL=http://platform-service:4003
|
||||
depends_on:
|
||||
@ -812,6 +950,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
localmemgpt-web:
|
||||
build:
|
||||
@ -823,10 +965,11 @@ services:
|
||||
NEXT_PUBLIC_BACKEND_URL: http://localhost:4019
|
||||
NEXT_PUBLIC_PLATFORM_URL: http://localhost:4003
|
||||
ports:
|
||||
- '3070:3070'
|
||||
- '127.0.0.1:3070:3070'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3070
|
||||
- HOSTNAME=0.0.0.0
|
||||
- BACKEND_URL=http://localmemgpt-backend:4019
|
||||
- PLATFORM_URL=http://platform-service:4003
|
||||
depends_on:
|
||||
@ -838,6 +981,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
efforise-web:
|
||||
build:
|
||||
@ -845,7 +992,7 @@ services:
|
||||
context: ../learning_ai_efforise
|
||||
dockerfile: client/Dockerfile
|
||||
ports:
|
||||
- '3080:3080'
|
||||
- '127.0.0.1:3080:3080'
|
||||
depends_on:
|
||||
efforise-backend:
|
||||
condition: service_healthy
|
||||
@ -855,6 +1002,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
# ── Local LLM Lab (no backend — dashboard talks directly to Ollama) ──
|
||||
|
||||
@ -867,10 +1018,11 @@ services:
|
||||
GITEA_NPM_HOST: ${GITEA_NPM_HOST:-host.docker.internal}
|
||||
OLLAMA_URL: http://host.docker.internal:11434
|
||||
ports:
|
||||
- '3075:3075'
|
||||
- '127.0.0.1:3075:3075'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3075
|
||||
- HOSTNAME=0.0.0.0
|
||||
- OLLAMA_URL=http://host.docker.internal:11434
|
||||
- OLLAMA_HOST=http://host.docker.internal:11434
|
||||
extra_hosts:
|
||||
@ -881,6 +1033,10 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════
|
||||
# VOLUMES
|
||||
|
||||
@ -33,27 +33,28 @@ services:
|
||||
retries: 6
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Azure Cosmos DB Emulator (prototype only) ─────────────────
|
||||
cosmos-emulator:
|
||||
image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview
|
||||
ports:
|
||||
- '8081:8081'
|
||||
- '1234:1234'
|
||||
environment:
|
||||
- PROTOCOL=http
|
||||
- ENABLE_EXPLORER=true
|
||||
- GATEWAY_PUBLIC_ENDPOINT=cosmos-emulator
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD-SHELL',
|
||||
'bash -lc ''exec 3<>/dev/tcp/127.0.0.1/8080; printf "GET /ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep -q "200 OK" <&3''',
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
# ── Azure Cosmos DB Emulator — REMOVED 2026-05-30 ─────────────
|
||||
#
|
||||
# Both image variants we tried were unfit for the prototype:
|
||||
# - `:vnext-preview` (Postgres-backed experimental): cross-partition
|
||||
# `queryFeed` returned plain-text PGCosmosError strings instead of
|
||||
# JSON, crashing @azure/cosmos at JSON.parse on every login,
|
||||
# register, OAuth, and feature-flag list call
|
||||
# - `:latest` (stable Linux port of Windows emulator): HTTPS-only
|
||||
# with a self-signed cert and core-dumped under modest load,
|
||||
# leaving services hung waiting on never-resolving futures
|
||||
#
|
||||
# Replacement: real Azure Cosmos DB account `cosmos-mywisprai` in
|
||||
# `rg-mywisprai` (West US 2), database `bytelyst`. All services pick
|
||||
# up the connection from `.env` (`COSMOS_ENDPOINT`, `COSMOS_KEY`,
|
||||
# `COSMOS_DATABASE`) via their `env_file: .env` entries below.
|
||||
#
|
||||
# If you need a local-only stack for offline development, prefer:
|
||||
# 1. Mocked Cosmos in tests (already wired across the workspace), or
|
||||
# 2. A scoped Cosmos account on a free Azure subscription with a
|
||||
# throwaway database
|
||||
# Do NOT resurrect the emulator service block without verifying both
|
||||
# of the above failure modes have been fixed upstream.
|
||||
|
||||
# ── Loki (Log Aggregation) ────────────────────────────────────
|
||||
loki:
|
||||
@ -130,6 +131,12 @@ services:
|
||||
- PORT=4003
|
||||
# Local/dev convenience: ensure Cosmos DB + containers exist.
|
||||
- COSMOS_AUTO_INIT=true
|
||||
# 2026-05-30: switched off the local Cosmos emulator (Postgres-backed
|
||||
# vnext-preview broke `queryFeed` with `PGCosmosError`; stable :latest
|
||||
# crashed under load with a core dump). Pointed at the real Azure
|
||||
# Cosmos DB account (`cosmos-mywisprai`, db `bytelyst`) instead. Values
|
||||
# come from `.env`; the cosmos-emulator service block in this compose
|
||||
# file is no longer needed and platform-service no longer depends on it.
|
||||
- PLATFORM_SERVICE_URL=http://platform-service:4003
|
||||
- EXTRACTION_SERVICE_URL=http://extraction-service:4005
|
||||
- MCP_SERVER_URL=http://mcp-server:4007
|
||||
@ -139,8 +146,6 @@ services:
|
||||
condition: service_healthy
|
||||
azurite:
|
||||
condition: service_healthy
|
||||
cosmos-emulator:
|
||||
condition: service_healthy
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.platform.rule=PathPrefix(`/api`) || PathPrefix(`/public`) || PathPrefix(`/health`)'
|
||||
@ -164,9 +169,7 @@ services:
|
||||
environment:
|
||||
- PORT=4005
|
||||
- PYTHON_SIDECAR_URL=http://localhost:4006
|
||||
depends_on:
|
||||
cosmos-emulator:
|
||||
condition: service_healthy
|
||||
# COSMOS_* come from `.env` (real Cosmos account; see top of file).
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.extraction.rule=PathPrefix(`/api/extract`) || PathPrefix(`/api/tasks`)'
|
||||
@ -230,12 +233,10 @@ services:
|
||||
- PORT=4009
|
||||
- NODE_ENV=development
|
||||
- PRODUCT_ID=clawcowork
|
||||
- COSMOS_ENDPOINT=https://cosmos-emulator:8081
|
||||
# COSMOS_* come from `.env` (real Cosmos account; see top of file).
|
||||
- PLATFORM_SERVICE_URL=http://platform-service:4003
|
||||
- EXTRACTION_SERVICE_URL=http://extraction-service:4005
|
||||
depends_on:
|
||||
cosmos-emulator:
|
||||
condition: service_healthy
|
||||
platform-service:
|
||||
condition: service_healthy
|
||||
labels:
|
||||
|
||||
148
docs/FLEET_CONTROL_PLANE.md
Normal file
148
docs/FLEET_CONTROL_PLANE.md
Normal file
@ -0,0 +1,148 @@
|
||||
# Fleet Control Plane — Operational Guide
|
||||
|
||||
> Phase 3 of the Agent Gigafactory. Adds tunable scoring, preemption, DAG decomposition, per-product budgets, and a tracker-web UI.
|
||||
|
||||
## Feature Flags
|
||||
|
||||
All Phase 3 features are **gated behind environment variables** (default OFF) for safe rollout:
|
||||
|
||||
| Flag | Default | Effect |
|
||||
| ------------------ | ------- | ----------------------------------------------------------------------------- |
|
||||
| `FLEET_PREEMPTION` | `""` | Enables seat-limit enforcement + critical-job preemption |
|
||||
| `FLEET_BUDGETS` | `""` | Enables per-product USD ceiling enforcement. Pauses jobs when budget exceeded |
|
||||
|
||||
Set to any truthy value (`"1"`, `"true"`, `"yes"`) to enable.
|
||||
|
||||
## Tunable Scoring Weights
|
||||
|
||||
Scoring determines which queued job a factory picks up next. The formula:
|
||||
|
||||
```
|
||||
score = w.age * ageMinutes + w.priority * priorityOrder + w.retries * attempts + w.capabilities * capabilityBonus
|
||||
```
|
||||
|
||||
### Weight Resolution Order
|
||||
|
||||
1. **Per-request override** — `weights` field in `POST /fleet/jobs/:id/claim` body
|
||||
2. **Product registry** — set via `setWeightRegistry({ [productId]: weights })`
|
||||
3. **Defaults** — `{ age: 1, priority: 10, retries: -2, capabilities: 5 }`
|
||||
|
||||
Each level does a **per-field merge** (not full object replacement).
|
||||
|
||||
## Preemption
|
||||
|
||||
When `FLEET_PREEMPTION` is enabled and a factory is at its `seatLimit`:
|
||||
|
||||
1. A critical-priority job arrives in `claimNextJob`
|
||||
2. `selectPreemptionVictim(runningJobs, incomingJob)` picks the lowest-scoring running job
|
||||
3. The victim is evicted: its lease is released with `checkpoint: true`, ensuring the job can resume
|
||||
4. The critical job takes the freed seat
|
||||
5. An event `{ type: 'preempted', victim, preemptor }` is recorded
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Only `critical` priority can trigger preemption
|
||||
- Never preempts jobs of equal or higher priority
|
||||
- Capability mismatch disqualifies a factory from preemption
|
||||
|
||||
## DAG Job Decomposition
|
||||
|
||||
Submit a composite job with children for parallel fan-out:
|
||||
|
||||
```http
|
||||
POST /fleet/jobs
|
||||
{
|
||||
"idempotencyKey": "parent-job",
|
||||
"kind": "composite",
|
||||
"children": [
|
||||
{ "idempotencyKey": "child-1", "bodyMd": "..." },
|
||||
{ "idempotencyKey": "child-2", "bodyMd": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Or add children later:
|
||||
|
||||
```http
|
||||
POST /fleet/jobs/:parentId/children
|
||||
{
|
||||
"children": [
|
||||
{ "idempotencyKey": "child-3", "bodyMd": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- Parent is automatically blocked until all children complete (children's idempotency keys become parent deps)
|
||||
- Children unblock parent via `maybeUnblockParent()` when transitioning to `shipped`/`done`
|
||||
- View the full DAG: `GET /fleet/jobs/:id/dag`
|
||||
|
||||
## Per-Product Budgets
|
||||
|
||||
Control spend per product with USD ceilings:
|
||||
|
||||
```http
|
||||
PUT /fleet/budgets/:productId
|
||||
{ "ceilingUsd": 100, "window": "monthly" }
|
||||
```
|
||||
|
||||
| Endpoint | Method | Effect |
|
||||
| ---------------------------------- | ------ | ----------------------- |
|
||||
| `/fleet/budgets/:productId` | GET | Read current budget |
|
||||
| `/fleet/budgets/:productId` | PUT | Create/update ceiling |
|
||||
| `/fleet/budgets/:productId/pause` | POST | Manually pause spending |
|
||||
| `/fleet/budgets/:productId/resume` | POST | Resume spending |
|
||||
|
||||
**Enforcement:** When `FLEET_BUDGETS` is enabled, `claimNextJob` checks budget status FIRST. If paused or ceiling exceeded → returns null (no job scan).
|
||||
|
||||
**Auto-pause:** `accrueSpend(productId, amount)` auto-pauses when `spentUsd >= ceilingUsd`.
|
||||
|
||||
## Fleet Control Plane UI (tracker-web)
|
||||
|
||||
Navigate to **Dashboard → Fleet** in tracker-web.
|
||||
|
||||
### Pages
|
||||
|
||||
| Route | Description |
|
||||
| ---------------------------- | ----------------------------------------------- |
|
||||
| `/dashboard/fleet` | Overview — factory health cards + recent jobs |
|
||||
| `/dashboard/fleet/jobs` | Job list with stage filter tabs |
|
||||
| `/dashboard/fleet/jobs/[id]` | Job detail — events, runs, artifacts, DAG, SHIP |
|
||||
| `/dashboard/fleet/budget` | Budget view — spend bar, pause/resume controls |
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
The UI calls platform-service fleet endpoints via `/api/fleet/[...path]` proxy. If the fleet module returns 404 (flags off), pages display informational empty states instead of errors.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Env Var | Default | Purpose |
|
||||
| ------------------ | ----------------------- | ----------------------------------- |
|
||||
| `PLATFORM_API_URL` | `http://localhost:4003` | Platform-service base URL for proxy |
|
||||
|
||||
## API Reference Summary
|
||||
|
||||
| Endpoint | Method | Phase | Notes |
|
||||
| ---------------------------------- | ------ | ----- | -------------------------------------------------- |
|
||||
| `/fleet/jobs` | GET | 2 | List jobs (query: stage, productId, limit, offset) |
|
||||
| `/fleet/jobs` | POST | 2 | Submit job (+ optional children[] for DAG) |
|
||||
| `/fleet/jobs/:id` | GET | 2 | Get job |
|
||||
| `/fleet/jobs/:id` | PATCH | 2 | Update stage (fenced) |
|
||||
| `/fleet/jobs/:id/claim` | POST | 2 | Factory claims next job |
|
||||
| `/fleet/jobs/:id/children` | POST | 3 | Add children to existing job |
|
||||
| `/fleet/jobs/:id/dag` | GET | 3 | Get DAG subtree |
|
||||
| `/fleet/factories` | GET | 2 | List factories |
|
||||
| `/fleet/factories/:id/heartbeat` | POST | 2 | Factory heartbeat |
|
||||
| `/fleet/budgets/:productId` | GET | 3 | Get budget |
|
||||
| `/fleet/budgets/:productId` | PUT | 3 | Upsert budget |
|
||||
| `/fleet/budgets/:productId/pause` | POST | 3 | Pause budget |
|
||||
| `/fleet/budgets/:productId/resume` | POST | 3 | Resume budget |
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
1. **Feature flags default OFF** — zero breaking changes to Phase 2 behavior
|
||||
2. **Budget checked first** — avoids expensive job scan when budget is exhausted
|
||||
3. **DAG via deps array** — reuses existing dependency resolution; no new scheduler logic needed
|
||||
4. **Preemption requires seat limit** — only triggers when factory genuinely can't take more work
|
||||
5. **UI degrades gracefully** — all API calls handle 404 → null/empty; no hard failures
|
||||
115
docs/ROADMAP_2026_DECISIONS.md
Normal file
115
docs/ROADMAP_2026_DECISIONS.md
Normal file
@ -0,0 +1,115 @@
|
||||
# UI/UX Roadmap 2026 — Pragmatic Decisions
|
||||
|
||||
These default decisions are recorded so implementation work can proceed
|
||||
without blocking. They are **reversible** — any of them can be revisited
|
||||
via an RFC in `docs/rfc/`. They are intentionally biased toward "ship
|
||||
something free and reversible now, upgrade if needed."
|
||||
|
||||
Companion to:
|
||||
|
||||
- `learning_ai_uxui_web/docs/ROADMAP_2026.md` §10 TODO ledger (items #9–#13)
|
||||
|
||||
---
|
||||
|
||||
## #9 — Storybook hosting: **self-hosted, free**
|
||||
|
||||
**Decision.** Use Storybook 8 in each `@bytelyst/*` package, deployed
|
||||
to Gitea Pages from CI. Skip Chromatic for now.
|
||||
|
||||
**Why.**
|
||||
|
||||
- Chromatic is $149/mo per project for the small plan and we have ~50
|
||||
packages — not justifiable until adoption + design-team usage is
|
||||
proven.
|
||||
- Visual regression is already covered for product surfaces by the
|
||||
showcase's Playwright `toHaveScreenshot` baselines (48 today).
|
||||
- Self-hosted SB on Gitea Pages costs $0 and uses infrastructure that
|
||||
already runs every other ByteLyst static site.
|
||||
|
||||
**Reopen if.** Designers start filing issues against story flaps that
|
||||
visual-regression isn't catching, or we need cross-browser/device
|
||||
coverage that local Playwright Chromium misses.
|
||||
|
||||
---
|
||||
|
||||
## #10 — `useChat` protocol: **adopt Vercel AI SDK shape, abstract transport**
|
||||
|
||||
**Decision.** `@bytelyst/ai-ui` exposes a `useChat` hook with the same
|
||||
return shape as Vercel AI SDK's `useChat` (messages, input,
|
||||
handleSubmit, stop, regenerate, isLoading, error). The **transport** is
|
||||
pluggable — products inject a `fetcher` or `streamProtocol` so we are
|
||||
not locked to Vercel's SSE wire format.
|
||||
|
||||
**Why.**
|
||||
|
||||
- Vercel AI SDK is the de-facto standard. Adopting the shape means
|
||||
every React engineer who has written a chat UI in 2024–2026 already
|
||||
knows our API.
|
||||
- Abstracting transport gives us LysnrAI / JarvisJr / NoteLett the
|
||||
freedom to use OpenAI-native, Anthropic-native, or our own server's
|
||||
SSE protocol without forking the component.
|
||||
|
||||
**Reopen if.** Vercel changes the hook shape in a breaking way, or
|
||||
products start needing capabilities (e.g., tool-call streaming
|
||||
semantics) that the Vercel shape can't express.
|
||||
|
||||
---
|
||||
|
||||
## #11 — `react-auth` vs `auth-client`: **keep both for now, fold in Wave 7**
|
||||
|
||||
**Decision.** Leave `@bytelyst/react-auth` and `@bytelyst/auth-client`
|
||||
as separate packages through Waves 1–6. Plan to merge `react-auth` into
|
||||
`auth-client` (as `auth-client/react` subpath export) in Wave 7 once
|
||||
both APIs have stabilized.
|
||||
|
||||
**Why.**
|
||||
|
||||
- `auth-client` is framework-agnostic; `react-auth` is the React
|
||||
binding. Today they have different version cadences and that's fine.
|
||||
- Merging now means a coordinated major-bump across both packages plus
|
||||
every product app — too much churn during foundational waves.
|
||||
|
||||
**Reopen if.** Drift between the two packages causes a real bug or a
|
||||
support burden between now and Wave 7.
|
||||
|
||||
---
|
||||
|
||||
## #12 — `dashboard-shell` vs `dashboard-components`: **keep both, defer merge**
|
||||
|
||||
**Decision.** Same as #11. Both packages stay independent through
|
||||
Wave 6. Wave 7 re-evaluates whether to merge `dashboard-components`
|
||||
into `dashboard-shell/*` subpath exports or vice versa.
|
||||
|
||||
**Why.** Same reasoning — the API contract isn't stable enough yet to
|
||||
justify the merge cost.
|
||||
|
||||
---
|
||||
|
||||
## #13 — Mobile-native UI scope: **tokens-only sharing**
|
||||
|
||||
**Decision.** This roadmap (`@bytelyst/*` shared **client** packages
|
||||
and the web showcase) does **not** include iOS/Android UI components.
|
||||
Those live in `kotlin-platform-sdk` and `swift-platform-sdk` with their
|
||||
own roadmaps. **The only thing we share across web/iOS/Android is
|
||||
`@bytelyst/design-tokens`** — already generating Kotlin and Swift
|
||||
output from a single JSON source.
|
||||
|
||||
**Why.**
|
||||
|
||||
- Cross-platform UI frameworks (React Native, KMP-Compose) impose
|
||||
architectural constraints the web platform doesn't have.
|
||||
- Token-only sharing is the proven model in the industry (Adobe Spectrum,
|
||||
Shopify Polaris, Salesforce Lightning).
|
||||
|
||||
**Reopen if.** A product needs to ship a web + native experience in
|
||||
parallel and the divergence cost gets large.
|
||||
|
||||
---
|
||||
|
||||
## Status closing TODOs
|
||||
|
||||
These decisions close TODO ledger items #9–#13 in
|
||||
`learning_ai_uxui_web/docs/ROADMAP_2026.md`. The roadmap is updated to
|
||||
strike them through with a link to this document.
|
||||
|
||||
Last reviewed: 2026-05-27
|
||||
115
docs/STORYBOOK_TEMPLATE.md
Normal file
115
docs/STORYBOOK_TEMPLATE.md
Normal file
@ -0,0 +1,115 @@
|
||||
# Storybook Template for `@bytelyst/*` Packages
|
||||
|
||||
> ROADMAP TODO #5 — canonical pattern for adding Storybook 8 to each
|
||||
> visual `@bytelyst/*` package.
|
||||
|
||||
## Status
|
||||
|
||||
| Package | Has Storybook | Stories | A11y addon |
|
||||
| -------------------------------- | ------------- | --------------------------------------------------------------- | ---------- |
|
||||
| `@bytelyst/ui` | ✅ | 5 (`Button`, `Card`, `Controls`, `Input`, `OperationalPreview`) | ✅ |
|
||||
| `@bytelyst/auth-ui` | ❌ | — | — |
|
||||
| `@bytelyst/dashboard-components` | ❌ | — | — |
|
||||
| `@bytelyst/dashboard-shell` | ❌ | — | — |
|
||||
| `@bytelyst/celebrations` | ❌ | — | — |
|
||||
| `@bytelyst/gentle-notifications` | ❌ | — | — |
|
||||
| `@bytelyst/quick-actions` | ❌ | — | — |
|
||||
| `@bytelyst/react-auth` | ❌ | — | — |
|
||||
|
||||
Rollout is incremental — each package added separately so failures are
|
||||
diagnosable.
|
||||
|
||||
## Canonical setup (mirrors `@bytelyst/ui`)
|
||||
|
||||
### 1. Add devDependencies
|
||||
|
||||
```sh
|
||||
pnpm --filter @bytelyst/<pkg> add -D \
|
||||
storybook@^8.5.0 \
|
||||
@storybook/react@^8.5.0 \
|
||||
@storybook/react-vite@^8.5.0 \
|
||||
@storybook/addon-essentials@^8.5.0 \
|
||||
@storybook/addon-a11y@^8.5.0
|
||||
```
|
||||
|
||||
### 2. Add scripts to `package.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Create `.storybook/main.ts`
|
||||
|
||||
```ts
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
||||
addons: ['@storybook/addon-essentials', '@storybook/addon-a11y'],
|
||||
framework: { name: '@storybook/react-vite', options: {} },
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
### 4. Create `.storybook/preview.ts`
|
||||
|
||||
```ts
|
||||
import type { Preview } from '@storybook/react';
|
||||
import '@bytelyst/design-tokens/css';
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#06070A' },
|
||||
{ name: 'elevated', value: '#0E1118' },
|
||||
{ name: 'light', value: '#F8F9FC' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
```
|
||||
|
||||
### 5. Add at least one `*.stories.tsx`
|
||||
|
||||
Pattern — one story file per component, **co-located** in `src/`:
|
||||
|
||||
```tsx
|
||||
// src/components/Button.stories.tsx
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Button } from './Button.js';
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: 'Components/Button',
|
||||
component: Button,
|
||||
parameters: { layout: 'centered' },
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const Primary: Story = { args: { children: 'Click me' } };
|
||||
export const Disabled: Story = { args: { children: 'Disabled', disabled: true } };
|
||||
```
|
||||
|
||||
## Hosting
|
||||
|
||||
**Decision (`docs/ROADMAP_2026_DECISIONS.md` §9):** self-hosted on
|
||||
Gitea Pages.
|
||||
|
||||
A future Gitea Actions workflow at `.gitea/workflows/storybook.yml`
|
||||
will build each package's Storybook on push to `main` and deploy the
|
||||
combined output to `storybook.bytelyst.com` (or equivalent).
|
||||
|
||||
Until that workflow lands, developers run `pnpm --filter @bytelyst/<pkg>
|
||||
run storybook` locally on `:6006`.
|
||||
978
docs/UI_ROADMAP_2026_V3_CROSS_REPO.md
Normal file
978
docs/UI_ROADMAP_2026_V3_CROSS_REPO.md
Normal file
@ -0,0 +1,978 @@
|
||||
# ByteLyst Cross-Repo UX Roadmap — v3 (Future-Proof, Elegant, Rich)
|
||||
|
||||
> **Version:** v3.2 — 2026-05-27 (showcase-first tracker amendment)
|
||||
> **Status:** Draft for review · **actively tracked** (see §11)
|
||||
> **Owners:** ByteLyst Platform UI guild
|
||||
> **Predecessor:** `learning_ai_uxui_web/docs/ROADMAP_2026.md` v2.5 (Waves 1–7)
|
||||
> **Scope:** Every product web app under `/Users/sd9235/code/mygh/` + the common platform packages that should serve them.
|
||||
> **Showcase ground truth:** `@/Users/sd9235/code/mygh/copilot/learning_ai_uxui_web` (`http://localhost:3010/showcase/*`)
|
||||
|
||||
This document **extends** the v2 roadmap rather than replacing it. v2 covered shared packages in isolation; v3 walks the ecosystem floor — looks at what each of the **15+ product webs** actually ships today, finds the duplication, and prescribes the next **6 platform waves (Wave 8 – Wave 13)** plus a per-product upgrade matrix, with explicit futurism amendments in v3.1 (§3.4 – §3.8, §6.1 – §6.3, Wave 13) and a **showcase-first execution tracker** added in v3.2 (§11).
|
||||
|
||||
> **What changed in v3.2 vs v3.1:** added §11 — a 202-item, machine-parsable checklist that coding agents flip as they ship. Codified the **showcase-first rule** (every package gets a `learning_ai_uxui_web` demo route before any product adopts). Renumbered hygiene §11 → §12.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where we are (May 2026)
|
||||
|
||||
### Shared `@bytelyst/*` packages currently published or in-tree (~73)
|
||||
|
||||
| Tier | Packages |
|
||||
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Foundation** | `errors`, `config`, `logger`, `testing`, `events`, `event-store`, `queue`, `storage`, `datastore`, `cosmos`, `blob`, `extraction`, `llm`, `llm-router`, `auth`, `field-encrypt`, `client-encrypt`, `palace`, `sync`, `offline-queue` |
|
||||
| **Service / SDK** | `api-client`, `auth-client`, `react-auth`, `secure-storage-web`, `platform-client`, `telemetry-client`, `feature-flag-client`, `kill-switch-client`, `diagnostics-client`, `feedback-client`, `subscription-client`, `survey-client`, `billing-client`, `blob-client`, `broadcast-client`, `org-client`, `marketplace-client`, `referral-client`, `mcp-client`, `ollama-client`, `gentle-notifications`, `time-references`, `celebrations`, `webhook-dispatch`, `push`, `accessibility`, `use-keyboard-shortcuts`, `use-theme`, `quick-actions` |
|
||||
| **Backend infra** | `backend-config`, `backend-flags`, `backend-telemetry`, `fastify-core`, `fastify-auth`, `fastify-sse` |
|
||||
| **UI / UX (v2)** | `design-tokens`, `ui`, `auth-ui`, `dashboard-components`, `dashboard-shell`, **`ai-ui`** (Wave 2), **`command-palette`** (Wave 3), **`motion`** (Wave 4), **`data-viz`** (Wave 5b), **`notifications-ui`** (Wave 7), `monitoring` |
|
||||
| **Native (out-of-scope for v3 web work)** | `kotlin-platform-sdk`, `swift-platform-sdk`, `swift-diagnostics`, `react-native-platform-sdk`, `speech`, `devops`, `create-app` |
|
||||
|
||||
### v2 wave status (as of `acb8f02b`)
|
||||
|
||||
| Wave | Theme | Status |
|
||||
| ---- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Foundations · tokens · density · LHCI · vis-reg | **✅ shipped** (DTCG v3 RFC + Storybook rollout still incremental) |
|
||||
| 2 | `@bytelyst/ai-ui` | **✅ 0.4.0 shipped** — 8 components, 2 hooks, 53/53 tests |
|
||||
| 3 | `@bytelyst/command-palette` | **✅ 0.1.0 shipped** — 26/26 tests, Cmd-K live in showcase |
|
||||
| 4 | `@bytelyst/motion` | **✅ 0.1.0 shipped** — Reveal, Stagger, NumberFlow, Tilt, ScrollProgress |
|
||||
| 5a | Forms additions to `@bytelyst/ui` | 📋 not started |
|
||||
| 5b | `@bytelyst/data-viz` | **✅ 0.1.0 shipped** — Sparkline, KpiCard, ProgressRing, Heatmap (+ SSR-safe useId fix in `acb8f02b`) |
|
||||
| 5c | `@bytelyst/adaptive-ui` (SchemaForm / StreamUI) | 📋 not started |
|
||||
| 6 | Mobile · inclusive · i18n | 📋 not started |
|
||||
| 7 | `@bytelyst/notifications-ui` | **✅ 0.1.0 shipped** — NotificationCenter, InboxItem, BannerStack, Announcement |
|
||||
| 7 | `@bytelyst/billing-ui` | 📋 not started |
|
||||
| 7 | `@bytelyst/onboarding-ui` | 📋 not started |
|
||||
| 7 | `@bytelyst/telemetry-ui`, `brand-*`, PWA, create-app | 📋 not started |
|
||||
|
||||
---
|
||||
|
||||
## 2. Cross-repo audit — what every product web actually ships
|
||||
|
||||
Sampled from every Next.js / Vite web in the workspace — v3.1 audit is **comprehensive** (was sample-based in v3.0):
|
||||
|
||||
```
|
||||
/copilot/learning_ai_uxui_web — reference showcase (Next.js 16)
|
||||
/learning_ai_clock/web — ChronoMind
|
||||
/learning_ai_notes/web — NoteLett
|
||||
/learning_ai_flowmonk/web — FlowMonk
|
||||
/learning_ai_jarvis_jr/web — JarvisJr
|
||||
/learning_ai_fastgap/web — NomGap (FastGap)
|
||||
/learning_ai_trails/web — ActionTrail
|
||||
/learning_ai_local_memory_gpt/web — LocalMemGPT
|
||||
/learning_ai_dev_intelli/web — DevIntelli
|
||||
/learning_voice_ai_agent/user-dashboard-web — LysnrAI user portal
|
||||
/learning_multimodal_memory_agents/mindlyst-native/web — MindLyst web shell
|
||||
/learning_ai_talk2obsidian/web — Talk2Obsidian
|
||||
/learning_ai_productivity_web — Productivity hub
|
||||
/learning_sidecar_setup/sidecar_dashboard_web — Sidecar dashboard
|
||||
/learning_ai_mac_tooling/dashboard — Mac Tooling dashboard
|
||||
/learning_ai_devops_tools/dashboard/web — DevOps tools dashboard
|
||||
/learning_agent_monitoring_fx/apps/web — Agent Monitoring FX
|
||||
/learning_ai_local_llms/dashboard — Local LLMs dashboard
|
||||
/learning_ai_efforise (Vite) — EffoRise client
|
||||
/learning_ai_common_plat/dashboards/admin-web — Platform admin
|
||||
/learning_ai_common_plat/dashboards/tracker-web — Issue tracker + roadmap
|
||||
```
|
||||
|
||||
**Total: 20 web apps** (12 product webs, 2 platform dashboards, 6 internal/utility dashboards). v3.1 widens scope from the 11 highlighted in v3.0.
|
||||
|
||||
### 2.1 `@bytelyst/*` package consumption matrix
|
||||
|
||||
| Product | `ui` | `design-tokens` | `react-auth` | `telemetry-client` | `feature-flag-client` | `dashboard-components` | `dashboard-shell` | `ai-ui` | `command-palette` | `motion` | `data-viz` | `notifications-ui` |
|
||||
| ---------------------- | -------------------- | --------------- | ------------ | ------------------ | --------------------- | ---------------------- | ----------------- | ------- | ----------------- | -------- | ---------- | ------------------ |
|
||||
| clock/web | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — | — |
|
||||
| notes/web | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — |
|
||||
| flowmonk/web | ✅ | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | — |
|
||||
| jarvisjr/web | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — | — |
|
||||
| fastgap/web | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — |
|
||||
| localmemgpt/web | ✅ | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — |
|
||||
| voice (user-dashboard) | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — |
|
||||
| trails/web | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — | — |
|
||||
| dev-intelli/web | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — | — |
|
||||
| nomgap/web | (sampled separately) | … | … | … | … | … | — | — | — | — | — | — |
|
||||
|
||||
**Observation:** none of the 11 product webs consume the **5 newest UI packages** (`ai-ui`, `command-palette`, `motion`, `data-viz`, `notifications-ui`). All five are shipped to source but **awaiting publish** to the Gitea npm registry (still tracked as TODO #14 in v2.5). This is the single highest-impact unblock in v3 — see Wave 8.
|
||||
|
||||
### 2.2 Duplicated / reimplemented surfaces
|
||||
|
||||
Patterns found 3+ times across product repos that should collapse into shared packages:
|
||||
|
||||
| Surface | Found in | Proposed home |
|
||||
| ------------------------------------------------------------------ | ------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| **`Skeleton.tsx`** (shimmer loading) | clock, notes, flowmonk, jarvisjr, fastgap, trails | `@bytelyst/ui/skeleton` (extend existing `ui`) |
|
||||
| **Modal / Dialog wrappers** (`CreateXModal.tsx`, `LinkXModal.tsx`) | clock (1), notes (4), fastgap (2) | `@bytelyst/ui/modal` v2 (composable; replaces bespoke) |
|
||||
| **Sidebar layout** (`Sidebar.tsx`, route-aware) | notes, flowmonk, jarvisjr (+ many more) | `@bytelyst/dashboard-shell@0.3.0` — generic, slot-based |
|
||||
| **`OnboardingOverlay.tsx` / tour steps** | clock (1), fastgap (1), notes (TBD) | new `@bytelyst/onboarding-ui` (Wave 7-pending → Wave 9) |
|
||||
| **`AgentTimeline.tsx`** (bespoke vertical thought-trace) | notes (1) | already in `@bytelyst/ai-ui` — direct swap (Wave 8) |
|
||||
| **`MemoryTimeline.tsx`** | notes (1), likely mindlyst | new `@bytelyst/timeline` (Wave 11) |
|
||||
| **Recharts wrappers** (KPI tiles, line/bar/area) | clock, jarvisjr, fastgap, voice (user-dashboard) | new `@bytelyst/charts` (Wave 9) |
|
||||
| **Tiptap editor** (rich-text body) | notes only (today); mindlyst, jarvisjr planned | new `@bytelyst/rich-text` (Wave 9) |
|
||||
| **Toast / sonner** | notes, fastgap | already in `@bytelyst/ui/toast` — sonner usage to deprecate (Wave 8 hygiene) |
|
||||
| **Date-fns + bespoke pickers** | clock, jarvisjr, voice | new `@bytelyst/ui/date-picker` (Wave 9.D) |
|
||||
| **Empty states** (`No items yet…`) | every product | new `@bytelyst/ui/empty-state` (Wave 9.D.3) |
|
||||
| **Settings panel** (account / API keys / billing / privacy) | every product | new `@bytelyst/settings-ui` (Wave 10) |
|
||||
| **Filter bar / search input + chips** | notes, fastgap, voice, jarvisjr | new `@bytelyst/ui/filter-bar` (Wave 9.D.5) |
|
||||
| **Data table / virtualised list** | notes, fastgap, voice | new `@bytelyst/data-table` (Wave 9, TanStack Table v9) |
|
||||
| **Privacy / Legal pages** | jarvisjr (only) | new `@bytelyst/legal-ui` (Wave 10) |
|
||||
|
||||
### 2.3 Tech currently mixed across products
|
||||
|
||||
| Need | Library in product | Recommendation |
|
||||
| -------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| Charting | `recharts` (5+ apps) | Wrap recharts in `@bytelyst/charts` with token theming — keep recharts as peer dep |
|
||||
| Toasts | `sonner` (notes, fastgap) | `@bytelyst/ui/toast` already exists; align all apps |
|
||||
| Drag-drop reordering | none yet | Adopt `@dnd-kit/sortable`, expose via `@bytelyst/ui/sortable` |
|
||||
| Virtualisation | none yet | Adopt `@tanstack/react-virtual`, expose via `@bytelyst/ui/virtual-list` |
|
||||
| Animations | `framer-motion` (clock) | Migrate to `@bytelyst/motion` (already shipped) — smaller bundle |
|
||||
| Forms | bespoke + `zod` (clock, flowmonk, jarvisjr) | Adopt `react-hook-form` + `zod` resolver, expose via `@bytelyst/ui/form` (Wave 5a) |
|
||||
| Date picking | none consistent | `@bytelyst/ui/date-picker` on `react-day-picker` v9 |
|
||||
| Rich text | `@tiptap/*` (notes) | `@bytelyst/rich-text` (Wave 9) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Future-proof UX themes & design patterns to adopt
|
||||
|
||||
The v2 roadmap nailed accessibility, motion, density, AI-native primitives. v3 layers in the **next-decade design language** that products like Linear, Arc, Raycast, Vercel, Apple Intelligence and Material 3 Expressive are pushing in 2025-26:
|
||||
|
||||
### 3.1 Visual language
|
||||
|
||||
| Theme | What it means | Where it lands |
|
||||
| --------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| **OKLCH / Display-P3 palettes** | Perceptually-uniform colour math; vibrant on modern displays; safe fallback to sRGB | `design-tokens` v3 — Tier 1 emits both `oklch()` and `color(display-p3 ...)` with `@supports` fallback |
|
||||
| **Glass + depth (selective)** | Translucent surfaces with `backdrop-filter`, layered shadows; **not** for body text | `ui/Surface`, modal/popover variants |
|
||||
| **Soft gradients + mesh** | OKLCH-driven background washes (no banding) | Landing pages, hero sections; `motion/MeshBackground` |
|
||||
| **Material 3 Expressive springs** | Larger, more emotive transitions (~600ms) for hero interactions | New `SPRINGS.expressive` preset in `@bytelyst/motion` |
|
||||
| **Ambient / calm UI** | Low-saturation default state; saturation lifts on focus | `data-mood` token tier (`calm` / `focus` / `celebrate`) |
|
||||
| **Tactile micro-feedback** | Press-state scale (0.98), hover lift, haptic-style cubic-bezier | Already in tokens; **codify in `@bytelyst/motion/Pressable`** |
|
||||
| **Variable typography** | Variable axes (`wght`, `opsz`, `GRAD`) on Inter Variable + Atkinson Hyperlegible | `design-tokens` Tier 1 — `font-variation-settings` |
|
||||
| **Iconography v2** | Lucide v0.500+ as default; add provider for Phosphor / Tabler swap | New `@bytelyst/icons` peer-deps re-export |
|
||||
|
||||
### 3.2 Platform capabilities to wire in (2025-26 web platform)
|
||||
|
||||
| Capability | Browser support | Where it lands |
|
||||
| ------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| **View Transitions API (same-doc + cross-doc)** | Chrome 126+ / Safari 18+ / Firefox 132+ (cross-doc) | `@bytelyst/motion/PageTransition` (Wave 4 stretch) — with fallback |
|
||||
| **CSS Anchor Positioning** | Chrome 125+ / Safari TP / Firefox flag | Replace JS-anchored tooltips/popovers in `@bytelyst/ui` |
|
||||
| **`<dialog>` + invoker `command`/`commandfor`** | Chrome 134+ | `ui/Modal` to use native `<dialog>` + invoker fallback |
|
||||
| **Popover API (`popover` attribute)** | All evergreen | `ui/Tooltip`, `ui/Dropdown`, `ui/Menu` upgrade |
|
||||
| **Container queries everywhere** | All evergreen | Already partial; codify `@container` breakpoints in `design-tokens` |
|
||||
| **`color-mix()` / `color()` / relative colour syntax** | All evergreen | Already used by `data-viz/Heatmap`; expand to `ui` |
|
||||
| **Scroll-driven animations (`animation-timeline: scroll()`)** | Chrome 115+ | `@bytelyst/motion/ScrollLinked` primitive |
|
||||
| **`@scope`** | Chrome 118+ / Safari 17.4+ | Scope brand overrides cleanly without selector wars |
|
||||
| **`text-wrap: balance` / `text-wrap: pretty`** | All evergreen | Default on headings via `ui/Heading` |
|
||||
| **CSS nesting** | All evergreen | Update `globals.css` patterns |
|
||||
| **`field-sizing: content`** | Chrome 123+ | `ui/Textarea` auto-grow without JS |
|
||||
| **Web Speech API** | All evergreen (Chrome best) | `@bytelyst/speech-ui` — voice-input button, dictation |
|
||||
| **WebAuthn / Passkeys** | All evergreen | `ui/PasskeyButton` (Wave 5a) |
|
||||
| **WebGPU** (when stable) | Chrome 113+ / Safari 26 | Document-heavy products only (e.g. mindlyst) — defer beyond v3 |
|
||||
| **OffscreenCanvas + Worker** | All evergreen | Charts re-render off-thread (`@bytelyst/charts`) |
|
||||
|
||||
### 3.3 AI-native UX patterns (extend Wave 2)
|
||||
|
||||
| Pattern | Surface |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| **Generative UI** (LLM emits component tree from JSON Schema) | `@bytelyst/adaptive-ui/StreamUI` (Wave 5c) |
|
||||
| **Suggestion chips** (next-action prediction) | `ai-ui/SuggestionRow` (Wave 8 v0.5) |
|
||||
| **Inline AI rewrite** (highlight → "Make shorter" / "Translate") | `ai-ui/InlineAI` (Wave 8 v0.5) |
|
||||
| **Confidence chrome** (delta arrow + ± range + source) | `data-viz/KpiCard` already supports trend; add `confidence` (Wave 9) |
|
||||
| **Agentic progress** (steps, retries, cancellations) | `ai-ui/AgentTimeline` already supports — add cancel/retry actions (Wave 8 v0.5) |
|
||||
| **Ambient assistant** (subtle dot, summon with ⌘J) | `@bytelyst/assistant-shell` (Wave 11) |
|
||||
| **Voice-first composer** (push-to-talk in `PromptComposer`) | `ai-ui/PromptComposer.voiceInput` (Wave 8 v0.6) — uses `@bytelyst/speech-ui` |
|
||||
| **Citations everywhere** (any text can attach `CitationChip`s) | `ui/Markdown` (Wave 5c) renders inline citations |
|
||||
|
||||
### 3.4 On-device & privacy-first AI — the trust differentiator
|
||||
|
||||
The single biggest 2026 customer-acquisition wedge: **"your data never leaves your device unless you ask it to."** Surfaces:
|
||||
|
||||
| Pattern | What it is | Where it lands |
|
||||
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| **WebLLM / transformers.js** in browser | Run 1B–8B-parameter models client-side via WebGPU; falls back to cloud if hardware can't | New `@bytelyst/on-device-ai` package (Wave 11) with `useOnDeviceModel()` hook + capability detection |
|
||||
| **"Privacy mode" toggle** | App-wide switch that routes every AI call to local model when capable; disables telemetry beacons | `@bytelyst/ai-ui/PrivacyBadge` chrome + `usePrivacyMode()` (Wave 11) |
|
||||
| **Explainable refusal** | When AI refuses, show _why_ (policy excerpt + appeal link), never a generic "I can't help" | `ai-ui/RefusalCard` (Wave 9 v0.5) |
|
||||
| **Cost transparency** | Inline per-turn cost ($ + tokens + provider), running session total | `ai-ui/CostMeter` (Wave 9 v0.5) — opt-in via `showCost` prop on `ChatStream` |
|
||||
| **Confidence transparency** | "Model is 73% confident; 2 of 3 sources agree" — surfaced on every AI response | `ai-ui/ConfidenceTag` (Wave 9 v0.5) |
|
||||
| **Provenance trails** | Every AI artefact carries a verifiable hash chain back to the prompt + model + tools used | `ai-ui/ProvenanceDrawer` (Wave 11) — backed by `@bytelyst/event-store` |
|
||||
| **Telemetry opt-out UX** | Single switch in `@bytelyst/settings-ui` honours **Global Privacy Control** (`Sec-GPC` header) | Wave 10 |
|
||||
| **"Why did the AI say this?"** debug overlay | Shift-click any AI response → opens inspector with system prompt, retrieved context, tool calls, raw stream | `ai-ui/DebugOverlay` (Wave 11) — dev/staging only by default |
|
||||
|
||||
### 3.5 Real-time collaboration — the Linear/Figma-tier patterns
|
||||
|
||||
Today's premium products are multiplayer. Wave 11 ships the primitives.
|
||||
|
||||
| Pattern | Tech | Surface |
|
||||
| ----------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **CRDT document collab** | **Yjs** as the canonical CRDT; **Automerge** swap supported via adapter | `@bytelyst/collab` (new in Wave 11) — `useSharedDoc()`, `<CollabProvider>` |
|
||||
| **Liveblocks-grade presence** | SSE/WebSocket transport via `@bytelyst/fastify-sse` | `@bytelyst/realtime-ui` — `<PresenceAvatars>`, `<TypingIndicator>`, `<LiveCursor>` (already in Wave 11) |
|
||||
| **Co-edit indicators** | Show "Sara is editing line 14" with avatar | `rich-text/CollabSelection` extension (Wave 11) |
|
||||
| **Conflict-free sync** | Offline edits replay on reconnect; never lose work | `@bytelyst/sync` + Yjs binding |
|
||||
| **Selective sharing** | Share-by-link UI with permission tier (view / comment / edit) | `@bytelyst/settings-ui/SharePanel` (Wave 10) |
|
||||
| **Comment threads** | Anchored to a selection range; resolves + reopens | `@bytelyst/collab/CommentThread` (Wave 11) |
|
||||
|
||||
### 3.6 Spatial / immersive — visionOS-inspired surfaces
|
||||
|
||||
Inspired by Apple Vision Pro, Arc Browser, Linear's depth layering. Subtle but unmistakably "next-gen".
|
||||
|
||||
| Pattern | Implementation |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| **Floating cards with depth** | Multi-stop shadows + 1px translucent border + `backdrop-filter: blur()`; never used for body text | `ui/SurfaceFloat` variant (Wave 9) |
|
||||
| **Parallax on scroll** | `motion/Parallax` driven by scroll-driven animations — zero-JS where supported | New primitive in `@bytelyst/motion@0.2.0` (Wave 9 hygiene) |
|
||||
| **3D model embed** | `<model-viewer>` web component, lazy-loaded behind `@bytelyst/file-ui` | Wave 11 |
|
||||
| **Cursor-aware spotlight** | Radial gradient that follows pointer, hugely effective on landing pages | `motion/Spotlight` (Wave 9) |
|
||||
| **Magnetic buttons** | Pointer-attracted CTAs, Arc-browser-style | `motion/Magnetic` (Wave 9) — already in v2 sketch |
|
||||
| **Tilt cards** | Already shipped in `motion@0.1.0` — add `<TiltGallery>` for product grids | Wave 9 |
|
||||
| **Glass-with-depth modal** | Backdrop blur + tinted vignette + spring entrance | `ui/Modal v2` (Wave 9) |
|
||||
| **Ambient gradient backgrounds** | OKLCH mesh that subtly shifts based on time-of-day or active route | `motion/MeshBackground` (Wave 9) |
|
||||
|
||||
### 3.7 Performance & sustainability — the Core Web Vital budgets
|
||||
|
||||
Every customer ultimately judges UX by speed. We publish per-route budgets and CI gates them.
|
||||
|
||||
| Metric | Target | How we measure |
|
||||
| --------------------------------------- | ------------- | --------------------------------------------- |
|
||||
| **LCP** (Largest Contentful Paint) | ≤ 2.5s p75 | Lighthouse + RUM via `@bytelyst/telemetry-ui` |
|
||||
| **INP** (Interaction to Next Paint) | ≤ 200ms p75 | `web-vitals@4` in `@bytelyst/telemetry-ui` |
|
||||
| **CLS** (Cumulative Layout Shift) | ≤ 0.1 p75 | Lighthouse |
|
||||
| **First-page JS** | ≤ 100 KB gzip | `size-limit` per-route budget |
|
||||
| **Image weight per page** | ≤ 500 KB | Lighthouse |
|
||||
| **CO₂ per page-view** | ≤ 0.5 g | `co2.js` in CI (Wave 12.6) |
|
||||
| **Time-to-Interactive on Moto G Power** | ≤ 4.5s | Lighthouse mobile profile in LHCI |
|
||||
|
||||
**Patterns we adopt to hit them:**
|
||||
|
||||
- `content-visibility: auto` on long lists & off-screen panels (10× INP improvement)
|
||||
- React 19 **Server Components** for static surfaces (legal-ui, settings-ui rails, marketing pages)
|
||||
- **Streaming SSR with progressive hydration** for AI surfaces
|
||||
- **Suspense islands** — never block a page on a slow chart
|
||||
- **Route prefetch** on hover/pointer-near (Next.js native + `@bytelyst/dashboard-shell` Sidebar opt-in)
|
||||
- **Image components** with AVIF + WebP negotiation, blurhash placeholders
|
||||
- **Cache-aware Cmd-K** — palette uses IndexedDB for the catalog so it opens instantly offline
|
||||
|
||||
### 3.8 Anti-patterns — what ByteLyst products will **never** ship
|
||||
|
||||
A short register, codified in `docs/design-system/ANTIPATTERNS.md` (created by Wave 9), and CI-checkable where possible.
|
||||
|
||||
| Forbidden pattern | Why | Detection |
|
||||
| --------------------------------------------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------ |
|
||||
| **Dark patterns** (cookie banner with hidden "Reject all", pre-checked opt-in, fake urgency timers) | EU EAA + ethics | Manual review + cookie-banner lint in `@bytelyst/legal-ui` |
|
||||
| **Auto-playing audio/video** without explicit user gesture | Accessibility + bandwidth | ESLint rule on `<video autoPlay>` / `<audio autoPlay>` |
|
||||
| **`prefers-reduced-motion` ignored** | Accessibility | `@bytelyst/motion` enforces; `bl-*` keyframes audited |
|
||||
| **`<div onClick>`** instead of `<button>` | Keyboard + screen reader | ESLint `jsx-a11y/click-events-have-key-events` |
|
||||
| **Hard-coded colours / sizes** outside `design-tokens` | Theming + density | Stylelint rule blocks hex / `px` outside tokens |
|
||||
| **Layout-shifting skeletons** | CLS | Skeletons must match content dimensions; visual-regression catches |
|
||||
| **Polling instead of streaming** for AI surfaces | UX latency + cost | Code review |
|
||||
| **Notification spam** | Trust | `@bytelyst/notifications-ui` rate-limits per kind |
|
||||
| **Modal stacks > 2 deep** | UX hygiene | `ui/Modal v2` enforces a single backdrop stack |
|
||||
| **Generic "Something went wrong"** errors | Trust | `@bytelyst/errors` taxonomy mandates a typed cause + user action |
|
||||
|
||||
---
|
||||
|
||||
## 4. The next six waves
|
||||
|
||||
Builds on v2.5. All effort in person-weeks (pw). Totals: **Wave 8 = 4 pw · Wave 9 = 10 pw · Wave 10 = 9 pw · Wave 11 = 8 pw · Wave 12 = 7 pw · Wave 13 = 9 pw → ~47 pw across 28 weeks** (was ~38 pw / 26 weeks in v3.0).
|
||||
|
||||
### **Wave 8 — Unblock & rollout** _(weeks 16–18, ~4 pw)_
|
||||
|
||||
> **Theme:** ship what's built; remove the showcase's "vendored snapshot" workaround; get every product onto the new packages.
|
||||
|
||||
| # | Status | Deliverable | Effort |
|
||||
| --- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ |
|
||||
| 8.1 | 📋 | Trigger common_plat publish workflow → push **`react-auth@0.2.0`**, **`dashboard-shell@0.2.0`**, **`design-tokens@0.2.0`**, **`ai-ui@0.4.0`**, **`command-palette@0.1.0`**, **`motion@0.1.0`**, **`data-viz@0.1.0`**, **`notifications-ui@0.1.0`** to the Gitea registry | 0.5 pw |
|
||||
| 8.2 | 📋 | Showcase: delete `src/lib/*-preview/` snapshots; swap imports to registry packages (TODO #14) | 0.5 pw |
|
||||
| 8.3 | 📋 | Cross-repo dep update PR: every product web pins `@bytelyst/design-tokens@0.2.0` + adopts at least one new package (`motion` is the easiest first win — landing-page reveals) | 1 pw |
|
||||
| 8.4 | 📋 | Migrate `learning_ai_notes/web/src/components/AgentTimeline.tsx` → `@bytelyst/ai-ui/AgentTimeline` (delete bespoke) | 0.5 pw |
|
||||
| 8.5 | 📋 | Migrate `learning_ai_clock` `framer-motion` usage → `@bytelyst/motion` (Reveal + Stagger only on landing + history pages) | 0.5 pw |
|
||||
| 8.6 | 📋 | Migrate every product `Skeleton.tsx` → `@bytelyst/ui/skeleton` (new sub-surface — see Wave 5a) | 0.5 pw |
|
||||
| 8.7 | 📋 | Wire `@bytelyst/command-palette` into 3 pilot products (notes, jarvisjr, fastgap) with at minimum nav + theme commands | 0.5 pw |
|
||||
|
||||
**DoD:** zero vendored snapshots in showcase · `pnpm outdated --filter "*web*"` shows current `@bytelyst/*` versions · 3 products live with Cmd-K.
|
||||
|
||||
---
|
||||
|
||||
### **Wave 9 — Data, content, search** _(weeks 17–20, ~10 pw)_
|
||||
|
||||
> **Theme:** the surfaces every product reinvented — charts, rich-text editing, filterable lists. Each is a separate package so products opt in.
|
||||
|
||||
| # | Package / surface | Notes | Effort |
|
||||
| --- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
|
||||
| 9.1 | **`@bytelyst/charts@0.1.0`** | Recharts wrapper + token theming. Components: `<LineChart>`, `<AreaChart>`, `<BarChart>`, `<StackedBar>`, `<Donut>`, `<Gauge>`, `<RadarChart>`. Plug-in `OffscreenCanvas` mode for big series. Peer dep on `recharts@^2.15`. | 2 pw |
|
||||
| 9.2 | **`@bytelyst/rich-text@0.1.0`** | Tiptap v2 wrapper. Exports: `<RichTextEditor>`, `<RichTextViewer>`, default toolbar (bold/italic/headings/lists/links/code), slash menu, mention extension. Theming via tokens. Peer dep on `@tiptap/*`. | 2 pw |
|
||||
| 9.3 | **`@bytelyst/data-table@0.1.0`** | TanStack Table v9 + TanStack Virtual. Sortable headers, column resize, pinned columns, faceted filters, row selection, keyboard nav, sticky header. Variants: `compact` / `comfortable`. | 2 pw |
|
||||
| 9.4 | **`@bytelyst/ui` v2.x — filter & search additions** | `<FilterBar>` (chips + clear-all), `<SearchInput>` (with suggestions slot), `<EmptyState>` (illustration slot + CTA), `<TagInput>`, `<Combobox>` (formalise Wave 5a). | 1.5 pw |
|
||||
| 9.5 | **`@bytelyst/ui` v2.x — skeleton + loading** | `<Skeleton>` shimmer with shape variants (`text` / `rect` / `circle` / `card`), `<SkeletonGroup>` (orchestrates fade-out → content), `<LoadingDots>`, `<Spinner>` with token-tinted variants. | 0.5 pw |
|
||||
| 9.6 | `ai-ui@0.5.0` | `<Markdown>` (with citation interop), `<CodeDiff>`, `<ExplainThis>`, `usePromptHistory()`, `useTokenCount()`. Closes Wave 2 deferred items. | 1 pw |
|
||||
| 9.7 | `data-viz@0.2.0` | `<RealtimeChart>` (SSE subscribed), `<Donut>` (re-export from charts), `<Gauge>` (token-tinted), `<TreeMap>`, `<Sankey>` (stretch). Add `confidence` prop to `<KpiCard>`. | 1 pw |
|
||||
|
||||
**DoD:** 3 products consume `charts` · 1 product (notes) consumes `rich-text` · `data-table` deployed in at least the tracker-web admin views.
|
||||
|
||||
---
|
||||
|
||||
### **Wave 10 — Product surfaces & shells** _(weeks 19–22, ~9 pw)_
|
||||
|
||||
> **Theme:** every product builds the same chrome from scratch. Stop doing that.
|
||||
|
||||
| # | Package / surface | Notes | Effort |
|
||||
| ---- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
|
||||
| 10.1 | **`@bytelyst/dashboard-shell@0.3.0`** | Slot-based shell: `<AppShell><Sidebar/><Topbar/><Main/></AppShell>`. Sidebar with collapsible groups, badges, route-active states. Topbar with breadcrumbs + user menu + Cmd-K trigger + theme picker + notifications bell slot. Density-aware. Mobile drawer mode auto-applied <768px. | 2 pw |
|
||||
| 10.2 | **`@bytelyst/onboarding-ui@0.1.0`** | `<OnboardingChecklist>`, `<TourStep>`, `<FeatureCallout>`, `<EmptyStateCTA>`, `useTour()`. Persists progress in localStorage. Token-themed. | 1.5 pw |
|
||||
| 10.3 | **`@bytelyst/billing-ui@0.1.0`** | `<PlanCard>`, `<UpgradeModal>`, `<UsageMeter>`, `<InvoiceList>`, `<PaymentMethodList>`. Wired to `platform-service` Stripe module via `@bytelyst/subscription-client` + `@bytelyst/billing-client`. | 1.5 pw |
|
||||
| 10.4 | **`@bytelyst/settings-ui@0.1.0`** | `<SettingsLayout>` (left-rail nav), `<ProfileSection>`, `<SecuritySection>` (passkey + 2FA), `<NotificationPrefs>`, `<DataExportRow>`, `<DangerZone>`. | 1 pw |
|
||||
| 10.5 | **`@bytelyst/legal-ui@0.1.0`** | `<PrivacyPolicyPage>`, `<TermsPage>`, `<CookieBanner>` (token-themed, EAA-compliant), `<DataProcessingNotice>`. Driven by a single MDX corpus shipped with each product. | 0.5 pw |
|
||||
| 10.6 | **`@bytelyst/telemetry-ui@0.1.0`** | `data-bl-event` attribute scraper + React provider; auto-emits to `@bytelyst/telemetry-client`. `useTrackedClick(name)` hook. PostHog + OpenTelemetry compatible. | 1 pw |
|
||||
| 10.7 | **`@bytelyst/timeline@0.1.0`** | Generic vertical event timeline — extracts pattern from `notes/MemoryTimeline.tsx`. Groups by day, infinite scroll, sticky date headers, search. | 1 pw |
|
||||
| 10.8 | **Brand-layer packages** | `@bytelyst/brand-flowmonk`, `brand-chronomind`, `brand-mindlyst` — Tier-2 token overrides. Multi-tenant runtime: `<BrandProvider brand="flowmonk">` reads from URL/header. | 0.5 pw |
|
||||
|
||||
**DoD:** 3 products use `dashboard-shell@0.3.0` end-to-end · onboarding-ui ships in nomgap (highest-value) · settings-ui used by ≥ 2 products.
|
||||
|
||||
---
|
||||
|
||||
### **Wave 11 — Adaptive / ambient / multimodal** _(weeks 21–24, ~8 pw)_
|
||||
|
||||
> **Theme:** the futuristic layer. Generative UI, ambient assistant, multimodal input.
|
||||
|
||||
| # | Package / surface | Notes | Effort |
|
||||
| ---- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
|
||||
| 11.1 | **`@bytelyst/adaptive-ui@0.1.0`** | `<SchemaForm>` (JSON Schema → accessible form, react-hook-form under the hood), `<SchemaTable>`, **`<StreamUI from={stream}>`** — renders LLM-emitted component trees from a constrained schema (à la Vercel AI SDK `streamUI`, but transport-agnostic). | 2.5 pw |
|
||||
| 11.2 | **`@bytelyst/assistant-shell@0.1.0`** | Ambient assistant — small floating dot, summon with ⌘J. Mounts `ai-ui/ChatStream` in a sheet with context-aware system prompt (sees current route + selected text + recent telemetry). | 2 pw |
|
||||
| 11.3 | **`@bytelyst/speech-ui@0.1.0`** | `<VoiceInputButton>` (Web Speech API + visualiser), `<DictationOverlay>`, `<TranscriptViewer>`. Integrates with `@bytelyst/speech` and `PromptComposer.voiceInput`. | 1.5 pw |
|
||||
| 11.4 | **`@bytelyst/file-ui@0.1.0`** | `<FileDrop>` (drag-drop + chunked upload via `@bytelyst/blob-client`), `<FilePreview>` (auto image/PDF/text), `<FileBrowser>` (grid + list views), `<ImageCrop>`. | 1 pw |
|
||||
| 11.5 | **`@bytelyst/realtime-ui@0.1.0`** | `<PresenceAvatars>`, `<TypingIndicator>`, `<LiveCursor>`, `useRealtimePresence(channel)`. Backed by `@bytelyst/fastify-sse` + `@bytelyst/sync`. | 1 pw |
|
||||
|
||||
**DoD:** assistant-shell live in ≥ 1 product (likely notes or mindlyst) · `StreamUI` renders a real LLM-emitted form in dev.
|
||||
|
||||
---
|
||||
|
||||
### **Wave 12 — Mobile-first, i18n, sustainability** _(weeks 23–26, ~7 pw)_
|
||||
|
||||
> **Theme:** finish what v2 Wave 6 sketched.
|
||||
|
||||
| # | Package / surface | Notes | Effort |
|
||||
| ---- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ |
|
||||
| 12.1 | **`@bytelyst/ui` v3 — mobile primitives** | `<BottomSheet>` (snap points + scrollable body), `<StickyActionBar>` (safe-area), `<TouchableRipple>`, `<PullToRefresh>`, mobile-specific variants of `Dropdown`/`Tooltip` (long-press). | 1.5 pw |
|
||||
| 12.2 | **`@bytelyst/i18n@0.1.0`** | `react-intl` wrapper, type-safe message catalogues, auto-extraction CLI. Default locales: `en`, `es`, `ar`, `ja`. | 1.5 pw |
|
||||
| 12.3 | **RTL audit** | Every primitive uses logical properties (`margin-inline-start`, `text-align: start`). Visual-regression test set for `dir="rtl"`. Codemod to convert `left`/`right` → `inline-start`/`inline-end` in product webs. | 1 pw |
|
||||
| 12.4 | **Inclusive design** | Atkinson Hyperlegible toggle, contrast lock-up (forces AAA), motion-pause toggle. VPAT 2.5 ACR drafted & published on showcase. | 1 pw |
|
||||
| 12.5 | **Showcase additions** | Viewport switcher per demo (375 / 768 / 1280 / fluid), per-demo a11y inspector, RTL toggle, locale switcher in theme picker. | 1 pw |
|
||||
| 12.6 | **Sustainability** | Per-package bundle/CO₂ budget in CI (`co2.js`), dark-mode default to save OLED energy, image-format negotiation helper. | 0.5 pw |
|
||||
| 12.7 | **PWA + create-app** | Showcase becomes installable PWA with offline catalog. `pnpm create @bytelyst/app` scaffolds new product webs wired with shell + auth + tokens + theme picker + Cmd-K + telemetry. | 0.5 pw |
|
||||
|
||||
**DoD:** 100% RTL visual-test pass · 4 locales live · `pnpm create @bytelyst/app my-app` produces a working dev server in < 60s.
|
||||
|
||||
---
|
||||
|
||||
### **Wave 13 — Futurism layer** _(weeks 25–28, ~9 pw)_ — the customer-magnet wave
|
||||
|
||||
> **Theme:** the surfaces that make new prospects say _"how did you build this?"_ on first sight. Everything in §3.4 – §3.6 lands here, packaged.
|
||||
|
||||
| # | Package / surface | Notes | Effort |
|
||||
| ---- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
|
||||
| 13.1 | **`@bytelyst/on-device-ai@0.1.0`** | WebLLM + transformers.js runners, capability detection (`useDeviceCapability()`), `useOnDeviceModel(modelId)` hook, `<ModelDownloadCard>` UI. Falls back to cloud when device can't run. Peer-deps `@mlc-ai/web-llm` + `@xenova/transformers`. | 2 pw |
|
||||
| 13.2 | **`@bytelyst/collab@0.1.0`** | Yjs-based CRDT layer with Automerge adapter. Exports: `<CollabProvider>`, `useSharedDoc(roomId)`, `useAwareness()`, `<CommentThread>`. Transport via `@bytelyst/fastify-sse`. | 2 pw |
|
||||
| 13.3 | **`ai-ui@0.5.0` trust surfaces** | `<CostMeter>`, `<ConfidenceTag>`, `<RefusalCard>`, `<ProvenanceDrawer>`, `<DebugOverlay>`, `<PrivacyBadge>`. Closes §3.4 amendments. | 1.5 pw |
|
||||
| 13.4 | **`motion@0.2.0` spatial** | `<Parallax>` (scroll-driven), `<Spotlight>` (cursor-follow), `<Magnetic>`, `<MeshBackground>`, `<TiltGallery>`. Pure CSS where possible; springs where not. | 1 pw |
|
||||
| 13.5 | **`@bytelyst/generative-theme@0.1.0`** | "Describe your brand" → LLM generates a Tier-2 token override file. Bonus surface: `<ThemeStudio>` interactive playground in showcase. | 1 pw |
|
||||
| 13.6 | **`@bytelyst/customizable-workspace@0.1.0`** | User-rearrangeable dashboards (drag-resize tiles), saved views per route, layout persistence to platform-service. Built on `@dnd-kit/sortable` + react-grid-layout fork. | 1 pw |
|
||||
| 13.7 | **`@bytelyst/media-ui@0.1.0`** | `<ImageGenStream>` (live image generation with progress), `<AudioWaveform>` (canvas-rendered + transcript scrubber), `<PdfPreview>` (pdf.js-backed lazy viewer), `<VideoPlayer>` (chapters + captions). | 1.5 pw |
|
||||
|
||||
**DoD:** ambient assistant lives in notes/web with on-device-AI fallback · one product ships CRDT-backed multi-user editing (likely notes or mindlyst) · `<ThemeStudio>` published in showcase · customisable workspace pilot in 1 product.
|
||||
|
||||
---
|
||||
|
||||
## 5. Per-product upgrade matrix
|
||||
|
||||
For each product web, the **3 highest-value adoptions** in the next two waves. (Each row is independent and can be tackled by the product team.)
|
||||
|
||||
| Product | Adoption #1 (Wave 8) | Adoption #2 (Wave 9) | Adoption #3 (Wave 10) | Wave 13 futurism hook |
|
||||
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| **clock/web** (ChronoMind) | Replace `framer-motion` with `@bytelyst/motion` Reveal + NumberFlow | `@bytelyst/charts` for time-distribution donut; deprecate inline recharts | `dashboard-shell@0.3.0` + `command-palette` | `<Spotlight>` + `<MeshBackground>` on landing |
|
||||
| **notes/web** (NoteLett) | Swap bespoke `AgentTimeline` → `ai-ui/AgentTimeline`; bespoke `Skeleton` → `ui/skeleton` | `@bytelyst/rich-text` for note body; `@bytelyst/timeline` for `MemoryTimeline` | `assistant-shell` (Wave 11) | **Pilot `@bytelyst/collab`** — CRDT-backed multi-user notes |
|
||||
| **flowmonk/web** | `@bytelyst/motion` + Cmd-K for task switching | `data-table` for schedule view; `data-viz/Heatmap` for streaks | `onboarding-ui` checklist on first task | `customizable-workspace` dashboard tiles |
|
||||
| **jarvisjr/web** | `@bytelyst/motion` on landing; swap recharts → `@bytelyst/charts` | `data-table` for sessions list | `billing-ui` upgrade modal | `<CostMeter>` on every AI turn; `<ProvenanceDrawer>` |
|
||||
| **fastgap/web** (NomGap) | Replace bespoke `Skeleton`; swap sonner → `ui/toast` | `@bytelyst/charts` for weight-trend; `data-viz/KpiCard` for streaks | `onboarding-ui` for first-fast tour | `media-ui/AudioWaveform` for guided fasting audio |
|
||||
| **localmemgpt/web** | `@bytelyst/motion` on chat threads; Cmd-K | Adopt `ai-ui/PromptComposer` + `ChatStream` (currently bespoke) | `file-ui/FileDrop` for memory imports | **Pilot `@bytelyst/on-device-ai`** — fully-local model option |
|
||||
| **voice / user-dashboard-web** (LysnrAI) | Adopt `@bytelyst/ui` (replaces direct `radix-ui` usage) | Swap recharts → `@bytelyst/charts`; `data-table` for transcripts | `billing-ui` + `settings-ui` | `media-ui/AudioWaveform` + `<ConfidenceTag>` on transcripts |
|
||||
| **trails/web** (ActionTrail) | `@bytelyst/motion` + `notifications-ui` for alert banner | `data-table` for action log; `timeline` for revert history | `settings-ui` | `<ProvenanceDrawer>` over every action |
|
||||
| **dev-intelli/web** | Cmd-K palette + `@bytelyst/motion` | `data-table` for diagnostics; `ai-ui/CodeDiff` for proposed fixes | `assistant-shell` (Wave 11) | `<DebugOverlay>` + `<RefusalCard>` |
|
||||
| **peakpulse/web** | `@bytelyst/motion` + Cmd-K | `@bytelyst/charts`; `data-viz/KpiCard` for streaks | `settings-ui` | `customizable-workspace` for personal dashboards |
|
||||
| **mindlyst/web** (multimodal-memory) | Adopt `@bytelyst/ui` + tokens; `motion` Reveal | `rich-text` + `media-ui/PdfPreview` for documents | `assistant-shell` + `timeline` | **Pilot `<ImageGenStream>` + `collab`** — multi-user memory canvas |
|
||||
| **talk2obsidian/web** | Adopt tokens + Cmd-K | `rich-text` aligned to Obsidian markdown subset | `settings-ui` + `file-ui` | `on-device-ai` for fully-local mode |
|
||||
| **productivity-web** | Tokens + Cmd-K + `motion` | `data-table` + `charts` for productivity metrics | `dashboard-shell@0.3.0` | `customizable-workspace` |
|
||||
| **sidecar-dashboard-web** | Tokens + `dashboard-shell@0.3.0` | `data-table` + `data-viz/RealtimeChart` | `notifications-ui` for incident stream | `<DebugOverlay>` + `<ProvenanceDrawer>` |
|
||||
| **mac-tooling / dashboard** | Tokens + `dashboard-shell@0.3.0` | `data-table` + `command-palette` | `settings-ui` | `customizable-workspace` |
|
||||
| **devops-tools / dashboard** | Tokens + Cmd-K | `data-table` + `data-viz/RealtimeChart` + `motion` | `dashboard-shell@0.3.0` + `settings-ui` | `<DebugOverlay>` + `<ProvenanceDrawer>` |
|
||||
| **agent-monitoring-fx** | Tokens + `dashboard-shell@0.3.0` | `data-table` + `data-viz` + `motion` | `notifications-ui` + `settings-ui` | `<CostMeter>` + `<ConfidenceTag>` for agent runs |
|
||||
| **local-llms / dashboard** | Tokens + Cmd-K | `data-table` + `motion` | `settings-ui` | **First-class `on-device-ai` host** — it _is_ the local-LLM dashboard |
|
||||
| **efforise / client** (Vite) | Tokens (Vite SDK adapter — see §6.1) + Cmd-K | `data-table` + `motion` | `settings-ui` | `customizable-workspace` |
|
||||
| **admin-web** (platform) | Already on `dashboard-shell`; add Cmd-K | `data-table` + `charts` | `settings-ui` + `billing-ui` | `<DebugOverlay>` + `<ProvenanceDrawer>` |
|
||||
| **tracker-web** (platform) | Already on `dashboard-shell`; add Cmd-K | `data-table` + `rich-text` for issue bodies | `notifications-ui` + `settings-ui` | **Pilot `@bytelyst/collab`** — multi-user issue editing |
|
||||
|
||||
> **Cross-cutting:** every product adopts `@bytelyst/telemetry-ui` (Wave 10.6) in the same PR that swaps in `dashboard-shell@0.3.0` — they share the topbar slot.
|
||||
|
||||
---
|
||||
|
||||
## 6. New common-platform packages proposed in v3
|
||||
|
||||
Summary table — **26 net-new packages** over Waves 8 – 13 (was 16 in v3.0; v3.1 amendment adds 8):
|
||||
|
||||
| Package | Wave | Pkg size budget (gzip) | Purpose |
|
||||
| ----------------------------------- | ---------- | ---------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `@bytelyst/charts` | 9 | 25 KB | Token-themed recharts wrapper + Donut/Gauge |
|
||||
| `@bytelyst/rich-text` | 9 | 40 KB | Tiptap wrapper + slash menu + mentions |
|
||||
| `@bytelyst/data-table` | 9 | 25 KB | TanStack Table + Virtual |
|
||||
| `@bytelyst/onboarding-ui` | 10 | 10 KB | Checklist + tour + callouts |
|
||||
| `@bytelyst/billing-ui` | 10 | 15 KB | Plan/upgrade/usage/invoice |
|
||||
| `@bytelyst/settings-ui` | 10 | 10 KB | Settings layout + sections |
|
||||
| `@bytelyst/legal-ui` | 10 | 6 KB | Privacy/terms/cookie banner |
|
||||
| `@bytelyst/telemetry-ui` | 10 | 4 KB | Auto-event tracking |
|
||||
| `@bytelyst/timeline` | 10 | 8 KB | Generic vertical event timeline |
|
||||
| `@bytelyst/brand-flowmonk` (× 3) | 10 | 1 KB each | Token overrides |
|
||||
| `@bytelyst/adaptive-ui` | 11 | 18 KB | SchemaForm + StreamUI |
|
||||
| `@bytelyst/assistant-shell` | 11 | 8 KB | Ambient ⌘J assistant |
|
||||
| `@bytelyst/speech-ui` | 11 | 6 KB | Voice input + dictation |
|
||||
| `@bytelyst/file-ui` | 11 | 12 KB | FileDrop + FileBrowser + ImageCrop |
|
||||
| `@bytelyst/realtime-ui` | 11 | 8 KB | Presence + typing + cursors |
|
||||
| `@bytelyst/i18n` | 12 | 12 KB | react-intl wrapper |
|
||||
| `@bytelyst/icons` | 3.x bonus | 0 KB (peer) | Lucide / Phosphor / Tabler swap provider |
|
||||
| `@bytelyst/on-device-ai` | 13 | 18 KB + WebLLM peer | WebLLM + transformers.js runners |
|
||||
| `@bytelyst/collab` | 13 | 30 KB + Yjs peer | CRDT layer (Yjs canonical, Automerge adapter) |
|
||||
| `@bytelyst/generative-theme` | 13 | 8 KB | "Describe your brand" → token override |
|
||||
| `@bytelyst/customizable-workspace` | 13 | 14 KB | Drag-resize tiles + saved views |
|
||||
| `@bytelyst/media-ui` | 13 | 20 KB + pdf.js peer | ImageGenStream + AudioWaveform + PdfPreview + VideoPlayer |
|
||||
| `@bytelyst/ai-ui` (0.5.0 surfaces) | 13 | +6 KB | CostMeter, ConfidenceTag, RefusalCard, ProvenanceDrawer, DebugOverlay, PrivacyBadge |
|
||||
| `@bytelyst/motion` (0.2.0 surfaces) | 13 | +4 KB | Parallax, Spotlight, Magnetic, MeshBackground, TiltGallery |
|
||||
| `@bytelyst/email-tokens` | 10 hygiene | 0 KB (build-time) | Same tokens, emitted to MJML/inline-CSS for transactional email |
|
||||
|
||||
Total net-new package bytes if a product consumes **every** package: ~330 KB gzip. Per-product realistic adoption (6-8 of these): ~80-110 KB gzip — still within the 100 KB JS budget for _first-page_ loads (the larger packages — collab, on-device-ai, media-ui — lazy-load behind dynamic imports).
|
||||
|
||||
### 6.1 RSC vs client — explicit guidance per package
|
||||
|
||||
React 19 + Next.js 16 give us **React Server Components**. Knowing which package is RSC-safe vs client-only is the difference between a 100 KB and a 300 KB first paint.
|
||||
|
||||
| Package | RSC-safe? | Notes |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| `design-tokens` | ✅ yes (build-time CSS) | Pure CSS variable emission |
|
||||
| `ui` (most primitives) | ✅ yes for stateless (Button, Heading, Card, Surface) | Interactive variants are `'use client'` |
|
||||
| `legal-ui`, `settings-ui` rail | ✅ yes | Static-shape components |
|
||||
| `motion` | ❌ client | DOM refs + RAF |
|
||||
| `ai-ui`, `command-palette`, `data-viz`, `notifications-ui` | ❌ client | All stateful |
|
||||
| `charts`, `data-table`, `rich-text`, `media-ui` | ❌ client | Recharts/TanStack/Tiptap/canvas |
|
||||
| `on-device-ai`, `collab`, `realtime-ui`, `assistant-shell`, `speech-ui` | ❌ client | Browser APIs |
|
||||
| `adaptive-ui/StreamUI` | 🟡 hybrid | Server emits initial tree; client hydrates streaming patches |
|
||||
|
||||
**Rule:** every package whose entry point is RSC-safe ships a `"react-server"` export condition in `package.json` that re-exports only the safe surface. Client-only files carry `'use client'` at the top.
|
||||
|
||||
### 6.2 Non-React consumers — Web Components interop
|
||||
|
||||
Products shipping non-React shells (Vite vanilla, Astro, native web-views in Tauri/Capacitor, Obsidian plugins) need a path. Three layers:
|
||||
|
||||
| Layer | What it is | Status |
|
||||
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
|
||||
| **Tokens** | Pure CSS; works everywhere | ✅ already shipped |
|
||||
| **`@bytelyst/ui/web-components`** | Lit-wrapped Button/Card/Toast/Skeleton/EmptyState as Custom Elements (`<bl-button>`, `<bl-card>`, ...). React + framework-agnostic. | New — Wave 9 hygiene (0.5 pw) |
|
||||
| **`@bytelyst/icons/web-components`** | Icon registry as `<bl-icon name="sparkles"/>` Custom Element | New — Wave 9 hygiene (0.25 pw) |
|
||||
|
||||
This unlocks `learning_ai_efforise` (Vite), `learning_ai_talk2obsidian` (Obsidian plugin), `learning_ai_local_llms/dashboard` (Vite), and any future native shell without a React port.
|
||||
|
||||
### 6.3 Adjacent token packages — brand consistency outside the web
|
||||
|
||||
| Package | Notes |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`@bytelyst/email-tokens`** | Same Tier-1/2 tokens, emitted as MJML-compatible inline-CSS for transactional emails (welcome, billing, password-reset). Brand stays consistent across web + email. |
|
||||
| **`@bytelyst/audio-tokens`** | Notification sound palette (`success.wav`, `mention.wav`, `error.wav`) — same brand intent. Future: `@bytelyst/notifications-ui` plays them based on `data-kind`. |
|
||||
| **`@bytelyst/motion-tokens`** | Spring presets exported as Lottie/Rive interpolation curves for designer hand-off. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Non-goals (still)
|
||||
|
||||
- Rebuilding charts from scratch — wrap recharts.
|
||||
- Custom Markdown parser — adopt `react-markdown` + remark plugins inside `@bytelyst/ui/Markdown`.
|
||||
- A new state library — products keep TanStack Query / Zustand / Redux as they choose.
|
||||
- A new test runner — Vitest + Playwright is the stack.
|
||||
- Native iOS/Android UI work — `swift-platform-sdk` / `kotlin-platform-sdk` continue to share **tokens only**.
|
||||
- **A bespoke CRDT** — we adopt Yjs (with Automerge adapter), never re-invent.
|
||||
- **A bespoke on-device AI runtime** — we adopt WebLLM / transformers.js, never re-invent.
|
||||
- **A new CSS-in-JS runtime** — CSS variables + token-driven utility classes; zero-runtime is the rule.
|
||||
- **Generic "agent framework"** UI — the AI surface stays narrowly scoped to the patterns in §3.3 + §3.4. No "build your own agent" UI in v3.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks & mitigations (v3 specific)
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
| ------------------------------------------------------------------- | ---------- | ------ | ----------------------------------------------------------------------------------------------------------- |
|
||||
| Publish-workflow regression blocks Wave 8 | Low | High | Manual `npm publish` fallback script already in `scripts/publish-outdated-gitea-packages.sh` |
|
||||
| Recharts API drift (peer-dep mismatch across products) | Med | Med | Pin to `^2.15`; `@bytelyst/charts` has type-only re-exports so a major bump fails build, not silent |
|
||||
| Tiptap StarterKit extension churn (v2 → v3) | Med | Low | Lock to a known-good range in `@bytelyst/rich-text/package.json` |
|
||||
| StreamUI security (LLM emits arbitrary components) | Med | High | Strict allow-list of renderable component names; never `dangerouslySetInnerHTML`; schema-validated props |
|
||||
| Browser support for View Transitions cross-doc | Low | Med | Mandatory crossfade fallback already in `@bytelyst/motion` |
|
||||
| Voice / passkey UX in corp networks (proxy strips WebAuthn) | Low | Low | Document; provide username/password fallback |
|
||||
| **On-device AI hardware fragmentation** (some users have no WebGPU) | High | Med | `useDeviceCapability()` gates UI; transparent fallback to cloud with `<PrivacyBadge>` reflecting it |
|
||||
| **CRDT memory bloat** on long-lived documents | Med | Med | Yjs garbage-collection enabled; snapshot + replay archive in `@bytelyst/event-store` |
|
||||
| **Generative-theme producing inaccessible palettes** | Med | High | LLM output passes contrast-checker + token-schema validator before persistence; AAA contrast lock available |
|
||||
| **`@bytelyst/media-ui` bundle weight** (pdf.js is ~500 KB) | High | Med | Lazy-loaded dynamic imports only; never in first-page JS |
|
||||
| **Customisable workspace layout collisions** across density modes | Med | Low | Layout persistence keyed by `[productId, userId, density]` triple |
|
||||
| **WebLLM model download UX** (1–4 GB on first run) | High | High | `<ModelDownloadCard>` shows progress + bandwidth est; defaults to smallest viable model; opt-in only |
|
||||
|
||||
---
|
||||
|
||||
## 9. Concrete next 14 days — kickoff
|
||||
|
||||
| Day | Action | Output |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
|
||||
| **1 (Wed)** | Trigger publish workflow with broad filter — close out the 8 unpublished packages (react-auth, dashboard-shell, design-tokens, ai-ui, command-palette, motion, data-viz, notifications-ui) | Gitea registry shows all 8 at latest source versions |
|
||||
| **2 (Thu)** | Showcase: delete all `src/lib/*-preview/` snapshots; swap to registry imports | TODO #14 closed |
|
||||
| **3 (Fri)** | Land cross-repo dep update PR for **all 20 web apps** — `@bytelyst/design-tokens@^0.2.0` + adopt `@bytelyst/motion` Reveal on landing | 20 webs on latest tokens; motion adopted incrementally |
|
||||
| **4 (Sat)** | Migrate `learning_ai_notes/web/AgentTimeline.tsx` → `@bytelyst/ai-ui/AgentTimeline`; delete bespoke | -250 LOC in notes/web |
|
||||
| **5 (Sun)** | Wire `command-palette` into notes + jarvisjr + fastgap | 3 products with Cmd-K |
|
||||
| **6 (Mon)** | **`@bytelyst/charts@0.1.0`** scaffold: `<LineChart>`, `<BarChart>`, `<AreaChart>` MVPs | New package published 0.1.0 |
|
||||
| **7 (Tue)** | `@bytelyst/charts` showcase demos + adoption in `clock/web` history view | 1 product on charts |
|
||||
| **8 (Wed)** | **`@bytelyst/rich-text@0.1.0`** scaffold | Package published 0.1.0 |
|
||||
| **9 (Thu)** | `@bytelyst/rich-text` adoption in `notes/web` body editor | Notes ships rich-text via shared package |
|
||||
| **10 (Fri)** | **`@bytelyst/data-table@0.1.0`** scaffold | Package published 0.1.0 |
|
||||
| **11 (Sat)** | `<Skeleton>` + `<EmptyState>` + `<SearchInput>` added to `@bytelyst/ui@0.2.0` | UI v0.2.0 published |
|
||||
| **12 (Sun)** | Migrate every product `Skeleton.tsx` → `@bytelyst/ui/skeleton`; codemod commit per repo | -1,400 LOC across product webs |
|
||||
| **13 (Mon)** | **`@bytelyst/dashboard-shell@0.3.0`** scaffolding kicks off | RFC drafted, scaffold landed |
|
||||
| **14 (Tue)** | Wave 9 retro + Wave 10 kickoff | Roadmap doc bumped to v3.2 |
|
||||
|
||||
### 9.1 Demo-first — showcase prototypes to build alongside the packages
|
||||
|
||||
Every package ships with at least one _aspirational_ showcase demo that doubles as customer-facing marketing. The eight to lead with (in priority order):
|
||||
|
||||
1. **`<MeshBackground>` + `<Spotlight>` landing hero** — the first thing a prospect sees.
|
||||
2. **On-device-AI chat with `<PrivacyBadge>`** — "this conversation never left your laptop".
|
||||
3. **`<CostMeter>` + `<ConfidenceTag>` AI dashboard** — every other AI product hides cost; we show it.
|
||||
4. **CRDT multi-user notes** — open the same demo URL in two windows, watch it sync.
|
||||
5. **`<ThemeStudio>` — generative branding playground** — prospects can brand the showcase to their company in 30 seconds.
|
||||
6. **`<CustomizableWorkspace>` dashboard** — drag tiles, save views, reload — it remembers.
|
||||
7. **`<ImageGenStream>` + `<AudioWaveform>`** — multimodal AI surfaces in one screen.
|
||||
8. **`<DebugOverlay>` (Shift-click on any AI response)** — transparency demo for technical evaluators.
|
||||
|
||||
---
|
||||
|
||||
## 10. Success metrics — end of v3 (Wave 13, ~28 weeks)
|
||||
|
||||
| Metric | v2.5 baseline | v3.1 target |
|
||||
| --------------------------------------------------- | ---------------------- | ---------------------------------------------------- |
|
||||
| `@bytelyst/*` packages published | 66 | **92** (66 + 26 net-new) |
|
||||
| Web apps on at least one new UI package | 0 / 20 | **20 / 20** |
|
||||
| Web apps on `dashboard-shell@0.3.0` | 0 | **12** |
|
||||
| Web apps on `@bytelyst/charts` | 0 | **8** |
|
||||
| Web apps on `@bytelyst/command-palette` | 0 | **20** (universal) |
|
||||
| Web apps with at least one Wave 13 futurism surface | 0 | **10+** |
|
||||
| Bespoke `Skeleton.tsx` files in product webs | ~7 | **0** |
|
||||
| Bespoke `*Modal.tsx` files | 10+ | **≤ 3** (product-specific only) |
|
||||
| Showcase demos | 90+ | **180+** |
|
||||
| Playwright tests | 94+ | **300+** |
|
||||
| Lighthouse Perf (showcase + each product) | unmeasured per product | **≥ 90 across the board** |
|
||||
| **INP p75** (each product) | unmeasured | **≤ 200ms** |
|
||||
| **LCP p75** (each product) | unmeasured | **≤ 2.5s** |
|
||||
| **CLS p75** (each product) | unmeasured | **≤ 0.1** |
|
||||
| First-page JS budget per route | unenforced | **≤ 100 KB gzip, CI-gated** |
|
||||
| WCAG 2.2 AA pass rate | partial | **100%** |
|
||||
| RTL visual-test pass rate | none | **100%** |
|
||||
| Locales | 1 (en) | **4** (en/es/ar/ja) |
|
||||
| Brand-layer packages | 0 | **3** |
|
||||
| EU EAA conformance statement | none | **published** |
|
||||
| **Privacy-mode toggle** present | 0 products | **all AI-touching products** |
|
||||
| **CRDT-backed multi-user editing** | 0 products | **≥ 2 products (notes, tracker-web)** |
|
||||
| **On-device AI option** | 0 products | **≥ 2 products (localmemgpt, local-llms dashboard)** |
|
||||
| **`<CostMeter>` visible in AI surfaces** | 0 products | **all AI-touching products** |
|
||||
| Cookie/consent UX dark-pattern audit pass | not run | **100%** |
|
||||
| Web Components export available | no | **`@bytelyst/ui/web-components` published** |
|
||||
| **`pnpm create @bytelyst/app`** scaffolding | none | **functional + documented** |
|
||||
|
||||
---
|
||||
|
||||
## 11. Showcase-first workflow & live progress tracker
|
||||
|
||||
> **Canonical showcase repo:** `@/Users/sd9235/code/mygh/copilot/learning_ai_uxui_web`
|
||||
> **Live URL (local dev):** `http://localhost:3010/showcase/<group>/<slug>`
|
||||
> **Why:** every package shipped from `common_plat` MUST land a working demo in the showcase **before** any product adopts it. The showcase is our "single pane of glass" — visual review, a11y/lighthouse gates, MSW-mocked dependencies, theme + density + RTL toggle, axe-clean — so we iterate cheaply and decide once.
|
||||
|
||||
### 11.0 The showcase-first rule (non-negotiable)
|
||||
|
||||
For every roadmap deliverable:
|
||||
|
||||
1. **Scaffold** in `common_plat/packages/<name>/` with tests.
|
||||
2. **Vendor snapshot** into `learning_ai_uxui_web/src/lib/<name>-preview/` (mirror byte-for-byte until publish).
|
||||
3. **Add showcase route** under `src/app/showcase/<group>/<slug>/page.tsx` + catalog entry in `src/catalog/routes.ts`.
|
||||
4. **MSW mock** any backend dependency in `src/mocks/handlers/`.
|
||||
5. **Smoke test** the route in `tests/smoke.spec.ts` (axe + screenshot baseline).
|
||||
6. **Publish** to Gitea registry; **delete** the `*-preview/` vendor dir; **swap** imports.
|
||||
7. **Adopt** in at least one product web — that PR is what closes the checklist row.
|
||||
|
||||
### 11.1 How agents update this tracker
|
||||
|
||||
Each row is a GitHub-style task list. Coding agents flip `- [ ]` → `- [x]` **in the same commit** that lands the work, and link the commit short-SHA in the trailing parens. Example:
|
||||
|
||||
```markdown
|
||||
- [x] **8.6 Skeleton codemod** — every product `Skeleton.tsx` swapped to `@bytelyst/ui/skeleton` _(common_plat acb8f02b · showcase 1105e42)_
|
||||
```
|
||||
|
||||
For multi-step rows, sub-bullets are tracked independently. Agents should leave a brief `// progress:` note in their commit body if a row is partially complete.
|
||||
|
||||
### 11.2 Progress at a glance
|
||||
|
||||
```
|
||||
TOTAL 58 / 202 🟩🟩🟩⬛⬛⬛⬛⬛⬛⬛ 29%
|
||||
─────────────────────────────────────────────
|
||||
Wave 8 Rollout 5 / 18 🟩🟩🟩⬛⬛⬛⬛⬛⬛⬛ 28%
|
||||
Wave 9 Data 21 / 42 🟩🟩🟩🟩🟩⬛⬛⬛⬛⬛ 50%
|
||||
Wave 10 Shells 0 / 35 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
|
||||
Wave 11 Adaptive 0 / 26 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
|
||||
Wave 12 Mobile 0 / 26 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
|
||||
Wave 13 Futurism 23 / 39 🟩🟩🟩🟩🟩🟩⬛⬛⬛⬛ 59%
|
||||
Cross-cutting 3 / 8 🟩🟩🟩🟩⬛⬛⬛⬛⬛⬛ 38%
|
||||
Magnet demos 6 / 8 🟩🟩🟩🟩🟩🟩🟩🟩⬛⬛ 75%
|
||||
```
|
||||
|
||||
> **Agents:** before pushing your commit, run `pnpm dlx tsx scripts/count-roadmap-progress.ts docs/UI_ROADMAP_2026_V3_CROSS_REPO.md` (to be authored in Wave 8.0) and paste the refreshed block in.
|
||||
|
||||
### 11.2.A Open TODOs raised during execution
|
||||
|
||||
Numbered list — coding agents drop `// ROADMAP-EXEC-TODO #N` comments at the point of need; the human operator can review and unblock.
|
||||
|
||||
| # | Title | Where it surfaced | What's needed |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **#1** | **Publish workflow run** — push 8 packages to Gitea registry (`react-auth@0.2.0`, `dashboard-shell@0.2.0`, `design-tokens@0.2.0`, `ai-ui@0.4.0`, `command-palette@0.1.0`, `motion@0.1.0`, `data-viz@0.1.0`, `notifications-ui@0.1.0`) | Wave 8.A.1 | Registry credentials / CI trigger. Until this runs, all 8.A.2–6, 8.C.\*, and the next `ui@0.2.0` adoption are gated. |
|
||||
| **#2** | **Add vitest setup to `@bytelyst/ui`** | Wave 9.D additions (Skeleton, SkeletonGroup, LoadingDots, SearchInput) | Decide whether to follow the `motion`/`data-viz` pattern (vitest + happy-dom + @testing-library/react devDeps) or stay typecheck-only. Recommended: copy motion's setup, then write tests. |
|
||||
| **#3** | **Visual-regression baseline refresh** — the new showcase routes (`/showcase/motion/all`, `/showcase/command-palette/global`, plus any UI demos shipping in Wave 9) need `toHaveScreenshot()` snapshots captured | Wave 8 / CC.1 | Run `pnpm baseline` in showcase and commit the snapshots. |
|
||||
| **#4** | **Republish `@bytelyst/ui@0.2.0` to Gitea registry** once it catches up | Wave 9 hygiene | After TODO #1 closes, re-trigger publish so product webs can pin `^0.2.0`. |
|
||||
|
||||
### 11.3 Wave 8 — Unblock & rollout · `5 / 18`
|
||||
|
||||
#### 8.A · Publishing & infra
|
||||
|
||||
- [ ] **8.A.1** Trigger common_plat publish workflow with broad filter — 8 packages to Gitea registry
|
||||
- [ ] **8.A.2** Showcase: delete `src/lib/motion-preview/` · swap to `@bytelyst/motion`
|
||||
- [ ] **8.A.3** Showcase: delete `src/lib/data-viz-preview/` · swap to `@bytelyst/data-viz`
|
||||
- [ ] **8.A.4** Showcase: delete `src/lib/notifications-ui-preview/` · swap to `@bytelyst/notifications-ui`
|
||||
- [ ] **8.A.5** Showcase: delete `src/lib/ai-ui-preview/` · swap to `@bytelyst/ai-ui`
|
||||
- [ ] **8.A.6** Showcase: delete `src/lib/command-palette-preview/` · swap to `@bytelyst/command-palette`
|
||||
- [x] **8.A.7** Author `scripts/count-roadmap-progress.ts` — parses this doc, emits the §11.2 block _(common_plat e72323b8)_
|
||||
|
||||
#### 8.B · Showcase demos to add
|
||||
|
||||
- [x] **8.B.1** `/showcase/motion/all` — Reveal · Stagger · NumberFlow · Tilt · ScrollProgress gallery (one page) _(showcase pending-commit)_
|
||||
- [x] **8.B.2** `/showcase/command-palette/global` — wired to `GlobalCommandPalette` provider; ⌘K opens; 30+ commands registered _(showcase pending-commit; provider in 77291af)_
|
||||
- [x] **8.B.3** `/showcase/data-viz/sparkline` — already exists, hydration regression test added (no SSR mismatch with `useId` fix) _(common_plat acb8f02b)_
|
||||
- [x] **8.B.4** `/showcase/data-viz/progress-ring` — a11y range-input `aria-label` fix verified; axe-clean _(showcase 8598dce)_
|
||||
|
||||
#### 8.C · Product migrations
|
||||
|
||||
- [ ] **8.C.1** `learning_ai_notes/web` — swap bespoke `AgentTimeline` → `@bytelyst/ai-ui/AgentTimeline`
|
||||
- [ ] **8.C.2** `learning_ai_clock/web` — drop `framer-motion`; adopt `@bytelyst/motion` Reveal + NumberFlow on `/landing` + `/history`
|
||||
- [ ] **8.C.3** All product webs — pin `@bytelyst/design-tokens@^0.2.0`
|
||||
- [ ] **8.C.4** `notes` + `jarvisjr` + `fastgap` — wire `@bytelyst/command-palette`
|
||||
|
||||
#### 8.D · Quality gates
|
||||
|
||||
- [ ] **8.D.1** Showcase: 0 vendored snapshots remaining (`ls src/lib/*-preview/ 2>/dev/null` returns nothing)
|
||||
- [ ] **8.D.2** Showcase: all 94 Playwright smoke tests still passing after package swap
|
||||
- [ ] **8.D.3** Showcase: visual-regression baseline updated post-swap
|
||||
|
||||
### 11.4 Wave 9 — Data, content, search · `21 / 42`
|
||||
|
||||
#### 9.A · `@bytelyst/charts@0.1.0`
|
||||
|
||||
- [x] **9.A.1** Package scaffold + `<LineChart>` (multi-series, smooth/straight, per-series colour) + 19/19 tests _(charts@0.1.0)_
|
||||
- [x] **9.A.2** `<BarChart>` with diverging-baseline support — 3 tests _(StackedBar deferred to 0.2.x)_
|
||||
- [x] **9.A.3** `<AreaChart>` (gradient fill + line stroke + single-point safety) — 2 tests
|
||||
- [x] **9.A.4** `<Donut>` (4 cases) + `<Gauge>` (4 cases, NaN-safe) — 8 tests
|
||||
- [ ] **9.A.5** `<RadarChart>` + tests _(deferred to charts@0.2.x)_
|
||||
- [x] **9.A.6** Token-driven theming — all primitives reference `var(--bl-*)` with sensible sRGB fallbacks
|
||||
- [x] **9.A.7** **Showcase:** `/showcase/charts/all` — gallery + 5 per-chart deep dives (`/charts/line` `/bar` `/area` `/donut` `/gauge`)
|
||||
- [ ] **9.A.8** **Showcase:** `/showcase/charts/realtime` — SSE-streamed line chart via MSW
|
||||
- [ ] **9.A.9** **Adoption:** `clock/web` history view replaces inline recharts
|
||||
|
||||
#### 9.B · `@bytelyst/rich-text@0.1.0`
|
||||
|
||||
- [ ] **9.B.1** Package scaffold with Tiptap StarterKit
|
||||
- [ ] **9.B.2** `<RichTextEditor>` + `<RichTextViewer>` components
|
||||
- [ ] **9.B.3** Default toolbar (bold/italic/headings/lists/links/code) + tests
|
||||
- [ ] **9.B.4** Slash-menu extension + tests
|
||||
- [ ] **9.B.5** Mention extension (async people search) + tests
|
||||
- [ ] **9.B.6** **Showcase:** `/showcase/rich-text/editor` — full editor with slash menu + mentions
|
||||
- [ ] **9.B.7** **Showcase:** `/showcase/rich-text/viewer` — read-only render of a fixture doc
|
||||
- [ ] **9.B.8** **Adoption:** `notes/web` body editor on `@bytelyst/rich-text`
|
||||
|
||||
#### 9.C · `@bytelyst/data-table@0.1.0`
|
||||
|
||||
- [ ] **9.C.1** Package scaffold on TanStack Table v9 + TanStack Virtual
|
||||
- [ ] **9.C.2** Sortable + filterable + paginated column behaviour + tests
|
||||
- [ ] **9.C.3** Column resize + pin + reorder + tests
|
||||
- [ ] **9.C.4** Row selection + bulk action bar + tests
|
||||
- [ ] **9.C.5** Virtualised 10k-row rendering + tests (INP ≤ 200ms)
|
||||
- [ ] **9.C.6** Density `compact` / `comfortable` variants
|
||||
- [ ] **9.C.7** **Showcase:** `/showcase/data-table/basic` — 50 rows, sort + filter + select
|
||||
- [ ] **9.C.8** **Showcase:** `/showcase/data-table/virtual` — 10,000 rows, scroll perf demo
|
||||
- [ ] **9.C.9** **Adoption:** `tracker-web` admin view on `@bytelyst/data-table`
|
||||
|
||||
#### 9.D · `@bytelyst/ui@0.2.0` additions
|
||||
|
||||
- [x] **9.D.1** `<Skeleton>` extended with `card` shape — 4 variants (`text`/`block`/`circle`/`card`) _(pending tests — see TODO #2)_
|
||||
- [x] **9.D.2** `<SkeletonGroup>` orchestrator — `loading` → `fallback` ↔ `children` swap with opacity fade _(pending tests — see TODO #2)_
|
||||
- [x] **9.D.3** `<EmptyState>` — already shipped in `@bytelyst/ui@0.1.x`; verified API (icon + title + description + actionLabel + onAction)
|
||||
- [x] **9.D.4** `<SearchInput>` — leading icon, clear-`x`, `suggestions` slot, 3 size variants, `searchbox` role _(pending tests — see TODO #2)_
|
||||
- [x] **9.D.5** `<FilterBar>` (already in 0.1.x) + `<TagInput>` + `<Combobox>` shipped \u2014 chip editor with Enter/comma commit + searchable select with keyboard nav _(pending tests \u2014 see TODO #2)_
|
||||
- [x] **9.D.6** `<LoadingDots>` added; `<LoadingSpinner>` already shipped — both token-tinted _(pending tests — see TODO #2)_
|
||||
- [x] **9.D.7** **Showcase:** `/showcase/ui/skeleton-gallery` — 4 shapes + SkeletonGroup + LoadingDots gallery
|
||||
- [x] **9.D.8** **Showcase:** `/showcase/ui/empty-states` — 6 idiomatic empty states (inbox-zero · no-results · welcome · offline · access-restricted · archive)
|
||||
- [x] **9.D.9** **Showcase:** `/showcase/ui/filter-bar-interactive` — SearchInput + 6 chips narrowing a live list
|
||||
- [ ] **9.D.10** **Codemod:** every product `Skeleton.tsx` → `@bytelyst/ui/skeleton` (delete bespoke)
|
||||
|
||||
#### 9.E · `ai-ui@0.5.0`
|
||||
|
||||
- [x] **9.E.1** `<Markdown>` dep-free subset renderer + `[cite:<id>]` chip interop — 5 tests _(ai-ui@0.6.0 · 98/98 passing)_
|
||||
- [x] **9.E.2** `<CodeDiff>` line-LCS diff in <100 LOC; split + unified views — 4 tests _(ai-ui@0.6.0)_
|
||||
- [x] **9.E.3** `<ExplainThis>` (selectionchange listener → floating CTA → onExplain({ text, rect })) — 2 tests _(ai-ui@0.6.0)_
|
||||
- [x] **9.E.4** `usePromptHistory()` bash-style ↑/↓ recall + localStorage persistence (storageKey, capacity, dedupe) — 4 tests _(ai-ui@0.6.0)_
|
||||
- [x] **9.E.5** `useTokenCount()` (chars/token configurable; optional USD cost; memoised) — 4 tests _(ai-ui@0.6.0)_
|
||||
- [x] **9.E.6** **Showcase:** `/showcase/ai-ui/markdown` — Q4 review sample with 3 citation chips (2 resolved, 1 missing fall-back)
|
||||
|
||||
### 11.5 Wave 10 — Product surfaces & shells · `0 / 35`
|
||||
|
||||
#### 10.A · `@bytelyst/dashboard-shell@0.3.0`
|
||||
|
||||
- [ ] **10.A.1** Slot-based `<AppShell>` API design + RFC merged
|
||||
- [ ] **10.A.2** `<Sidebar>` (collapsible groups · badges · route-active states) + tests
|
||||
- [ ] **10.A.3** `<Topbar>` (breadcrumbs · user menu · Cmd-K trigger · theme picker · notifications-bell slot) + tests
|
||||
- [ ] **10.A.4** Mobile drawer auto-applied < 768px + tests
|
||||
- [ ] **10.A.5** Density-aware spacing
|
||||
- [ ] **10.A.6** **Showcase:** `/showcase/shell/full` — interactive full-shell demo with nav switching
|
||||
- [ ] **10.A.7** **Adoption:** ≥ 3 products on `dashboard-shell@0.3.0`
|
||||
|
||||
#### 10.B · `@bytelyst/onboarding-ui@0.1.0`
|
||||
|
||||
- [ ] **10.B.1** `<OnboardingChecklist>` + tests
|
||||
- [ ] **10.B.2** `<TourStep>` + `useTour()` + tests
|
||||
- [ ] **10.B.3** `<FeatureCallout>` + `<EmptyStateCTA>` + tests
|
||||
- [ ] **10.B.4** localStorage persistence + tests
|
||||
- [ ] **10.B.5** **Showcase:** `/showcase/onboarding/checklist` + `/showcase/onboarding/tour`
|
||||
- [ ] **10.B.6** **Adoption:** `nomgap` first-day tour
|
||||
|
||||
#### 10.C · `@bytelyst/billing-ui@0.1.0`
|
||||
|
||||
- [ ] **10.C.1** `<PlanCard>` + `<UpgradeModal>` + tests
|
||||
- [ ] **10.C.2** `<UsageMeter>` + `<InvoiceList>` + `<PaymentMethodList>` + tests
|
||||
- [ ] **10.C.3** Wired to `@bytelyst/subscription-client` + `@bytelyst/billing-client`
|
||||
- [ ] **10.C.4** **Showcase:** `/showcase/billing/upgrade-flow` — full upgrade flow with MSW Stripe mock
|
||||
- [ ] **10.C.5** **Adoption:** `jarvisjr` + `nomgap` upgrade modals
|
||||
|
||||
#### 10.D · `@bytelyst/settings-ui@0.1.0`
|
||||
|
||||
- [ ] **10.D.1** `<SettingsLayout>` (left-rail nav) + tests
|
||||
- [ ] **10.D.2** `<ProfileSection>` + `<SecuritySection>` (passkey + 2FA) + tests
|
||||
- [ ] **10.D.3** `<NotificationPrefs>` + `<DataExportRow>` + `<DangerZone>` + tests
|
||||
- [ ] **10.D.4** **Showcase:** `/showcase/settings/full` — all sections wired
|
||||
- [ ] **10.D.5** **Adoption:** ≥ 2 products on `settings-ui`
|
||||
|
||||
#### 10.E · `@bytelyst/legal-ui@0.1.0`
|
||||
|
||||
- [ ] **10.E.1** `<PrivacyPolicyPage>` + `<TermsPage>` MDX-driven
|
||||
- [ ] **10.E.2** `<CookieBanner>` (EAA-compliant, **no dark patterns** — lint enforced)
|
||||
- [ ] **10.E.3** `<DataProcessingNotice>`
|
||||
- [ ] **10.E.4** **Showcase:** `/showcase/legal/all` — every legal surface rendered
|
||||
|
||||
#### 10.F · `@bytelyst/telemetry-ui@0.1.0`
|
||||
|
||||
- [ ] **10.F.1** `data-bl-event` attribute scraper + React provider
|
||||
- [ ] **10.F.2** `useTrackedClick(name)` hook + tests
|
||||
- [ ] **10.F.3** PostHog + OpenTelemetry adapters
|
||||
- [ ] **10.F.4** **Showcase:** `/showcase/telemetry/live` — events stream visible in a dev panel
|
||||
|
||||
#### 10.G · `@bytelyst/timeline@0.1.0` & brand layers
|
||||
|
||||
- [ ] **10.G.1** `<Timeline>` (grouped by day, sticky headers, infinite scroll) + tests
|
||||
- [ ] **10.G.2** **Showcase:** `/showcase/timeline/memory-pattern` — replicates `notes/MemoryTimeline.tsx`
|
||||
- [ ] **10.G.3** Brand packages: `brand-flowmonk`, `brand-chronomind`, `brand-mindlyst` (Tier-2 overrides)
|
||||
- [ ] **10.G.4** **Showcase:** brand switcher next to theme picker
|
||||
|
||||
### 11.6 Wave 11 — Adaptive / ambient / multimodal · `0 / 26`
|
||||
|
||||
#### 11.A · `@bytelyst/adaptive-ui@0.1.0`
|
||||
|
||||
- [ ] **11.A.1** `<SchemaForm>` (JSON Schema → form via `react-hook-form`) + tests
|
||||
- [ ] **11.A.2** `<SchemaTable>` (editable from schema) + tests
|
||||
- [ ] **11.A.3** `<StreamUI>` — LLM-emitted component tree, **strict allow-list** of renderable names
|
||||
- [ ] **11.A.4** **Showcase:** `/showcase/adaptive-ui/schema-form` — paste a JSON Schema, get a form
|
||||
- [ ] **11.A.5** **Showcase:** `/showcase/adaptive-ui/stream-ui` — streamed AI-emitted tree from MSW mock
|
||||
|
||||
#### 11.B · `@bytelyst/assistant-shell@0.1.0`
|
||||
|
||||
- [ ] **11.B.1** Floating dot trigger · summon with ⌘J + tests
|
||||
- [ ] **11.B.2** Sheet hosts `ai-ui/ChatStream` with route-aware system prompt
|
||||
- [ ] **11.B.3** **Showcase:** `/showcase/assistant/global` — every page exposes ⌘J
|
||||
- [ ] **11.B.4** **Adoption:** `notes/web` first product host
|
||||
|
||||
#### 11.C · `@bytelyst/speech-ui@0.1.0`
|
||||
|
||||
- [ ] **11.C.1** `<VoiceInputButton>` (Web Speech API + waveform visualiser)
|
||||
- [ ] **11.C.2** `<DictationOverlay>` + `<TranscriptViewer>`
|
||||
- [ ] **11.C.3** `ai-ui/PromptComposer.voiceInput` integration
|
||||
- [ ] **11.C.4** **Showcase:** `/showcase/speech/dictation` + `/showcase/speech/composer-voice`
|
||||
|
||||
#### 11.D · `@bytelyst/file-ui@0.1.0`
|
||||
|
||||
- [ ] **11.D.1** `<FileDrop>` (chunked upload via `@bytelyst/blob-client`)
|
||||
- [ ] **11.D.2** `<FilePreview>` (image/PDF/text auto)
|
||||
- [ ] **11.D.3** `<FileBrowser>` (grid + list views)
|
||||
- [ ] **11.D.4** `<ImageCrop>`
|
||||
- [ ] **11.D.5** **Showcase:** `/showcase/file/upload` + `/showcase/file/browser`
|
||||
|
||||
#### 11.E · `@bytelyst/realtime-ui@0.1.0`
|
||||
|
||||
- [ ] **11.E.1** `<PresenceAvatars>` + `useRealtimePresence(channel)`
|
||||
- [ ] **11.E.2** `<TypingIndicator>`
|
||||
- [ ] **11.E.3** `<LiveCursor>`
|
||||
- [ ] **11.E.4** **Showcase:** `/showcase/realtime/cursors` — open in 2 windows, see live cursors
|
||||
|
||||
#### 11.F · Trust amendments
|
||||
|
||||
- [ ] **11.F.1** `ai-ui/AgentTimeline` gains cancel + retry actions
|
||||
- [ ] **11.F.2** `ai-ui/SuggestionRow` (next-action prediction) + tests
|
||||
- [ ] **11.F.3** `ai-ui/InlineAI` (highlight → rewrite) + tests
|
||||
- [ ] **11.F.4** **Showcase:** `/showcase/ai-ui/inline-rewrite` — highlight text in an editor → rewrite tray
|
||||
|
||||
### 11.7 Wave 12 — Mobile · i18n · sustainability · `0 / 26`
|
||||
|
||||
#### 12.A · Mobile primitives in `@bytelyst/ui@0.3.0`
|
||||
|
||||
- [ ] **12.A.1** `<BottomSheet>` (snap points + scrollable body) + tests
|
||||
- [ ] **12.A.2** `<StickyActionBar>` (safe-area-aware) + tests
|
||||
- [ ] **12.A.3** `<TouchableRipple>` + tests
|
||||
- [ ] **12.A.4** `<PullToRefresh>` + tests
|
||||
- [ ] **12.A.5** Mobile-variant `<Dropdown>` / `<Tooltip>` (long-press) + tests
|
||||
- [ ] **12.A.6** **Showcase:** `/showcase/mobile/all` — every mobile primitive on a 375px viewport
|
||||
|
||||
#### 12.B · `@bytelyst/i18n@0.1.0`
|
||||
|
||||
- [ ] **12.B.1** `react-intl` wrapper + type-safe catalogues
|
||||
- [ ] **12.B.2** Auto-extraction CLI (`pnpm i18n:extract`)
|
||||
- [ ] **12.B.3** Default locales: `en`, `es`, `ar`, `ja`
|
||||
- [ ] **12.B.4** **Showcase:** locale switcher in theme picker
|
||||
- [ ] **12.B.5** **Showcase:** every demo translated into all 4 locales (or marked `intl-pending`)
|
||||
|
||||
#### 12.C · RTL audit
|
||||
|
||||
- [ ] **12.C.1** Stylelint rule blocks `left:` / `right:` outside `dir`-scoped contexts
|
||||
- [ ] **12.C.2** Codemod: convert physical → logical properties across `common_plat/packages/*`
|
||||
- [ ] **12.C.3** **Showcase:** `dir="rtl"` toggle next to theme picker
|
||||
- [ ] **12.C.4** Visual-regression: every demo also snapshotted at `dir="rtl"`
|
||||
|
||||
#### 12.D · Inclusive design
|
||||
|
||||
- [ ] **12.D.1** Atkinson Hyperlegible font toggle in theme picker
|
||||
- [ ] **12.D.2** AAA contrast lock-up toggle
|
||||
- [ ] **12.D.3** Motion-pause toggle (overrides `prefers-reduced-motion`)
|
||||
- [ ] **12.D.4** VPAT 2.5 ACR doc drafted + reviewed externally
|
||||
- [ ] **12.D.5** EU EAA conformance statement published on `/legal/accessibility`
|
||||
|
||||
#### 12.E · Showcase additions
|
||||
|
||||
- [ ] **12.E.1** Per-demo viewport switcher (375 / 768 / 1280 / fluid)
|
||||
- [ ] **12.E.2** Per-demo a11y inspector panel (axe + colour-contrast)
|
||||
|
||||
#### 12.F · Sustainability + PWA
|
||||
|
||||
- [ ] **12.F.1** `co2.js` budget enforced in CI per package
|
||||
- [ ] **12.F.2** Showcase becomes installable PWA with offline catalog
|
||||
- [ ] **12.F.3** `pnpm create @bytelyst/app my-app` scaffolds a working dev server < 60s
|
||||
- [ ] **12.F.4** **Showcase:** `/showcase/sustainability/budget-card` — visualises live page CO₂
|
||||
|
||||
### 11.8 Wave 13 — Futurism layer · `23 / 39`
|
||||
|
||||
#### 13.A · `@bytelyst/on-device-ai@0.1.0`
|
||||
|
||||
- [ ] **13.A.1** WebLLM runner adapter
|
||||
- [ ] **13.A.2** transformers.js runner adapter
|
||||
- [ ] **13.A.3** `useDeviceCapability()` hook + tests
|
||||
- [ ] **13.A.4** `useOnDeviceModel(modelId)` hook + tests
|
||||
- [ ] **13.A.5** `<ModelDownloadCard>` UI (progress + bandwidth est.) + tests
|
||||
- [ ] **13.A.6** Transparent cloud fallback when device incapable + tests
|
||||
- [ ] **13.A.7** **Showcase:** `/showcase/futurism/on-device-chat` — full chat that runs locally, with `<PrivacyBadge>` honest about mode
|
||||
- [ ] **13.A.8** **Adoption:** `localmemgpt/web` first host
|
||||
|
||||
#### 13.B · `@bytelyst/collab@0.1.0`
|
||||
|
||||
- [ ] **13.B.1** Yjs canonical layer + tests
|
||||
- [ ] **13.B.2** Automerge adapter + tests
|
||||
- [ ] **13.B.3** `<CollabProvider>` + `useSharedDoc(roomId)` + `useAwareness()` + tests
|
||||
- [ ] **13.B.4** `<CommentThread>` (anchored to selection range) + tests
|
||||
- [ ] **13.B.5** SSE transport via `@bytelyst/fastify-sse`
|
||||
- [ ] **13.B.6** **Showcase:** `/showcase/futurism/crdt-notes` — same URL, two windows, watch it sync
|
||||
- [ ] **13.B.7** **Adoption:** `notes/web` + `tracker-web` pilots
|
||||
|
||||
#### 13.C · `ai-ui@0.5.0` trust surfaces
|
||||
|
||||
- [x] **13.C.1** `<CostMeter>` + 5 tests — live token + USD readout with neutral/ok/warn/danger budget tiers, NaN-safe _(ai-ui@0.5.0 · 67/67 passing)_
|
||||
- [x] **13.C.2** `<ConfidenceTag>` + 5 tests — buckets `[0..1]` scores or accepts explicit level; custom thresholds; `showScore` percent _(ai-ui@0.5.0)_
|
||||
- [x] **13.C.3** `<RefusalCard>` + 4 tests — 6 reason archetypes · calm-not-red tinting · up-to-3 actions · footer slot _(ai-ui@0.5.0)_
|
||||
- [x] **13.C.4** `<ProvenanceDrawer>` slide-in dialog + Escape/backdrop close + body scroll lock + empty-state — 5 tests _(ai-ui@0.5.0+)_
|
||||
- [x] **13.C.5** `<DebugOverlay>` Shift/Alt/Meta modifier-click reveals JSON inspector — 4 tests, MAG.8 enabled _(ai-ui@0.5.0+)_
|
||||
- [x] **13.C.6** `<PrivacyBadge>` 4 modes (on-device / cloud / hybrid / unknown) + detail line + iconOnly — 3 tests _(ai-ui@0.5.0+)_
|
||||
- [x] **13.C.7** **Showcase:** `/showcase/futurism/trust-surfaces` — every trust component on one demo dashboard (MAG.3)
|
||||
|
||||
#### 13.D · `motion@0.2.0` spatial primitives
|
||||
|
||||
- [x] **13.D.1** `<Parallax>` scroll-driven translate3d via rAF · speed multiplier + axis (y/x) + reduced-motion bypass — 2 tests _(motion@0.2.1)_
|
||||
- [x] **13.D.2** `<Spotlight>` (cursor-follow radial gradient via two CSS custom props, no React re-render) + 3 tests _(common_plat motion@0.2.0 · 23/23 passing)_
|
||||
- [x] **13.D.3** `<Magnetic>` (Arc-style pointer-attracted wrapper with field-radius + clamped strength + reduced-motion fallback) + 2 tests _(common_plat motion@0.2.0)_
|
||||
- [x] **13.D.4** `<MeshBackground>` (4-stop OKLCH gradient with drifting blobs · 3 mood tiers · reduced-motion static fallback) + 2 tests _(common_plat motion@0.2.0)_
|
||||
- [x] **13.D.5** `<TiltGallery>` horizontal rail of cursor-tilting tiles · arrow-key scroll · scroll-snap — 3 tests _(motion@0.2.1)_
|
||||
- [x] **13.D.6** **Showcase:** `/showcase/futurism/spatial-hero` — full marketing-grade landing page with MeshBackground + Spotlight + 2 Magnetic CTAs + 4 NumberFlow KPIs + StaggerList
|
||||
|
||||
#### 13.E · `@bytelyst/generative-theme@0.1.0`
|
||||
|
||||
- [x] **13.E.1** Brand-prompt → token override generator — 7 deterministic palettes (midnight / citrus / forest / ocean / rose / graphite / violet) + pluggable async LLM hook _(generative-theme@0.1.0 · 18/18 passing)_
|
||||
- [x] **13.E.2** WCAG contrast utilities + AA/AAA-lock enforcement (parseHex / relativeLuminance / contrast / adjustForContrast / enforceContrast / auditTheme / applyTheme)
|
||||
- [x] **13.E.3** **Showcase:** `/showcase/futurism/theme-studio` (MAG.5) — 7 quick prompts + AA/AAA/off toggle + live preview with per-pairing contrast report
|
||||
|
||||
#### 13.F · `@bytelyst/customizable-workspace@0.1.0`
|
||||
|
||||
- [x] **13.F.1** Drag-reorder + resize tiles — native HTML5 drag (zero @dnd-kit dependency), keyboard ←/→ resize + ↑/↓ move, 1/2/3/4 size buttons _(customizable-workspace@0.1.0 · 10/10 passing)_
|
||||
- [x] **13.F.2** `LayoutPersistence` adapter + `reconcile()` defensive against schema drift — localStorage default; hosts on platform-service wire a server-backed adapter
|
||||
- [x] **13.F.3** **Showcase:** `/showcase/futurism/workspace` (MAG.6) — 6 tiles backed by Sparkline + LineChart, reload-persistent
|
||||
|
||||
#### 13.G · `@bytelyst/media-ui@0.1.0`
|
||||
|
||||
- [x] **13.G.1** `<ImageGenStream>` (4-status state machine · progress overlay · blur-on-streaming) — 4 tests _(media-ui@0.1.0 · 10/10 passing)_
|
||||
- [x] **13.G.2** `<AudioWaveform>` canvas + click-to-seek + DPR-aware paint + WebAudio peak decode — 3 tests
|
||||
- [ ] **13.G.3** `<PdfPreview>` (pdf.js lazy) + tests _(deferred to media-ui@0.2.x — needs the pdf.js runtime; only routes that use it should pay the bundle cost)_
|
||||
- [x] **13.G.4** `<VideoPlayer>` native `<video controls>` + chapter buttons + caption rail — 3 tests
|
||||
- [x] **13.G.5** **Showcase:** `/showcase/futurism/multimodal` (MAG.7) — ImageGenStream + AudioWaveform + VideoPlayer on one page (PdfPreview slot reserved for 0.2.x)
|
||||
|
||||
### 11.9 Cross-cutting · `3 / 8`
|
||||
|
||||
- [ ] **CC.1** Visual-regression baseline refreshed after each wave close (≥ 1 snapshot per new demo)
|
||||
- [ ] **CC.2** Lighthouse CI gates: Perf/A11y/SEO ≥ 90 on every showcase route
|
||||
- [x] **CC.3** axe-core gate active — every showcase route is asserted axe-clean in `tests/smoke.spec.ts` (currently 129/129 passing, 0 critical / 0 serious)
|
||||
- [ ] **CC.4** Bundle size budget per package — `size-limit` enforced in `common_plat` CI
|
||||
- [ ] **CC.5** Storybook 8 deployed per package (Gitea Pages)
|
||||
- [x] **CC.6** `docs/design-system/ANTIPATTERNS.md` published \u2014 12 anti-patterns codified (tokens, skeletons, tag/combobox, raw fetch, hidden privacy/cost, motion w/o reduced, SSR ids, console.log, cross-product imports, `any`, untested primitives, focus-blocking animations)
|
||||
- [ ] **CC.7** Public roadmap page in `tracker-web` renders this doc live
|
||||
- [x] **CC.8** `scripts/count-roadmap-progress.ts` wired into `.husky/pre-commit` — staging the roadmap re-runs the counter + re-stages the refreshed file so commits stay self-consistent
|
||||
|
||||
### 11.10 The eight customer-magnet demos (§9.1 — kept here for tracking)
|
||||
|
||||
Each is the _capstone_ demo of its package family. Marketing-grade.
|
||||
|
||||
- [x] **MAG.1** `/showcase/futurism/spatial-hero` — `<MeshBackground>` + `<Spotlight>` landing hero (Wave 13.D.6) **✨ the customer-magnet hero is live**
|
||||
- [ ] **MAG.2** `/showcase/futurism/on-device-chat` — fully-local chat with honest `<PrivacyBadge>` (Wave 13.A.7)
|
||||
- [x] **MAG.3** `/showcase/futurism/trust-surfaces` — `<CostMeter>` + `<ConfidenceTag>` + `<RefusalCard>` dashboard (Wave 13.C.7) **✨ the trust-surfaces magnet is live** _(ProvenanceDrawer pending in 13.C.4)_
|
||||
- [ ] **MAG.4** `/showcase/futurism/crdt-notes` — open two windows, watch them sync (Wave 13.B.6)
|
||||
- [x] **MAG.5** `/showcase/futurism/theme-studio` — generative branding playground (Wave 13.E.3) **✨ the theme-studio magnet is live**
|
||||
- [x] **MAG.6** `/showcase/futurism/workspace` — drag tiles, save view, reload (Wave 13.F.3) **✨ the workspace magnet is live**
|
||||
- [x] **MAG.7** `/showcase/futurism/multimodal` — image-gen + audio waveform + video (Wave 13.G.5) **✨ the multimodal magnet is live** _(PDF deferred per 13.G.3)_
|
||||
- [x] **MAG.8** `/showcase/futurism/debug-overlay` (also `/ai-ui/debug-overlay`) — Shift-click any AI response → inspector (Wave 13.C.5) **✨ the debug-overlay magnet is live**
|
||||
|
||||
---
|
||||
|
||||
## 12. Document hygiene
|
||||
|
||||
- **v3.0** (initial commit): new cross-repo UX roadmap; built on v2.5 (showcase repo). Added Waves 8–12, 16 net-new packages, per-product upgrade matrix, and modern-web-platform adoption guidance.
|
||||
- **v3.1** (review pass): fixed under-counts (15+ webs not 11; 26 net-new packages not 16). Added five major futurism subsections (§3.4 on-device & privacy-first AI, §3.5 real-time CRDT collab, §3.6 spatial/visionOS surfaces, §3.7 Core Web Vital budgets, §3.8 anti-patterns). Introduced **Wave 13** (futurism layer) with 7 new packages: `on-device-ai`, `collab`, `generative-theme`, `customizable-workspace`, `media-ui`, plus `ai-ui@0.5` trust surfaces and `motion@0.2` spatial primitives. Added §6.1 RSC vs client guidance, §6.2 Web Components interop for non-React consumers, §6.3 adjacent token packages (email, audio, motion-tokens). Expanded per-product matrix to all 20 web apps including platform dashboards. Added 6 new risk-matrix rows and §9.1 demo-first list of 8 customer-facing prototypes.
|
||||
- **v3.2** (this revision): added **§11 showcase-first workflow & live progress tracker** — **202 machine-parsable checklist items** across Waves 8 – 13 + cross-cutting + 8 customer-magnet demos. Codified the showcase-first rule: every package lands a `learning_ai_uxui_web` demo route before any product adopts. Tracker designed for coding agents to flip checkboxes inline with their commits (with auto-counter script `scripts/count-roadmap-progress.ts` slated for Wave 8.A.7). Renumbered hygiene § 11 → § 12.
|
||||
- Mirror at `learning_ai_uxui_web/docs/ROADMAP_2026_V3_CROSS_REPO.md` after each commit.
|
||||
- Future versions bump as waves close (`v3.3`, `v3.4`, ...).
|
||||
|
||||
**Owner:** ByteLyst Platform UI guild
|
||||
**Discussion:** `tracker-web` (issue tag: `roadmap-2026`, milestone: `v3`)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user