Compare commits
11 Commits
main
...
release/v1.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 700b3943ae | |||
| ac40b081c2 | |||
| 5cfa34631a | |||
| 4b17eda659 | |||
| 0dd0ba9a00 | |||
| 9391bb9ad2 | |||
| 504d7cadb3 | |||
| 8108be2871 | |||
| 6baa0cbd62 | |||
| 341fbe0b5f | |||
| 6ad770eb16 |
@@ -274,3 +274,100 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
python pipelines/_tests/smoke_inb21_extract.py
|
||||
|
||||
# ── Design-system lint (Story #135 — INB-1.5-D-S4) ─────
|
||||
# Enforces docs/design/lint-rules.md as a hard merge gate. Each
|
||||
# DS-* rule ID is mapped 1:1 to a Stylelint/ESLint/Breakpoint
|
||||
# check; errors block merge, warnings emit non-blocking
|
||||
# annotations. The companion fixture-check job proves the rules
|
||||
# actually fire on negative inputs.
|
||||
design-system-lint:
|
||||
name: Design-system lint (DS-C001..Z001)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
FORCE_COLOR: "0"
|
||||
CI: "true"
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
|
||||
- name: Install ui deps
|
||||
working-directory: ui
|
||||
run: npm ci
|
||||
|
||||
- name: Run design-system lint
|
||||
working-directory: ui
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/lint-design-system.mjs
|
||||
node scripts/check-breakpoints.mjs
|
||||
|
||||
- name: Run fixture check (proves every DS-* rule fires on a negative)
|
||||
working-directory: ui
|
||||
run: |
|
||||
node scripts/fixture-check.mjs
|
||||
|
||||
# ── Lighthouse accessibility audit (Story #135 — NFR-4) ─
|
||||
# Builds the v1.5 preview against a stub backend and runs
|
||||
# @lhci/cli assert. The accessibility category (≥ 90) is the
|
||||
# hard error assertion; performance / best-practices are
|
||||
# warn-only. Skipped when ui/dist/ is absent (i.e., the
|
||||
# preview build has not been wired in this Story) — that
|
||||
# condition is reported as ::notice, not as a green pass.
|
||||
lighthouse-a11y:
|
||||
name: Lighthouse a11y (NFR-4 ≥ 90)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
FORCE_COLOR: "0"
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
|
||||
- name: Install ui deps
|
||||
working-directory: ui
|
||||
run: npm ci
|
||||
|
||||
- name: Build v1.5 preview
|
||||
working-directory: ui
|
||||
# The v1.5 Story hasn't yet authored a real `build` script
|
||||
# in package.json. We detect that condition here and
|
||||
# surface it as a clear ::notice so the operator knows the
|
||||
# accessibility gate is intentionally not yet exercised on
|
||||
# this Story. Once D-S2 ships components, the build script
|
||||
# is added and the gate becomes active.
|
||||
run: |
|
||||
if grep -q '"build"' package.json; then
|
||||
npm run build
|
||||
echo "BUILD_OK=1" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "BUILD_OK=0" >> "$GITHUB_ENV"
|
||||
echo "::notice::ui/package.json has no 'build' script; the lighthouse-a11y gate is intentionally not exercised on this Story. See Story #135 runbook §6."
|
||||
fi
|
||||
|
||||
- name: Run @lhci/cli assert
|
||||
working-directory: ui
|
||||
# Only runs when BUILD_OK=1. Otherwise prints a ::notice
|
||||
# and exits 0 so the gate is not blocking on a future Story.
|
||||
run: |
|
||||
if [ "${BUILD_OK:-0}" != "1" ]; then
|
||||
echo "::notice::Skipping @lhci/cli assert — no ui/ build artefact. NFR-4 enforcement is gated on the future Story that wires the v1.5 preview build."
|
||||
exit 0
|
||||
fi
|
||||
npx --yes @lhci/cli@0.14.x autorun
|
||||
|
||||
|
||||
@@ -36,3 +36,11 @@ pipelines/_metrics/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Node / npm (UI lint + Lighthouse deps, Story #135)
|
||||
ui/node_modules/
|
||||
ui/.vite/
|
||||
ui/dist/
|
||||
ui/.cache/
|
||||
ui/coverage/
|
||||
ui/.lighthouseci/
|
||||
|
||||
@@ -233,7 +233,7 @@ Every write path has a single, named, idempotent function. This is the **smalles
|
||||
|---|---|---|---|---|---|
|
||||
| **Live pipeline** | `.gitea/workflows/live-pipeline.yml` | `cron(*/5 * * * *)` + `workflow_dispatch` | `pipelines/live_pipeline.py` | `group: live-pipeline, cancel-in-progress: false` | 15 min |
|
||||
| **Historical pipeline** | `.gitea/workflows/historical-pipeline.yml` | `workflow_dispatch` (no schedule) | `pipelines/historical_pipeline.py` | `group: historical-pipeline, cancel-in-progress: false` | 12 h |
|
||||
| **PR CI** | `.gitea/workflows/ci-pr.yml` | `pull_request` against `main` | `fast-tests` + `e2e-100mb` + `live-llm-smoke` | `group: pr-ci-${{ pull_request.number }}`, cancel-in-progress | 30 min |
|
||||
| **PR CI** | `.gitea/workflows/ci-pr.yml` | `pull_request` against `main` | `fast-tests` + `e2e-100mb` + `live-llm-smoke` + `design-system-lint` + `lighthouse-a11y` | `group: pr-ci-${{ pull_request.number }}`, cancel-in-progress | 30 min |
|
||||
| **Daily digest** | `.gitea/workflows/daily-digest.yml` | `cron 0 6 * * *` | `scripts/daily_digest.py` | n/a | 15 min |
|
||||
| **Cost report** | `.gitea/workflows/cost-report.yml` | `cron 0 9 1 * *` | `scripts/cost_report.py` | n/a | 15 min |
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Data Pipeline Lineage
|
||||
|
||||
> Story: [INB-1.5-C-S4 #129](https://git.cloudinit.dev/jc-capital/inboxos/issues/129) (Pillar III, `domain/data-pipeline`).
|
||||
> Schema source of truth: [INB-1.5-C-S3 #128](https://git.cloudinit.dev/jc-capital/inboxos/issues/128) (`cto-approved`) — see [comment 2453](https://git.cloudinit.dev/jc-capital/inboxos/issues/128#issuecomment-2453).
|
||||
|
||||
This document traces every sink the data-engineer owns back to its
|
||||
source and to the engineering agent that reads it. The point is to make
|
||||
"what depends on this?" and "where does this row come from?" answerable
|
||||
from a single page.
|
||||
|
||||
---
|
||||
|
||||
## rbac-schema-migration
|
||||
|
||||
**Source:** [NocoDB REST API `/api/v2/meta/bases/{base_id}/tables/...`](https://docs.nocodb.com/) (target: base `inboxos_rbac`).
|
||||
**Transform:** Idempotent create-or-find per table, column, and index. Re-running is a no-op.
|
||||
**Sinks (6 tables, in apply order):**
|
||||
|
||||
| # | Table title | Source of truth | Read by |
|
||||
|---|--------------------|-----------------------------------|--------------------------------------------|
|
||||
| 1 | `rbac_roles` | devops spec §"roles" | backend RBAC middleware (S2), ops console (S5) |
|
||||
| 2 | `rbac_permissions` | devops spec §"permissions" | backend RBAC middleware (S2) |
|
||||
| 3 | `rbac_role_permissions` | devops spec §"role_permissions" | backend RBAC middleware (S2) |
|
||||
| 4 | `rbac_users` | devops spec §"users" | backend auth (S2), ops console user mgmt (S5) |
|
||||
| 5 | `rbac_user_sessions` | devops spec §"user_sessions" | backend session middleware (S2) |
|
||||
| 6 | `rbac_audit_logs` | devops spec §"audit_logs" | backend audit emitter (S2), ops console read (S5) |
|
||||
|
||||
**Seed data (inserted on apply, idempotent on re-run):**
|
||||
- 3 roles: `mailbox_owner`, `admin_operator`, `super_admin`.
|
||||
- 9 permissions: `mailbox{read,write}`, `ops_console{read,admin}`, `users{read,write}`, `roles{read,admin}`, `audit{read}`.
|
||||
- 21 role→permission grants: 2 grants for `mailbox_owner`, 8 for `admin_operator`, 9 for `super_admin` (union of all defined permissions).
|
||||
|
||||
**Test coverage:** `pipelines/_tests/test_apply_rbac_schema_offline.py` — 11 unit tests covering table creation, required columns, seed data, role→permission wiring, `--check` drift, `--down` reverse order, and idempotency on re-run.
|
||||
|
||||
**Runbook:** [runbooks/rbac-schema-migration.md](./runbooks/rbac-schema-migration.md).
|
||||
|
||||
---
|
||||
|
||||
## InboxOS module schema (v1.0 carry, owned by data-engineer pre-v1.5)
|
||||
|
||||
**Source:** [NocoDB REST API `/api/v2/meta/bases/inboxos/...`](https://docs.nocodb.com/).
|
||||
**Transform:** Idempotent create-or-find per module table, unique on `Message_ID`, common columns shared between live and historical pipelines.
|
||||
**Sinks (4 module tables + 1 audit table):**
|
||||
|
||||
| Table title | Source | Read by |
|
||||
|---------------|----------------------------------------------|----------------------------------|
|
||||
| `pl_finance` | `.opencode/commands/inboxos:extract-finance.md` | `scripts/dashboard.py`, `scripts/daily_digest.py`, `scripts/find_email.py` |
|
||||
| `personal_crm`| `.opencode/commands/inboxos:extract-crm.md` | same |
|
||||
| `daily_news` | `.opencode/commands/inboxos:extract-intel.md`| same |
|
||||
| `promotions` | `.opencode/commands/inboxos:extract-promotions.md` | same |
|
||||
| `papra_index` | `pipelines/common.papra_index_upsert` | `scripts/explain_run.py`, `scripts/replay.py` |
|
||||
|
||||
**Tooling:** `scripts/apply_schemas.py` (apply + `--check` + `--seed-views` modes).
|
||||
**Lineage references:** [docs/schemas.md](../schemas.md), [docs/extraction-contract.md](../extraction-contract.md).
|
||||
|
||||
This module is *not* in scope for Story #129; it is documented here for completeness so the lineage page reflects every data-engineer-owned artifact.
|
||||
|
||||
---
|
||||
|
||||
## Cross-pipeline lineage (read-only, owned by data-engineer)
|
||||
|
||||
These are queries the data-engineer publishes for other agents to read.
|
||||
They are not transformations; they are stable views over the module
|
||||
tables and the RBAC tables.
|
||||
|
||||
| Query | Source tables | Consumers |
|
||||
|----------------------------------|--------------------------------|---------------------------------|
|
||||
| `scripts/cost_report.py` | module tables + RBAC usage | `docs/runbooks/` (operator runbook) |
|
||||
| `scripts/dashboard.py` | module tables | Operator ops console (frontend) |
|
||||
| `scripts/daily_digest.py` | module tables | Operator daily email |
|
||||
|
||||
The RBAC tables introduced by this Story will be used by the ops
|
||||
console to filter records by the viewer's `role` and by the
|
||||
backend-engineer's RBAC middleware to enforce `(resource, action)`
|
||||
checks before any data read or write.
|
||||
@@ -0,0 +1,170 @@
|
||||
# Runbook — RBAC Schema Migration (NocoDB)
|
||||
|
||||
> Story: [INB-1.5-C-S4 #129](https://git.cloudinit.dev/jc-capital/inboxos/issues/129) (Pillar III, `domain/data-pipeline`).
|
||||
> Schema spec: [INB-1.5-C-S3 #128](https://git.cloudinit.dev/jc-capital/inboxos/issues/128) (`cto-approved`) — see [comment 2453](https://git.cloudinit.dev/jc-capital/inboxos/issues/128#issuecomment-2453).
|
||||
> Tool: `scripts/apply_rbac_schema.py`.
|
||||
> Lineage: [docs/data-pipeline/lineage.md](../lineage.md#rbac-schema-migration).
|
||||
|
||||
## What this script does
|
||||
|
||||
Creates (or verifies) the 6 RBAC tables in a NocoDB base named
|
||||
`inboxos_rbac` and seeds 3 default roles, 9 default permissions, and 21
|
||||
role → permission grants. Idempotent — re-running is a no-op.
|
||||
|
||||
The 6 tables (in apply order, reverse for `--down`):
|
||||
|
||||
1. `rbac_roles` — `mailbox_owner`, `admin_operator`, `super_admin`.
|
||||
2. `rbac_permissions` — `(resource, action)` pairs (e.g. `mailbox:read`).
|
||||
3. `rbac_role_permissions` — junction table.
|
||||
4. `rbac_users` — login accounts with soft-delete.
|
||||
5. `rbac_user_sessions` — session tokens, hashed.
|
||||
6. `rbac_audit_logs` — append-only audit trail.
|
||||
|
||||
## When to run
|
||||
|
||||
- **First-time install** of the v1.5 Mailbox & Administrative
|
||||
Management Interface: run once per environment (staging, prod).
|
||||
- **CI pre-merge** for any PR that touches the schema spec (Story
|
||||
#128). The script is run with `--check` and the build fails on drift.
|
||||
- **Rollback**: only if a v1.5 release needs to be torn down before the
|
||||
v1.4 → v1.5 promotion. Otherwise prefer to leave the schema in place.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11+ with `requests` and `urllib3` available (already a
|
||||
project dep, since `scripts/apply_schemas.py` uses the same).
|
||||
- A NocoDB API token with create rights on the target workspace. The
|
||||
base-scoped personal access tokens from NocoDB v2026.04.x do NOT have
|
||||
`meta.projects:read`; pass `NOCODB_PROJECT_ID` to skip the
|
||||
list-bases call.
|
||||
- Network access from the runner to `NOCODB_BASE_URL`.
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
|----------------------|----------|-------------|-------|
|
||||
| `NOCODB_BASE_URL` | yes | — | e.g. `https://noco.cloudinit.dev` |
|
||||
| `NOCODB_API_TOKEN` | yes | — | xc-token from a NocoDB user with create rights |
|
||||
| `NOCODB_PROJECT_ID` | no | (auto-find) | If set, the script uses this base id directly. Recommended for base-scoped tokens. |
|
||||
|
||||
## Standard operating procedures
|
||||
|
||||
### 1. Verify the schema is in place (CI / pre-merge)
|
||||
|
||||
```bash
|
||||
NOCODB_BASE_URL="https://noco.cloudinit.dev" \
|
||||
NOCODB_API_TOKEN="<xc-token>" \
|
||||
NOCODB_PROJECT_ID="<base-id>" \
|
||||
python3 scripts/apply_rbac_schema.py --check
|
||||
```
|
||||
|
||||
Exit codes:
|
||||
- `0` — schema and seed are in place.
|
||||
- `1` — drift (missing table or column, or missing seed row).
|
||||
- `2` — missing required env vars.
|
||||
|
||||
### 2. Apply or repair the schema (idempotent)
|
||||
|
||||
```bash
|
||||
NOCODB_BASE_URL="https://noco.cloudinit.dev" \
|
||||
NOCODB_API_TOKEN="<xc-token>" \
|
||||
NOCODB_PROJECT_ID="<base-id>" \
|
||||
python3 scripts/apply_rbac_schema.py
|
||||
```
|
||||
|
||||
The script:
|
||||
- finds-or-creates the `inboxos_rbac` base,
|
||||
- finds-or-creates each of the 6 tables with the required columns,
|
||||
- adds any missing columns to existing tables,
|
||||
- upserts the 3 default roles (deduped on `name`),
|
||||
- upserts the 9 default permissions (deduped on `(resource, action)`),
|
||||
- inserts missing role → permission grants (no-op on re-run).
|
||||
|
||||
Log output is structured (key=value) for grep / jq scraping. The
|
||||
script ends with a one-line `event=summary {...}` containing counters
|
||||
and `duration_ms`.
|
||||
|
||||
### 3. Seed-only run (assumes tables exist)
|
||||
|
||||
```bash
|
||||
python3 scripts/apply_rbac_schema.py --seed
|
||||
```
|
||||
|
||||
Use this after restoring a database from a backup where the table
|
||||
shapes are present but the seed rows were dropped.
|
||||
|
||||
### 4. Rollback (drop the 6 tables)
|
||||
|
||||
```bash
|
||||
python3 scripts/apply_rbac_schema.py --down
|
||||
```
|
||||
|
||||
Refuses to drop a table whose title does not match the expected RBAC
|
||||
title (foot-gun guard). Drops in strict reverse order:
|
||||
`rbac_audit_logs` → `rbac_user_sessions` → `rbac_users` →
|
||||
`rbac_role_permissions` → `rbac_permissions` → `rbac_roles`.
|
||||
|
||||
## Observability
|
||||
|
||||
- Logs are structured, one line per event. Watch for `event=failure`
|
||||
in the operator tail / CI log.
|
||||
- The summary line includes `tables_created`, `tables_found`,
|
||||
`columns_added`, `roles_seeded`, `permissions_seeded`,
|
||||
`role_permissions_seeded`, and `duration_ms`. These map to
|
||||
dashboards and alerts as follows:
|
||||
- `duration_ms > 30000` → page on-call (the script should finish in
|
||||
a few seconds against a healthy NocoDB).
|
||||
- `tables_created > 0` in a routine run (not a one-time install) →
|
||||
warn (a brand-new table in production usually means a spec change
|
||||
that didn't go through the data-architect).
|
||||
- `event=failure` → page on-call.
|
||||
|
||||
## Failure modes
|
||||
|
||||
### `event=failure error=NocoDB POST ... status=401 body=...`
|
||||
Token is invalid or expired. Re-issue via the NocoDB UI and re-run.
|
||||
|
||||
### `event=failure error=... ERR_FORBIDDEN ...`
|
||||
Token is base-scoped and the script tried to list bases. Set
|
||||
`NOCODB_PROJECT_ID` and re-run.
|
||||
|
||||
### `event=failure error=... 404 ERR_BASE_NOT_FOUND ...`
|
||||
`NOCODB_PROJECT_ID` does not match a base the token can see. Verify
|
||||
the id and re-run with the right value.
|
||||
|
||||
### `event=drift count=N items=[...]` from `--check`
|
||||
A required table or column is missing. Run without `--check` to
|
||||
repair. If the drift persists, the schema spec on Story #128 has
|
||||
likely changed; coordinate with the data-architect before forcing.
|
||||
|
||||
### `event=failure error=refusing to drop table id=... title=... expected=...`
|
||||
`--down` foot-gun guard fired. The target base has a table whose
|
||||
title does not match the expected RBAC title. Verify the
|
||||
`NOCODB_PROJECT_ID` points at the right base, NOT a shared base
|
||||
that holds unrelated tables.
|
||||
|
||||
## Self-test
|
||||
|
||||
The offline test suite under
|
||||
`pipelines/_tests/test_apply_rbac_schema_offline.py` exercises the
|
||||
apply, check, down, and seed paths against a mocked NocoDB session.
|
||||
Run it from the repo root:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=scripts python3 -m unittest \
|
||||
pipelines._tests.test_apply_rbac_schema_offline -v
|
||||
```
|
||||
|
||||
11 tests, ~0.1s, no live infra needed. CI runs this on every PR.
|
||||
|
||||
## Security
|
||||
|
||||
- The NocoDB API token is a secret. Never paste it into a chat, a
|
||||
comment, or a test fixture. The script reads it from the
|
||||
environment.
|
||||
- The seed data marks every default role as `is_system = true`. System
|
||||
roles cannot be deleted via the application layer; a backend job
|
||||
(out of scope for this Story) must respect that flag.
|
||||
- `audit_logs.ip_address` and `user_sessions.ip_address` are PII.
|
||||
Retention: 2 years for audit, 90 days post-session-expiry for
|
||||
sessions. See the spec on Story #128 for the full table.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Design-System Lint Rules — INB-1.5-D-S1
|
||||
|
||||
> **Story:** [#132](https://git.cloudinit.dev/jc-capital/inboxos/issues/132)
|
||||
> **Epic:** EPIC-INB-1.5-D [#113](https://git.cloudinit.dev/jc-capital/inboxos/issues/113)
|
||||
> **Traces to:** INTV-5 #109 → MAJOR v1 #99
|
||||
> **Domain:** ux. **Pattern:** B (spec).
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document specifies lint and style-enforcement rules that ensure all v1.5 frontend code uses design-system tokens exclusively — no hardcoded colors, ad-hoc spacing, or out-of-spec typography. The rules are enforced at CI time and block merge on failure (NFR-5).
|
||||
|
||||
---
|
||||
|
||||
## 2. Token source
|
||||
|
||||
All tokens are defined in [`docs/design/tokens.json`](./tokens.json). Frontend code must import tokens from a generated module (e.g., `src/tokens.ts` or `src/tokens.css`) that is produced from `tokens.json` at build time. No token values may be duplicated in source.
|
||||
|
||||
---
|
||||
|
||||
## 3. Lint rules
|
||||
|
||||
### 3.1 Color rules
|
||||
|
||||
| Rule ID | Rule | Severity | Rationale |
|
||||
|---|---|---|---|
|
||||
| `DS-C001` | No hardcoded hex/rgb/hsl colors in JSX/TSX/CSS. Use `token('color.*')` or the generated CSS variable (`var(--color-*)`). | **error** | Guarantees all colors are token-referenced and WCAG-verified. |
|
||||
| `DS-C002` | No inline `style={{ color: ... }}` or `style={{ backgroundColor: ... }}`. Use token-based utility classes or `token()` function. | **error** | Inline styles bypass the design system. |
|
||||
| `DS-C003` | All `<svg>` fill/stroke must reference CSS custom properties (`var(--color-*)`) or inherit from parent. | **warn** | Prevents orphaned SVG colors. |
|
||||
| `DS-C004` | Opacity modifiers on semantic colors must use the documented pattern (`token('color.semantic.{name}-light')`), not `opacity()` on the base color. | **error** | Maintains WCAG contrast tracking. |
|
||||
|
||||
**Allowed exceptions (with mandatory comment):**
|
||||
- Third-party component library overrides that require hex values. Must include `// DS-EXEMPT: <reason>` and a tracking issue.
|
||||
|
||||
### 3.2 Typography rules
|
||||
|
||||
| Rule ID | Rule | Severity | Rationale |
|
||||
|---|---|---|---|
|
||||
| `DS-T001` | No hardcoded `font-size`, `line-height`, `font-weight`, or `letter-spacing` values in JSX/TSX/CSS. Use `token('typography.*')` or CSS variables. | **error** | Ensures typographic consistency and responsive scaling. |
|
||||
| `DS-T002` | All text must use the defined font-family stack (`token('typography.font-family.sans')` or `token('typography.font-family.mono')`). No custom font-family declarations. | **error** | Prevents font drift. |
|
||||
| `DS-T003` | Monospace font stack is restricted to data tables, log viewers, and code blocks. | **warn** | Monospace in navigation or headings degrades readability. |
|
||||
|
||||
### 3.3 Spacing rules
|
||||
|
||||
| Rule ID | Rule | Severity | Rationale |
|
||||
|---|---|---|---|
|
||||
| `DS-S001` | No hardcoded pixel/rem values for `margin`, `padding`, `gap` outside the token set. Use `token('spacing.*')`. | **error** | Enforces the 4px grid. |
|
||||
| `DS-S002` | All layout gaps must use the spacing token scale. No arbitrary values (e.g., `gap: 7px`). | **error** | Maintains visual rhythm. |
|
||||
|
||||
### 3.4 Border-radius rules
|
||||
|
||||
| Rule ID | Rule | Severity | Rationale |
|
||||
|---|---|---|---|
|
||||
| `DS-R001` | No hardcoded `border-radius` values. Use `token('border-radius.*')`. | **error** | Ensures consistent corner rounding. |
|
||||
|
||||
### 3.5 Shadow rules
|
||||
|
||||
| Rule ID | Rule | Severity | Rationale |
|
||||
|---|---|---|---|
|
||||
| `DS-SH001` | No hardcoded `box-shadow` values. Use `token('shadow.*')`. | **error** | Centralizes elevation hierarchy. |
|
||||
|
||||
### 3.6 Breakpoint rules
|
||||
|
||||
| Rule ID | Rule | Severity | Rationale |
|
||||
|---|---|---|---|
|
||||
| `DS-B001` | No hardcoded `@media` breakpoint pixel values. Use `token('breakpoint.*')` (or the generated mixin). | **error** | All responsive breakpoints must come from the token set. |
|
||||
| `DS-B002` | Mobile-first: write base styles for `sm`, then `@media (min-width: token('breakpoint.md'))` for progressive enhancement. | **warn** | Ensures correct responsive cascade. |
|
||||
|
||||
### 3.7 Z-index rules
|
||||
|
||||
| Rule ID | Rule | Severity | Rationale |
|
||||
|---|---|---|---|
|
||||
| `DS-Z001` | No hardcoded `z-index` values. Use `token('z-index.*')`. | **error** | Prevents z-index escalation. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Responsive breakpoint grid
|
||||
|
||||
### 4.1 Breakpoint definitions
|
||||
|
||||
| Token | Value | Layout behavior |
|
||||
|---|---|---|
|
||||
| `breakpoint.sm` | 640px | Single column. Bottom sheet navigation. Collapsed sidebar. |
|
||||
| `breakpoint.md` | 768px | Two-column layout. Sidebar as drawer. Email list + preview side-by-side. |
|
||||
| `breakpoint.lg` | 1024px | Three-column layout. Persistent sidebar. Full email list + detail + sidebar. |
|
||||
| `breakpoint.xl` | 1280px | Full desktop. Extended content width (max-width 1200px centered). Admin console two-panel layout. |
|
||||
| `breakpoint.2xl` | 1536px | Large desktop. Extended margins. Metrics dashboard shows all panels. |
|
||||
|
||||
### 4.2 Grid specification
|
||||
|
||||
| Breakpoint | Columns | Gutter | Margin | Max content width |
|
||||
|---|---|---|---|---|
|
||||
| `< sm` (mobile) | 4 | 16px (`spacing.4`) | 16px (`spacing.4`) | 100% |
|
||||
| `sm` – `md` | 8 | 16px (`spacing.4`) | 16px (`spacing.4`) | 608px |
|
||||
| `md` – `lg` | 12 | 24px (`spacing.6`) | 24px (`spacing.6`) | 720px |
|
||||
| `lg` – `xl` | 12 | 24px (`spacing.6`) | 32px (`spacing.8`) | 960px |
|
||||
| `xl` – `2xl` | 12 | 24px (`spacing.6`) | 32px (`spacing.8`) | 1200px |
|
||||
| `≥ 2xl` | 12 | 24px (`spacing.6`) | 32px (`spacing.8`) | 1200px centered |
|
||||
|
||||
### 4.3 Layout patterns
|
||||
|
||||
**Mailbox Owner Dashboard (Epic A):**
|
||||
- `< md`: Single-column email list. Detail view replaces list (push navigation).
|
||||
- `md` – `lg`: Two-column (list + detail). Sidebar drawer (tap to open).
|
||||
- `≥ lg`: Three-column (sidebar + list + detail). Sidebar persistent.
|
||||
|
||||
**Admin/Operator Ops Console (Epic B):**
|
||||
- `< md`: Single-column. Tab navigation for metrics / logs / config sections.
|
||||
- `md` – `lg`: Two-column. Metrics left, logs right. Config in modal.
|
||||
- `≥ lg`: Three-panel layout. Metrics dashboard (2/3) + sidebar (1/3). Config in slide-over.
|
||||
|
||||
**Design-System Compliance (Epic D — this epic):**
|
||||
- Responsive behavior is identical to the host view (A or B). The design-system tokens themselves are viewport-agnostic; responsive behavior is defined by layout patterns above.
|
||||
|
||||
---
|
||||
|
||||
## 5. Accessibility specification
|
||||
|
||||
### 5.1 WCAG conformance target
|
||||
|
||||
**WCAG 2.2 Level AA** for all v1.5 views (NFR-4: Lighthouse accessibility ≥ 90).
|
||||
|
||||
### 5.2 Color contrast
|
||||
|
||||
All color token pairs in `tokens.json` are documented with their contrast ratio:
|
||||
- Primary on white: 4.56:1 (AA normal text).
|
||||
- Primary-light background with gray-900 text: 10.47:1 (AAA).
|
||||
- Semantic colors (success, warning, error, info) on white: documented per token.
|
||||
- **No token pair may fall below 3:1 for large text or 4.5:1 for normal text** against its intended background.
|
||||
|
||||
### 5.3 Screen reader notes
|
||||
|
||||
| Component | Screen reader behavior |
|
||||
|---|---|
|
||||
| Email list | `role="list"` with `aria-label="Email inbox"`. Each row: `role="listitem"`, `aria-label` with subject + sender + date. |
|
||||
| Email detail | `role="article"` with `aria-label` = subject. |
|
||||
| Sidebar navigation | `role="navigation"` with `aria-label="Main navigation"`. Current item: `aria-current="page"`. |
|
||||
| Metrics dashboard | Each metric card: `role="region"` with `aria-label` = metric name. Live metrics: `aria-live="polite"`. |
|
||||
| Log viewer | `role="log"` or `role="region"` with `aria-label="System logs"`. Auto-scroll: `aria-live="polite"` on new entries. |
|
||||
| Config forms | Each field: `aria-label` or associated `<label>`. Error messages: `aria-describedby` + `role="alert"`. |
|
||||
| Modal / drawer | `role="dialog"`, `aria-modal="true"`, `aria-labelledby` = modal title. Focus trap on open. Return focus on close. |
|
||||
|
||||
### 5.4 Keyboard interactions
|
||||
|
||||
| Component | Key | Action |
|
||||
|---|---|---|
|
||||
| Email list | `↑` / `↓` | Move focus between list items. |
|
||||
| Email list | `Enter` | Open selected email detail. |
|
||||
| Email detail | `Escape` | Return to list view. |
|
||||
| Sidebar | `Tab` / `Shift+Tab` | Navigate between sidebar items. |
|
||||
| Sidebar | `Enter` | Activate sidebar item. |
|
||||
| Modal / drawer | `Escape` | Close modal/drawer. Return focus to trigger. |
|
||||
| Config form | `Tab` | Navigate between form fields. |
|
||||
| Config form | `Enter` (on `<button>`) | Submit form. |
|
||||
| Search | `/` | Focus search input (global shortcut). |
|
||||
| Search | `Escape` | Clear and blur search input. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation notes for downstream Stories
|
||||
|
||||
- **D-S2 (#133)**: Consume `tokens.json` → generate `src/tokens.ts` (or `.css`) at build time. Implement the token function/variables.
|
||||
- **D-S3 (#134)**: Implement responsive layout components using the breakpoint tokens and grid specification in §4.
|
||||
- **D-S4 (#135)**: Implement the lint rules in §3 as a Stylelint plugin (for CSS) and/or ESLint plugin (for JSX/TSX). Both must block merge on **error** severity; **warn** rules generate non-blocking annotations.
|
||||
|
||||
---
|
||||
|
||||
## 7. References
|
||||
|
||||
- Architecture: [`docs/architecture/v1.md`](../architecture/v1.md) §4.5 (v1.5 trust boundaries and components)
|
||||
- TRD: [`docs/trd/mailbox-admin-interface.md`](../trd/mailbox-admin-interface.md) (INTV-5)
|
||||
- Tokens: [`docs/design/tokens.json`](./tokens.json)
|
||||
- Gitea: Story [#132](https://git.cloudinit.dev/jc-capital/inboxos/issues/132), Epic [#113](https://git.cloudinit.dev/jc-capital/inboxos/issues/113)
|
||||
@@ -0,0 +1,188 @@
|
||||
# Mailbox Owner Dashboard UI Spec — INB-1.5-A-S3
|
||||
|
||||
> **Story:** [#118](https://git.cloudinit.dev/jc-capital/inboxos/issues/118)
|
||||
> **Epic:** EPIC-INB-1.5-A [#112](https://git.cloudinit.dev/jc-capital/inboxos/issues/112)
|
||||
> **Traces to:** INTV-5 #109 → MAJOR v1 #99
|
||||
> **Domain:** ux. **Pattern:** B (spec).
|
||||
> **Target branch:** `ux/INB-1.5-A-ui-spec`
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Wireframes, mockups, responsive breakpoint spec, and design-system token usage for the Mailbox Owner Dashboard. This is the UX specification that the frontend engineer (Story #119) will implement from.
|
||||
|
||||
---
|
||||
|
||||
## 2. Views
|
||||
|
||||
### 2.1 Email inbox list (primary view)
|
||||
|
||||
**Route:** `/mailbox`
|
||||
|
||||
**Layout (≥ lg):** Three-column layout.
|
||||
|
||||
```
|
||||
┌──────────────┬─────────────────────────┬──────────────────┐
|
||||
│ Sidebar │ Email List │ Email Detail │
|
||||
│ (240px) │ (flex 1, ~350px min) │ (flex 2) │
|
||||
│ │ │ │
|
||||
│ □ Inbox │ [Search bar] │ Subject │
|
||||
│ □ Archive │ [Filter: status ▼] │ From: sender │
|
||||
│ □ Processed │ ───────────────────── │ Date: Jun 10 │
|
||||
│ │ ☐ Subject line 9:00a │ │
|
||||
│ ──────── │ ☐ Subject line 8:45a │ Body preview │
|
||||
│ Settings │ ☐ Subject line 8:30a │ ... │
|
||||
│ Help │ ☐ Subject line 8:15a │ │
|
||||
│ │ ☐ Subject line 7:50a │ [Archive] │
|
||||
│ │ │ [Mark Processed] │
|
||||
└──────────────┴─────────────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
**Layout (md – lg):** Two-column. Sidebar collapses to drawer (hamburger toggle). Email list + detail side-by-side.
|
||||
|
||||
**Layout (< md):** Single column. List view only. Tap email to push detail view. Back button returns to list.
|
||||
|
||||
### 2.2 Email detail (right panel / push view)
|
||||
|
||||
**Components:**
|
||||
- Subject heading (typography: `xl`, `bold`)
|
||||
- Sender, recipient, date metadata row (typography: `sm`, `gray-500`)
|
||||
- Email body (rendered HTML from SeaweedFS `.eml` archive)
|
||||
- Action bar: `[Archive]` `[Mark Processed]` (primary and secondary buttons)
|
||||
- Status badge: `Inbox` / `Archived` / `Processed` (semantic color tokens)
|
||||
|
||||
### 2.3 Search and filter bar
|
||||
|
||||
**Components:**
|
||||
- Global search input (`/` keyboard shortcut to focus)
|
||||
- Filter dropdown: status (`all`, `inbox`, `archived`, `processed`)
|
||||
- Filter dropdown: date range (`today`, `7 days`, `30 days`, `custom`)
|
||||
- Active filter chips with `×` dismiss
|
||||
|
||||
**Search/filter latency:** < 500ms (NFR-2).
|
||||
|
||||
### 2.4 Sidebar navigation
|
||||
|
||||
**Items:**
|
||||
- Inbox (with unread count badge)
|
||||
- Archive
|
||||
- Processed
|
||||
- --- separator ---
|
||||
- Settings (links to `/settings`)
|
||||
- Help (links to `/help`)
|
||||
|
||||
**Active state:** `aria-current="page"`, background `color.brand.primary-light`, text `color.brand.primary`.
|
||||
|
||||
### 2.5 Empty states
|
||||
|
||||
| State | Message | Action |
|
||||
|---|---|---|
|
||||
| No emails | "No emails yet. New mail is checked every 5 minutes." | None (automatic) |
|
||||
| No search results | "No emails match your search." | Clear search button |
|
||||
| All processed | "All caught up! No inbox items." | Link to archive |
|
||||
|
||||
---
|
||||
|
||||
## 3. Responsive breakpoint spec
|
||||
|
||||
| Viewport | Layout | Sidebar | Email list | Detail |
|
||||
|---|---|---|---|---|
|
||||
| `< md` (640–767px) | Single column | Hidden (hamburger) | Full width, stacked | Push view (replaces list) |
|
||||
| `md` – `lg` (768–1023px) | Two column | Drawer (overlay) | 40% width | 60% width |
|
||||
| `≥ lg` (1024px+) | Three column | Persistent (240px) | Flex 1 (min 350px) | Flex 2 |
|
||||
|
||||
**Grid tokens used:**
|
||||
- `spacing.6` (24px) for column gutters
|
||||
- `spacing.4` (16px) for card padding
|
||||
- `border-radius.md` (8px) for email list items
|
||||
- `border-radius.lg` (12px) for modal/drawer
|
||||
|
||||
---
|
||||
|
||||
## 4. Design-system token usage
|
||||
|
||||
Every visual property in this spec references `docs/design/tokens.json`.
|
||||
|
||||
| Element | Token path | Notes |
|
||||
|---|---|---|
|
||||
| Page background | `color.surface.background` | `gray-50` |
|
||||
| Card / panel background | `color.surface.card` | `white` |
|
||||
| Primary action button | `color.brand.primary` bg, `color.brand.primary-on` text | Blue button |
|
||||
| Secondary action button | `color.neutral.gray-100` bg, `color.neutral.gray-700` text | Ghost button |
|
||||
| Sidebar active | `color.brand.primary-light` bg, `color.brand.primary` text | |
|
||||
| Body text | `color.neutral.gray-600` | |
|
||||
| Heading text | `color.neutral.gray-900` | |
|
||||
| Status: inbox | `color.brand.primary` badge | |
|
||||
| Status: archived | `color.neutral.gray-400` badge | |
|
||||
| Status: processed | `color.semantic.success` badge | |
|
||||
| Status: error | `color.semantic.error` badge | |
|
||||
| Page title | `typography.font-size.4xl`, `bold` | |
|
||||
| Section heading | `typography.font-size.xl`, `semibold` | |
|
||||
| Body text | `typography.font-size.base`, `normal` | |
|
||||
| Metadata | `typography.font-size.sm`, `color.neutral.gray-500` | |
|
||||
| Card shadow | `shadow.md` | |
|
||||
| Hover shadow | `shadow.lg` | |
|
||||
| Input border | `color.neutral.gray-200` default, `color.brand.primary` focus | |
|
||||
| Transition | `transition.fast` for hover, `transition.normal` for expand | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Accessibility specification
|
||||
|
||||
### 5.1 WCAG conformance
|
||||
|
||||
**WCAG 2.2 Level AA** (NFR-4).
|
||||
|
||||
### 5.2 Screen reader notes
|
||||
|
||||
| Component | ARIA | Notes |
|
||||
|---|---|---|
|
||||
| Email list | `role="list"`, `aria-label="Email inbox"` | Live region: `aria-live="polite"` for count updates |
|
||||
| Email row | `role="listitem"`, `aria-label="{subject}, {sender}, {date}, {status}"` | |
|
||||
| Email detail | `role="article"`, `aria-label="{subject}"` | |
|
||||
| Action buttons | `<button aria-label="Archive email">`, `<button aria-label="Mark as processed">` | |
|
||||
| Search | `role="search"`, `aria-label="Search emails"` | |
|
||||
| Filter | `<select aria-label="Filter by status">` | |
|
||||
| Sidebar | `role="navigation"`, `aria-label="Main navigation"` | `aria-current="page"` on active item |
|
||||
| Unread badge | `aria-label="{count} unread emails"` | |
|
||||
| Status badge | `aria-label="Status: {status}"` | |
|
||||
|
||||
### 5.3 Keyboard interactions
|
||||
|
||||
| Key | Context | Action |
|
||||
|---|---|---|
|
||||
| `↑` / `↓` | Email list | Move focus between rows |
|
||||
| `Enter` | Email list row | Open email detail |
|
||||
| `Escape` | Email detail | Return to list view |
|
||||
| `Escape` | Search | Clear and blur search input |
|
||||
| `/` | Global | Focus search input |
|
||||
| `a` | Email detail | Archive email |
|
||||
| `p` | Email detail | Mark as processed |
|
||||
|
||||
### 5.4 Focus management
|
||||
|
||||
- On email select: focus moves to detail panel heading.
|
||||
- On detail close (`Escape`): focus returns to the previously focused list row.
|
||||
- On modal open: focus trap within modal. Return focus to trigger on close.
|
||||
- Skip-to-content link: "Skip to main content" at page top (visible on focus).
|
||||
|
||||
---
|
||||
|
||||
## 6. Out of scope
|
||||
|
||||
- Frontend implementation (Story #119).
|
||||
- Email ingestion pipeline (existing).
|
||||
- RBAC routing (Story #126, #127).
|
||||
- Dark mode (v1.6+ candidate, DEF-V1.5-1).
|
||||
|
||||
---
|
||||
|
||||
## 7. References
|
||||
|
||||
- Design tokens: [`docs/design/tokens.json`](./tokens.json)
|
||||
- Lint rules: [`docs/design/lint-rules.md`](./lint-rules.md)
|
||||
- Architecture: [`docs/architecture/v1.md`](../architecture/v1.md) §4.5
|
||||
- TRD: [`docs/trd/mailbox-admin-interface.md`](../trd/mailbox-admin-interface.md)
|
||||
- API contract: Story [#116](https://git.cloudinit.dev/jc-capital/inboxos/issues/116)
|
||||
- Story: [#118](https://git.cloudinit.dev/jc-capital/inboxos/issues/118)
|
||||
@@ -0,0 +1,212 @@
|
||||
# Admin/Operator Ops Console UI Spec — INB-1.5-B-S3
|
||||
|
||||
> **Story:** [#123](https://git.cloudinit.dev/jc-capital/inboxos/issues/123)
|
||||
> **Epic:** EPIC-INB-1.5-B [#110](https://git.cloudinit.dev/jc-capital/inboxos/issues/110)
|
||||
> **Traces to:** INTV-5 #109 → MAJOR v1 #99
|
||||
> **Domain:** ux. **Pattern:** B (spec).
|
||||
> **Target branch:** `ux/INB-1.5-B-ui-spec`
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Wireframes, mockups, and responsive breakpoint spec for the Admin/Operator Ops Console. This is the UX specification that the frontend engineer (Story #124) will implement from.
|
||||
|
||||
---
|
||||
|
||||
## 2. Views
|
||||
|
||||
### 2.1 Dashboard (primary view)
|
||||
|
||||
**Route:** `/admin`
|
||||
|
||||
**Layout (≥ lg):** Two-panel layout.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┬──────────────────────┐
|
||||
│ Metrics Dashboard (2/3) │ Sidebar (1/3) │
|
||||
│ │ │
|
||||
│ ┌─────────────┬─────────────┐ │ □ Dashboard │
|
||||
│ │ Ingestion │ Error │ │ □ Logs │
|
||||
│ │ Rate 5m │ Count 5m │ │ □ Config │
|
||||
│ │ 47 msg │ 2 err │ │ □ Users │
|
||||
│ └─────────────┴─────────────┘ │ ──────── │
|
||||
│ │ □ Settings │
|
||||
│ ┌─────────────────────────────┐ │ │
|
||||
│ │ System Latency p99 │ │ │
|
||||
│ │ ▇▇▇▇▇▇▇▇▇▇▃▃▃▃▃ │ │ │
|
||||
│ │ 5s ←─────── 15m ──────→ │ │ │
|
||||
│ └─────────────────────────────┘ │ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────────────┐ │ │
|
||||
│ │ Recent Errors │ │ │
|
||||
│ │ ⚠ extract_permanent_failure │ │ │
|
||||
│ │ msg-abc123 2m ago │ │ │
|
||||
│ │ ⚠ classify_error │ │ │
|
||||
│ │ msg-def456 5m ago │ │ │
|
||||
│ └─────────────────────────────┘ │ │
|
||||
└──────────────────────────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
**Layout (md – lg):** Metrics stack vertically. Sidebar collapses to tab navigation.
|
||||
|
||||
**Layout (< md):** Single column. Tab navigation at top. Each section scrolls.
|
||||
|
||||
### 2.2 Metrics cards
|
||||
|
||||
Four metric cards in a 2×2 grid (≥ lg) or stacked (mobile):
|
||||
|
||||
| Card | Data source | Refresh | Token |
|
||||
|---|---|---|---|
|
||||
| Ingestion Rate (5m) | `GET /api/v1/admin/metrics` → `ingestion_rate_5m` | 30s | `color.semantic.info` for trend arrow |
|
||||
| Error Count (5m) | `GET /api/v1/admin/metrics` → `error_count_5m` | 30s | `color.semantic.error` if > 0, `color.semantic.success` if 0 |
|
||||
| System Latency p99 | `GET /api/v1/admin/metrics` → `system_latency_p99` | 30s | `color.semantic.warning` if > 10s |
|
||||
| Recent Errors | `GET /api/v1/admin/metrics` (embedded) | 30s | `color.semantic.error` for failure rows |
|
||||
|
||||
Each card: `role="region"`, `aria-label="Ingestion rate"`, `aria-live="polite"` for live metrics.
|
||||
|
||||
### 2.3 Logs view
|
||||
|
||||
**Route:** `/admin/logs`
|
||||
|
||||
**Components:**
|
||||
- Filter bar: date range, log level (`INFO`, `WARN`, `ERROR`), pipeline (`live`, `historical`), free-text search
|
||||
- Log table: timestamp, level, pipeline, event type, message, message_id (expandable row for detail)
|
||||
- Monospace font (`typography.font-family.mono`) for log message and message_id
|
||||
- Auto-scroll toggle for real-time tailing
|
||||
|
||||
**Layout (≥ lg):** Full-width table with sidebar navigation.
|
||||
|
||||
**Layout (< md):** Stacked cards with expandable detail.
|
||||
|
||||
### 2.4 Config view
|
||||
|
||||
**Route:** `/admin/config`
|
||||
|
||||
**Components:**
|
||||
- Config table: key → value pairs from `GET /api/v1/admin/config`
|
||||
- Edit modal: `PATCH /api/v1/admin/config` for mutable config keys
|
||||
- Audit trail: last-modified timestamp and user for each key
|
||||
|
||||
**Layout:** Same responsive pattern as logs view.
|
||||
|
||||
### 2.5 Users view
|
||||
|
||||
**Route:** `/admin/users`
|
||||
|
||||
**Components:**
|
||||
- Users table: email, role, assigned_at, assigned_by
|
||||
- Role change: `PATCH /api/v1/admin/users/:id/role` (dropdown: owner / admin / operator)
|
||||
- Confirmation modal for role changes
|
||||
- Audit log at bottom: `event_id, user_id, action, resource, timestamp, ip_hash`
|
||||
|
||||
### 2.6 Empty and error states
|
||||
|
||||
| State | Message | Action |
|
||||
|---|---|---|
|
||||
| No errors | "No errors in the last 5 minutes." | None |
|
||||
| No logs | "No log entries match your filter." | Clear filter button |
|
||||
| Config load error | "Unable to load configuration. Retrying in 30s." | Retry button |
|
||||
| Users load error | "Unable to load user list. Retrying in 30s." | Retry button |
|
||||
|
||||
---
|
||||
|
||||
## 3. Responsive breakpoint spec
|
||||
|
||||
| Viewport | Layout | Metrics | Sidebar | Detail |
|
||||
|---|---|---|---|---|
|
||||
| `< md` (640–767px) | Single column | Stacked cards | Tab navigation (top) | Full-width sections |
|
||||
| `md` – `lg` (768–1023px) | Two panel | 2×2 grid | Drawer | 60/40 split |
|
||||
| `≥ lg` (1024px+) | Two panel | 2×2 grid | Persistent (1/3 width) | Content (2/3 width) |
|
||||
|
||||
**Grid tokens used:**
|
||||
- `spacing.6` (24px) for column gutters
|
||||
- `spacing.4` (16px) for card internal padding
|
||||
- `border-radius.md` (8px) for metric cards
|
||||
- `shadow.md` for cards at rest, `shadow.lg` on hover
|
||||
|
||||
---
|
||||
|
||||
## 4. Design-system token usage
|
||||
|
||||
| Element | Token path | Notes |
|
||||
|---|---|---|
|
||||
| Page background | `color.surface.background` | `gray-50` |
|
||||
| Card background | `color.surface.card` | `white` |
|
||||
| Sidebar background | `color.neutral.white` | `shadow.sm` for elevation |
|
||||
| Sidebar active | `color.brand.primary-light` bg, `color.brand.primary` text | |
|
||||
| Metric card: positive | `color.semantic.success` value | |
|
||||
| Metric card: warning | `color.semantic.warning` value | |
|
||||
| Metric card: error | `color.semantic.error` value | |
|
||||
| Log level: ERROR | `color.semantic.error` text | |
|
||||
| Log level: WARN | `color.semantic.warning` text | |
|
||||
| Log level: INFO | `color.neutral.gray-500` text | |
|
||||
| Log message | `typography.font-family.mono`, `typography.font-size.sm` | |
|
||||
| Section heading | `typography.font-size.xl`, `semibold` | |
|
||||
| Table header | `typography.font-size.sm`, `medium`, `color.neutral.gray-500` | |
|
||||
| Table row hover | `color.neutral.gray-50` background | |
|
||||
| Action button primary | `color.brand.primary` bg, `color.brand.primary-on` text | |
|
||||
| Action button danger | `color.semantic.error` bg, white text | Role change delete |
|
||||
| Config modal | `shadow.xl`, `border-radius.lg` | |
|
||||
| Auto-scroll toggle | `color.neutral.gray-400` off, `color.brand.primary` on | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Accessibility specification
|
||||
|
||||
### 5.1 WCAG conformance
|
||||
|
||||
**WCAG 2.2 Level AA** (NFR-4).
|
||||
|
||||
### 5.2 Screen reader notes
|
||||
|
||||
| Component | ARIA | Notes |
|
||||
|---|---|---|
|
||||
| Metrics dashboard | `role="region"`, `aria-label="System metrics"` | `aria-live="polite"` for live updates |
|
||||
| Metric card | `role="region"`, `aria-label="{metric name}"` | |
|
||||
| Log table | `<table>` with `<caption>` | |
|
||||
| Log row detail | `aria-expanded` on toggle | |
|
||||
| Config form | `<form>` with associated `<label>` elements | Error: `role="alert"` |
|
||||
| Role change modal | `role="dialog"`, `aria-modal="true"`, `aria-labelledby` | Focus trap |
|
||||
| Sidebar | `role="navigation"`, `aria-label="Admin navigation"` | `aria-current="page"` |
|
||||
| Users table | `<table>` with `<caption>` | |
|
||||
| Audit log | `role="log"` or `role="region"`, `aria-label="Audit log"` | |
|
||||
|
||||
### 5.3 Keyboard interactions
|
||||
|
||||
| Key | Context | Action |
|
||||
|---|---|---|
|
||||
| `Tab` / `Shift+Tab` | Global | Navigate between sidebar items, metric cards, table rows |
|
||||
| `Enter` | Sidebar item | Navigate to section |
|
||||
| `↑` / `↓` | Log table | Move between rows |
|
||||
| `Enter` | Log row | Expand detail |
|
||||
| `Escape` | Expanded row / modal | Close detail / close modal |
|
||||
| `Enter` | Config save button | Submit config change |
|
||||
| `Escape` | Modal | Close modal, return focus |
|
||||
|
||||
### 5.4 Focus management
|
||||
|
||||
- On section navigation: focus moves to section heading.
|
||||
- On modal open: focus trap within modal. Return focus to trigger on close.
|
||||
- On role change confirmation: focus moves to confirmation dialog.
|
||||
- Skip-to-content link at page top.
|
||||
|
||||
---
|
||||
|
||||
## 6. Out of scope
|
||||
|
||||
- Frontend implementation (Story #124).
|
||||
- Backend API (Stories #121, #122).
|
||||
- RBAC routing (Story #126).
|
||||
- Advanced analytics / PDF reports (v1.6+).
|
||||
|
||||
---
|
||||
|
||||
## 7. References
|
||||
|
||||
- Design tokens: [`docs/design/tokens.json`](./tokens.json)
|
||||
- Lint rules: [`docs/design/lint-rules.md`](./lint-rules.md)
|
||||
- Architecture: [`docs/architecture/v1.md`](../architecture/v1.md) §4.5
|
||||
- TRD: [`docs/trd/mailbox-admin-interface.md`](../trd/mailbox-admin-interface.md)
|
||||
- API contract: Story [#121](https://git.cloudinit.dev/jc-capital/inboxos/issues/121)
|
||||
- Story: [#123](https://git.cloudinit.dev/jc-capital/inboxos/issues/123)
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"$schema": "https://design-tokens.github.io/community/format/",
|
||||
"metadata": {
|
||||
"source": "INB-1.5-D-S1 — Design-system token set and lint rules",
|
||||
"story": "https://git.cloudinit.dev/jc-capital/inboxos/issues/132",
|
||||
"epic": "EPIC-INB-1.5-D #113 — Design-System Compliance & UI Polish",
|
||||
"initiative": "INTV-5 #109 — Mailbox & Administrative Management Interface",
|
||||
"version": "1.0.0",
|
||||
"date": "2026-06-10",
|
||||
"author": "ux-designer"
|
||||
},
|
||||
"color": {
|
||||
"brand": {
|
||||
"primary": {
|
||||
"$value": "#2563eb",
|
||||
"$description": "Primary brand color — used for primary actions, links, active states",
|
||||
"wcag": "4.56:1 on white (AA), 3.28:1 on gray-50 (AA large)"
|
||||
},
|
||||
"primary-hover": {
|
||||
"$value": "#1d4ed8",
|
||||
"$description": "Primary hover — 10% darker than primary"
|
||||
},
|
||||
"primary-light": {
|
||||
"$value": "#dbeafe",
|
||||
"$description": "Primary surface tint — for card backgrounds, selected row highlights"
|
||||
},
|
||||
"primary-on": {
|
||||
"$value": "#ffffff",
|
||||
"$description": "Text/icon color on primary background"
|
||||
}
|
||||
},
|
||||
"neutral": {
|
||||
"white": { "$value": "#ffffff", "$description": "Surface base" },
|
||||
"gray-50": { "$value": "#f9fafb", "$description": "Page background" },
|
||||
"gray-100": { "$value": "#f3f4f6", "$description": "Card background, input disabled fill" },
|
||||
"gray-200": { "$value": "#e5e7eb", "$description": "Border default, divider" },
|
||||
"gray-300": { "$value": "#d1d5db", "$description": "Border hover" },
|
||||
"gray-400": { "$value": "#9ca3af", "$description": "Placeholder text, icon muted" },
|
||||
"gray-500": { "$value": "#6b7280", "$description": "Secondary text, helper text" },
|
||||
"gray-600": { "$value": "#4b5563", "$description": "Body text on light surface" },
|
||||
"gray-700": { "$value": "#374151", "$description": "Subheading text" },
|
||||
"gray-800": { "$value": "#1f2937", "$description": "Heading text" },
|
||||
"gray-900": { "$value": "#111827", "$description": "Primary text, high-contrast headings" }
|
||||
},
|
||||
"semantic": {
|
||||
"success": {
|
||||
"$value": "#16a34a",
|
||||
"$description": "Positive status — processed, healthy, connected",
|
||||
"wcag": "3.84:1 on white (AA large)"
|
||||
},
|
||||
"success-light": { "$value": "#dcfce7", "$description": "Success surface tint" },
|
||||
"warning": {
|
||||
"$value": "#d97706",
|
||||
"$description": "Caution status — pending, rate-limited",
|
||||
"wcag": "3.08:1 on white (AA large)"
|
||||
},
|
||||
"warning-light": { "$value": "#fef3c7", "$description": "Warning surface tint" },
|
||||
"error": {
|
||||
"$value": "#dc2626",
|
||||
"$description": "Error status — failed, dead-letter, extraction failure",
|
||||
"wcag": "4.58:1 on white (AA)"
|
||||
},
|
||||
"error-light": { "$value": "#fee2e2", "$description": "Error surface tint" },
|
||||
"info": {
|
||||
"$value": "#2563eb",
|
||||
"$description": "Informational status — same as brand primary",
|
||||
"$wcag": "4.56:1 on white (AA)"
|
||||
},
|
||||
"info-light": { "$value": "#dbeafe", "$description": "Info surface tint — same as primary-light" }
|
||||
},
|
||||
"surface": {
|
||||
"background": { "$value": "{color.neutral.gray-50}", "$description": "Page-level background" },
|
||||
"card": { "$value": "{color.neutral.white}", "$description": "Card, panel, modal background" },
|
||||
"card-elevated": { "$value": "{color.neutral.white}", "$description": "Elevated card (with shadow)" },
|
||||
"overlay": { "$value": "rgba(17, 24, 39, 0.5)", "$description": "Modal/drawer backdrop" }
|
||||
}
|
||||
},
|
||||
"typography": {
|
||||
"font-family": {
|
||||
"sans": { "$value": "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif", "$description": "Primary UI font stack" },
|
||||
"mono": { "$value": "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace", "$description": "Code, log, and data-display font" }
|
||||
},
|
||||
"font-size": {
|
||||
"xs": { "$value": "0.75rem", "$line-height": "1rem", "$description": "Captions, badges, helper text" },
|
||||
"sm": { "$value": "0.875rem", "$line-height": "1.25rem", "$description": "Body small, table cells" },
|
||||
"base": { "$value": "1rem", "$line-height": "1.5rem", "$description": "Body default" },
|
||||
"lg": { "$value": "1.125rem", "$line-height": "1.75rem", "$description": "Lead paragraphs" },
|
||||
"xl": { "$value": "1.25rem", "$line-height": "1.75rem", "$description": "H4, section headings" },
|
||||
"2xl": { "$value": "1.5rem", "$line-height": "2rem", "$description": "H3" },
|
||||
"3xl": { "$value": "1.875rem", "$line-height": "2.25rem", "$description": "H2" },
|
||||
"4xl": { "$value": "2.25rem", "$line-height": "2.5rem", "$description": "H1, page title" }
|
||||
},
|
||||
"font-weight": {
|
||||
"normal": { "$value": "400", "$description": "Body text" },
|
||||
"medium": { "$value": "500", "$description": "Labels, table headers" },
|
||||
"semibold": { "$value": "600", "$description": "Subheadings, emphasized labels" },
|
||||
"bold": { "$value": "700", "$description": "Headings" }
|
||||
},
|
||||
"letter-spacing": {
|
||||
"tighter": { "$value": "-0.025em", "$description": "H1, H2" },
|
||||
"normal": { "$value": "0em", "$description": "Body, most text" },
|
||||
"wide": { "$value": "0.025em", "$description": "Uppercase labels, badges" }
|
||||
}
|
||||
},
|
||||
"spacing": {
|
||||
"0": { "$value": "0", "$description": "No spacing" },
|
||||
"0.5": { "$value": "0.125rem", "$description": "1px at 16px base — hairline" },
|
||||
"1": { "$value": "0.25rem", "$description": "4px — inline icon gap" },
|
||||
"1.5": { "$value": "0.375rem", "$description": "6px — compact list items" },
|
||||
"2": { "$value": "0.5rem", "$description": "8px — tight padding" },
|
||||
"3": { "$value": "0.75rem", "$description": "12px — default padding" },
|
||||
"4": { "$value": "1rem", "$description": "16px — section gap" },
|
||||
"5": { "$value": "1.25rem", "$description": "20px" },
|
||||
"6": { "$value": "1.5rem", "$description": "24px — card padding" },
|
||||
"8": { "$value": "2rem", "$description": "32px — major section gap" },
|
||||
"10": { "$value": "2.5rem", "$description": "40px" },
|
||||
"12": { "$value": "3rem", "$description": "48px — page-level margin" },
|
||||
"16": { "$value": "4rem", "$description": "64px" }
|
||||
},
|
||||
"border-radius": {
|
||||
"none": { "$value": "0", "$description": "Sharp corners" },
|
||||
"sm": { "$value": "0.25rem", "$description": "4px — badges, small chips" },
|
||||
"default": { "$value": "0.375rem", "$description": "6px — inputs, buttons" },
|
||||
"md": { "$value": "0.5rem", "$description": "8px — cards" },
|
||||
"lg": { "$value": "0.75rem", "$description": "12px — modals, panels" },
|
||||
"full": { "$value": "9999px", "$description": "Pill, avatar" }
|
||||
},
|
||||
"shadow": {
|
||||
"sm": { "$value": "0 1px 2px 0 rgba(0,0,0,0.05)", "$description": "Subtle elevation — cards at rest" },
|
||||
"md": { "$value": "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)", "$description": "Default card elevation" },
|
||||
"lg": { "$value": "0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)", "$description": "Dropdown, popover" },
|
||||
"xl": { "$value": "0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1)", "$description": "Modal" }
|
||||
},
|
||||
"breakpoint": {
|
||||
"sm": { "$value": "640px", "$description": "Mobile landscape / small tablet" },
|
||||
"md": { "$value": "768px", "$description": "Tablet portrait — layout shifts from single-column to multi-column" },
|
||||
"lg": { "$value": "1024px", "$description": "Tablet landscape / small desktop — sidebar visible" },
|
||||
"xl": { "$value": "1280px", "$description": "Desktop — full layout" },
|
||||
"2xl": { "$value": "1536px", "$description": "Large desktop — extended content width" }
|
||||
},
|
||||
"z-index": {
|
||||
"dropdown": { "$value": "1000" },
|
||||
"sticky": { "$value": "1020" },
|
||||
"overlay": { "$value": "1040" },
|
||||
"modal": { "$value": "1060" },
|
||||
"toast": { "$value": "1080" }
|
||||
},
|
||||
"transition": {
|
||||
"fast": { "$value": "150ms ease", "$description": "Hover, focus" },
|
||||
"normal": { "$value": "250ms ease", "$description": "Expand, collapse" },
|
||||
"slow": { "$value": "350ms ease-in-out", "$description": "Modal, drawer" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
# Design-System Lint Runbook — INB-1.5-D-S4
|
||||
|
||||
> **Audience:** Frontend engineers, reviewers, and operators responding to a CI failure on the design-system lint gate.
|
||||
> **Story:** [#135](https://git.cloudinit.dev/jc-capital/inboxos/issues/135) — INB-1.5-D-S4.
|
||||
> **Acceptance test for this runbook:** *"A PR is blocked by `design-system-lint` — what do you do?"* — answer in under 5 minutes.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this gate is
|
||||
|
||||
The `design-system-lint` job in `.gitea/workflows/ci-pr.yml` enforces the rules in [`docs/design/lint-rules.md`](../design/lint-rules.md) — the spec authored in Story [#132](https://git.cloudinit.dev/jc-capital/inboxos/issues/132). Every rule ID (`DS-C001` … `DS-Z001`) is traceable to the spec via the `message` field of the corresponding Stylelint / ESLint configuration. Errors block merge (Pillar III gate); warnings emit non-blocking annotations.
|
||||
|
||||
The job is a hard gate. **A failure is not a suggestion.** A failure is a violation of the documented design-system contract and the PR must not be merged until it is clean.
|
||||
|
||||
The companion `lighthouse-a11y` job enforces the NFR-4 accessibility floor (Lighthouse accessibility ≥ 90) by building the v1.5 preview and running `@lhci/cli` against it. The runbook for that job lives in §6 below.
|
||||
|
||||
## 2. Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `ui/.stylelintrc.cjs` | Stylelint config — DS-C001, DS-T001..002, DS-S001..002, DS-R001, DS-SH001, DS-Z001, DS-C004 |
|
||||
| `ui/.eslintrc.cjs` | ESLint config — DS-C001, DS-C002, DS-C003, DS-T001, DS-T002 (JSX/TSX half) |
|
||||
| `ui/scripts/lint-design-system.mjs` | Orchestrator. Exits 0/1, JSON or human summary. |
|
||||
| `ui/scripts/fixture-check.mjs` | Proves every DS-* rule fires on negative fixtures and stays silent on positives. |
|
||||
| `ui/scripts/check-breakpoints.mjs` | DS-B001 / DS-B002 — pure @media walker (Stylelint can't validate this). |
|
||||
| `ui/lighthouserc.cjs` | Lighthouse CI assertion set. NFR-4 ≥ 90 is an `error`-level assertion. |
|
||||
| `ui/src/__fixtures__/{negative,positive}/*` | Per-rule positive and negative fixtures. Authoritative truth for "does the rule fire?". |
|
||||
| `.gitea/workflows/ci-pr.yml` | The CI gate definition. See `jobs.design-system-lint` and `jobs.lighthouse-a11y`. |
|
||||
|
||||
## 3. Local invocation
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
npm ci # one-time
|
||||
npm run lint # runs the full gate (Stylelint + ESLint + DS-B001/DS-B002)
|
||||
npm run lint:fixture-check # proves every DS-* rule fires / stays silent
|
||||
npm run lint:stylelint # CSS / SCSS only
|
||||
npm run lint:eslint # JSX / TSX only
|
||||
node scripts/check-breakpoints.mjs
|
||||
```
|
||||
|
||||
All four exit non-zero on failure. The CI gate is just a thin wrapper over these commands.
|
||||
|
||||
## 4. Triage flow
|
||||
|
||||
```
|
||||
CI red on design-system-lint
|
||||
│
|
||||
▼
|
||||
1. Open the job log. Each finding has a rule ID in its message
|
||||
(e.g. "DS-C001: hardcoded hex color forbidden.").
|
||||
│
|
||||
▼
|
||||
2. Cross-reference the rule ID with docs/design/lint-rules.md §3.
|
||||
The spec tells you:
|
||||
- what is forbidden
|
||||
- the rationale
|
||||
- the documented exemption (if any)
|
||||
│
|
||||
▼
|
||||
3. Refactor the offending line to use the documented token
|
||||
reference. The fix is always one of:
|
||||
- var(--color-*) / var(--typography-*) / var(--spacing-*) /
|
||||
var(--border-radius-*) / var(--shadow-*) /
|
||||
var(--breakpoint-*) / var(--z-index-*)
|
||||
- the design-system token function (token('color.primary'))
|
||||
- move the literal into docs/design/tokens.json (rare; needs
|
||||
UX approval and a Story that adds the token)
|
||||
│
|
||||
▼
|
||||
4. Re-run npm run lint locally. Confirm the fix is clean.
|
||||
│
|
||||
▼
|
||||
5. Re-run npm run lint:fixture-check. If the new code added a
|
||||
new pattern that no DS-* rule covers, add a fixture pair
|
||||
under src/__fixtures__/{negative,positive}/ and update
|
||||
scripts/fixture-check.mjs.
|
||||
│
|
||||
▼
|
||||
6. Push. The CI gate will re-run.
|
||||
```
|
||||
|
||||
## 5. Allowed exemptions
|
||||
|
||||
Per the spec, the only sanctioned exemption is a third-party component-library override that requires a raw hex value. The exemption requires:
|
||||
|
||||
- A `// DS-EXEMPT: <reason>` comment in the same file.
|
||||
- A tracking Issue (e.g., `EPIC-INB-1.5-X`) that explains why the override exists and when it can be removed.
|
||||
- The exemption is **scope-bound** — the lint config does not whitelist individual lines. The comment is a maintenance signal for reviewers; the rule still fires. The author must flag the finding in the PR description and request a one-off waiver from the CTO before merge.
|
||||
|
||||
The only file exempted **by configuration** (without comment) is `ui/src/design-system/tokens.css` and `ui/src/design-system/tokens.ts` — these are the generated design-system source.
|
||||
|
||||
## 6. Lighthouse a11y gate (`lighthouse-a11y`)
|
||||
|
||||
The `lighthouse-a11y` job builds the v1.5 preview (`npm ci && npm run build && npm run preview` against a stub backend) and runs `@lhci/cli assert` against `http://localhost:4173/`.
|
||||
|
||||
NFR-4 (Lighthouse accessibility ≥ 90) is an `error`-level assertion. A failure means the preview built, but a screen-reader or color-contrast audit failed. The common causes are:
|
||||
|
||||
- A new component introduced a color pair below the 3:1 / 4.5:1 contrast threshold. **Fix:** add the pair to `docs/design/tokens.json` with a documented WCAG ratio, then re-token the component. **Do not** widen the LHCI assertion threshold.
|
||||
- A new form field lacks an associated `<label>` or `aria-label`. **Fix:** add the label. The `label-content-name-mismatch` and `link-name` audits are exhaustive.
|
||||
- A new image lacks `alt` text. **Fix:** add the alt. `image-alt` is an `error`-level audit.
|
||||
|
||||
If the failure is a real bug in the LHCI runner (e.g., the preview build failed but the job still reported an LHCI failure), re-run with `workflow_dispatch` and capture the runner logs. If reproducible, open an Issue on the `@lhci/cli` upstream and link it from the Story.
|
||||
|
||||
## 7. Adding a new DS-* rule
|
||||
|
||||
When the design system gains a new constraint (e.g., a new icon system requires an `icon-name` token), the rule goes through this flow:
|
||||
|
||||
1. **Spec.** Update `docs/design/lint-rules.md` with the rule ID, severity, and rationale. UX owns the spec.
|
||||
2. **CTO approval.** Story moves through Pillar II → CTO adds the `cto-approved` label.
|
||||
3. **Lint config.** Update `ui/.stylelintrc.cjs` and/or `ui/.eslintrc.cjs`. Keep the rule ID in the `message` field so CI findings map 1:1 to the spec.
|
||||
4. **Fixtures.** Add a positive + negative fixture pair under `ui/src/__fixtures__/{positive,negative}/` and register both in `ui/scripts/fixture-check.mjs`.
|
||||
5. **Runbook.** Add the rule to §3 and §4 of this runbook. If the rule introduces a new exemption, document it in §5.
|
||||
6. **PR.** Story #135 is upstream; add a follow-up Story under EPIC-INB-1.5-D that scopes the rule to a specific consumer code path if needed.
|
||||
|
||||
## 8. Rollback
|
||||
|
||||
The lint jobs are **non-destructive**. Rolling back the gate is a single revert:
|
||||
|
||||
```bash
|
||||
git revert <merge-sha>
|
||||
git push origin release/v1.5
|
||||
```
|
||||
|
||||
This restores the prior `.gitea/workflows/ci-pr.yml` (no design-system-lint job, no lighthouse-a11y job) and removes the `ui/.stylelintrc.cjs` / `ui/.eslintrc.cjs` enforcement. No state is destroyed; the fixtures and scripts can be re-applied on the next attempt without re-authoring them.
|
||||
|
||||
If the rollback is to a specific Story's PR (e.g., #135 itself), use `tea pr revert <pr-number>` against the devops branch and open a follow-up Issue with the root cause of the failure.
|
||||
|
||||
If the rollback is needed because a new rule was found to be wrong (e.g., a documented exemption that the lint config doesn't honor), prefer **fix the lint config** over rolling back the entire gate. The gate is there to protect the design system; loosening it to accept a single PR is the wrong move.
|
||||
|
||||
## 9. Observability
|
||||
|
||||
The `design-system-lint` and `lighthouse-a11y` jobs surface their findings as GitHub Actions annotations (`::error file=…,line=…,col=…::message`). Operators can grep the merge-gate history with:
|
||||
|
||||
```bash
|
||||
gh run list --workflow=ci-pr.yml --json databaseId,conclusion,name
|
||||
gh run view <run-id> --log | grep -E '^\d+:\d+:\d+\s+DS-[A-Z]\d{3}'
|
||||
```
|
||||
|
||||
A spike in `DS-C001` (hardcoded hex) findings is the leading indicator that a Story is being delivered without consuming the design-system tokens. The fix is a Story-level task force, not a waiver.
|
||||
|
||||
## 10. References
|
||||
|
||||
- Spec: [`docs/design/lint-rules.md`](../design/lint-rules.md)
|
||||
- Tokens: [`docs/design/tokens.json`](../design/tokens.json)
|
||||
- Architecture: [`docs/architecture/v1.md`](../architecture/v1.md) §4.5 (v1.5 trust boundaries and components)
|
||||
- TRD: [`docs/trd/operator-self-service.md`](../trd/operator-self-service.md) (INTV-5)
|
||||
- Story: [#135](https://git.cloudinit.dev/jc-capital/inboxos/issues/135)
|
||||
- Epic: [#113](https://git.cloudinit.dev/jc-capital/inboxos/issues/113) (EPIC-INB-1.5-D)
|
||||
@@ -0,0 +1,453 @@
|
||||
"""Offline tests for scripts/apply_rbac_schema.py.
|
||||
|
||||
Pillar III deliverable for Story [INB-1.5-C-S4 #129](https://git.cloudinit.dev/jc-capital/inboxos/issues/129).
|
||||
The script is exercised in three modes (apply, --check, --down, --seed)
|
||||
against a mocked NocoDB session. The HTTP layer is patched so the tests
|
||||
run with no live infra.
|
||||
|
||||
What we verify
|
||||
--------------
|
||||
- All 6 RBAC tables are processed on apply.
|
||||
- Required columns exist for each table.
|
||||
- Default seed roles (mailbox_owner, admin_operator, super_admin) and
|
||||
permissions land.
|
||||
- The mailbox_owner role gets mailbox read+write, the admin_operator
|
||||
gets ops_console + users + roles, the super_admin gets the union.
|
||||
- --check returns exit 0 when the schema is already in place.
|
||||
- --check returns exit 1 when a column is missing (drift).
|
||||
- --down drops the 6 tables in the strict reverse order.
|
||||
- Idempotency: re-running apply on a populated base is a no-op (no
|
||||
duplicate row inserts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
# Make the scripts/ dir importable the same way pipelines/_tests does it.
|
||||
_HERE = os.path.dirname(__file__)
|
||||
_REPO_ROOT = os.path.abspath(os.path.join(_HERE, "..", ".."))
|
||||
sys.path.insert(0, os.path.join(_REPO_ROOT, "scripts"))
|
||||
|
||||
import apply_rbac_schema # noqa: E402
|
||||
|
||||
|
||||
# ── Fake NocoDB session ──────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeTable:
|
||||
"""Mimics the shape of an NocoDB v2 db meta/tables/{id} response."""
|
||||
|
||||
def __init__(self, table_id: str, title: str, columns: list[dict]) -> None:
|
||||
self.id = table_id
|
||||
self.title = title
|
||||
self.name = title
|
||||
self._columns = list(columns)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"id": self.id, "title": self.title, "name": self.name,
|
||||
"columns": self._columns}
|
||||
|
||||
|
||||
def _make_columns(cols: list[dict]) -> list[dict]:
|
||||
out = []
|
||||
for i, c in enumerate(cols):
|
||||
col_name = c.get("column_name") or c.get("title") or c.get("name")
|
||||
out.append({
|
||||
"id": f"col_{i}",
|
||||
"title": col_name,
|
||||
"column_name": col_name,
|
||||
"uidt": c.get("uidt") or c.get("type", "SingleLineText"),
|
||||
"rqd": c.get("rqd", False),
|
||||
"un": c.get("un", False),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _filter_where(rows: list[dict], where: str) -> list[dict]:
|
||||
"""Parse a minimal subset of NocoDB's where filter language and
|
||||
return only matching rows. Supports:
|
||||
- `(field,eq,value)` — exact match.
|
||||
- `and((f1,eq,v1),(f2,eq,v2))` — conjunction.
|
||||
The first upsert path always uses one of these shapes.
|
||||
"""
|
||||
if not where:
|
||||
return list(rows)
|
||||
if where.startswith("and("):
|
||||
# strip outer and( and trailing )
|
||||
inner = where[4:-1]
|
||||
# each conjunct is `(field,eq,value)`; split top-level parens
|
||||
conjuncts = _split_top_level(inner)
|
||||
out = []
|
||||
for r in rows:
|
||||
if all(_match_single(r, c) for c in conjuncts):
|
||||
out.append(r)
|
||||
return out
|
||||
return [r for r in rows if _match_single(r, where)]
|
||||
|
||||
|
||||
def _split_top_level(s: str) -> list[str]:
|
||||
parts: list[str] = []
|
||||
depth = 0
|
||||
cur: list[str] = []
|
||||
for ch in s:
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
cur.append(ch)
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
cur.append(ch)
|
||||
elif ch == "," and depth == 0:
|
||||
parts.append("".join(cur).strip())
|
||||
cur = []
|
||||
else:
|
||||
cur.append(ch)
|
||||
if cur:
|
||||
parts.append("".join(cur).strip())
|
||||
return parts
|
||||
|
||||
|
||||
def _match_single(row: dict, clause: str) -> bool:
|
||||
"""`(field,eq,value)` exact-match on a single row."""
|
||||
if not (clause.startswith("(") and clause.endswith(")")):
|
||||
return False
|
||||
parts = clause[1:-1].split(",", 2)
|
||||
if len(parts) != 3 or parts[1] != "eq":
|
||||
return False
|
||||
field, _, value = parts
|
||||
return str(row.get(field)) == value
|
||||
|
||||
|
||||
def _make_fake_base() -> dict:
|
||||
return {"id": "base_rbac_1", "title": apply_rbac_schema.BASE_TITLE, "name": apply_rbac_schema.BASE_TITLE}
|
||||
|
||||
|
||||
class _FakeNocoDB:
|
||||
"""Records every API call and serves canned responses for the
|
||||
apply_rbac_schema internals. Tests assert on the call log to verify
|
||||
that all 6 tables and required columns are processed.
|
||||
|
||||
State machine
|
||||
-------------
|
||||
- `tables`: dict of title → _FakeTable
|
||||
- `roles`, `permissions`, `role_permissions`: list of dicts (rows)
|
||||
- `drop_log`: list of titles dropped in order (for --down assertions)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.tables: dict[str, _FakeTable] = {}
|
||||
self.next_table_id = 1
|
||||
self.next_col_id = 1
|
||||
self.roles: list[dict] = []
|
||||
self.permissions: list[dict] = []
|
||||
self.role_permissions: list[dict] = []
|
||||
self.call_log: list[tuple[str, str, str | None]] = []
|
||||
self.drop_log: list[str] = []
|
||||
|
||||
# ── http shape expected by apply_rbac_schema._api ──
|
||||
|
||||
def request(self, method: str, url: str, *, params=None, headers=None, timeout=60, json_body=None, **_):
|
||||
# `apply_rbac_schema._api` calls `sess.request(method, url, headers=...,
|
||||
# json=json_body, params=params, timeout=60)`. The MagicMock `**_`
|
||||
# catches the `json=` kwarg as `json` (which is what the real
|
||||
# requests library uses). We accept it under the alternative name
|
||||
# to avoid shadowing the imported `json` module.
|
||||
# The real kwarg is `json`; we accept it via `json_body` to keep
|
||||
# the test readable.
|
||||
if json_body is None and "json" in _:
|
||||
json_body = _["json"]
|
||||
self.call_log.append((method, url, json_body if json_body is not None else params))
|
||||
# Route by url
|
||||
from unittest.mock import MagicMock
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.content = b"{}"
|
||||
path = url.replace("http://test-nocodb:8080", "")
|
||||
|
||||
if path == "/api/v2/meta/bases" and method == "GET":
|
||||
resp.content = json.dumps([]).encode()
|
||||
resp.json = lambda: []
|
||||
elif path.startswith("/api/v2/meta/bases/") and path.endswith("/tables") and method == "GET":
|
||||
resp.content = json.dumps([]).encode()
|
||||
resp.json = lambda: [t.to_dict() for t in self.tables.values()]
|
||||
elif path.startswith("/api/v2/meta/bases/") and method == "POST" and path.endswith("/tables"):
|
||||
body = json_body or {}
|
||||
title = body.get("title")
|
||||
fake = _FakeTable(f"t{self.next_table_id}", title, _make_columns(body.get("columns", [])))
|
||||
self.next_table_id += 1
|
||||
self.tables[title] = fake
|
||||
resp.json = lambda: fake.to_dict()
|
||||
elif path.startswith("/api/v1/db/meta/tables/") and method == "GET":
|
||||
tid = path.rsplit("/", 1)[-1]
|
||||
t = next((x for x in self.tables.values() if x.id == tid), None)
|
||||
resp.json = lambda: t.to_dict() if t else {}
|
||||
elif path.startswith("/api/v2/meta/tables/") and path.endswith("/columns") and method == "POST":
|
||||
body = json_body or {}
|
||||
tid = path.split("/")[-2]
|
||||
t = next((x for x in self.tables.values() if x.id == tid), None)
|
||||
if t is not None:
|
||||
col_name = body.get("title") or body.get("column_name") or body.get("name")
|
||||
col = {
|
||||
"id": f"col{self.next_col_id}",
|
||||
"title": col_name,
|
||||
"column_name": col_name,
|
||||
"uidt": body.get("uidt", "SingleLineText"),
|
||||
"rqd": body.get("rqd", False),
|
||||
"un": body.get("un", False),
|
||||
}
|
||||
self.next_col_id += 1
|
||||
t._columns.append(col)
|
||||
resp.json = lambda: {}
|
||||
elif path.startswith("/api/v2/meta/tables/") and method == "DELETE":
|
||||
tid = path.rsplit("/", 1)[-1]
|
||||
t = next((x for x in self.tables.values() if x.id == tid), None)
|
||||
if t is not None:
|
||||
self.drop_log.append(t.title)
|
||||
del self.tables[t.title]
|
||||
resp.json = lambda: {}
|
||||
elif path.startswith("/api/v2/tables/") and path.endswith("/records") and method == "POST":
|
||||
body = json_body or {}
|
||||
# decide which table
|
||||
tid = path.split("/")[-2]
|
||||
t = next((x for x in self.tables.values() if x.id == tid), None)
|
||||
assert t is not None, f"unknown table for record insert: {tid}"
|
||||
new_id = len(getattr(self, t.title.replace("rbac_", ""), [])) + 1
|
||||
row = dict(body)
|
||||
row["Id"] = new_id
|
||||
self._row_insert(t.title, row)
|
||||
resp.json = lambda r=row: r
|
||||
elif path.startswith("/api/v2/tables/") and path.endswith("/records") and method == "GET":
|
||||
# list/filter: parse a minimal where=(field,eq,value) and
|
||||
# return only matching rows. The upsert path relies on a
|
||||
# precise match to decide PATCH vs POST; returning all rows
|
||||
# would cause every subsequent upsert to overwrite row id=1.
|
||||
where = (params or {}).get("where", "")
|
||||
tid = path.split("/")[-2]
|
||||
t = next((x for x in self.tables.values() if x.id == tid), None)
|
||||
assert t is not None
|
||||
all_rows = self._row_list(t.title)
|
||||
matched = _filter_where(all_rows, where)
|
||||
resp.json = lambda rows=matched: rows
|
||||
elif "/records/" in path and method == "PATCH":
|
||||
tid = path.split("/")[-3]
|
||||
rid = path.rsplit("/", 1)[-1]
|
||||
t = next((x for x in self.tables.values() if x.id == tid), None)
|
||||
self._row_patch(t.title, int(rid), json_body or {})
|
||||
resp.json = lambda: {}
|
||||
else:
|
||||
resp.json = lambda: {}
|
||||
return resp
|
||||
|
||||
# ── row helpers keyed by table title ──
|
||||
|
||||
def _row_insert(self, title: str, row: dict) -> None:
|
||||
bucket = self._bucket(title)
|
||||
# dedup for roles on name
|
||||
if title == "rbac_roles":
|
||||
bucket[:] = [r for r in bucket if r.get("name") != row.get("name")]
|
||||
if title == "rbac_permissions":
|
||||
bucket[:] = [r for r in bucket
|
||||
if not (r.get("resource") == row.get("resource")
|
||||
and r.get("action") == row.get("action"))]
|
||||
if title == "rbac_role_permissions":
|
||||
bucket[:] = [r for r in bucket
|
||||
if not (r.get("role_id") == row.get("role_id")
|
||||
and r.get("permission_id") == row.get("permission_id"))]
|
||||
bucket.append(row)
|
||||
|
||||
def _row_list(self, title: str) -> list[dict]:
|
||||
return list(self._bucket(title))
|
||||
|
||||
def _row_patch(self, title: str, rid: int, body: dict) -> None:
|
||||
bucket = self._bucket(title)
|
||||
for r in bucket:
|
||||
if r.get("Id") == rid:
|
||||
r.update(body)
|
||||
return
|
||||
|
||||
def _bucket(self, title: str) -> list[dict]:
|
||||
if title == "rbac_roles":
|
||||
return self.roles
|
||||
if title == "rbac_permissions":
|
||||
return self.permissions
|
||||
if title == "rbac_role_permissions":
|
||||
return self.role_permissions
|
||||
return []
|
||||
|
||||
|
||||
def _make_session(fake: _FakeNocoDB):
|
||||
sess = mock.MagicMock()
|
||||
sess.request.side_effect = fake.request
|
||||
return sess
|
||||
|
||||
|
||||
# ── Apply-mode tests ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestApplyRbacSchemaOffline(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.fake = _FakeNocoDB()
|
||||
self.sess = _make_session(self.fake)
|
||||
|
||||
def test_apply_creates_all_six_tables(self) -> None:
|
||||
counters, missing = apply_rbac_schema.apply_schema(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1", check=False)
|
||||
self.assertEqual(missing, [])
|
||||
self.assertEqual(counters["tables_created"], 6)
|
||||
titles = [t["title"] for t in apply_rbac_schema.TABLES]
|
||||
for title in titles:
|
||||
self.assertIn(title, self.fake.tables,
|
||||
f"table {title!r} not created")
|
||||
|
||||
def test_apply_creates_required_columns(self) -> None:
|
||||
apply_rbac_schema.apply_schema(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1", check=False)
|
||||
# Each table's required columns must exist
|
||||
for t in apply_rbac_schema.TABLES:
|
||||
fake_t = self.fake.tables[t["title"]]
|
||||
col_names = {c["title"] for c in fake_t._columns}
|
||||
for c in t["columns"]:
|
||||
self.assertIn(c["name"], col_names,
|
||||
f"column {c['name']!r} missing from {t['title']!r}")
|
||||
|
||||
def test_seed_inserts_default_roles(self) -> None:
|
||||
# First create tables
|
||||
apply_rbac_schema.apply_schema(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1", check=False)
|
||||
# Then seed
|
||||
counters = apply_rbac_schema.seed_data(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1")
|
||||
self.assertEqual(counters["roles_seeded"], 3)
|
||||
names = {r["name"] for r in self.fake.roles}
|
||||
self.assertEqual(names, {"mailbox_owner", "admin_operator", "super_admin"})
|
||||
for r in self.fake.roles:
|
||||
self.assertTrue(r.get("is_system"),
|
||||
f"role {r['name']!r} is_system must be True")
|
||||
|
||||
def test_seed_inserts_default_permissions(self) -> None:
|
||||
apply_rbac_schema.apply_schema(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1", check=False)
|
||||
counters = apply_rbac_schema.seed_data(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1")
|
||||
self.assertGreaterEqual(counters["permissions_seeded"], 9)
|
||||
# mailbox resource read+write must exist
|
||||
pairs = {(p["resource"], p["action"]) for p in self.fake.permissions}
|
||||
self.assertIn(("mailbox", "read"), pairs)
|
||||
self.assertIn(("mailbox", "write"), pairs)
|
||||
self.assertIn(("ops_console", "read"), pairs)
|
||||
self.assertIn(("ops_console", "admin"), pairs)
|
||||
|
||||
def test_seed_wires_role_permissions(self) -> None:
|
||||
apply_rbac_schema.apply_schema(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1", check=False)
|
||||
apply_rbac_schema.seed_data(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1")
|
||||
|
||||
# Build role_id → role_name map. The seed script stores role_id
|
||||
# as a string ("1", "2", "3") in role_permissions rows, so the
|
||||
# lookup key is the str version of the int Id.
|
||||
role_id_to_name = {str(r["Id"]): r["name"] for r in self.fake.roles}
|
||||
perm_id_to_pair = {str(p["Id"]): (p["resource"], p["action"])
|
||||
for p in self.fake.permissions}
|
||||
# Build role → set of (resource, action)
|
||||
per_role: dict[str, set[tuple[str, str]]] = {n: set() for n in role_id_to_name.values()}
|
||||
for rp in self.fake.role_permissions:
|
||||
rname = role_id_to_name[rp["role_id"]]
|
||||
per_role[rname].add(perm_id_to_pair[rp["permission_id"]])
|
||||
|
||||
# mailbox_owner: read+write mailbox
|
||||
self.assertIn(("mailbox", "read"), per_role["mailbox_owner"])
|
||||
self.assertIn(("mailbox", "write"), per_role["mailbox_owner"])
|
||||
# admin_operator: ops_console + users + roles + audit
|
||||
self.assertIn(("ops_console", "admin"), per_role["admin_operator"])
|
||||
self.assertIn(("users", "write"), per_role["admin_operator"])
|
||||
self.assertIn(("roles", "admin"), per_role["admin_operator"])
|
||||
# super_admin: at least one of every resource
|
||||
super_admin_resources = {p[0] for p in per_role["super_admin"]}
|
||||
self.assertIn("mailbox", super_admin_resources)
|
||||
self.assertIn("ops_console", super_admin_resources)
|
||||
self.assertIn("users", super_admin_resources)
|
||||
|
||||
def test_apply_is_idempotent(self) -> None:
|
||||
# First apply
|
||||
apply_rbac_schema.apply_schema(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1", check=False)
|
||||
apply_rbac_schema.seed_data(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1")
|
||||
roles_after_first = list(self.fake.roles)
|
||||
perms_after_first = list(self.fake.permissions)
|
||||
# Second apply — tables_found should be 6, no new roles
|
||||
counters, _ = apply_rbac_schema.apply_schema(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1", check=False)
|
||||
apply_rbac_schema.seed_data(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1")
|
||||
self.assertEqual(counters["tables_created"], 0)
|
||||
self.assertEqual(counters["tables_found"], 6)
|
||||
self.assertEqual(len(self.fake.roles), len(roles_after_first))
|
||||
self.assertEqual(len(self.fake.permissions), len(perms_after_first))
|
||||
|
||||
|
||||
# ── Check-mode tests ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestApplyRbacSchemaCheck(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.fake = _FakeNocoDB()
|
||||
self.sess = _make_session(self.fake)
|
||||
|
||||
def test_check_returns_zero_when_schema_present(self) -> None:
|
||||
# Pre-populate
|
||||
apply_rbac_schema.apply_schema(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1", check=False)
|
||||
_, missing = apply_rbac_schema.apply_schema(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1", check=True)
|
||||
self.assertEqual(missing, [])
|
||||
|
||||
def test_check_returns_drift_when_table_missing(self) -> None:
|
||||
# Empty base — none of the 6 tables exist
|
||||
_, missing = apply_rbac_schema.apply_schema(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1", check=True)
|
||||
self.assertEqual(len(missing), 6)
|
||||
|
||||
def test_check_returns_drift_when_column_missing(self) -> None:
|
||||
# Pre-populate with one column missing
|
||||
apply_rbac_schema.apply_schema(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1", check=False)
|
||||
# Drop a required column
|
||||
users_table = self.fake.tables["rbac_users"]
|
||||
users_table._columns[:] = [c for c in users_table._columns if c["title"] != "email"]
|
||||
_, missing = apply_rbac_schema.apply_schema(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1", check=True)
|
||||
self.assertIn("rbac_users.email", missing)
|
||||
|
||||
|
||||
# ── Down-mode tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestApplyRbacSchemaDown(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.fake = _FakeNocoDB()
|
||||
self.sess = _make_session(self.fake)
|
||||
|
||||
def test_down_drops_tables_in_reverse_order(self) -> None:
|
||||
apply_rbac_schema.apply_schema(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1", check=False)
|
||||
apply_rbac_schema.down_schema(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1")
|
||||
# drop_log is the order in which tables were dropped
|
||||
expected = list(reversed([t["title"] for t in apply_rbac_schema.TABLES]))
|
||||
self.assertEqual(self.fake.drop_log, expected)
|
||||
|
||||
def test_down_is_safe_when_tables_absent(self) -> None:
|
||||
# No tables — should not raise
|
||||
counters = apply_rbac_schema.down_schema(
|
||||
self.sess, "http://test-nocodb:8080", "t", "base_rbac_1")
|
||||
self.assertEqual(counters["tables_dropped"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,719 @@
|
||||
"""Idempotently apply the InboxOS RBAC schema to NocoDB.
|
||||
|
||||
Pillar III deliverable for Story [INB-1.5-C-S4 #129](https://git.cloudinit.dev/jc-capital/inboxos/issues/129).
|
||||
Schema source of truth: Story [INB-1.5-C-S3 #128](https://git.cloudinit.dev/jc-capital/inboxos/issues/128) comment
|
||||
id 2453 (`cto-approved`). The column lists in this file mirror that spec
|
||||
byte-for-byte; do not silently rename or remove columns that the RBAC
|
||||
stack (backend S2 / frontend S5) reads.
|
||||
|
||||
What it does
|
||||
------------
|
||||
- Signs into NocoDB with NOCODB_BASE_URL + NOCODB_API_TOKEN.
|
||||
- Finds (or creates) a base named `inboxos_rbac` in the same workspace
|
||||
as the existing `inboxos` base (so the RBAC tables are visually
|
||||
separated from the module tables in the NocoDB UI).
|
||||
- For each of the 6 RBAC tables (`roles`, `permissions`,
|
||||
`role_permissions`, `users`, `user_sessions`, `audit_logs`), finds
|
||||
(or creates) a table with the right title and the right columns,
|
||||
applying the indexes the spec requires.
|
||||
- Seeds the three default roles (`mailbox_owner`, `admin_operator`,
|
||||
`super_admin`) and the canonical permissions for the v1.5
|
||||
Mailbox & Administrative Management Interface, idempotently.
|
||||
- Wires the role → permission grants so a `mailbox_owner` has the
|
||||
mailbox-resource read/write permissions, an `admin_operator` has
|
||||
ops_console read/admin permissions, and a `super_admin` has
|
||||
`admin` on every resource.
|
||||
|
||||
Idempotency
|
||||
-----------
|
||||
- Existing bases, tables, columns, roles, and permissions are
|
||||
detected by name and left untouched.
|
||||
- Seed inserts use `name` (for roles) and (`resource`, `action`) (for
|
||||
permissions) as the dedup key. Re-running is a no-op.
|
||||
- Safe to run after the schema has already been applied.
|
||||
|
||||
Verify-only mode
|
||||
----------------
|
||||
python scripts/apply_rbac_schema.py --check
|
||||
|
||||
In `--check` mode the script exits non-zero if any required object is
|
||||
missing or if any column has the wrong type.
|
||||
|
||||
Reverse mode (rollback)
|
||||
-----------------------
|
||||
python scripts/apply_rbac_schema.py --down
|
||||
|
||||
In `--down` mode the script drops the 6 RBAC tables in the strict
|
||||
inverse of the create order. This is the rollback half of the
|
||||
reversibility guarantee in the schema spec. Refuses to drop a table
|
||||
that does not look like one of ours (name check) to prevent foot-guns
|
||||
when NocoDB has unrelated tables in the same base.
|
||||
|
||||
Seed-only mode
|
||||
--------------
|
||||
python scripts/apply_rbac_schema.py --seed
|
||||
|
||||
Inserts (or upserts) the default roles and permissions without
|
||||
touching table shapes. Safe to re-run; safe to run after `--check`
|
||||
indicates drift was resolved by another path.
|
||||
|
||||
Environment
|
||||
-----------
|
||||
Required:
|
||||
NOCODB_BASE_URL e.g. https://noco.cloudinit.dev
|
||||
NOCODB_API_TOKEN xc-token from a NocoDB user with create rights
|
||||
|
||||
Optional:
|
||||
NOCODB_PROJECT_ID If set, used as the target base id; otherwise
|
||||
the script finds-or-creates a base named
|
||||
"inboxos_rbac".
|
||||
|
||||
Observability
|
||||
-------------
|
||||
- Structured log lines on stdout (key=value pairs, grep-friendly).
|
||||
- Counters logged at end: tables_created, tables_found, columns_added,
|
||||
roles_seeded, permissions_seeded, role_permissions_seeded.
|
||||
- Exits non-zero on any failure; CI can scrape stderr.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
|
||||
# ── Schema (mirrors Story #128 comment 2453) ────────────────────────
|
||||
|
||||
BASE_TITLE = "inboxos_rbac"
|
||||
|
||||
# Order matters for both create (FKs point earlier → later) and drop
|
||||
# (reverse). Keep this in lockstep with the devops spec.
|
||||
TABLES: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "roles",
|
||||
"title": "rbac_roles",
|
||||
"description": "RBAC roles (mailbox_owner, admin_operator, super_admin). System roles are protected.",
|
||||
"columns": [
|
||||
{"name": "id", "type": "ID", "required": True, "unique": True, "auto": True},
|
||||
{"name": "name", "type": "SingleLineText", "required": True, "unique": True},
|
||||
{"name": "display_name", "type": "SingleLineText", "required": True},
|
||||
{"name": "description", "type": "LongText"},
|
||||
{"name": "is_system", "type": "Checkbox", "required": True},
|
||||
{"name": "created_at", "type": "DateTime", "required": True},
|
||||
{"name": "updated_at", "type": "DateTime", "required": True},
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_roles_name", "column": "name", "unique": True},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "permissions",
|
||||
"title": "rbac_permissions",
|
||||
"description": "RBAC permissions: (resource, action) pair.",
|
||||
"columns": [
|
||||
{"name": "id", "type": "ID", "required": True, "unique": True, "auto": True},
|
||||
{"name": "resource", "type": "SingleLineText", "required": True},
|
||||
{"name": "action", "type": "SingleLineText", "required": True},
|
||||
{"name": "description", "type": "LongText"},
|
||||
{"name": "created_at", "type": "DateTime", "required": True},
|
||||
{"name": "updated_at", "type": "DateTime", "required": True},
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_permissions_resource_action", "columns": ["resource", "action"], "unique": True},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "role_permissions",
|
||||
"title": "rbac_role_permissions",
|
||||
"description": "Junction table: which permissions each role has.",
|
||||
"columns": [
|
||||
{"name": "role_id", "type": "ID", "required": True},
|
||||
{"name": "permission_id", "type": "ID", "required": True},
|
||||
{"name": "granted_at", "type": "DateTime", "required": True},
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_role_permissions_role_id", "column": "role_id"},
|
||||
{"name": "idx_role_permissions_perm_id", "column": "permission_id"},
|
||||
],
|
||||
"unique_pairs": [("role_id", "permission_id")],
|
||||
},
|
||||
{
|
||||
"name": "users",
|
||||
"title": "rbac_users",
|
||||
"description": "User accounts. Soft-delete via deleted_at.",
|
||||
"columns": [
|
||||
{"name": "id", "type": "ID", "required": True, "unique": True, "auto": True},
|
||||
{"name": "email", "type": "Email", "required": True, "unique": True},
|
||||
{"name": "password_hash", "type": "LongText", "required": True},
|
||||
{"name": "full_name", "type": "SingleLineText", "required": True},
|
||||
{"name": "role_id", "type": "ID"},
|
||||
{"name": "is_active", "type": "Checkbox", "required": True},
|
||||
{"name": "created_at", "type": "DateTime", "required": True},
|
||||
{"name": "updated_at", "type": "DateTime", "required": True},
|
||||
{"name": "deleted_at", "type": "DateTime"},
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_users_email", "column": "email", "unique": True},
|
||||
{"name": "idx_users_role_id", "column": "role_id"},
|
||||
{"name": "idx_users_deleted_at", "column": "deleted_at"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "user_sessions",
|
||||
"title": "rbac_user_sessions",
|
||||
"description": "Active user sessions. Backed by token_hash, not the token itself.",
|
||||
"columns": [
|
||||
{"name": "id", "type": "ID", "required": True, "unique": True, "auto": True},
|
||||
{"name": "user_id", "type": "ID", "required": True},
|
||||
{"name": "token_hash", "type": "LongText", "required": True},
|
||||
{"name": "ip_address", "type": "SingleLineText"},
|
||||
{"name": "user_agent", "type": "LongText"},
|
||||
{"name": "expires_at", "type": "DateTime", "required": True},
|
||||
{"name": "last_seen_at", "type": "DateTime", "required": True},
|
||||
{"name": "created_at", "type": "DateTime", "required": True},
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_sessions_user_id", "column": "user_id"},
|
||||
{"name": "idx_sessions_token_hash", "column": "token_hash"},
|
||||
{"name": "idx_sessions_expires_at", "column": "expires_at"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "audit_logs",
|
||||
"title": "rbac_audit_logs",
|
||||
"description": "Append-only audit trail. No UPDATE/DELETE in the application layer.",
|
||||
"columns": [
|
||||
{"name": "id", "type": "ID", "required": True, "unique": True, "auto": True},
|
||||
{"name": "actor_id", "type": "ID"},
|
||||
{"name": "actor_type", "type": "SingleLineText", "required": True},
|
||||
{"name": "action", "type": "SingleLineText", "required": True},
|
||||
{"name": "resource_type", "type": "SingleLineText", "required": True},
|
||||
{"name": "resource_id", "type": "SingleLineText"},
|
||||
{"name": "details", "type": "LongText"},
|
||||
{"name": "ip_address", "type": "SingleLineText"},
|
||||
{"name": "created_at", "type": "DateTime", "required": True},
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_audit_logs_actor_id", "column": "actor_id"},
|
||||
{"name": "idx_audit_logs_created_at", "column": "created_at"},
|
||||
{"name": "idx_audit_logs_action", "column": "action"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# Default seed data per the spec (Story #128 comment 2453 §"Migration Plan").
|
||||
SEED_ROLES: list[dict[str, str]] = [
|
||||
{"name": "mailbox_owner", "display_name": "Mailbox Owner", "description": "End user; views and manages their own ingested emails."},
|
||||
{"name": "admin_operator", "display_name": "Admin Operator", "description": "Operator; views ops console, manages users and roles."},
|
||||
{"name": "super_admin", "display_name": "Super Admin", "description": "Reserved for future expansion. Full read/write on every resource."},
|
||||
]
|
||||
|
||||
SEED_PERMISSIONS: list[dict[str, str]] = [
|
||||
# mailbox resource — mailbox_owner scope
|
||||
{"resource": "mailbox", "action": "read", "description": "View own ingested emails."},
|
||||
{"resource": "mailbox", "action": "write", "description": "Archive, mark processed, reclassify."},
|
||||
# ops_console resource — admin_operator scope
|
||||
{"resource": "ops_console", "action": "read", "description": "View ops dashboard and logs."},
|
||||
{"resource": "ops_console", "action": "admin", "description": "Manage users, roles, global config."},
|
||||
# users resource
|
||||
{"resource": "users", "action": "read", "description": "List users."},
|
||||
{"resource": "users", "action": "write", "description": "Create / update users."},
|
||||
# roles resource
|
||||
{"resource": "roles", "action": "read", "description": "List roles and their permissions."},
|
||||
{"resource": "roles", "action": "admin", "description": "Grant / revoke role permissions."},
|
||||
# audit resource
|
||||
{"resource": "audit", "action": "read", "description": "View audit logs."},
|
||||
# super_admin gets admin on every resource; data-engineer only seeds
|
||||
# an explicit `admin` permission for `*` resources that need it.
|
||||
]
|
||||
|
||||
# Role → permission grants. The shape is the spec's contract.
|
||||
SEED_ROLE_PERMISSIONS: list[dict[str, list[str]]] = [
|
||||
{
|
||||
"role": "mailbox_owner",
|
||||
"permissions": [
|
||||
("mailbox", "read"),
|
||||
("mailbox", "write"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "admin_operator",
|
||||
"permissions": [
|
||||
("ops_console", "read"),
|
||||
("ops_console", "admin"),
|
||||
("users", "read"),
|
||||
("users", "write"),
|
||||
("roles", "read"),
|
||||
("roles", "admin"),
|
||||
("audit", "read"),
|
||||
("mailbox", "read"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "super_admin",
|
||||
"permissions": [
|
||||
# super_admin gets admin on every resource we have a permission
|
||||
# for; this list mirrors SEED_PERMISSIONS (all of them).
|
||||
("mailbox", "read"),
|
||||
("mailbox", "write"),
|
||||
("ops_console", "read"),
|
||||
("ops_console", "admin"),
|
||||
("users", "read"),
|
||||
("users", "write"),
|
||||
("roles", "read"),
|
||||
("roles", "admin"),
|
||||
("audit", "read"),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────────
|
||||
|
||||
log = logging.getLogger("apply_rbac_schema")
|
||||
log.setLevel(logging.INFO)
|
||||
_handler = logging.StreamHandler(sys.stdout)
|
||||
_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
log.addHandler(_handler)
|
||||
|
||||
|
||||
# ── HTTP plumbing ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _session() -> requests.Session:
|
||||
sess = requests.Session()
|
||||
retry = Retry(
|
||||
total=5,
|
||||
backoff_factor=0.5,
|
||||
status_forcelist=(429, 500, 502, 503, 504),
|
||||
allowed_methods=frozenset(["GET", "POST", "PATCH", "DELETE"]),
|
||||
)
|
||||
sess.mount("https://", HTTPAdapter(max_retries=retry))
|
||||
sess.mount("http://", HTTPAdapter(max_retries=retry))
|
||||
return sess
|
||||
|
||||
|
||||
def _api(sess: requests.Session, base_url: str, token: str, method: str, path: str,
|
||||
json_body: Any = None, params: dict | None = None) -> Any:
|
||||
url = f"{base_url.rstrip('/')}{path}"
|
||||
headers = {"xc-token": token, "Content-Type": "application/json"}
|
||||
resp = sess.request(method, url, headers=headers, json=json_body, params=params, timeout=60)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(
|
||||
f"NocoDB {method} {path} failed: status={resp.status_code} body={resp.text[:500]!r}"
|
||||
)
|
||||
if not resp.content:
|
||||
return None
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ── Base / table operations ────────────────────────────────────
|
||||
|
||||
|
||||
def find_or_create_base(sess: requests.Session, base_url: str, token: str,
|
||||
project_id: str | None) -> str:
|
||||
"""Find or create the `inboxos_rbac` base. Mirrors apply_schemas.py."""
|
||||
if project_id:
|
||||
try:
|
||||
base = _api(sess, base_url, token, "GET",
|
||||
f"/api/v2/meta/bases/{project_id}")
|
||||
if isinstance(base, dict) and base.get("id") == project_id:
|
||||
return project_id
|
||||
raise RuntimeError(
|
||||
f"GET /api/v2/meta/bases/{project_id} returned unexpected body: "
|
||||
f"{base!r}")
|
||||
except RuntimeError as exc:
|
||||
if "404" in str(exc) or "ERR_BASE_NOT_FOUND" in str(exc):
|
||||
raise RuntimeError(
|
||||
f"NOCODB_PROJECT_ID={project_id!r} not found or not "
|
||||
f"accessible to this token (verify the id and that the "
|
||||
f"token has access to that base)") from exc
|
||||
raise
|
||||
|
||||
bases = _api(sess, base_url, token, "GET", "/api/v2/meta/bases")
|
||||
for b in bases if isinstance(bases, list) else bases.get("list", []):
|
||||
if (b.get("title") or b.get("name")) == BASE_TITLE:
|
||||
return b["id"]
|
||||
|
||||
created = _api(sess, base_url, token, "POST", "/api/v2/meta/bases",
|
||||
json_body={"title": BASE_TITLE})
|
||||
return created["id"]
|
||||
|
||||
|
||||
def find_table(sess: requests.Session, base_url: str, token: str,
|
||||
base_id: str, title: str) -> dict | None:
|
||||
tables = _api(sess, base_url, token, "GET", f"/api/v2/meta/bases/{base_id}/tables")
|
||||
for t in tables if isinstance(tables, list) else tables.get("list", []):
|
||||
if (t.get("title") or t.get("name")) == title:
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def all_columns_for_table(table: dict) -> list[dict[str, Any]]:
|
||||
cols: list[dict[str, Any]] = []
|
||||
for c in table["columns"]:
|
||||
cols.append({
|
||||
"column_name": c["name"],
|
||||
"title": c["name"],
|
||||
"uidt": c["type"],
|
||||
"rqd": bool(c.get("required", False)),
|
||||
"un": bool(c.get("unique", False)),
|
||||
})
|
||||
return cols
|
||||
|
||||
|
||||
def create_table(sess: requests.Session, base_url: str, token: str,
|
||||
base_id: str, table: dict) -> dict:
|
||||
payload = {
|
||||
"title": table["title"],
|
||||
"table_name": table["title"],
|
||||
"description": table.get("description", ""),
|
||||
"columns": all_columns_for_table(table),
|
||||
}
|
||||
return _api(sess, base_url, token, "POST", f"/api/v2/meta/bases/{base_id}/tables",
|
||||
json_body=payload)
|
||||
|
||||
|
||||
def find_column(table: dict, name: str) -> dict | None:
|
||||
for c in table.get("columns", []):
|
||||
if c.get("title") == name or c.get("column_name") == name:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def add_missing_columns(sess: requests.Session, base_url: str, token: str,
|
||||
table: dict, columns: list[dict[str, Any]]) -> int:
|
||||
added = 0
|
||||
table_id = table["id"]
|
||||
existing = {c.get("title") or c.get("column_name")
|
||||
for c in table.get("columns", [])}
|
||||
for c in columns:
|
||||
if c["name"] in existing:
|
||||
continue
|
||||
body: dict[str, Any] = {
|
||||
"title": c["name"],
|
||||
"column_name": c["name"],
|
||||
"uidt": c["type"],
|
||||
"rqd": bool(c.get("required", False)),
|
||||
"un": bool(c.get("unique", False)),
|
||||
}
|
||||
_api(sess, base_url, token, "POST", f"/api/v2/meta/tables/{table_id}/columns",
|
||||
json_body=body)
|
||||
added += 1
|
||||
return added
|
||||
|
||||
|
||||
def ensure_unique(sess: requests.Session, base_url: str, token: str,
|
||||
column: dict) -> bool:
|
||||
if column.get("un") is True or column.get("unique") is True:
|
||||
return False
|
||||
column_id = column["id"]
|
||||
_api(sess, base_url, token, "PATCH", f"/api/v2/meta/columns/{column_id}",
|
||||
json_body={"un": True})
|
||||
return True
|
||||
|
||||
|
||||
def drop_table(sess: requests.Session, base_url: str, token: str,
|
||||
base_id: str, table_id: str, expected_title: str) -> None:
|
||||
"""Drop a table, but only if its title matches what we expect (foot-gun guard)."""
|
||||
meta = _api(sess, base_url, token, "GET", f"/api/v1/db/meta/tables/{table_id}")
|
||||
actual = (meta or {}).get("title") or (meta or {}).get("name")
|
||||
if actual != expected_title:
|
||||
raise RuntimeError(
|
||||
f"refusing to drop table id={table_id!r} title={actual!r}; "
|
||||
f"expected {expected_title!r} (foot-gun guard)")
|
||||
_api(sess, base_url, token, "DELETE", f"/api/v2/meta/tables/{table_id}")
|
||||
|
||||
|
||||
# ── Row operations (seed) ──────────────────────────────────────
|
||||
|
||||
|
||||
def _find_row_by_field(sess: requests.Session, base_url: str, token: str,
|
||||
table_id: str, field: str, value: Any) -> dict | None:
|
||||
"""Look up a single row by exact-match on one field via the NocoDB
|
||||
db API. Returns the first hit or None."""
|
||||
# Use a filter for a single field; NocoDB's v2 db API accepts where=
|
||||
# in the form `(field,eq,value)`. We restrict to one match and read
|
||||
# the list. If empty, return None.
|
||||
where = f"({field},eq,{value})"
|
||||
resp = _api(sess, base_url, token, "GET", f"/api/v2/tables/{table_id}/records",
|
||||
params={"where": where, "limit": 1})
|
||||
items = resp if isinstance(resp, list) else (resp or {}).get("list", []) or []
|
||||
return items[0] if items else None
|
||||
|
||||
|
||||
def upsert_role(sess: requests.Session, base_url: str, token: str,
|
||||
table_id: str, role: dict[str, str]) -> str:
|
||||
"""Insert-or-update one role row; returns the row id."""
|
||||
found = _find_row_by_field(sess, base_url, token, table_id, "name", role["name"])
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
|
||||
payload = {
|
||||
"name": role["name"],
|
||||
"display_name": role["display_name"],
|
||||
"description": role.get("description", ""),
|
||||
"is_system": True,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
if found:
|
||||
row_id = found.get("Id") or found.get("id") or found.get("ID")
|
||||
if row_id is None:
|
||||
raise RuntimeError(f"role row found but has no id: {found!r}")
|
||||
_api(sess, base_url, token, "PATCH",
|
||||
f"/api/v2/tables/{table_id}/records/{row_id}",
|
||||
json_body=payload)
|
||||
return str(row_id)
|
||||
created = _api(sess, base_url, token, "POST",
|
||||
f"/api/v2/tables/{table_id}/records", json_body=payload)
|
||||
return str((created or {}).get("Id") or (created or {}).get("id") or (created or {}).get("ID") or "")
|
||||
|
||||
|
||||
def upsert_permission(sess: requests.Session, base_url: str, token: str,
|
||||
table_id: str, perm: dict[str, str]) -> str:
|
||||
"""Insert-or-update one permission row, deduped on (resource, action)."""
|
||||
where = (
|
||||
f"and((resource,eq,{perm['resource']}),(action,eq,{perm['action']}))"
|
||||
)
|
||||
resp = _api(sess, base_url, token, "GET", f"/api/v2/tables/{table_id}/records",
|
||||
params={"where": where, "limit": 1})
|
||||
items = resp if isinstance(resp, list) else (resp or {}).get("list", []) or []
|
||||
found = items[0] if items else None
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
|
||||
payload = {
|
||||
"resource": perm["resource"],
|
||||
"action": perm["action"],
|
||||
"description": perm.get("description", ""),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
if found:
|
||||
row_id = found.get("Id") or found.get("id") or found.get("ID")
|
||||
_api(sess, base_url, token, "PATCH",
|
||||
f"/api/v2/tables/{table_id}/records/{row_id}",
|
||||
json_body=payload)
|
||||
return str(row_id)
|
||||
created = _api(sess, base_url, token, "POST",
|
||||
f"/api/v2/tables/{table_id}/records", json_body=payload)
|
||||
return str((created or {}).get("Id") or (created or {}).get("id") or (created or {}).get("ID") or "")
|
||||
|
||||
|
||||
def upsert_role_permission(sess: requests.Session, base_url: str, token: str,
|
||||
table_id: str, role_id: str, permission_id: str) -> bool:
|
||||
"""Insert a role_permission row if not present. Returns True if inserted."""
|
||||
where = f"and((role_id,eq,{role_id}),(permission_id,eq,{permission_id}))"
|
||||
resp = _api(sess, base_url, token, "GET", f"/api/v2/tables/{table_id}/records",
|
||||
params={"where": where, "limit": 1})
|
||||
items = resp if isinstance(resp, list) else (resp or {}).get("list", []) or []
|
||||
if items:
|
||||
return False
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
|
||||
_api(sess, base_url, token, "POST", f"/api/v2/tables/{table_id}/records",
|
||||
json_body={"role_id": role_id, "permission_id": permission_id, "granted_at": now})
|
||||
return True
|
||||
|
||||
|
||||
# ── Apply / Check / Down / Seed ────────────────────────────────
|
||||
|
||||
|
||||
def apply_schema(sess: requests.Session, base_url: str, token: str,
|
||||
base_id: str, check: bool) -> tuple[dict[str, int], list[str]]:
|
||||
"""Create the 6 tables and required columns/indexes. Idempotent.
|
||||
|
||||
Returns a small dict of counters (created/found/added) and the
|
||||
list of missing object references (only populated in check mode).
|
||||
"""
|
||||
counters: dict[str, int] = {"tables_created": 0, "tables_found": 0, "columns_added": 0}
|
||||
drift: list[str] = []
|
||||
for t in TABLES:
|
||||
table = find_table(sess, base_url, token, base_id, t["title"])
|
||||
if not table:
|
||||
if check:
|
||||
log.error("MISSING table %s", t["title"])
|
||||
drift.append(t["title"])
|
||||
continue
|
||||
table = create_table(sess, base_url, token, base_id, t)
|
||||
counters["tables_created"] += 1
|
||||
log.info("event=table_created name=%s id=%s", t["title"], table.get("id"))
|
||||
else:
|
||||
counters["tables_found"] += 1
|
||||
log.info("event=table_found name=%s id=%s", t["title"], table.get("id"))
|
||||
|
||||
# Re-fetch with full column list (apply_schemas.py:412 workaround)
|
||||
table = _api(sess, base_url, token, "GET", f"/api/v1/db/meta/tables/{table['id']}")
|
||||
existing = {c.get("title") or c.get("column_name")
|
||||
for c in table.get("columns", [])}
|
||||
if check:
|
||||
for c in t["columns"]:
|
||||
if c["name"] not in existing:
|
||||
log.error("MISSING column %s.%s", t["title"], c["name"])
|
||||
drift.append(f"{t['title']}.{c['name']}")
|
||||
continue
|
||||
|
||||
columns_to_add = [c for c in t["columns"] if c["name"] not in existing]
|
||||
if columns_to_add:
|
||||
added = add_missing_columns(sess, base_url, token, table, columns_to_add)
|
||||
counters["columns_added"] += added
|
||||
if added:
|
||||
log.info("event=columns_added table=%s count=%d", t["title"], added)
|
||||
|
||||
# Indexes (NocoDB 'unique' is implemented via column `un` flag for
|
||||
# single-column indexes; multi-column composite uniques use the
|
||||
# unique_pairs list).
|
||||
for idx in t.get("indexes", []):
|
||||
col_name = idx.get("column")
|
||||
if col_name:
|
||||
col = find_column(table, col_name)
|
||||
if col and idx.get("unique") and ensure_unique(sess, base_url, token, col):
|
||||
log.info("event=unique_added table=%s column=%s", t["title"], col_name)
|
||||
|
||||
return counters, drift
|
||||
|
||||
|
||||
def seed_data(sess: requests.Session, base_url: str, token: str,
|
||||
base_id: str) -> dict[str, int]:
|
||||
"""Insert default roles, permissions, and role → permission grants."""
|
||||
counters = {"roles_seeded": 0, "permissions_seeded": 0, "role_permissions_seeded": 0}
|
||||
|
||||
# Resolve table ids
|
||||
table_ids: dict[str, str] = {}
|
||||
for t in TABLES:
|
||||
found = find_table(sess, base_url, token, base_id, t["title"])
|
||||
if not found:
|
||||
raise RuntimeError(f"seed cannot find table {t['title']!r}; run apply first")
|
||||
table_ids[t["name"]] = found["id"]
|
||||
|
||||
# Roles
|
||||
role_ids: dict[str, str] = {}
|
||||
for role in SEED_ROLES:
|
||||
row_id = upsert_role(sess, base_url, token, table_ids["roles"], role)
|
||||
role_ids[role["name"]] = row_id
|
||||
counters["roles_seeded"] += 1
|
||||
log.info("event=role_seeded name=%s id=%s", role["name"], row_id)
|
||||
|
||||
# Permissions
|
||||
perm_ids: dict[tuple[str, str], str] = {}
|
||||
for perm in SEED_PERMISSIONS:
|
||||
row_id = upsert_permission(sess, base_url, token, table_ids["permissions"], perm)
|
||||
perm_ids[(perm["resource"], perm["action"])] = row_id
|
||||
counters["permissions_seeded"] += 1
|
||||
log.info("event=permission_seeded resource=%s action=%s id=%s",
|
||||
perm["resource"], perm["action"], row_id)
|
||||
|
||||
# role_permissions
|
||||
for rp in SEED_ROLE_PERMISSIONS:
|
||||
role_id = role_ids.get(rp["role"])
|
||||
if not role_id:
|
||||
raise RuntimeError(f"unknown role {rp['role']!r} in seed_role_permissions")
|
||||
for resource, action in rp["permissions"]:
|
||||
perm_id = perm_ids.get((resource, action))
|
||||
if not perm_id:
|
||||
raise RuntimeError(
|
||||
f"unknown permission ({resource!r}, {action!r}) for role {rp['role']!r}")
|
||||
if upsert_role_permission(sess, base_url, token, table_ids["role_permissions"],
|
||||
role_id, perm_id):
|
||||
counters["role_permissions_seeded"] += 1
|
||||
log.info("event=role_permission_seeded role=%s resource=%s action=%s",
|
||||
rp["role"], resource, action)
|
||||
|
||||
return counters
|
||||
|
||||
|
||||
def down_schema(sess: requests.Session, base_url: str, token: str,
|
||||
base_id: str) -> dict[str, int]:
|
||||
"""Drop the 6 RBAC tables in the strict inverse of the create order."""
|
||||
counters = {"tables_dropped": 0}
|
||||
for t in reversed(TABLES):
|
||||
found = find_table(sess, base_url, token, base_id, t["title"])
|
||||
if not found:
|
||||
log.info("event=table_already_absent name=%s", t["title"])
|
||||
continue
|
||||
drop_table(sess, base_url, token, base_id, found["id"], t["title"])
|
||||
counters["tables_dropped"] += 1
|
||||
log.info("event=table_dropped name=%s", t["title"])
|
||||
return counters
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="Verify the schema is in place; do not modify anything.")
|
||||
parser.add_argument("--down", action="store_true",
|
||||
help="Drop the 6 RBAC tables in reverse order. Use only for rollback.")
|
||||
parser.add_argument("--seed", action="store_true",
|
||||
help="Insert default roles / permissions / role_permissions only.")
|
||||
args = parser.parse_args()
|
||||
|
||||
base_url = os.environ.get("NOCODB_BASE_URL", "").strip()
|
||||
token = os.environ.get("NOCODB_API_TOKEN", "").strip()
|
||||
project_id = os.environ.get("NOCODB_PROJECT_ID", "").strip() or None
|
||||
if not base_url or not token:
|
||||
log.error("NOCODB_BASE_URL and NOCODB_API_TOKEN are required.")
|
||||
return 2
|
||||
|
||||
sess = _session()
|
||||
started = time.time()
|
||||
|
||||
try:
|
||||
if args.down:
|
||||
# --down needs the base id; either explicit project_id or
|
||||
# find-or-create (we expect it to exist).
|
||||
base_id = find_or_create_base(sess, base_url, token, project_id)
|
||||
log.info("event=down base_id=%s", base_id)
|
||||
counters = down_schema(sess, base_url, token, base_id)
|
||||
_log_counters(counters, started, mode="down")
|
||||
return 0
|
||||
|
||||
if args.seed:
|
||||
# --seed: seed only, assume tables already exist. If they
|
||||
# do not, seed_data() raises — the operator must run
|
||||
# apply first.
|
||||
base_id = find_or_create_base(sess, base_url, token, project_id)
|
||||
log.info("event=seed_only base_id=%s", base_id)
|
||||
counters = seed_data(sess, base_url, token, base_id)
|
||||
_log_counters(counters, started, mode="seed")
|
||||
return 0
|
||||
|
||||
# Default mode: apply + seed
|
||||
base_id = find_or_create_base(sess, base_url, token, project_id)
|
||||
log.info("event=base_ready base_id=%s title=%s", base_id, BASE_TITLE)
|
||||
counters, missing = apply_schema(sess, base_url, token, base_id, args.check)
|
||||
if args.check:
|
||||
if missing:
|
||||
log.error("event=drift count=%d items=%s", len(missing), missing)
|
||||
_log_counters(counters, started, mode="check")
|
||||
return 1
|
||||
_log_counters(counters, started, mode="check")
|
||||
return 0
|
||||
|
||||
# Real apply: also seed
|
||||
seed_counters = seed_data(sess, base_url, token, base_id)
|
||||
counters.update(seed_counters)
|
||||
_log_counters(counters, started, mode="apply")
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.error("event=failure error=%s", exc)
|
||||
return 1
|
||||
|
||||
|
||||
def _log_counters(counters: dict[str, int], started: float, *, mode: str) -> None:
|
||||
duration_ms = int((time.time() - started) * 1000)
|
||||
payload = dict(counters)
|
||||
payload["mode"] = mode
|
||||
payload["duration_ms"] = duration_ms
|
||||
log.info("event=summary %s", json.dumps(payload, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,108 @@
|
||||
/* InboxOS — Design-system ESLint config (JSX/TSX)
|
||||
*
|
||||
* Enforces the JSX/TSX half of docs/design/lint-rules.md (Story #132).
|
||||
* Specifically: DS-C001 (no hardcoded colors in JSX style props),
|
||||
* DS-C002 (no inline style attribute at all), DS-C003 (SVG fill/stroke
|
||||
* must use CSS custom properties), DS-T001 (no hardcoded font-size /
|
||||
* line-height / font-weight / letter-spacing in style props), DS-T002
|
||||
* (no custom font-family in style props).
|
||||
*
|
||||
* Errors block merge (Pillar III gate); warnings emit non-blocking
|
||||
* annotations only.
|
||||
*
|
||||
* Companion: scripts/lint-design-system.mjs
|
||||
* Owner: devops (Story #135)
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const designTokenMessage = "Use var(--*) or token('color.*' | 'typography.*' | 'spacing.*' | 'border-radius.*' | 'shadow.*' | 'breakpoint.*' | 'z-index.*').";
|
||||
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2022: true,
|
||||
node: true,
|
||||
},
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
plugins: ["@typescript-eslint"],
|
||||
extends: ["eslint:recommended"],
|
||||
rules: {
|
||||
// DS-C002 — no inline style attribute on JSX elements. The
|
||||
// design system is enforced via CSS classes / CSS modules that
|
||||
// resolve to token() references.
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector: "JSXAttribute[name.name='style']",
|
||||
message: "DS-C002: inline style attribute forbidden. Use a token-based CSS class or component prop.",
|
||||
},
|
||||
// DS-C001 — hex literals anywhere in the JSX/TSX source
|
||||
{
|
||||
selector: "Literal[value=/^#[0-9a-fA-F]{3,8}$/]",
|
||||
message: `DS-C001: hardcoded hex color forbidden. ${designTokenMessage}`,
|
||||
},
|
||||
{
|
||||
selector: "TemplateElement[value.raw=/#[0-9a-fA-F]{3,8}/]",
|
||||
message: `DS-C001: hardcoded hex color forbidden. ${designTokenMessage}`,
|
||||
},
|
||||
// DS-T001 — hardcoded font-size/line-height/font-weight/
|
||||
// letter-spacing in JSX style props
|
||||
{
|
||||
selector: "Property[key.name=/^(font-size|line-height|font-weight|letter-spacing)$/]",
|
||||
message: `DS-T001: hardcoded typography value forbidden. ${designTokenMessage}`,
|
||||
},
|
||||
// DS-T002 — no custom font-family in JSX style props
|
||||
{
|
||||
selector: "Property[key.name='font-family']",
|
||||
message: "DS-T002: font-family must reference var(--font-family-sans) or var(--font-family-mono).",
|
||||
},
|
||||
// DS-C003 — SVG fill/stroke must reference CSS custom
|
||||
// properties or inherit from parent.
|
||||
{
|
||||
selector: "JSXAttribute[name.name=/^(fill|stroke)$/] > Literal[value=/^#[0-9a-fA-F]/]",
|
||||
message: "DS-C003: SVG fill/stroke must reference a CSS custom property (var(--color-*)) or inherit.",
|
||||
},
|
||||
],
|
||||
"no-unused-vars": "off",
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
// tokens.ts is generated from tokens.json; literal hex strings
|
||||
// here are the design-system source, not a consumer of it.
|
||||
// The accompanying tokens.test.ts asserts that tokens.json
|
||||
// contains the expected hex literals — those assertions are
|
||||
// not consumer code, so the rule must stay off there too.
|
||||
files: [
|
||||
"src/design-system/tokens.ts",
|
||||
"src/design-system/tokens.json",
|
||||
"src/design-system/tokens.test.ts",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-syntax": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Storybook stories are demo/documentation files. Inline styles
|
||||
// are used in stories for visual illustration only — they are not
|
||||
// production component code. DS-C002 (no inline style) does not
|
||||
// apply to story files.
|
||||
files: ["**/*.stories.tsx"],
|
||||
rules: {
|
||||
"no-restricted-syntax": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
ignorePatterns: [
|
||||
"node_modules/",
|
||||
"dist/",
|
||||
"src/design-system/tokens.css",
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
# InboxOS UI dev-dependencies
|
||||
node_modules/
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.stories.tsx"],
|
||||
addons: [
|
||||
"@storybook/addon-essentials",
|
||||
"@storybook/addon-a11y",
|
||||
],
|
||||
framework: {
|
||||
name: "@storybook/react-vite",
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: "tag",
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Preview } from "@storybook/react";
|
||||
import "../src/design-system/tokens.css";
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: "gray-50",
|
||||
values: [
|
||||
{ name: "gray-50", value: "#f9fafb" },
|
||||
{ name: "white", value: "#ffffff" },
|
||||
],
|
||||
},
|
||||
a11y: {
|
||||
element: "#storybook-root",
|
||||
config: {},
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -0,0 +1,218 @@
|
||||
/* InboxOS — Design-system Stylelint config (Story #135)
|
||||
*
|
||||
* Enforces docs/design/lint-rules.md (Story #132). Every rule ID is
|
||||
* traceable to the spec via the `message` field. Errors block merge
|
||||
* (Pillar III gate); warnings emit non-blocking annotations only.
|
||||
*
|
||||
* Companion: scripts/lint-design-system.mjs
|
||||
* Owner: devops (Story #135)
|
||||
*
|
||||
* IMPORTANT: this file is the canonical devops deliverable. Component
|
||||
* Stories (e.g., INB-1.5-D-S3 layout) must add their per-file
|
||||
* overrides in this `overrides` block, not replace the rules.
|
||||
*
|
||||
* Design note: we use declaration-property-value-allowed-list
|
||||
* (not the disallowed variant) because the disallowed list checks
|
||||
* the raw value as a string and cannot handle multi-value
|
||||
* declarations like `padding: 0 var(--spacing-1)`. The allowed
|
||||
* list with a multi-value regex (multiValueRegex below) correctly
|
||||
* accepts every legal combination of the documented tokens and
|
||||
* the global keywords.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
// Helper: build a stylelint-compatible regex string (with
|
||||
// surrounding `/.../` delimiters) that matches a single value or
|
||||
// a multi-value declaration where every value is one of the
|
||||
// supplied tokens (separated by spaces).
|
||||
//
|
||||
// Each token is either:
|
||||
// - a RegExp (its `.source` is used verbatim; the caller is
|
||||
// responsible for NOT including `^` or `$` anchors — the
|
||||
// outer alternation wrapper adds them), or
|
||||
// - a literal string (`0`, `auto`, `inherit`, etc.) — the
|
||||
// function escapes regex metacharacters automatically.
|
||||
function multiValueRegexLiteral(tokens) {
|
||||
const alts = tokens
|
||||
.map((t) => {
|
||||
if (t instanceof RegExp) {
|
||||
return t.source;
|
||||
}
|
||||
return t.replace(/[.*+?^${}()|[\]]/g, "\\$&");
|
||||
})
|
||||
.join("|");
|
||||
// Wrap as `^(alts)( alts)*$` and emit as a stylelint regex literal.
|
||||
// Stylelint requires the leading and trailing `/` so it knows this
|
||||
// is a regex and not a literal value.
|
||||
return `/^(${alts})(?:\\s+(${alts}))*$/`;
|
||||
}
|
||||
|
||||
/** @type {import('stylelint').Config} */
|
||||
module.exports = {
|
||||
extends: ["stylelint-config-standard"],
|
||||
rules: {
|
||||
// ── DS-C001 — no hardcoded hex/rgb/hsl/named colors ─────
|
||||
"color-no-hex": [true, {
|
||||
message: "DS-C001: no hardcoded hex colors. Use var(--color-*) or token('color.*').",
|
||||
}],
|
||||
"color-named": ["never", {
|
||||
message: "DS-C001: no named colors. Use var(--color-*) or token('color.*').",
|
||||
}],
|
||||
|
||||
// ── DS-C002 — no inline style attribute (CSS-side analogue
|
||||
// is no `!important`. The JSX/TSX side is enforced by
|
||||
// .eslintrc.cjs.)
|
||||
"declaration-no-important": true,
|
||||
|
||||
// ── DS-T001 / DS-T002 / DS-S001 / DS-S002 / DS-R001 / DS-SH001 / DS-Z001
|
||||
// Use allowed-list with a multi-value regex (see header comment).
|
||||
"declaration-property-value-allowed-list": {
|
||||
// DS-T002 — font-family must reference the documented stack
|
||||
"/^font-family$/": [
|
||||
multiValueRegexLiteral([
|
||||
/var\(--font-family-[a-z0-9-]+\)/,
|
||||
"inherit", "initial", "unset", "revert", "revert-layer",
|
||||
]),
|
||||
],
|
||||
// DS-T001
|
||||
"/^font-size$/": [
|
||||
multiValueRegexLiteral([
|
||||
/var\(--font-size-[a-z0-9-]+\)/,
|
||||
"inherit", "initial", "unset", "revert", "revert-layer",
|
||||
]),
|
||||
],
|
||||
"/^line-height$/": [
|
||||
multiValueRegexLiteral([
|
||||
/var\(--line-height-[a-z0-9-]+\)/,
|
||||
"inherit", "initial", "unset", "revert", "revert-layer",
|
||||
]),
|
||||
],
|
||||
"/^font-weight$/": [
|
||||
multiValueRegexLiteral([
|
||||
/var\(--font-weight-[a-z0-9-]+\)/,
|
||||
"inherit", "initial", "unset", "revert", "revert-layer",
|
||||
]),
|
||||
],
|
||||
"/^letter-spacing$/": [
|
||||
multiValueRegexLiteral([
|
||||
/var\(--letter-spacing-[a-z0-9-]+\)/,
|
||||
"inherit", "initial", "unset", "revert", "revert-layer",
|
||||
]),
|
||||
],
|
||||
// DS-S001 / DS-S002
|
||||
"/^margin(-(top|right|bottom|left|inline|block|inline-start|inline-end|block-start|block-end))?$/": [
|
||||
multiValueRegexLiteral([
|
||||
/var\(--spacing-[a-z0-9-]+\)/,
|
||||
"0", "auto", "inherit", "initial", "unset", "revert", "revert-layer",
|
||||
]),
|
||||
],
|
||||
"/^padding(-(top|right|bottom|left|inline|block|inline-start|inline-end|block-start|block-end))?$/": [
|
||||
multiValueRegexLiteral([
|
||||
/var\(--spacing-[a-z0-9-]+\)/,
|
||||
"0", "auto", "inherit", "initial", "unset", "revert", "revert-layer",
|
||||
]),
|
||||
],
|
||||
"/^gap(-(row|column|inline|block))?$/": [
|
||||
multiValueRegexLiteral([
|
||||
/var\(--spacing-[a-z0-9-]+\)/,
|
||||
"0", "normal", "inherit", "initial", "unset", "revert", "revert-layer",
|
||||
]),
|
||||
],
|
||||
// DS-R001
|
||||
"/^border-radius(-(top|bottom|left|right|top-left|top-right|bottom-left|bottom-right|inline-start|inline-end|block-start|block-end))?$/": [
|
||||
multiValueRegexLiteral([
|
||||
/var\(--border-radius-[a-z0-9-]+\)/,
|
||||
"0", "inherit", "initial", "unset", "revert", "revert-layer",
|
||||
]),
|
||||
],
|
||||
// DS-SH001
|
||||
"/^box-shadow$/": [
|
||||
multiValueRegexLiteral([
|
||||
/var\(--shadow-[a-z0-9-]+\)/,
|
||||
"none", "inherit", "initial", "unset", "revert", "revert-layer",
|
||||
]),
|
||||
],
|
||||
// DS-Z001
|
||||
"/^z-index$/": [
|
||||
multiValueRegexLiteral([
|
||||
/var\(--z-index-[a-z0-9-]+\)/,
|
||||
"auto", "inherit", "initial", "unset", "revert", "revert-layer",
|
||||
]),
|
||||
],
|
||||
},
|
||||
|
||||
// ── DS-C004 — opacity() forbidden in favor of -light surface tokens
|
||||
"function-disallowed-list": [
|
||||
["/^opacity$/"],
|
||||
{
|
||||
message: "DS-C004: opacity() forbidden. Use the documented -light surface tokens instead.",
|
||||
},
|
||||
],
|
||||
|
||||
// ── DS-B001 / DS-B002 — handled by scripts/check-breakpoints.mjs
|
||||
// (Stylelint cannot validate the pixel value inside @media.)
|
||||
|
||||
// ── BEM selector pattern (used by component CSS) ──────────
|
||||
"selector-class-pattern": [
|
||||
"^[a-z][a-z0-9-]*((__|__[a-z][a-z0-9-]*)(--|--[a-z][a-z0-9-]*)?)*$|^[_]",
|
||||
{ resolveNestedSelectors: true, message: "Class names must follow BEM convention (block__element--modifier)." },
|
||||
],
|
||||
"custom-property-pattern": [
|
||||
"^[a-z][a-z0-9-]*(_[a-z0-9-]+)*$",
|
||||
{ message: "Custom property names must be kebab-case with optional underscore-separated numeric suffixes (e.g., --spacing-0_5)." },
|
||||
],
|
||||
|
||||
// ── Relax stylelint-config-standard defaults that fight us ──
|
||||
"at-rule-no-unknown": [true, {
|
||||
ignoreAtRules: ["layer", "responsive", "media", "supports", "container"],
|
||||
}],
|
||||
"color-function-notation": null,
|
||||
"alpha-value-notation": null,
|
||||
"color-hex-length": null,
|
||||
"media-feature-range-notation": null,
|
||||
"function-disallowed-list": null,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
// tokens.css is generated from tokens.json and is the only place
|
||||
// raw color literals are allowed. Anyone editing it is editing
|
||||
// the design-system source. The font-family stack contains mixed-
|
||||
// case font names (BlinkMacSystemFont, Roboto, Arial) which are
|
||||
// the documented stack — we disable the lowercase enforcement
|
||||
// here to keep the generated file identical to the source.
|
||||
files: ["src/design-system/tokens.css"],
|
||||
rules: {
|
||||
"color-no-hex": null,
|
||||
"color-named": null,
|
||||
"declaration-property-value-allowed-list": null,
|
||||
"declaration-no-important": null,
|
||||
"value-keyword-case": null,
|
||||
"custom-property-empty-line-before": null,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Component CSS files (Story #134, INB-1.5-D-S3) use design-system
|
||||
// tokens with underscore-separated numeric suffixes (e.g. --spacing-0_5,
|
||||
// --spacing-1_5) which the global allowed-list regex [a-z0-9-] does not
|
||||
// match. The tokens are defined in tokens.css and are canonical. Allow
|
||||
// them here so the lint gate passes while the regex is updated centrally
|
||||
// in a follow-up.
|
||||
files: [
|
||||
"src/feedback/StatusMessage.css",
|
||||
"src/navigation/SidebarNav.css",
|
||||
],
|
||||
rules: {
|
||||
"declaration-property-value-allowed-list": {
|
||||
"/^padding(-(top|right|bottom|left|inline|block|inline-start|inline-end|block-start|block-end))?$/": [
|
||||
"/^(var\\(--spacing-[a-z0-9_-]+\\)|0|auto|inherit|initial|unset|revert|revert-layer)(?:\\s+(var\\(--spacing-[a-z0-9_-]+\\)|0|auto|inherit|initial|unset|revert|revert-layer))*$/",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
ignoreFiles: [
|
||||
"node_modules/**",
|
||||
"dist/**",
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"url": [
|
||||
"http://localhost:4173/"
|
||||
],
|
||||
"numberOfRuns": 1,
|
||||
"settings": {
|
||||
"preset": "desktop",
|
||||
"skipAudits": [
|
||||
"uses-http2",
|
||||
"is-on-https",
|
||||
"redirects-http",
|
||||
"uses-rel-preconnect"
|
||||
]
|
||||
}
|
||||
},
|
||||
"assert": {
|
||||
"preset": "lighthouse:no-pwa",
|
||||
"assertions": {
|
||||
"categories:accessibility": [
|
||||
"error",
|
||||
{ "minScore": 0.90 }
|
||||
],
|
||||
"categories:best-practices": [
|
||||
"warn",
|
||||
{ "minScore": 0.85 }
|
||||
],
|
||||
"categories:performance": [
|
||||
"warn",
|
||||
{ "minScore": 0.80 }
|
||||
],
|
||||
"color-contrast": "error",
|
||||
"image-alt": "error",
|
||||
"label-content-name-mismatch": "error",
|
||||
"link-name": "error",
|
||||
"list": "error",
|
||||
"listitem": "error",
|
||||
"meta-viewport": "error",
|
||||
"tabindex": "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+8341
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@inboxos/ui",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"description": "InboxOS frontend components — design-system tokens, responsive layouts, navigation, and feedback (INB-1.5-D-S3 #134) and design-system lint enforcement (INB-1.5-D-S4 #135).",
|
||||
"license": "UNLICENSED",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "node scripts/lint-design-system.mjs && node scripts/check-breakpoints.mjs",
|
||||
"lint:stylelint": "stylelint \"src/**/*.css\" \"src/**/*.scss\"",
|
||||
"lint:eslint": "eslint \"src/**/*.{ts,tsx,js,jsx}\"",
|
||||
"lint:fixture-check": "node scripts/fixture-check.mjs",
|
||||
"lint:breakpoints": "node scripts/check-breakpoints.mjs",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-a11y": "^8.4.7",
|
||||
"@storybook/addon-essentials": "^8.4.7",
|
||||
"@storybook/react": "^8.4.7",
|
||||
"@storybook/react-vite": "^8.4.7",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^2.1.8",
|
||||
"eslint": "^8.57.1",
|
||||
"jsdom": "^25.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"storybook": "^8.4.7",
|
||||
"stylelint": "^16.10.0",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const responsiveMedia = require("./scripts/postcss-responsive-media.cjs");
|
||||
|
||||
module.exports = {
|
||||
plugins: [responsiveMedia()],
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check-breakpoints.mjs
|
||||
//
|
||||
// Story #135 — DS-B001 / DS-B002 enforcement.
|
||||
//
|
||||
// DS-B001: "No hardcoded `@media` breakpoint pixel values. Use
|
||||
// `token('breakpoint.*')` (or the generated mixin)."
|
||||
// DS-B002: "Mobile-first: write base styles for `sm`, then
|
||||
// `@media (min-width: token('breakpoint.md'))` for progressive
|
||||
// enhancement."
|
||||
//
|
||||
// Pure Stylelint cannot validate the @media pixel value (it accepts
|
||||
// any value). This script walks every .css / .scss file under src/
|
||||
// outside the design-system layer and reports any @media query whose
|
||||
// expression is a raw pixel value (700px, 900px, …) instead of a
|
||||
// var(--breakpoint-*) reference. Each finding is a hard error that
|
||||
// blocks merge (Pillar III gate).
|
||||
//
|
||||
// Owner: devops (Story #135 — INB-1.5-D-S4)
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { resolve, dirname, extname, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, "..");
|
||||
const SRC = process.argv[2]
|
||||
? resolve(process.argv[2])
|
||||
: resolve(ROOT, "src");
|
||||
// Exemption applies only to the default src/ tree, not scratch
|
||||
// trees passed by the fixture check.
|
||||
const EXEMPT_DIRS = process.argv[2]
|
||||
? []
|
||||
: [resolve(ROOT, "src/design-system"), resolve(ROOT, "src/__fixtures__")];
|
||||
const STYLE_EXTS = new Set([".css", ".scss", ".sass"]);
|
||||
|
||||
function isExempt(full) {
|
||||
return EXEMPT_DIRS.some((d) => full.startsWith(d + "/") || full === d);
|
||||
}
|
||||
|
||||
const PIXEL_VALUE = /(\d+(?:\.\d+)?)\s*(px|em|rem)/g;
|
||||
const MEDIA_QUERY = /@media\s+([^{]+)\{/g;
|
||||
|
||||
function* walk(dir) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (entry === "node_modules" || entry === "dist" || entry === "_scratch") continue;
|
||||
const full = resolve(dir, entry);
|
||||
if (isExempt(full)) continue;
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
yield* walk(full);
|
||||
} else if (STYLE_EXTS.has(extname(entry))) {
|
||||
yield full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const findings = [];
|
||||
function varBreakpointPresent(expr) {
|
||||
// Accept any expression that contains at least one var(--breakpoint-*)
|
||||
// reference. Also accept 'print', 'screen', 'all', and the mixin keyword
|
||||
// form `@media responsive(md)` which the build resolves.
|
||||
if (/@media\s+responsive\(/.test("@media " + expr)) return true;
|
||||
if (/var\(--breakpoint-/.test(expr)) return true;
|
||||
if (/^[\w-]+$/.test(expr)) return true; // 'print', 'screen', 'all', 'speech'
|
||||
return false;
|
||||
}
|
||||
for (const file of walk(SRC)) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
let m;
|
||||
MEDIA_QUERY.lastIndex = 0;
|
||||
while ((m = MEDIA_QUERY.exec(text)) !== null) {
|
||||
const expr = m[1].trim();
|
||||
// Allow var(--breakpoint-*) references and the documented
|
||||
// mixin/keyword short-hands. Reject raw numeric pixel/em/rem.
|
||||
if (varBreakpointPresent(expr)) continue;
|
||||
const offsets = [];
|
||||
let p;
|
||||
PIXEL_VALUE.lastIndex = 0;
|
||||
while ((p = PIXEL_VALUE.exec(expr)) !== null) {
|
||||
offsets.push({ value: p[0], unit: p[2] });
|
||||
}
|
||||
if (offsets.length === 0) continue;
|
||||
// Find the line number of the @media in the source.
|
||||
const startIdx = m.index;
|
||||
const before = text.slice(0, startIdx);
|
||||
const line = before.split("\n").length;
|
||||
findings.push({
|
||||
file: relative(SRC, file),
|
||||
line,
|
||||
rule: "DS-B001",
|
||||
text: `DS-B001: @media uses raw ${offsets.map((o) => o.value).join(", ")} value(s). Use var(--breakpoint-*) (sm/md/lg/xl/2xl).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (findings.length) {
|
||||
console.log("DS-B001 breakpoint violations (must use var(--breakpoint-*)):");
|
||||
for (const f of findings) {
|
||||
console.log(` ${f.file}:${f.line} ${f.text}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("DS-B001/DS-B002: all @media queries use var(--breakpoint-*) or keyword form.");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/fixture-check.mjs
|
||||
//
|
||||
// Proves every DS-* lint rule in docs/design/lint-rules.md fires on
|
||||
// a deliberate negative fixture and stays silent on the positive
|
||||
// counterpart. Used by:
|
||||
// - npm run lint:fixture-check (local dev)
|
||||
// - .gitea/workflows/ci-pr.yml::design-system-lint (CI gate)
|
||||
//
|
||||
// Each rule ID listed in the spec has a pair:
|
||||
// __fixtures__/negative/<rule>.css|tsx — must produce ≥ 1 error
|
||||
// __fixtures__/positive/<rule>.css|tsx — must produce 0 errors
|
||||
//
|
||||
// Owner: devops (Story #135 — INB-1.5-D-S4)
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { resolve, dirname, basename, extname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, "..");
|
||||
const NEG_DIR = resolve(ROOT, "src/__fixtures__/negative");
|
||||
const POS_DIR = resolve(ROOT, "src/__fixtures__/positive");
|
||||
const SCRATCH = resolve(ROOT, "src/__fixtures__/_scratch");
|
||||
|
||||
const RULES = [
|
||||
{
|
||||
id: "DS-C001",
|
||||
description: "No hardcoded hex colors in CSS / JSX.",
|
||||
file: "DS-C001.css",
|
||||
negative: "/* DS-C001 negative */\n.bad {\n color: #ff0000;\n}\n",
|
||||
positive: "/* DS-C001 positive */\n.good {\n color: var(--color-brand-primary);\n}\n",
|
||||
},
|
||||
{
|
||||
id: "DS-C002",
|
||||
description: "No inline style attribute in JSX.",
|
||||
file: "DS-C002.tsx",
|
||||
negative:
|
||||
"export const Bad = () => <div style={{ color: 'red' }}>x</div>;\n",
|
||||
positive:
|
||||
"export const Good = () => <div className=\"text-primary\">x</div>;\n",
|
||||
},
|
||||
{
|
||||
id: "DS-T001",
|
||||
description: "No hardcoded font-size / line-height / font-weight / letter-spacing.",
|
||||
file: "DS-T001.css",
|
||||
negative: "/* DS-T001 negative */\n.bad {\n font-size: 14px;\n line-height: 1.6;\n font-weight: 600;\n letter-spacing: 0.05em;\n}\n",
|
||||
positive: "/* DS-T001 positive */\n.good {\n font-size: var(--font-size-sm);\n line-height: var(--line-height-sm);\n font-weight: var(--font-weight-semibold);\n letter-spacing: var(--letter-spacing-tight);\n}\n",
|
||||
},
|
||||
{
|
||||
id: "DS-T002",
|
||||
description: "No custom font-family in JSX style.",
|
||||
file: "DS-T002.tsx",
|
||||
negative:
|
||||
"export const Bad = () => <div style={{ fontFamily: 'Comic Sans MS' }}>x</div>;\n",
|
||||
positive:
|
||||
"export const Good = () => <div className=\"font-sans\">x</div>;\n",
|
||||
},
|
||||
{
|
||||
id: "DS-S001",
|
||||
description: "Spacing values must come from var(--spacing-*).",
|
||||
file: "DS-S001.css",
|
||||
negative: "/* DS-S001 negative */\n.bad {\n margin: 13px;\n padding: 7px;\n gap: 11px;\n}\n",
|
||||
positive: "/* DS-S001 positive */\n.good {\n margin: var(--spacing-4);\n padding: var(--spacing-2);\n gap: var(--spacing-6);\n}\n",
|
||||
},
|
||||
{
|
||||
id: "DS-R001",
|
||||
description: "border-radius must come from var(--border-radius-*).",
|
||||
file: "DS-R001.css",
|
||||
negative: "/* DS-R001 negative */\n.bad {\n border-radius: 5px;\n}\n",
|
||||
positive: "/* DS-R001 positive */\n.good {\n border-radius: var(--border-radius-md);\n}\n",
|
||||
},
|
||||
{
|
||||
id: "DS-SH001",
|
||||
description: "box-shadow must come from var(--shadow-*).",
|
||||
file: "DS-SH001.css",
|
||||
negative: "/* DS-SH001 negative */\n.bad {\n box-shadow: 0 2px 4px rgba(0,0,0,0.1);\n}\n",
|
||||
positive: "/* DS-SH001 positive */\n.good {\n box-shadow: var(--shadow-card);\n}\n",
|
||||
},
|
||||
{
|
||||
id: "DS-B001",
|
||||
description: "No hardcoded breakpoint pixel values.",
|
||||
file: "DS-B001.css",
|
||||
negative: "/* DS-B001 negative */\n@media (min-width: 700px) {\n .bad { display: flex; }\n}\n",
|
||||
positive: "/* DS-B001 positive */\n@media (min-width: var(--breakpoint-md)) {\n .good { display: flex; }\n}\n",
|
||||
tool: "breakpoints",
|
||||
},
|
||||
{
|
||||
id: "DS-Z001",
|
||||
description: "z-index must come from var(--z-index-*).",
|
||||
file: "DS-Z001.css",
|
||||
negative: "/* DS-Z001 negative */\n.bad {\n z-index: 9999;\n}\n",
|
||||
positive: "/* DS-Z001 positive */\n.good {\n z-index: var(--z-index-modal);\n}\n",
|
||||
},
|
||||
{
|
||||
id: "DS-C003",
|
||||
description: "SVG fill/stroke must reference a CSS custom property.",
|
||||
file: "DS-C003.tsx",
|
||||
negative:
|
||||
"export const Bad = () => <svg fill=\"#abcdef\"><circle /></svg>;\n",
|
||||
positive:
|
||||
"export const Good = () => <svg fill=\"currentColor\"><circle /></svg>;\n",
|
||||
},
|
||||
{
|
||||
id: "DS-C004",
|
||||
description: "opacity() forbidden; use the documented -light surface tokens.",
|
||||
file: "DS-C004.css",
|
||||
negative: "/* DS-C004 negative */\n.bad {\n background: opacity(#2563eb, 0.1);\n}\n",
|
||||
positive: "/* DS-C004 positive */\n.good {\n background: var(--color-brand-primary-light);\n}\n",
|
||||
},
|
||||
];
|
||||
|
||||
mkdirSync(NEG_DIR, { recursive: true });
|
||||
mkdirSync(POS_DIR, { recursive: true });
|
||||
mkdirSync(SCRATCH, { recursive: true });
|
||||
|
||||
for (const r of RULES) {
|
||||
writeFileSync(resolve(NEG_DIR, r.file), r.negative);
|
||||
writeFileSync(resolve(POS_DIR, r.file), r.positive);
|
||||
}
|
||||
|
||||
function run(cmd, cmdArgs) {
|
||||
return new Promise((resPromise, rejPromise) => {
|
||||
const c = spawn(cmd, cmdArgs, {
|
||||
cwd: ROOT,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let out = "";
|
||||
let err = "";
|
||||
c.stdout.on("data", (b) => (out += b.toString()));
|
||||
c.stderr.on("data", (b) => (err += b.toString()));
|
||||
c.on("error", rejPromise);
|
||||
c.on("close", (code) => resPromise({ code: code ?? 1, out, err }));
|
||||
});
|
||||
}
|
||||
|
||||
if (!existsSync(resolve(ROOT, "node_modules/stylelint"))) {
|
||||
console.error("ui/node_modules missing. Run `npm install` in ui/ first.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// We exercise each rule in isolation by running the linters against
|
||||
// scratch copies of just the positive / negative fixture files. This
|
||||
// keeps the fixture check independent of any real consumer code that
|
||||
// may live in src/.
|
||||
// We pass explicit file lists (not globs) because npx --no-install
|
||||
// forwards arguments without shell expansion.
|
||||
const styleFiles = RULES.filter((r) => extname(r.file) === ".css" && r.tool !== "breakpoints").map(
|
||||
(r) => r.file,
|
||||
);
|
||||
const scriptFiles = RULES.filter((r) => extname(r.file) === ".tsx").map(
|
||||
(r) => r.file,
|
||||
);
|
||||
const breakpointFiles = RULES.filter((r) => r.tool === "breakpoints").map(
|
||||
(r) => r.file,
|
||||
);
|
||||
|
||||
const results = [];
|
||||
for (const dir of [NEG_DIR, POS_DIR]) {
|
||||
const kind = basename(dir); // 'negative' or 'positive'
|
||||
if (styleFiles.length) {
|
||||
const targets = styleFiles.map((f) => join(dir, f));
|
||||
const res = await run("npx", [
|
||||
"--no-install",
|
||||
"stylelint",
|
||||
...targets,
|
||||
"--formatter",
|
||||
"json",
|
||||
]);
|
||||
let parsed = [];
|
||||
try {
|
||||
// Stylelint writes the JSON formatter payload to stderr.
|
||||
const text = res.err.trim() || res.out.trim();
|
||||
parsed = JSON.parse(text);
|
||||
parsed = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
parsed = [];
|
||||
}
|
||||
const allWarnings = [];
|
||||
for (const p of parsed) {
|
||||
const src = p.source || "";
|
||||
for (const w of p.warnings || []) {
|
||||
allWarnings.push({
|
||||
file: basename(src),
|
||||
rule: w.rule,
|
||||
text: w.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
kind,
|
||||
tool: "stylelint",
|
||||
warnings: allWarnings,
|
||||
});
|
||||
}
|
||||
if (scriptFiles.length) {
|
||||
const targets = scriptFiles.map((f) => join(dir, f));
|
||||
const res = await run("npx", [
|
||||
"--no-install",
|
||||
"eslint",
|
||||
...targets,
|
||||
"--format",
|
||||
"json",
|
||||
]);
|
||||
let parsed = [];
|
||||
try {
|
||||
parsed = JSON.parse(res.out);
|
||||
parsed = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
parsed = [];
|
||||
}
|
||||
const allMessages = [];
|
||||
for (const p of parsed) {
|
||||
const src = p.filePath || "";
|
||||
for (const m of p.messages || []) {
|
||||
allMessages.push({
|
||||
file: basename(src),
|
||||
rule: m.ruleId,
|
||||
text: m.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
kind,
|
||||
tool: "eslint",
|
||||
messages: allMessages,
|
||||
});
|
||||
}
|
||||
if (breakpointFiles.length) {
|
||||
// The breakpoint check walks every .css / .scss file under src/
|
||||
// and reports raw pixel @media values. We isolate the fixture
|
||||
// check by pointing the script at a scratch tree that contains
|
||||
// only the fixtures for this kind.
|
||||
const scratchSrc = resolve(SCRATCH, `bp-${kind}`);
|
||||
mkdirSync(scratchSrc, { recursive: true });
|
||||
for (const f of breakpointFiles) {
|
||||
const src = join(dir, f);
|
||||
const dst = join(scratchSrc, f);
|
||||
mkdirSync(dirname(dst), { recursive: true });
|
||||
const { readFileSync } = await import("node:fs");
|
||||
writeFileSync(dst, readFileSync(src));
|
||||
}
|
||||
const res = await run("node", [
|
||||
resolve(ROOT, "scripts/check-breakpoints.mjs"),
|
||||
scratchSrc,
|
||||
]);
|
||||
// Parse lines like " file.css:LINE DS-B001: ..." out of stdout.
|
||||
const allHits = [];
|
||||
for (const line of (res.out || "").split("\n")) {
|
||||
const m = line.match(/(\S+\.css):(\d+)\s+DS-B001:/);
|
||||
if (m) {
|
||||
allHits.push({ file: basename(m[1]), rule: "DS-B001", text: line.trim() });
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
kind,
|
||||
tool: "breakpoints",
|
||||
messages: allHits,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
for (const r of RULES) {
|
||||
const ext = extname(r.file);
|
||||
const tool = r.tool || (ext === ".css" ? "stylelint" : "eslint");
|
||||
const neg = results.find(
|
||||
(x) => x.kind === "negative" && x.tool === tool,
|
||||
);
|
||||
const pos = results.find(
|
||||
(x) => x.kind === "positive" && x.tool === tool,
|
||||
);
|
||||
if (!neg || !pos) {
|
||||
failures.push(`${r.id}: missing tool result for ${tool}`);
|
||||
continue;
|
||||
}
|
||||
const negHits = (neg.warnings || neg.messages || []).filter(
|
||||
(x) => x.file === r.file,
|
||||
);
|
||||
const posHits = (pos.warnings || pos.messages || []).filter(
|
||||
(x) => x.file === r.file,
|
||||
);
|
||||
if (negHits.length === 0) {
|
||||
failures.push(
|
||||
`${r.id}: negative fixture did NOT trigger a lint error (expected ≥ 1). ` +
|
||||
`Spec: ${r.description}`,
|
||||
);
|
||||
}
|
||||
if (posHits.length > 0) {
|
||||
failures.push(
|
||||
`${r.id}: positive fixture triggered ${posHits.length} lint error(s) (expected 0). ` +
|
||||
`Spec: ${r.description}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup scratch dir
|
||||
rmSync(SCRATCH, { recursive: true, force: true });
|
||||
|
||||
console.log("");
|
||||
console.log("Design-system fixture check");
|
||||
console.log("===========================");
|
||||
console.log(` rules tested: ${RULES.length}`);
|
||||
const totalNeg = results.find((x) => x.kind === "negative");
|
||||
const totalPos = results.find((x) => x.kind === "positive");
|
||||
const negCount = (totalNeg.warnings || []).length + (totalNeg.messages || []).length;
|
||||
const posCount = (totalPos.warnings || []).length + (totalPos.messages || []).length;
|
||||
console.log(` negative hits: ${negCount} (expected ≥ ${RULES.length})`);
|
||||
console.log(` positive hits: ${posCount} (expected 0)`);
|
||||
if (failures.length) {
|
||||
console.log("");
|
||||
console.log("FAILURES:");
|
||||
for (const f of failures) console.log(` - ${f}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("");
|
||||
console.log("All DS-* rules fired on negative fixtures and stayed silent on positive fixtures.");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/lint-design-system.mjs
|
||||
//
|
||||
// InboxOS design-system lint runner. Invokes Stylelint and ESLint over
|
||||
// the design-system consumer surface, then enforces the DS-* rules
|
||||
// from docs/design/lint-rules.md as a hard gate.
|
||||
//
|
||||
// Exit codes:
|
||||
// 0 — clean (no errors, warnings are allowed)
|
||||
// 1 — one or more error-severity findings; the PR gate must block
|
||||
// 2 — tooling failure (missing dependency, malformed config)
|
||||
//
|
||||
// Owner: devops (Story #135 — INB-1.5-D-S4)
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/lint-design-system.mjs
|
||||
// node scripts/lint-design-system.mjs --json # machine-readable
|
||||
// node scripts/lint-design-system.mjs --no-warn # warnings -> errors
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { resolve, dirname, extname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, "..");
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const JSON_OUT = args.has("--json");
|
||||
const NO_WARN = args.has("--no-warn");
|
||||
|
||||
function run(cmd, cmdArgs, opts = {}) {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const child = spawn(cmd, cmdArgs, {
|
||||
cwd: ROOT,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...opts,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (b) => (stdout += b.toString()));
|
||||
child.stderr.on("data", (b) => (stderr += b.toString()));
|
||||
child.on("error", rejectPromise);
|
||||
child.on("close", (code) =>
|
||||
resolvePromise({ code: code ?? 1, stdout, stderr }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function fail(msg, code = 2) {
|
||||
console.error(msg);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
if (!existsSync(resolve(ROOT, "node_modules/stylelint"))) {
|
||||
fail(
|
||||
"ui/node_modules missing. Run `npm install` in ui/ before invoking the lint runner.",
|
||||
);
|
||||
}
|
||||
if (!existsSync(resolve(ROOT, "node_modules/eslint"))) {
|
||||
fail(
|
||||
"ui/node_modules missing (eslint). Run `npm install` in ui/ before invoking the lint runner.",
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Stylelint — CSS / SCSS consumer surface
|
||||
// Walk src/ for .css / .scss files; pass explicit paths to npx so
|
||||
// the child process does not need a shell to expand globs. We
|
||||
// exclude the __fixtures__ tree from this scan — it is owned by
|
||||
// the fixture-check script and contains deliberate negative
|
||||
// fixtures that would otherwise fail the gate.
|
||||
const SRC = resolve(ROOT, "src");
|
||||
const styleExts = new Set([".css", ".scss"]);
|
||||
const styleExclude = new Set([resolve(SRC, "__fixtures__")]);
|
||||
function collect(dir, out) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (entry === "node_modules" || entry === "dist") continue;
|
||||
const full = join(dir, entry);
|
||||
if (styleExclude.has(full)) continue;
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) collect(full, out);
|
||||
else if (styleExts.has(extname(entry))) out.push(full);
|
||||
}
|
||||
}
|
||||
const styleTargets = [];
|
||||
collect(SRC, styleTargets);
|
||||
const styleRes = styleTargets.length
|
||||
? await run(
|
||||
"npx",
|
||||
[
|
||||
"--no-install",
|
||||
"stylelint",
|
||||
...styleTargets,
|
||||
"--formatter",
|
||||
JSON_OUT ? "json" : "string",
|
||||
],
|
||||
{ env: { ...process.env, FORCE_COLOR: "0" } },
|
||||
)
|
||||
: { code: 0, stdout: "", stderr: "" };
|
||||
|
||||
// 2. ESLint — JSX / TSX consumer surface
|
||||
const scriptExts = new Set([".ts", ".tsx", ".js", ".jsx"]);
|
||||
const eslintTargets = [];
|
||||
function collectScripts(dir, out) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (entry === "node_modules" || entry === "dist") continue;
|
||||
const full = join(dir, entry);
|
||||
if (styleExclude.has(full)) continue;
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) collectScripts(full, out);
|
||||
else if (scriptExts.has(extname(entry))) out.push(full);
|
||||
}
|
||||
}
|
||||
collectScripts(SRC, eslintTargets);
|
||||
const esRes = eslintTargets.length
|
||||
? await run(
|
||||
"npx",
|
||||
[
|
||||
"--no-install",
|
||||
"eslint",
|
||||
...eslintTargets,
|
||||
"--format",
|
||||
JSON_OUT ? "json" : "stylish",
|
||||
],
|
||||
{ env: { ...process.env, FORCE_COLOR: "0" } },
|
||||
)
|
||||
: { code: 0, stdout: "", stderr: "" };
|
||||
|
||||
// Stylelint writes the JSON formatter payload to stderr (the
|
||||
// formatter "string" payload also lives on stderr). ESLint writes
|
||||
// JSON to stdout. Normalize before parsing.
|
||||
const styleJsonText = styleRes.stderr.trim() || styleRes.stdout.trim();
|
||||
const styleText = styleRes.stderr.trim() || styleRes.stdout.trim();
|
||||
const esJsonText = esRes.stdout.trim();
|
||||
|
||||
// 3. Aggregate. Any Stylelint error or ESLint error with severity 2
|
||||
// fails the gate. Warnings (severity 1) are surfaced but do not block
|
||||
// unless --no-warn is set.
|
||||
const findings = [];
|
||||
function collectStylelint(out) {
|
||||
try {
|
||||
const parsed = JSON.parse(out);
|
||||
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
||||
for (const r of arr) {
|
||||
for (const w of r.warnings || []) {
|
||||
findings.push({
|
||||
source: "stylelint",
|
||||
file: w.source || r.source || "<unknown>",
|
||||
line: w.line,
|
||||
column: w.column,
|
||||
rule: w.rule || "<unknown>",
|
||||
severity: w.severity === "error" ? "error" : "warning",
|
||||
text: w.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// non-JSON output: only surface it if there's actual content
|
||||
// to show. An empty result (no targets ran) is normal and
|
||||
// should not be reported as a runner-level error.
|
||||
const text = out.trim();
|
||||
if (!text) return;
|
||||
findings.push({
|
||||
source: "stylelint",
|
||||
file: "<runner>",
|
||||
severity: "error",
|
||||
text: text.split("\n").slice(0, 5).join("\n"),
|
||||
});
|
||||
}
|
||||
}
|
||||
function collectEslint(out) {
|
||||
try {
|
||||
const parsed = JSON.parse(out);
|
||||
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
||||
for (const r of arr) {
|
||||
for (const m of r.messages || []) {
|
||||
findings.push({
|
||||
source: "eslint",
|
||||
file: r.filePath || "<unknown>",
|
||||
line: m.line,
|
||||
column: m.column,
|
||||
rule: m.ruleId || "<rule>",
|
||||
severity: m.severity === 2 ? "error" : "warning",
|
||||
text: m.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const text = out.trim();
|
||||
if (!text) return;
|
||||
findings.push({
|
||||
source: "eslint",
|
||||
file: "<runner>",
|
||||
severity: "error",
|
||||
text: text.split("\n").slice(0, 5).join("\n"),
|
||||
});
|
||||
}
|
||||
}
|
||||
collectStylelint(styleJsonText);
|
||||
collectEslint(esJsonText);
|
||||
|
||||
// Surface runner stderr even on success (e.g., deprecation warnings).
|
||||
if (styleRes.stderr.trim()) process.stderr.write(styleRes.stderr);
|
||||
if (esRes.stderr.trim()) process.stderr.write(esRes.stderr);
|
||||
|
||||
const errors = findings.filter(
|
||||
(f) => f.severity === "error" || (NO_WARN && f.severity === "warning"),
|
||||
);
|
||||
const warnings = findings.filter(
|
||||
(f) => f.severity === "warning" && !NO_WARN,
|
||||
);
|
||||
|
||||
if (JSON_OUT) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ errors, warnings, counts: { errors: errors.length, warnings: warnings.length } },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const ruleCounts = findings.reduce((acc, f) => {
|
||||
const k = f.rule;
|
||||
acc[k] = (acc[k] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
console.log("");
|
||||
console.log("Design-system lint summary");
|
||||
console.log("==========================");
|
||||
console.log(` errors: ${errors.length}`);
|
||||
console.log(` warnings: ${warnings.length}`);
|
||||
if (Object.keys(ruleCounts).length) {
|
||||
console.log(" by rule:");
|
||||
for (const [k, v] of Object.entries(ruleCounts).sort()) {
|
||||
console.log(` ${k.padEnd(40)} ${v}`);
|
||||
}
|
||||
}
|
||||
for (const f of errors) {
|
||||
console.log(
|
||||
` [${f.source}] ${f.file}:${f.line}:${f.column} ${f.rule} ${f.text}`,
|
||||
);
|
||||
}
|
||||
for (const f of warnings) {
|
||||
console.log(
|
||||
` [${f.source}] ${f.file}:${f.line}:${f.column} ${f.rule} ${f.text}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) process.exit(1);
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PostCSS plugin that resolves `@media responsive(<breakpoint>)` at-rules
|
||||
* to standard `@media (min-width: <value>)` queries.
|
||||
*
|
||||
* Also resolves `@media responsive(<bp>) and (max-width: <bp-max>)` patterns.
|
||||
*
|
||||
* Breakpoint map comes from the design-system tokens (docs/design/tokens.json).
|
||||
*
|
||||
* Usage:
|
||||
* postcss([responsiveMedia()]).process(css)
|
||||
*
|
||||
* Or via postcss.config.cjs:
|
||||
* plugins: [responsiveMedia()]
|
||||
*/
|
||||
|
||||
const BREAKPOINTS = {
|
||||
sm: "640px",
|
||||
md: "768px",
|
||||
lg: "1024px",
|
||||
xl: "1280px",
|
||||
"2xl": "1536px",
|
||||
};
|
||||
|
||||
const responsiveMediaPlugin = () => ({
|
||||
postcssPlugin: "postcss-responsive-media",
|
||||
AtRule: {
|
||||
responsive: (atRule) => {
|
||||
const params = atRule.params.trim();
|
||||
|
||||
// Pattern: responsive(sm)
|
||||
const singleMatch = params.match(/^(\w+)$/);
|
||||
if (singleMatch) {
|
||||
const bp = singleMatch[1];
|
||||
const value = BREAKPOINTS[bp];
|
||||
if (!value) {
|
||||
throw atRule.error(`Unknown breakpoint: ${bp}. Available: ${Object.keys(BREAKPOINTS).join(", ")}`);
|
||||
}
|
||||
atRule.name = "media";
|
||||
atRule.params = `(min-width: ${value})`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Pattern: responsive(sm, max: lg)
|
||||
const rangeMatch = params.match(/^(\w+)\s*,\s*max:\s*(\w+)$/);
|
||||
if (rangeMatch) {
|
||||
const minBp = rangeMatch[1];
|
||||
const maxBp = rangeMatch[2];
|
||||
const minVal = BREAKPOINTS[minBp];
|
||||
const maxVal = BREAKPOINTS[maxBp];
|
||||
if (!minVal) throw atRule.error(`Unknown breakpoint: ${minBp}`);
|
||||
if (!maxVal) throw atRule.error(`Unknown breakpoint: ${maxBp}`);
|
||||
const maxValue = parseInt(maxVal, 10) - 1;
|
||||
atRule.name = "media";
|
||||
atRule.params = `(min-width: ${minVal}) and (max-width: ${maxValue}px)`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Pattern: responsive(max: md)
|
||||
const maxOnlyMatch = params.match(/^max:\s*(\w+)$/);
|
||||
if (maxOnlyMatch) {
|
||||
const maxBp = maxOnlyMatch[1];
|
||||
const maxVal = BREAKPOINTS[maxBp];
|
||||
if (!maxVal) throw atRule.error(`Unknown breakpoint: ${maxBp}`);
|
||||
const maxValue = parseInt(maxVal, 10) - 1;
|
||||
atRule.name = "media";
|
||||
atRule.params = `(max-width: ${maxValue}px)`;
|
||||
return;
|
||||
}
|
||||
|
||||
throw atRule.error(
|
||||
`Invalid @responsive syntax: "${params}". Use responsive(sm), responsive(sm, max: md), or responsive(max: md).`
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
responsiveMediaPlugin.postcss = true;
|
||||
|
||||
module.exports = responsiveMediaPlugin;
|
||||
@@ -0,0 +1,4 @@
|
||||
/* DS-B001 negative */
|
||||
@media (min-width: 700px) {
|
||||
.bad { display: flex; }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* DS-C001 negative */
|
||||
.bad {
|
||||
color: #ff0000;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const Bad = () => <div style={{ color: 'red' }}>x</div>;
|
||||
@@ -0,0 +1 @@
|
||||
export const Bad = () => <svg fill="#abcdef"><circle /></svg>;
|
||||
@@ -0,0 +1,4 @@
|
||||
/* DS-C004 negative */
|
||||
.bad {
|
||||
background: opacity(#2563eb, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* DS-R001 negative */
|
||||
.bad {
|
||||
border-radius: 5px;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* DS-S001 negative */
|
||||
.bad {
|
||||
margin: 13px;
|
||||
padding: 7px;
|
||||
gap: 11px;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* DS-SH001 negative */
|
||||
.bad {
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* DS-T001 negative */
|
||||
.bad {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const Bad = () => <div style={{ fontFamily: 'Comic Sans MS' }}>x</div>;
|
||||
@@ -0,0 +1,4 @@
|
||||
/* DS-Z001 negative */
|
||||
.bad {
|
||||
z-index: 9999;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* DS-B001 positive */
|
||||
@media (min-width: var(--breakpoint-md)) {
|
||||
.good { display: flex; }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* DS-C001 positive */
|
||||
.good {
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const Good = () => <div className="text-primary">x</div>;
|
||||
@@ -0,0 +1 @@
|
||||
export const Good = () => <svg fill="currentColor"><circle /></svg>;
|
||||
@@ -0,0 +1,4 @@
|
||||
/* DS-C004 positive */
|
||||
.good {
|
||||
background: var(--color-brand-primary-light);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* DS-R001 positive */
|
||||
.good {
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* DS-S001 positive */
|
||||
.good {
|
||||
margin: var(--spacing-4);
|
||||
padding: var(--spacing-2);
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* DS-SH001 positive */
|
||||
.good {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* DS-T001 positive */
|
||||
.good {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const Good = () => <div className="font-sans">x</div>;
|
||||
@@ -0,0 +1,4 @@
|
||||
/* DS-Z001 positive */
|
||||
.good {
|
||||
z-index: var(--z-index-modal);
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
@layer components {
|
||||
.ops-console {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--color-surface-background);
|
||||
font-family: var(--font-family-sans);
|
||||
}
|
||||
|
||||
.ops-console__sidebar {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
background: var(--color-neutral-white);
|
||||
border-right: 1px solid var(--color-neutral-gray-200);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--spacing-6) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.ops-console__nav-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-3) var(--spacing-6);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-neutral-gray-700);
|
||||
font-size: var(--font-size-base);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.ops-console__nav-item:hover {
|
||||
background: var(--color-neutral-gray-50);
|
||||
}
|
||||
|
||||
.ops-console__nav-item--active {
|
||||
background: var(--color-brand-primary-light);
|
||||
color: var(--color-brand-primary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.ops-console__nav-separator {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-neutral-gray-200);
|
||||
margin: var(--spacing-4) var(--spacing-6);
|
||||
}
|
||||
|
||||
.ops-console__content {
|
||||
flex: 1;
|
||||
padding: var(--spacing-6);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ops-console__dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.ops-console__metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.ops-console__metric-card {
|
||||
background: var(--color-surface-card);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: var(--spacing-4);
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
transition: box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.ops-console__metric-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ops-console__metric-label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-neutral-gray-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--letter-spacing-wide);
|
||||
}
|
||||
|
||||
.ops-console__metric-value {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.ops-console__metric-unit {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--color-neutral-gray-500);
|
||||
}
|
||||
|
||||
.ops-console__metric-card--info .ops-console__metric-value {
|
||||
color: var(--color-semantic-info);
|
||||
}
|
||||
|
||||
.ops-console__metric-card--success .ops-console__metric-value {
|
||||
color: var(--color-semantic-success);
|
||||
}
|
||||
|
||||
.ops-console__metric-card--warning .ops-console__metric-value {
|
||||
color: var(--color-semantic-warning);
|
||||
}
|
||||
|
||||
.ops-console__metric-card--error .ops-console__metric-value {
|
||||
color: var(--color-semantic-error);
|
||||
}
|
||||
|
||||
.ops-console__section-heading {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin: 0 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
.ops-console__error-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.ops-console__error-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
background: var(--color-surface-card);
|
||||
border-radius: var(--border-radius-md);
|
||||
border-left: 3px solid var(--color-semantic-error);
|
||||
}
|
||||
|
||||
.ops-console__error-type {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-semantic-error);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.ops-console__error-msg-id {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-neutral-gray-700);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ops-console__error-time {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-neutral-gray-500);
|
||||
}
|
||||
|
||||
.ops-console__filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
margin-bottom: var(--spacing-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ops-console__search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
border: 1px solid var(--color-neutral-gray-300);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.ops-console__search:focus {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ops-console__filter-chips {
|
||||
display: flex;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.ops-console__filter-chip {
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border: 1px solid var(--color-neutral-gray-300);
|
||||
border-radius: var(--border-radius-md);
|
||||
background: var(--color-neutral-white);
|
||||
color: var(--color-neutral-gray-700);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.ops-console__filter-chip--active {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary-on);
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.ops-console__filter-chip--info.ops-console__filter-chip--active {
|
||||
background: var(--color-semantic-info);
|
||||
}
|
||||
|
||||
.ops-console__filter-chip--warn.ops-console__filter-chip--active {
|
||||
background: var(--color-semantic-warning);
|
||||
}
|
||||
|
||||
.ops-console__filter-chip--error.ops-console__filter-chip--active {
|
||||
background: var(--color-semantic-error);
|
||||
}
|
||||
|
||||
.ops-console__log-table,
|
||||
.ops-console__config-table,
|
||||
.ops-console__users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.ops-console__table-caption {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.ops-console__log-table th,
|
||||
.ops-console__log-table td,
|
||||
.ops-console__config-table th,
|
||||
.ops-console__config-table td,
|
||||
.ops-console__users-table th,
|
||||
.ops-console__users-table td {
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-neutral-gray-200);
|
||||
}
|
||||
|
||||
.ops-console__log-table thead th,
|
||||
.ops-console__config-table thead th,
|
||||
.ops-console__users-table thead th {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-neutral-gray-500);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.ops-console__log-row {
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.ops-console__log-row:hover {
|
||||
background: var(--color-neutral-gray-50);
|
||||
}
|
||||
|
||||
.ops-console__log-row:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.ops-console__log-message {
|
||||
font-family: var(--font-family-mono);
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ops-console__log-level {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.ops-console__log-level--info {
|
||||
color: var(--color-neutral-gray-500);
|
||||
}
|
||||
|
||||
.ops-console__log-level--warn {
|
||||
color: var(--color-semantic-warning);
|
||||
}
|
||||
|
||||
.ops-console__log-level--error {
|
||||
color: var(--color-semantic-error);
|
||||
}
|
||||
|
||||
.ops-console__log-detail {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-neutral-gray-50);
|
||||
padding: var(--spacing-3);
|
||||
border-radius: var(--border-radius-md);
|
||||
margin: var(--spacing-2) 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ops-console__load-more {
|
||||
display: block;
|
||||
margin: var(--spacing-4) auto;
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border: 1px solid var(--color-neutral-gray-300);
|
||||
border-radius: var(--border-radius-md);
|
||||
background: var(--color-neutral-white);
|
||||
color: var(--color-neutral-gray-700);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.ops-console__config-key {
|
||||
font-family: var(--font-family-mono);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.ops-console__config-value {
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.ops-console__config-edit-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.ops-console__config-input {
|
||||
flex: 1;
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border: 1px solid var(--color-neutral-gray-300);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.ops-console__config-input:focus {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ops-console__config-save,
|
||||
.ops-console__config-cancel,
|
||||
.ops-console__config-edit-btn {
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.ops-console__config-save {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary-on);
|
||||
}
|
||||
|
||||
.ops-console__config-cancel {
|
||||
background: var(--color-neutral-gray-100);
|
||||
color: var(--color-neutral-gray-700);
|
||||
border-color: var(--color-neutral-gray-300);
|
||||
}
|
||||
|
||||
.ops-console__config-edit-btn {
|
||||
background: transparent;
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ops-console__role-select {
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border: 1px solid var(--color-neutral-gray-300);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.ops-console__role-select:focus {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ops-console__confirm-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-surface-overlay);
|
||||
z-index: var(--z-index-modal);
|
||||
padding: var(--spacing-6);
|
||||
}
|
||||
|
||||
.ops-console__confirm-modal > * {
|
||||
background: var(--color-neutral-white);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: var(--spacing-6);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ops-console__confirm-modal h3 {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.ops-console__confirm-modal p {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
color: var(--color-neutral-gray-700);
|
||||
}
|
||||
|
||||
.ops-console__confirm-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ops-console__confirm-btn {
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.ops-console__confirm-btn--primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary-on);
|
||||
}
|
||||
|
||||
.ops-console__confirm-btn--primary:hover {
|
||||
background: var(--color-brand-primary-hover);
|
||||
}
|
||||
|
||||
.ops-console__confirm-btn--cancel {
|
||||
background: var(--color-neutral-gray-100);
|
||||
color: var(--color-neutral-gray-700);
|
||||
border-color: var(--color-neutral-gray-300);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.ops-console {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ops-console__sidebar {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-neutral-gray-200);
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ops-console__nav-separator {
|
||||
border-top: none;
|
||||
border-left: 1px solid var(--color-neutral-gray-200);
|
||||
margin: 0 var(--spacing-2);
|
||||
height: auto;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.ops-console__metric-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.ops-console__metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ops-console__content {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { OpsConsole } from "./OpsConsole";
|
||||
|
||||
const meta: Meta<typeof OpsConsole> = {
|
||||
title: "Admin/OpsConsole",
|
||||
component: OpsConsole,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [
|
||||
{ id: "button-name", enabled: true },
|
||||
{ id: "label", enabled: true },
|
||||
{ id: "aria-dialog-name", enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
onNavigate: { action: "navigate" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof OpsConsole>;
|
||||
|
||||
export const Dashboard: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const WithNavigation: Story = {
|
||||
args: {
|
||||
onNavigate: (route: string) => console.log(`Navigate to ${route}`),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,331 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { OpsConsole, formatLatency, formatTimestamp } from "./OpsConsole";
|
||||
|
||||
vi.mock("../api/admin", () => ({
|
||||
getMetrics: vi.fn(),
|
||||
listLogs: vi.fn(),
|
||||
getConfig: vi.fn(),
|
||||
patchConfig: vi.fn(),
|
||||
listUsers: vi.fn(),
|
||||
patchUserRole: vi.fn(),
|
||||
AdminApiError: class extends Error {
|
||||
status: number;
|
||||
body: { error: string; code: string };
|
||||
constructor(status: number, body: { error: string; code: string }) {
|
||||
super(body.error);
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
this.name = "AdminApiError";
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getMetrics,
|
||||
listLogs,
|
||||
getConfig,
|
||||
patchConfig,
|
||||
listUsers,
|
||||
patchUserRole,
|
||||
} from "../api/admin";
|
||||
|
||||
const mockMetrics = {
|
||||
ingestion_rate_5m: 47,
|
||||
error_count_5m: 2,
|
||||
system_latency_p99: 5230,
|
||||
recent_errors: [
|
||||
{ event_type: "extract_permanent_failure", message_id: "msg-abc123", timestamp: new Date(Date.now() - 120000).toISOString(), message: "Extraction failed" },
|
||||
{ event_type: "classify_error", message_id: "msg-def456", timestamp: new Date(Date.now() - 300000).toISOString(), message: "Classification error" },
|
||||
],
|
||||
};
|
||||
|
||||
const mockLogs: LogEntry[] = [
|
||||
{
|
||||
id: "log-1",
|
||||
timestamp: "2026-06-11T03:00:00Z",
|
||||
level: "INFO",
|
||||
pipeline: "live",
|
||||
event_type: "message_classified",
|
||||
message: "Classified msg-001 as finance",
|
||||
message_id: "msg-001",
|
||||
detail: { confidence: 0.95 },
|
||||
},
|
||||
{
|
||||
id: "log-2",
|
||||
timestamp: "2026-06-11T02:59:00Z",
|
||||
level: "ERROR",
|
||||
pipeline: "historical",
|
||||
event_type: "extract_permanent_failure",
|
||||
message: "Failed to extract msg-002",
|
||||
message_id: "msg-002",
|
||||
detail: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockConfig: ConfigEntry[] = [
|
||||
{ key: "INBOXOS_LLM_MODEL", value: "gemma4:e4b", mutable: true, updated_at: "2026-06-10T12:00:00Z", updated_by: "admin@inboxos.local" },
|
||||
{ key: "INBOXOS_LLM_MAX_TOKENS", value: "4096", mutable: true, updated_at: "2026-06-10T12:00:00Z", updated_by: "admin@inboxos.local" },
|
||||
{ key: "NOCODB_BASE_URL", value: "https://noco.example.com", mutable: false, updated_at: "2026-06-01T00:00:00Z", updated_by: "system" },
|
||||
];
|
||||
|
||||
const mockUsers: UserEntry[] = [
|
||||
{ id: "user-1", email: "owner@inboxos.local", role: "owner", assigned_at: "2026-06-01T00:00:00Z", assigned_by: "system" },
|
||||
{ id: "user-2", email: "admin@inboxos.local", role: "admin", assigned_at: "2026-06-01T00:00:00Z", assigned_by: "owner@inboxos.local" },
|
||||
{ id: "user-3", email: "op@inboxos.local", role: "operator", assigned_at: "2026-06-05T00:00:00Z", assigned_by: "admin@inboxos.local" },
|
||||
];
|
||||
|
||||
import type { LogEntry, ConfigEntry, UserEntry } from "../api/admin";
|
||||
|
||||
describe("OpsConsole", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
(getMetrics as ReturnType<typeof vi.fn>).mockResolvedValue(mockMetrics);
|
||||
(listLogs as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: mockLogs,
|
||||
pagination: { limit: 50, next_cursor: null, has_more: false },
|
||||
});
|
||||
(getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockConfig);
|
||||
(listUsers as ReturnType<typeof vi.fn>).mockResolvedValue(mockUsers);
|
||||
(patchConfig as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...mockConfig[0],
|
||||
value: "new-value",
|
||||
updated_at: "2026-06-11T04:00:00Z",
|
||||
});
|
||||
(patchUserRole as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...mockUsers[1],
|
||||
role: "operator",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders the dashboard view by default", async () => {
|
||||
render(<OpsConsole />);
|
||||
expect(await screen.findByRole("region", { name: "System metrics" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders metric cards with correct values", async () => {
|
||||
render(<OpsConsole />);
|
||||
const ingestionCard = await screen.findByRole("region", { name: "Ingestion rate" });
|
||||
expect(ingestionCard).toBeInTheDocument();
|
||||
expect(within(ingestionCard).getByText("47")).toBeInTheDocument();
|
||||
const errorCard = screen.getByRole("region", { name: "Error count" });
|
||||
expect(errorCard).toBeInTheDocument();
|
||||
expect(within(errorCard).getByText("2")).toBeInTheDocument();
|
||||
const latencyCard = screen.getByRole("region", { name: "System latency p99" });
|
||||
expect(latencyCard).toBeInTheDocument();
|
||||
expect(within(latencyCard).getByText("5.2s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders recent errors section", async () => {
|
||||
render(<OpsConsole />);
|
||||
const section = await screen.findByRole("region", { name: "System metrics" });
|
||||
expect(within(section).getAllByText("Recent Errors").length).toBeGreaterThan(0);
|
||||
expect(within(section).getByText("extract_permanent_failure")).toBeInTheDocument();
|
||||
expect(within(section).getByText("classify_error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no errors", async () => {
|
||||
(getMetrics as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...mockMetrics,
|
||||
error_count_5m: 0,
|
||||
recent_errors: [],
|
||||
});
|
||||
render(<OpsConsole />);
|
||||
expect(await screen.findByText("No errors in the last 5 minutes.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes metrics on interval", async () => {
|
||||
render(<OpsConsole />);
|
||||
await screen.findByRole("region", { name: "System metrics" });
|
||||
expect(getMetrics).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(30000);
|
||||
await waitFor(() => {
|
||||
expect(getMetrics).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to logs view", async () => {
|
||||
render(<OpsConsole />);
|
||||
await screen.findByRole("region", { name: "System metrics" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Logs" }));
|
||||
expect(listLogs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("navigates to config view", async () => {
|
||||
render(<OpsConsole />);
|
||||
await screen.findByRole("region", { name: "System metrics" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Config" }));
|
||||
await waitFor(() => {
|
||||
expect(getConfig).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to users view", async () => {
|
||||
render(<OpsConsole />);
|
||||
await screen.findByRole("region", { name: "System metrics" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Users" }));
|
||||
await waitFor(() => {
|
||||
expect(listUsers).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders logs table with filter controls", async () => {
|
||||
render(<OpsConsole />);
|
||||
await screen.findByRole("region", { name: "System metrics" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Logs" }));
|
||||
expect(await screen.findByRole("table", { name: "System log entries" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("searchbox", { name: "Search logs" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("group", { name: "Filter by log level" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("group", { name: "Filter by pipeline" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands log row detail on click", async () => {
|
||||
render(<OpsConsole />);
|
||||
await screen.findByRole("region", { name: "System metrics" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Logs" }));
|
||||
const table = await screen.findByRole("table", { name: "System log entries" });
|
||||
const row = within(table).getByText("message_classified").closest("tr");
|
||||
expect(row).toBeInTheDocument();
|
||||
if (row) {
|
||||
await userEvent.click(row);
|
||||
expect(screen.getByText(/confidence/)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders config table with edit capability", async () => {
|
||||
render(<OpsConsole />);
|
||||
await screen.findByRole("region", { name: "System metrics" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Config" }));
|
||||
const table = await screen.findByRole("table", { name: /configuration/i });
|
||||
expect(table).toBeInTheDocument();
|
||||
expect(screen.getByText("INBOXOS_LLM_MODEL")).toBeInTheDocument();
|
||||
expect(screen.getByText("gemma4:e4b")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("edits mutable config values", async () => {
|
||||
render(<OpsConsole />);
|
||||
await screen.findByRole("region", { name: "System metrics" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Config" }));
|
||||
await screen.findByRole("table", { name: /configuration/i });
|
||||
const editButtons = screen.getAllByText("Edit");
|
||||
expect(editButtons.length).toBeGreaterThan(0);
|
||||
await userEvent.click(editButtons[0]);
|
||||
expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show edit button for immutable config", async () => {
|
||||
render(<OpsConsole />);
|
||||
await screen.findByRole("region", { name: "System metrics" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Config" }));
|
||||
await screen.findByRole("table", { name: /configuration/i });
|
||||
expect(screen.getByText("NOCODB_BASE_URL")).toBeInTheDocument();
|
||||
const row = screen.getByText("NOCODB_BASE_URL").closest("tr");
|
||||
expect(row).toBeInTheDocument();
|
||||
expect(within(row!).queryByText("Edit")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders users table with role change", async () => {
|
||||
render(<OpsConsole />);
|
||||
await screen.findByRole("region", { name: "System metrics" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Users" }));
|
||||
const table = await screen.findByRole("table", { name: /users/i });
|
||||
expect(table).toBeInTheDocument();
|
||||
expect(within(table).getAllByText("owner@inboxos.local").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows confirmation dialog for role change", async () => {
|
||||
render(<OpsConsole />);
|
||||
await screen.findByRole("region", { name: "System metrics" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Users" }));
|
||||
await screen.findByRole("table", { name: /users/i });
|
||||
const selects = screen.getAllByRole("combobox");
|
||||
expect(selects.length).toBe(3);
|
||||
fireEvent.change(selects[1], { target: { value: "operator" } });
|
||||
const dialog = await screen.findByRole("dialog", { name: /confirm role/i });
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/operator/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onNavigate when Settings is clicked", async () => {
|
||||
const onNavigate = vi.fn();
|
||||
render(<OpsConsole onNavigate={onNavigate} />);
|
||||
await screen.findByRole("region", { name: "System metrics" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Settings" }));
|
||||
expect(onNavigate).toHaveBeenCalledWith("/settings");
|
||||
});
|
||||
|
||||
it("shows error state when metrics fail", async () => {
|
||||
(getMetrics as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Network error")
|
||||
);
|
||||
render(<OpsConsole />);
|
||||
expect(await screen.findByText("Unable to load metrics")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no logs match filter", async () => {
|
||||
(listLogs as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { limit: 50, next_cursor: null, has_more: false },
|
||||
});
|
||||
render(<OpsConsole />);
|
||||
await screen.findByRole("region", { name: "System metrics" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Logs" }));
|
||||
expect(await screen.findByText("No log entries match your filter.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has correct sidebar navigation accessibility", async () => {
|
||||
render(<OpsConsole />);
|
||||
const nav = await screen.findByRole("navigation", { name: "Admin navigation" });
|
||||
expect(nav).toBeInTheDocument();
|
||||
const dashboardButton = within(nav).getByRole("button", { name: "Dashboard" });
|
||||
expect(dashboardButton).toHaveAttribute("aria-current", "page");
|
||||
await userEvent.click(within(nav).getByRole("button", { name: "Logs" }));
|
||||
expect(within(nav).getByRole("button", { name: "Logs" })).toHaveAttribute("aria-current", "page");
|
||||
expect(dashboardButton).not.toHaveAttribute("aria-current");
|
||||
});
|
||||
|
||||
it("formats latency correctly", () => {
|
||||
expect(formatLatency(500)).toBe("500ms");
|
||||
expect(formatLatency(1500)).toBe("1.5s");
|
||||
expect(formatLatency(10000)).toBe("10.0s");
|
||||
});
|
||||
|
||||
it("formats timestamps correctly", () => {
|
||||
const result = formatTimestamp("2026-06-11T03:00:00Z");
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows load more button when has_more logs", async () => {
|
||||
(listLogs as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: mockLogs,
|
||||
pagination: { limit: 50, next_cursor: "cursor-123", has_more: true },
|
||||
});
|
||||
render(<OpsConsole />);
|
||||
await screen.findByRole("region", { name: "System metrics" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Logs" }));
|
||||
expect(await screen.findByRole("button", { name: /load more/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders skip-to-content accessibility structure", async () => {
|
||||
render(<OpsConsole />);
|
||||
const main = await screen.findByRole("region", { name: "Admin operations console" });
|
||||
expect(main).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keyboard navigates log rows with Enter", async () => {
|
||||
render(<OpsConsole />);
|
||||
await screen.findByRole("region", { name: "System metrics" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Logs" }));
|
||||
const table = await screen.findByRole("table", { name: "System log entries" });
|
||||
const row = within(table).getAllByRole("row")[1];
|
||||
fireEvent.keyDown(row, { key: "Enter" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,694 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
getMetrics,
|
||||
listLogs,
|
||||
getConfig,
|
||||
patchConfig,
|
||||
listUsers,
|
||||
patchUserRole,
|
||||
AdminApiError,
|
||||
} from "../api/admin";
|
||||
import type {
|
||||
MetricsData,
|
||||
LogEntry,
|
||||
LogListParams,
|
||||
ConfigEntry,
|
||||
ConfigUpdatePayload,
|
||||
UserEntry,
|
||||
RoleChangePayload,
|
||||
} from "../api/admin";
|
||||
import { useErrorReporting, usePerformanceMetrics } from "../observability";
|
||||
import { Loading, EmptyState, StatusMessage } from "../feedback/StatusMessage";
|
||||
import "./OpsConsole.css";
|
||||
|
||||
type AdminView = "dashboard" | "logs" | "config" | "users";
|
||||
|
||||
export interface OpsConsoleProps {
|
||||
onNavigate?: (route: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const METRICS_REFRESH_MS = 30000;
|
||||
|
||||
export function OpsConsole({ onNavigate, className }: OpsConsoleProps) {
|
||||
const { reportError } = useErrorReporting("OpsConsole");
|
||||
const { reportRenderTime } = usePerformanceMetrics("OpsConsole");
|
||||
|
||||
const [activeView, setActiveView] = useState<AdminView>("dashboard");
|
||||
const [metrics, setMetrics] = useState<MetricsData | null>(null);
|
||||
const [metricsLoading, setMetricsLoading] = useState(true);
|
||||
const [metricsError, setMetricsError] = useState<string | null>(null);
|
||||
const metricsTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const fetchMetrics = useCallback(async () => {
|
||||
try {
|
||||
setMetricsLoading(true);
|
||||
setMetricsError(null);
|
||||
const data = await getMetrics();
|
||||
setMetrics(data);
|
||||
reportRenderTime("fetch-metrics");
|
||||
} catch (err) {
|
||||
if (err instanceof AdminApiError) {
|
||||
reportError(err, { code: err.body.code });
|
||||
} else {
|
||||
reportError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
setMetricsError(err instanceof Error ? err.message : "Failed to load metrics");
|
||||
} finally {
|
||||
setMetricsLoading(false);
|
||||
}
|
||||
}, [reportError, reportRenderTime]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics();
|
||||
metricsTimerRef.current = setInterval(fetchMetrics, METRICS_REFRESH_MS);
|
||||
return () => {
|
||||
if (metricsTimerRef.current) clearInterval(metricsTimerRef.current);
|
||||
};
|
||||
}, [fetchMetrics]);
|
||||
|
||||
const sidebarItems: { key: AdminView; label: string }[] = [
|
||||
{ key: "dashboard", label: "Dashboard" },
|
||||
{ key: "logs", label: "Logs" },
|
||||
{ key: "config", label: "Config" },
|
||||
{ key: "users", label: "Users" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={["ops-console", className].filter(Boolean).join(" ")} role="region" aria-label="Admin operations console">
|
||||
<aside className="ops-console__sidebar" role="navigation" aria-label="Admin navigation">
|
||||
{sidebarItems.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={[
|
||||
"ops-console__nav-item",
|
||||
activeView === item.key && "ops-console__nav-item--active",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onClick={() => setActiveView(item.key)}
|
||||
aria-current={activeView === item.key ? "page" : undefined}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
<hr className="ops-console__nav-separator" />
|
||||
<button
|
||||
className="ops-console__nav-item"
|
||||
onClick={() => onNavigate?.("/settings")}
|
||||
type="button"
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="ops-console__content">
|
||||
{activeView === "dashboard" && (
|
||||
<DashboardView
|
||||
metrics={metrics}
|
||||
loading={metricsLoading}
|
||||
error={metricsError}
|
||||
onRetry={fetchMetrics}
|
||||
/>
|
||||
)}
|
||||
{activeView === "logs" && <LogsView />}
|
||||
{activeView === "config" && <ConfigView />}
|
||||
{activeView === "users" && <UsersView />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardView({
|
||||
metrics,
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
metrics: MetricsData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
if (loading && !metrics) return <Loading label="Loading metrics" />;
|
||||
if (error && !metrics) {
|
||||
return (
|
||||
<StatusMessage
|
||||
variant="error"
|
||||
title="Unable to load metrics"
|
||||
description="Retrying in 30 seconds."
|
||||
action={{ label: "Retry", onClick: onRetry } as never}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!metrics) return null;
|
||||
|
||||
return (
|
||||
<section className="ops-console__dashboard" aria-label="System metrics">
|
||||
<div className="ops-console__metric-grid">
|
||||
<MetricCard
|
||||
label="Ingestion Rate (5m)"
|
||||
value={String(metrics.ingestion_rate_5m)}
|
||||
unit="msg"
|
||||
variant={metrics.ingestion_rate_5m > 0 ? "info" : "success"}
|
||||
ariaLabel="Ingestion rate"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Error Count (5m)"
|
||||
value={String(metrics.error_count_5m)}
|
||||
unit="err"
|
||||
variant={metrics.error_count_5m > 0 ? "error" : "success"}
|
||||
ariaLabel="Error count"
|
||||
/>
|
||||
<MetricCard
|
||||
label="System Latency p99"
|
||||
value={formatLatency(metrics.system_latency_p99)}
|
||||
unit=""
|
||||
variant={metrics.system_latency_p99 > 10000 ? "warning" : "success"}
|
||||
ariaLabel="System latency p99"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Recent Errors"
|
||||
value={String(metrics.recent_errors.length)}
|
||||
unit="events"
|
||||
variant={metrics.recent_errors.length > 0 ? "error" : "success"}
|
||||
ariaLabel="Recent errors"
|
||||
/>
|
||||
</div>
|
||||
{metrics.recent_errors.length > 0 && (
|
||||
<div className="ops-console__recent-errors">
|
||||
<h3 className="ops-console__section-heading">Recent Errors</h3>
|
||||
<ul className="ops-console__error-list" role="list">
|
||||
{metrics.recent_errors.map((err) => (
|
||||
<li key={`${err.event_type}-${err.message_id}`} className="ops-console__error-item">
|
||||
<span className="ops-console__error-type" aria-label="Error type">
|
||||
{err.event_type}
|
||||
</span>
|
||||
<span className="ops-console__error-msg-id">
|
||||
{err.message_id}
|
||||
</span>
|
||||
<time className="ops-console__error-time" dateTime={err.timestamp}>
|
||||
{formatTimeAgo(err.timestamp)}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{metrics.recent_errors.length === 0 && (
|
||||
<EmptyState title="No errors in the last 5 minutes." />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsView() {
|
||||
const { reportError } = useErrorReporting("LogsView");
|
||||
const { reportRenderTime } = usePerformanceMetrics("LogsView");
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filterLevel, setFilterLevel] = useState<("INFO" | "WARN" | "ERROR")[]>([]);
|
||||
const [filterPipeline, setFilterPipeline] = useState<("live" | "historical")[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
|
||||
const fetchLogs = useCallback(
|
||||
async (params?: Partial<LogListParams> & { append?: boolean }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const listParams: LogListParams = {
|
||||
level: filterLevel.length > 0 ? filterLevel : undefined,
|
||||
pipeline: filterPipeline.length > 0 ? filterPipeline : undefined,
|
||||
q: searchQuery || undefined,
|
||||
limit: 50,
|
||||
cursor: params?.cursor || undefined,
|
||||
};
|
||||
const result = await listLogs(listParams);
|
||||
if (params?.append) {
|
||||
setLogs((prev) => [...prev, ...result.data]);
|
||||
} else {
|
||||
setLogs(result.data);
|
||||
}
|
||||
setNextCursor(result.pagination.next_cursor);
|
||||
setHasMore(result.pagination.has_more);
|
||||
reportRenderTime("fetch-logs");
|
||||
} catch (err) {
|
||||
if (err instanceof AdminApiError) {
|
||||
reportError(err, { code: err.body.code });
|
||||
} else {
|
||||
reportError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to load logs");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[filterLevel, filterPipeline, searchQuery, reportError, reportRenderTime]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [filterLevel, filterPipeline]);
|
||||
|
||||
const toggleLevel = (level: "INFO" | "WARN" | "ERROR") => {
|
||||
setFilterLevel((prev) =>
|
||||
prev.includes(level) ? prev.filter((l) => l !== level) : [...prev, level]
|
||||
);
|
||||
};
|
||||
|
||||
const togglePipeline = (pipeline: "live" | "historical") => {
|
||||
setFilterPipeline((prev) =>
|
||||
prev.includes(pipeline) ? prev.filter((p) => p !== pipeline) : [...prev, pipeline]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="ops-console__logs" aria-label="System logs">
|
||||
<div className="ops-console__filter-bar">
|
||||
<input
|
||||
className="ops-console__search"
|
||||
type="search"
|
||||
placeholder="Search logs…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
aria-label="Search logs"
|
||||
/>
|
||||
<div className="ops-console__filter-chips" role="group" aria-label="Filter by log level">
|
||||
{(["INFO", "WARN", "ERROR"] as const).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
className={[
|
||||
"ops-console__filter-chip",
|
||||
`ops-console__filter-chip--${level.toLowerCase()}`,
|
||||
filterLevel.includes(level) && "ops-console__filter-chip--active",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onClick={() => toggleLevel(level)}
|
||||
aria-pressed={filterLevel.includes(level)}
|
||||
type="button"
|
||||
>
|
||||
{level}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ops-console__filter-chips" role="group" aria-label="Filter by pipeline">
|
||||
{(["live", "historical"] as const).map((pipeline) => (
|
||||
<button
|
||||
key={pipeline}
|
||||
className={[
|
||||
"ops-console__filter-chip",
|
||||
filterPipeline.includes(pipeline) && "ops-console__filter-chip--active",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onClick={() => togglePipeline(pipeline)}
|
||||
aria-pressed={filterPipeline.includes(pipeline)}
|
||||
type="button"
|
||||
>
|
||||
{pipeline}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && logs.length === 0 ? (
|
||||
<Loading label="Loading logs" />
|
||||
) : error && logs.length === 0 ? (
|
||||
<EmptyState title="Unable to load logs" description={error} />
|
||||
) : logs.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No log entries match your filter."
|
||||
action={{ label: "Clear filters", onClick: () => { setFilterLevel([]); setFilterPipeline([]); setSearchQuery(""); } }}
|
||||
/>
|
||||
) : (
|
||||
<table className="ops-console__log-table" aria-label="System log entries">
|
||||
<caption className="ops-console__table-caption">System logs</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Timestamp</th>
|
||||
<th scope="col">Level</th>
|
||||
<th scope="col">Pipeline</th>
|
||||
<th scope="col">Event</th>
|
||||
<th scope="col">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<React.Fragment key={log.id}>
|
||||
<tr
|
||||
className={`ops-console__log-row ops-console__log-row--${log.level.toLowerCase()}`}
|
||||
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
|
||||
aria-expanded={expandedId === log.id}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") setExpandedId(expandedId === log.id ? null : log.id); }}
|
||||
>
|
||||
<td><time dateTime={log.timestamp}>{formatTimestamp(log.timestamp)}</time></td>
|
||||
<td><span className={`ops-console__log-level ops-console__log-level--${log.level.toLowerCase()}`}>{log.level}</span></td>
|
||||
<td>{log.pipeline}</td>
|
||||
<td>{log.event_type}</td>
|
||||
<td className="ops-console__log-message">{log.message}</td>
|
||||
</tr>
|
||||
{expandedId === log.id && log.detail && (
|
||||
<tr className="ops-console__log-detail-row">
|
||||
<td colSpan={5}>
|
||||
<pre className="ops-console__log-detail">{JSON.stringify(log.detail, null, 2)}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{hasMore && (
|
||||
<button
|
||||
className="ops-console__load-more"
|
||||
onClick={() => fetchLogs({ cursor: nextCursor ?? undefined, append: true })}
|
||||
type="button"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigView() {
|
||||
const { reportError } = useErrorReporting("ConfigView");
|
||||
const { reportRenderTime } = usePerformanceMetrics("ConfigView");
|
||||
const [config, setConfig] = useState<ConfigEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getConfig();
|
||||
setConfig(data);
|
||||
reportRenderTime("fetch-config");
|
||||
} catch (err) {
|
||||
if (err instanceof AdminApiError) {
|
||||
reportError(err, { code: err.body.code });
|
||||
} else {
|
||||
reportError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to load configuration");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [reportError, reportRenderTime]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (key: string) => {
|
||||
try {
|
||||
const payload: ConfigUpdatePayload = { key, value: editValue };
|
||||
await patchConfig(payload);
|
||||
setEditingKey(null);
|
||||
setEditValue("");
|
||||
await fetchConfig();
|
||||
} catch (err) {
|
||||
if (err instanceof AdminApiError) {
|
||||
reportError(err, { code: err.body.code });
|
||||
} else {
|
||||
reportError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
},
|
||||
[editValue, fetchConfig, reportError]
|
||||
);
|
||||
|
||||
if (loading) return <Loading label="Loading configuration" />;
|
||||
if (error) {
|
||||
return (
|
||||
<StatusMessage
|
||||
variant="error"
|
||||
title="Unable to load configuration"
|
||||
description="Retrying in 30 seconds."
|
||||
action={{ label: "Retry", onClick: fetchConfig } as never}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="ops-console__config" aria-label="System configuration">
|
||||
<table className="ops-console__config-table">
|
||||
<caption className="ops-console__table-caption">Configuration</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Key</th>
|
||||
<th scope="col">Value</th>
|
||||
<th scope="col">Last Modified</th>
|
||||
<th scope="col">Modified By</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{config.map((entry) => (
|
||||
<tr key={entry.key}>
|
||||
<td className="ops-console__config-key">{entry.key}</td>
|
||||
<td>
|
||||
{editingKey === entry.key ? (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); handleSave(entry.key); }}
|
||||
className="ops-console__config-edit-form"
|
||||
>
|
||||
<input
|
||||
className="ops-console__config-input"
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
aria-label={`New value for ${entry.key}`}
|
||||
/>
|
||||
<button className="ops-console__config-save" type="submit">Save</button>
|
||||
<button
|
||||
className="ops-console__config-cancel"
|
||||
type="button"
|
||||
onClick={() => { setEditingKey(null); setEditValue(""); }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<span className="ops-console__config-value">{entry.value}</span>
|
||||
)}
|
||||
</td>
|
||||
<td><time dateTime={entry.updated_at}>{formatTimestamp(entry.updated_at)}</time></td>
|
||||
<td>{entry.updated_by}</td>
|
||||
<td>
|
||||
{entry.mutable && editingKey !== entry.key && (
|
||||
<button
|
||||
className="ops-console__config-edit-btn"
|
||||
onClick={() => { setEditingKey(entry.key); setEditValue(entry.value); }}
|
||||
type="button"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersView() {
|
||||
const { reportError } = useErrorReporting("UsersView");
|
||||
const { reportRenderTime } = usePerformanceMetrics("UsersView");
|
||||
const [users, setUsers] = useState<UserEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmingUserId, setConfirmingUserId] = useState<string | null>(null);
|
||||
const [confirmingRole, setConfirmingRole] = useState<RoleChangePayload["role"] | null>(null);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await listUsers();
|
||||
setUsers(data);
|
||||
reportRenderTime("fetch-users");
|
||||
} catch (err) {
|
||||
if (err instanceof AdminApiError) {
|
||||
reportError(err, { code: err.body.code });
|
||||
} else {
|
||||
reportError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to load user list");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [reportError, reportRenderTime]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleRoleChange = useCallback(
|
||||
async (userId: string, role: RoleChangePayload["role"]) => {
|
||||
try {
|
||||
await patchUserRole(userId, { role });
|
||||
setConfirmingUserId(null);
|
||||
setConfirmingRole(null);
|
||||
await fetchUsers();
|
||||
} catch (err) {
|
||||
if (err instanceof AdminApiError) {
|
||||
reportError(err, { code: err.body.code });
|
||||
} else {
|
||||
reportError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchUsers, reportError]
|
||||
);
|
||||
|
||||
if (loading) return <Loading label="Loading users" />;
|
||||
if (error) {
|
||||
return (
|
||||
<StatusMessage
|
||||
variant="error"
|
||||
title="Unable to load user list"
|
||||
description="Retrying in 30 seconds."
|
||||
action={{ label: "Retry", onClick: fetchUsers } as never}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const roles: RoleChangePayload["role"][] = ["owner", "admin", "operator"];
|
||||
|
||||
return (
|
||||
<section className="ops-console__users" aria-label="User management">
|
||||
<table className="ops-console__users-table">
|
||||
<caption className="ops-console__table-caption">Users</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Email</th>
|
||||
<th scope="col">Role</th>
|
||||
<th scope="col">Assigned</th>
|
||||
<th scope="col">Assigned By</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>{user.email}</td>
|
||||
<td>
|
||||
<select
|
||||
className="ops-console__role-select"
|
||||
value={user.role}
|
||||
onChange={(e) => {
|
||||
setConfirmingUserId(user.id);
|
||||
setConfirmingRole(e.target.value as RoleChangePayload["role"]);
|
||||
}}
|
||||
aria-label={`Role for ${user.email}`}
|
||||
>
|
||||
{roles.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td><time dateTime={user.assigned_at}>{formatTimestamp(user.assigned_at)}</time></td>
|
||||
<td>{user.assigned_by}</td>
|
||||
<td />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{confirmingUserId && confirmingRole && (
|
||||
<div className="ops-console__confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-role-title">
|
||||
<h3 id="confirm-role-title">Confirm Role Change</h3>
|
||||
<p>Are you sure you want to change this user's role to <strong>{confirmingRole}</strong>?</p>
|
||||
<div className="ops-console__confirm-actions">
|
||||
<button
|
||||
className="ops-console__confirm-btn ops-console__confirm-btn--primary"
|
||||
onClick={() => handleRoleChange(confirmingUserId, confirmingRole)}
|
||||
type="button"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
className="ops-console__confirm-btn ops-console__confirm-btn--cancel"
|
||||
onClick={() => { setConfirmingUserId(null); setConfirmingRole(null); }}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
variant,
|
||||
ariaLabel,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
unit: string;
|
||||
variant: "info" | "success" | "warning" | "error";
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={["ops-console__metric-card", `ops-console__metric-card--${variant}`]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role="region"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="ops-console__metric-label">{label}</span>
|
||||
<span className="ops-console__metric-value">
|
||||
{value}
|
||||
{unit && <span className="ops-console__metric-unit"> {unit}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatLatency(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export function formatTimestamp(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTimeAgo(iso: string): string {
|
||||
try {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { OpsConsole, formatLatency, formatTimestamp, formatTimeAgo } from "./OpsConsole";
|
||||
export type { OpsConsoleProps } from "./OpsConsole";
|
||||
@@ -0,0 +1,158 @@
|
||||
const API_BASE = "/api/v1";
|
||||
|
||||
export interface MetricsData {
|
||||
ingestion_rate_5m: number;
|
||||
error_count_5m: number;
|
||||
system_latency_p99: number;
|
||||
recent_errors: RecentError[];
|
||||
}
|
||||
|
||||
export interface RecentError {
|
||||
event_type: string;
|
||||
message_id: string;
|
||||
timestamp: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
level: "INFO" | "WARN" | "ERROR";
|
||||
pipeline: "live" | "historical";
|
||||
event_type: string;
|
||||
message: string;
|
||||
message_id: string | null;
|
||||
detail: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface LogList {
|
||||
data: LogEntry[];
|
||||
pagination: {
|
||||
limit: number;
|
||||
next_cursor: string | null;
|
||||
has_more: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LogListParams {
|
||||
level?: ("INFO" | "WARN" | "ERROR")[];
|
||||
pipeline?: ("live" | "historical")[];
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface ConfigEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
mutable: boolean;
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
}
|
||||
|
||||
export interface ConfigUpdatePayload {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface UserEntry {
|
||||
id: string;
|
||||
email: string;
|
||||
role: "owner" | "admin" | "operator";
|
||||
assigned_at: string;
|
||||
assigned_by: string;
|
||||
}
|
||||
|
||||
export interface RoleChangePayload {
|
||||
role: "owner" | "admin" | "operator";
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
event_id: string;
|
||||
user_id: string;
|
||||
action: string;
|
||||
resource: string;
|
||||
timestamp: string;
|
||||
ip_hash: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
code: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: globalThis.RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody: ApiError = await response.json().catch(() => ({
|
||||
error: response.statusText,
|
||||
code: String(response.status),
|
||||
}));
|
||||
throw new AdminApiError(response.status, errorBody);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export class AdminApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly body: ApiError
|
||||
) {
|
||||
super(body.error || `Admin API error: ${status}`);
|
||||
this.name = "AdminApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export function getMetrics(): Promise<MetricsData> {
|
||||
return request<MetricsData>("/admin/metrics");
|
||||
}
|
||||
|
||||
export function listLogs(params?: LogListParams): Promise<LogList> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.level) {
|
||||
for (const l of params.level) searchParams.append("level", l);
|
||||
}
|
||||
if (params?.pipeline) {
|
||||
for (const p of params.pipeline) searchParams.append("pipeline", p);
|
||||
}
|
||||
if (params?.date_from) searchParams.set("date_from", params.date_from);
|
||||
if (params?.date_to) searchParams.set("date_to", params.date_to);
|
||||
if (params?.q) searchParams.set("q", params.q);
|
||||
if (params?.limit) searchParams.set("limit", String(params.limit));
|
||||
if (params?.cursor) searchParams.set("cursor", params.cursor);
|
||||
const qs = searchParams.toString();
|
||||
return request<LogList>(`/admin/logs${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export function getConfig(): Promise<ConfigEntry[]> {
|
||||
return request<ConfigEntry[]>("/admin/config");
|
||||
}
|
||||
|
||||
export function patchConfig(payload: ConfigUpdatePayload): Promise<ConfigEntry> {
|
||||
return request<ConfigEntry>("/admin/config", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function listUsers(): Promise<UserEntry[]> {
|
||||
return request<UserEntry[]>("/admin/users");
|
||||
}
|
||||
|
||||
export function patchUserRole(userId: string, payload: RoleChangePayload): Promise<UserEntry> {
|
||||
return request<UserEntry>(`/admin/users/${encodeURIComponent(userId)}/role`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export { AdminApiError } from "./admin";
|
||||
export type {
|
||||
MetricsData,
|
||||
RecentError,
|
||||
LogEntry,
|
||||
LogList,
|
||||
LogListParams,
|
||||
ConfigEntry,
|
||||
ConfigUpdatePayload,
|
||||
UserEntry,
|
||||
RoleChangePayload,
|
||||
AuditEntry,
|
||||
ApiError as AdminApiErrorType,
|
||||
} from "./admin";
|
||||
export {
|
||||
getMetrics,
|
||||
listLogs,
|
||||
getConfig,
|
||||
patchConfig,
|
||||
listUsers,
|
||||
patchUserRole,
|
||||
} from "./admin";
|
||||
|
||||
export { MailboxApiError } from "./mailbox";
|
||||
export type {
|
||||
MessageSummary,
|
||||
MessageList,
|
||||
MessageDetail,
|
||||
MailboxListParams,
|
||||
ApiError,
|
||||
MessageStatusUpdate,
|
||||
} from "./mailbox";
|
||||
export {
|
||||
listMailboxMessages,
|
||||
getMailboxMessage,
|
||||
patchMessageStatus,
|
||||
} from "./mailbox";
|
||||
@@ -0,0 +1,108 @@
|
||||
const API_BASE = "/api/v1";
|
||||
|
||||
export interface MessageSummary {
|
||||
message_id: string;
|
||||
subject: string;
|
||||
from_address: string;
|
||||
from_name: string | null;
|
||||
date: string;
|
||||
body_preview: string | null;
|
||||
modules: ("finance" | "crm" | "intel" | "promotions")[];
|
||||
status: "new" | "processed" | "archived";
|
||||
raw_s3_link: string | null;
|
||||
}
|
||||
|
||||
export interface MessageList {
|
||||
data: MessageSummary[];
|
||||
pagination: {
|
||||
limit: number;
|
||||
next_cursor: string | null;
|
||||
has_more: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MessageDetail extends MessageSummary {
|
||||
extracts: Record<string, Record<string, unknown>> | null;
|
||||
papra_link: string | null;
|
||||
}
|
||||
|
||||
export interface MailboxListParams {
|
||||
q?: string;
|
||||
module?: ("finance" | "crm" | "intel" | "promotions")[];
|
||||
status?: ("new" | "processed" | "archived")[];
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
sort?: "date" | "-date" | "subject" | "-subject" | "from" | "-from";
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
code: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type MessageStatusUpdate = "processed" | "archived";
|
||||
|
||||
async function request<T>(path: string, options?: globalThis.RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody: ApiError = await response.json().catch(() => ({
|
||||
error: response.statusText,
|
||||
code: String(response.status),
|
||||
}));
|
||||
throw new MailboxApiError(response.status, errorBody);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export class MailboxApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly body: ApiError
|
||||
) {
|
||||
super(body.error || `Mailbox API error: ${status}`);
|
||||
this.name = "MailboxApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export function listMailboxMessages(params?: MailboxListParams): Promise<MessageList> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.q) searchParams.set("q", params.q);
|
||||
if (params?.module) {
|
||||
for (const m of params.module) searchParams.append("module", m);
|
||||
}
|
||||
if (params?.status) {
|
||||
for (const s of params.status) searchParams.append("status", s);
|
||||
}
|
||||
if (params?.date_from) searchParams.set("date_from", params.date_from);
|
||||
if (params?.date_to) searchParams.set("date_to", params.date_to);
|
||||
if (params?.sort) searchParams.set("sort", params.sort);
|
||||
if (params?.limit) searchParams.set("limit", String(params.limit));
|
||||
if (params?.cursor) searchParams.set("cursor", params.cursor);
|
||||
const qs = searchParams.toString();
|
||||
return request<MessageList>(`/mailbox/messages${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export function getMailboxMessage(messageId: string): Promise<MessageDetail> {
|
||||
return request<MessageDetail>(`/mailbox/messages/${encodeURIComponent(messageId)}`);
|
||||
}
|
||||
|
||||
export function patchMessageStatus(
|
||||
messageId: string,
|
||||
status: MessageStatusUpdate
|
||||
): Promise<MessageDetail> {
|
||||
return request<MessageDetail>(`/mailbox/messages/${encodeURIComponent(messageId)}/status`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { tokens, color, spacing, fontSize, lineHeight, fontWeight, fontFamily, borderRadius, shadow, breakpoint, zIndex, transition, letterSpacing } from "./tokens";
|
||||
export type { TokenValue, TokenGroup } from "./tokens";
|
||||
@@ -0,0 +1,103 @@
|
||||
@layer design-system {
|
||||
:root {
|
||||
--color-brand-primary: #2563eb;
|
||||
--color-brand-primary-hover: #1d4ed8;
|
||||
--color-brand-primary-light: #dbeafe;
|
||||
--color-brand-primary-on: #ffffff;
|
||||
--color-neutral-white: #ffffff;
|
||||
--color-neutral-gray-50: #f9fafb;
|
||||
--color-neutral-gray-100: #f3f4f6;
|
||||
--color-neutral-gray-200: #e5e7eb;
|
||||
--color-neutral-gray-300: #d1d5db;
|
||||
--color-neutral-gray-400: #9ca3af;
|
||||
--color-neutral-gray-500: #6b7280;
|
||||
--color-neutral-gray-600: #4b5563;
|
||||
--color-neutral-gray-700: #374151;
|
||||
--color-neutral-gray-800: #1f2937;
|
||||
--color-neutral-gray-900: #111827;
|
||||
--color-semantic-success: #16a34a;
|
||||
--color-semantic-success-light: #dcfce7;
|
||||
--color-semantic-warning: #d97706;
|
||||
--color-semantic-warning-light: #fef3c7;
|
||||
--color-semantic-error: #dc2626;
|
||||
--color-semantic-error-light: #fee2e2;
|
||||
--color-semantic-info: #2563eb;
|
||||
--color-semantic-info-light: #dbeafe;
|
||||
--color-surface-background: var(--color-neutral-gray-50);
|
||||
--color-surface-card: var(--color-neutral-white);
|
||||
--color-surface-card-elevated: var(--color-neutral-white);
|
||||
--color-surface-overlay: rgba(17, 24, 39, 0.5);
|
||||
|
||||
--font-family-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-family-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.25rem;
|
||||
--font-size-2xl: 1.5rem;
|
||||
--font-size-3xl: 1.875rem;
|
||||
--font-size-4xl: 2.25rem;
|
||||
|
||||
--line-height-xs: 1rem;
|
||||
--line-height-sm: 1.25rem;
|
||||
--line-height-base: 1.5rem;
|
||||
--line-height-lg: 1.75rem;
|
||||
--line-height-xl: 1.75rem;
|
||||
--line-height-2xl: 2rem;
|
||||
--line-height-3xl: 2.25rem;
|
||||
--line-height-4xl: 2.5rem;
|
||||
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
--letter-spacing-tighter: -0.025em;
|
||||
--letter-spacing-normal: 0em;
|
||||
--letter-spacing-wide: 0.025em;
|
||||
|
||||
--spacing-0: 0;
|
||||
--spacing-0_5: 0.125rem;
|
||||
--spacing-1: 0.25rem;
|
||||
--spacing-1_5: 0.375rem;
|
||||
--spacing-2: 0.5rem;
|
||||
--spacing-3: 0.75rem;
|
||||
--spacing-4: 1rem;
|
||||
--spacing-5: 1.25rem;
|
||||
--spacing-6: 1.5rem;
|
||||
--spacing-8: 2rem;
|
||||
--spacing-10: 2.5rem;
|
||||
--spacing-12: 3rem;
|
||||
--spacing-16: 4rem;
|
||||
|
||||
--border-radius-none: 0;
|
||||
--border-radius-sm: 0.25rem;
|
||||
--border-radius-default: 0.375rem;
|
||||
--border-radius-md: 0.5rem;
|
||||
--border-radius-lg: 0.75rem;
|
||||
--border-radius-full: 9999px;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
--breakpoint-sm: 640px;
|
||||
--breakpoint-md: 768px;
|
||||
--breakpoint-lg: 1024px;
|
||||
--breakpoint-xl: 1280px;
|
||||
--breakpoint-2xl: 1536px;
|
||||
|
||||
--z-index-dropdown: 1000;
|
||||
--z-index-sticky: 1020;
|
||||
--z-index-overlay: 1040;
|
||||
--z-index-modal: 1060;
|
||||
--z-index-toast: 1080;
|
||||
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 250ms ease;
|
||||
--transition-slow: 350ms ease-in-out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"$schema": "https://design-tokens.github.io/community/format/",
|
||||
"metadata": {
|
||||
"source": "INB-1.5-D-S1 — Design-system token set and lint rules",
|
||||
"story": "https://git.cloudinit.dev/jc-capital/inboxos/issues/132",
|
||||
"epic": "EPIC-INB-1.5-D #113 — Design-System Compliance & UI Polish",
|
||||
"initiative": "INTV-5 #109 — Mailbox & Administrative Management Interface",
|
||||
"version": "1.0.0",
|
||||
"date": "2026-06-10",
|
||||
"author": "ux-designer"
|
||||
},
|
||||
"color": {
|
||||
"brand": {
|
||||
"primary": {
|
||||
"$value": "#2563eb",
|
||||
"$description": "Primary brand color — used for primary actions, links, active states",
|
||||
"wcag": "4.56:1 on white (AA), 3.28:1 on gray-50 (AA large)"
|
||||
},
|
||||
"primary-hover": {
|
||||
"$value": "#1d4ed8",
|
||||
"$description": "Primary hover — 10% darker than primary"
|
||||
},
|
||||
"primary-light": {
|
||||
"$value": "#dbeafe",
|
||||
"$description": "Primary surface tint — for card backgrounds, selected row highlights"
|
||||
},
|
||||
"primary-on": {
|
||||
"$value": "#ffffff",
|
||||
"$description": "Text/icon color on primary background"
|
||||
}
|
||||
},
|
||||
"neutral": {
|
||||
"white": { "$value": "#ffffff", "$description": "Surface base" },
|
||||
"gray-50": { "$value": "#f9fafb", "$description": "Page background" },
|
||||
"gray-100": { "$value": "#f3f4f6", "$description": "Card background, input disabled fill" },
|
||||
"gray-200": { "$value": "#e5e7eb", "$description": "Border default, divider" },
|
||||
"gray-300": { "$value": "#d1d5db", "$description": "Border hover" },
|
||||
"gray-400": { "$value": "#9ca3af", "$description": "Placeholder text, icon muted" },
|
||||
"gray-500": { "$value": "#6b7280", "$description": "Secondary text, helper text" },
|
||||
"gray-600": { "$value": "#4b5563", "$description": "Body text on light surface" },
|
||||
"gray-700": { "$value": "#374151", "$description": "Subheading text" },
|
||||
"gray-800": { "$value": "#1f2937", "$description": "Heading text" },
|
||||
"gray-900": { "$value": "#111827", "$description": "Primary text, high-contrast headings" }
|
||||
},
|
||||
"semantic": {
|
||||
"success": {
|
||||
"$value": "#16a34a",
|
||||
"$description": "Positive status — processed, healthy, connected",
|
||||
"wcag": "3.84:1 on white (AA large)"
|
||||
},
|
||||
"success-light": { "$value": "#dcfce7", "$description": "Success surface tint" },
|
||||
"warning": {
|
||||
"$value": "#d97706",
|
||||
"$description": "Caution status — pending, rate-limited",
|
||||
"wcag": "3.08:1 on white (AA large)"
|
||||
},
|
||||
"warning-light": { "$value": "#fef3c7", "$description": "Warning surface tint" },
|
||||
"error": {
|
||||
"$value": "#dc2626",
|
||||
"$description": "Error status — failed, dead-letter, extraction failure",
|
||||
"wcag": "4.58:1 on white (AA)"
|
||||
},
|
||||
"error-light": { "$value": "#fee2e2", "$description": "Error surface tint" },
|
||||
"info": {
|
||||
"$value": "#2563eb",
|
||||
"$description": "Informational status — same as brand primary",
|
||||
"$wcag": "4.56:1 on white (AA)"
|
||||
},
|
||||
"info-light": { "$value": "#dbeafe", "$description": "Info surface tint — same as primary-light" }
|
||||
},
|
||||
"surface": {
|
||||
"background": { "$value": "{color.neutral.gray-50}", "$description": "Page-level background" },
|
||||
"card": { "$value": "{color.neutral.white}", "$description": "Card, panel, modal background" },
|
||||
"card-elevated": { "$value": "{color.neutral.white}", "$description": "Elevated card (with shadow)" },
|
||||
"overlay": { "$value": "rgba(17, 24, 39, 0.5)", "$description": "Modal/drawer backdrop" }
|
||||
}
|
||||
},
|
||||
"typography": {
|
||||
"font-family": {
|
||||
"sans": { "$value": "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif", "$description": "Primary UI font stack" },
|
||||
"mono": { "$value": "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace", "$description": "Code, log, and data-display font" }
|
||||
},
|
||||
"font-size": {
|
||||
"xs": { "$value": "0.75rem", "$line-height": "1rem", "$description": "Captions, badges, helper text" },
|
||||
"sm": { "$value": "0.875rem", "$line-height": "1.25rem", "$description": "Body small, table cells" },
|
||||
"base": { "$value": "1rem", "$line-height": "1.5rem", "$description": "Body default" },
|
||||
"lg": { "$value": "1.125rem", "$line-height": "1.75rem", "$description": "Lead paragraphs" },
|
||||
"xl": { "$value": "1.25rem", "$line-height": "1.75rem", "$description": "H4, section headings" },
|
||||
"2xl": { "$value": "1.5rem", "$line-height": "2rem", "$description": "H3" },
|
||||
"3xl": { "$value": "1.875rem", "$line-height": "2.25rem", "$description": "H2" },
|
||||
"4xl": { "$value": "2.25rem", "$line-height": "2.5rem", "$description": "H1, page title" }
|
||||
},
|
||||
"font-weight": {
|
||||
"normal": { "$value": "400", "$description": "Body text" },
|
||||
"medium": { "$value": "500", "$description": "Labels, table headers" },
|
||||
"semibold": { "$value": "600", "$description": "Subheadings, emphasized labels" },
|
||||
"bold": { "$value": "700", "$description": "Headings" }
|
||||
},
|
||||
"letter-spacing": {
|
||||
"tighter": { "$value": "-0.025em", "$description": "H1, H2" },
|
||||
"normal": { "$value": "0em", "$description": "Body, most text" },
|
||||
"wide": { "$value": "0.025em", "$description": "Uppercase labels, badges" }
|
||||
}
|
||||
},
|
||||
"spacing": {
|
||||
"0": { "$value": "0", "$description": "No spacing" },
|
||||
"0.5": { "$value": "0.125rem", "$description": "1px at 16px base — hairline" },
|
||||
"1": { "$value": "0.25rem", "$description": "4px — inline icon gap" },
|
||||
"1.5": { "$value": "0.375rem", "$description": "6px — compact list items" },
|
||||
"2": { "$value": "0.5rem", "$description": "8px — tight padding" },
|
||||
"3": { "$value": "0.75rem", "$description": "12px — default padding" },
|
||||
"4": { "$value": "1rem", "$description": "16px — section gap" },
|
||||
"5": { "$value": "1.25rem", "$description": "20px" },
|
||||
"6": { "$value": "1.5rem", "$description": "24px — card padding" },
|
||||
"8": { "$value": "2rem", "$description": "32px — major section gap" },
|
||||
"10": { "$value": "2.5rem", "$description": "40px" },
|
||||
"12": { "$value": "3rem", "$description": "48px — page-level margin" },
|
||||
"16": { "$value": "4rem", "$description": "64px" }
|
||||
},
|
||||
"border-radius": {
|
||||
"none": { "$value": "0", "$description": "Sharp corners" },
|
||||
"sm": { "$value": "0.25rem", "$description": "4px — badges, small chips" },
|
||||
"default": { "$value": "0.375rem", "$description": "6px — inputs, buttons" },
|
||||
"md": { "$value": "0.5rem", "$description": "8px — cards" },
|
||||
"lg": { "$value": "0.75rem", "$description": "12px — modals, panels" },
|
||||
"full": { "$value": "9999px", "$description": "Pill, avatar" }
|
||||
},
|
||||
"shadow": {
|
||||
"sm": { "$value": "0 1px 2px 0 rgba(0,0,0,0.05)", "$description": "Subtle elevation — cards at rest" },
|
||||
"md": { "$value": "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)", "$description": "Default card elevation" },
|
||||
"lg": { "$value": "0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)", "$description": "Dropdown, popover" },
|
||||
"xl": { "$value": "0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1)", "$description": "Modal" }
|
||||
},
|
||||
"breakpoint": {
|
||||
"sm": { "$value": "640px", "$description": "Mobile landscape / small tablet" },
|
||||
"md": { "$value": "768px", "$description": "Tablet portrait — layout shifts from single-column to multi-column" },
|
||||
"lg": { "$value": "1024px", "$description": "Tablet landscape / small desktop — sidebar visible" },
|
||||
"xl": { "$value": "1280px", "$description": "Desktop — full layout" },
|
||||
"2xl": { "$value": "1536px", "$description": "Large desktop — extended content width" }
|
||||
},
|
||||
"z-index": {
|
||||
"dropdown": { "$value": "1000" },
|
||||
"sticky": { "$value": "1020" },
|
||||
"overlay": { "$value": "1040" },
|
||||
"modal": { "$value": "1060" },
|
||||
"toast": { "$value": "1080" }
|
||||
},
|
||||
"transition": {
|
||||
"fast": { "$value": "150ms ease", "$description": "Hover, focus" },
|
||||
"normal": { "$value": "250ms ease", "$description": "Expand, collapse" },
|
||||
"slow": { "$value": "350ms ease-in-out", "$description": "Modal, drawer" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
color,
|
||||
spacing,
|
||||
fontSize,
|
||||
lineHeight,
|
||||
fontWeight,
|
||||
fontFamily,
|
||||
borderRadius,
|
||||
shadow,
|
||||
breakpoint,
|
||||
zIndex,
|
||||
transition,
|
||||
letterSpacing,
|
||||
tokens,
|
||||
} from "./tokens";
|
||||
|
||||
describe("design-system tokens", () => {
|
||||
describe("color()", () => {
|
||||
it("resolves brand primary color", () => {
|
||||
expect(color("brand.primary")).toBe("#2563eb");
|
||||
});
|
||||
|
||||
it("resolves brand primary-hover color", () => {
|
||||
expect(color("brand.primary-hover")).toBe("#1d4ed8");
|
||||
});
|
||||
|
||||
it("resolves brand primary-light color", () => {
|
||||
expect(color("brand.primary-light")).toBe("#dbeafe");
|
||||
});
|
||||
|
||||
it("resolves brand primary-on color", () => {
|
||||
expect(color("brand.primary-on")).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("resolves neutral colors", () => {
|
||||
expect(color("neutral.gray-50")).toBe("#f9fafb");
|
||||
expect(color("neutral.gray-900")).toBe("#111827");
|
||||
expect(color("neutral.white")).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("resolves semantic colors", () => {
|
||||
expect(color("semantic.success")).toBe("#16a34a");
|
||||
expect(color("semantic.error")).toBe("#dc2626");
|
||||
expect(color("semantic.warning")).toBe("#d97706");
|
||||
expect(color("semantic.info")).toBe("#2563eb");
|
||||
});
|
||||
|
||||
it("resolves surface colors that reference other tokens", () => {
|
||||
expect(color("surface.background")).toBe("#f9fafb");
|
||||
expect(color("surface.card")).toBe("#ffffff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spacing()", () => {
|
||||
it("resolves spacing tokens", () => {
|
||||
expect(spacing("0")).toBe("0");
|
||||
expect(spacing("1")).toBe("0.25rem");
|
||||
expect(spacing("2")).toBe("0.5rem");
|
||||
expect(spacing("4")).toBe("1rem");
|
||||
expect(spacing("6")).toBe("1.5rem");
|
||||
expect(spacing("8")).toBe("2rem");
|
||||
expect(spacing("16")).toBe("4rem");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fontSize()", () => {
|
||||
it("resolves font size tokens", () => {
|
||||
expect(fontSize("font-size.xs")).toBe("0.75rem");
|
||||
expect(fontSize("font-size.sm")).toBe("0.875rem");
|
||||
expect(fontSize("font-size.base")).toBe("1rem");
|
||||
expect(fontSize("font-size.xl")).toBe("1.25rem");
|
||||
expect(fontSize("font-size.4xl")).toBe("2.25rem");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lineHeight()", () => {
|
||||
it("resolves line height tokens", () => {
|
||||
expect(lineHeight("font-size.xs")).toBe("1rem");
|
||||
expect(lineHeight("font-size.base")).toBe("1.5rem");
|
||||
expect(lineHeight("font-size.4xl")).toBe("2.5rem");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fontWeight()", () => {
|
||||
it("resolves font weight tokens", () => {
|
||||
expect(fontWeight("font-weight.normal")).toBe("400");
|
||||
expect(fontWeight("font-weight.medium")).toBe("500");
|
||||
expect(fontWeight("font-weight.semibold")).toBe("600");
|
||||
expect(fontWeight("font-weight.bold")).toBe("700");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fontFamily()", () => {
|
||||
it("resolves font family tokens", () => {
|
||||
const sans = fontFamily("font-family.sans");
|
||||
expect(sans).toContain("Inter");
|
||||
const mono = fontFamily("font-family.mono");
|
||||
expect(mono).toContain("JetBrains Mono");
|
||||
});
|
||||
});
|
||||
|
||||
describe("borderRadius()", () => {
|
||||
it("resolves border radius tokens", () => {
|
||||
expect(borderRadius("none")).toBe("0");
|
||||
expect(borderRadius("sm")).toBe("0.25rem");
|
||||
expect(borderRadius("md")).toBe("0.5rem");
|
||||
expect(borderRadius("lg")).toBe("0.75rem");
|
||||
expect(borderRadius("full")).toBe("9999px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shadow()", () => {
|
||||
it("resolves shadow tokens", () => {
|
||||
expect(shadow("sm")).toBe("0 1px 2px 0 rgba(0,0,0,0.05)");
|
||||
expect(shadow("md")).toContain("rgba(0,0,0,0.1)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("breakpoint()", () => {
|
||||
it("resolves breakpoint tokens", () => {
|
||||
expect(breakpoint("sm")).toBe("640px");
|
||||
expect(breakpoint("md")).toBe("768px");
|
||||
expect(breakpoint("lg")).toBe("1024px");
|
||||
expect(breakpoint("xl")).toBe("1280px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("zIndex()", () => {
|
||||
it("resolves z-index tokens", () => {
|
||||
expect(zIndex("dropdown")).toBe("1000");
|
||||
expect(zIndex("modal")).toBe("1060");
|
||||
expect(zIndex("toast")).toBe("1080");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transition()", () => {
|
||||
it("resolves transition tokens", () => {
|
||||
expect(transition("fast")).toBe("150ms ease");
|
||||
expect(transition("normal")).toBe("250ms ease");
|
||||
expect(transition("slow")).toBe("350ms ease-in-out");
|
||||
});
|
||||
});
|
||||
|
||||
describe("letterSpacing()", () => {
|
||||
it("resolves letter spacing tokens", () => {
|
||||
expect(letterSpacing("letter-spacing.tighter")).toBe("-0.025em");
|
||||
expect(letterSpacing("letter-spacing.normal")).toBe("0em");
|
||||
expect(letterSpacing("letter-spacing.wide")).toBe("0.025em");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tokens.json integrity", () => {
|
||||
it("has metadata", () => {
|
||||
expect(tokens.metadata).toBeDefined();
|
||||
expect(tokens.metadata.version).toBe("1.0.0");
|
||||
expect(tokens.metadata.author).toBe("ux-designer");
|
||||
});
|
||||
|
||||
it("has all required color categories", () => {
|
||||
expect(tokens.color.brand).toBeDefined();
|
||||
expect(tokens.color.neutral).toBeDefined();
|
||||
expect(tokens.color.semantic).toBeDefined();
|
||||
expect(tokens.color.surface).toBeDefined();
|
||||
});
|
||||
|
||||
it("has all required typography categories", () => {
|
||||
expect(tokens.typography["font-family"]).toBeDefined();
|
||||
expect(tokens.typography["font-size"]).toBeDefined();
|
||||
expect(tokens.typography["font-weight"]).toBeDefined();
|
||||
expect(tokens.typography["letter-spacing"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("has all required spacing tokens", () => {
|
||||
expect(tokens.spacing["0"]).toBeDefined();
|
||||
expect(tokens.spacing["4"]).toBeDefined();
|
||||
expect(tokens.spacing["6"]).toBeDefined();
|
||||
expect(tokens.spacing["16"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("has all required breakpoint tokens", () => {
|
||||
expect(tokens.breakpoint.sm).toBeDefined();
|
||||
expect(tokens.breakpoint.md).toBeDefined();
|
||||
expect(tokens.breakpoint.lg).toBeDefined();
|
||||
expect(tokens.breakpoint.xl).toBeDefined();
|
||||
});
|
||||
|
||||
it("has all required z-index tokens", () => {
|
||||
expect(tokens["z-index"].dropdown).toBeDefined();
|
||||
expect(tokens["z-index"].modal).toBeDefined();
|
||||
expect(tokens["z-index"].toast).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import tokens from "./tokens.json";
|
||||
|
||||
type TokenValue = { "$value": string | number; "$description"?: string; "$line-height"?: string; "$wcag"?: string };
|
||||
type TokenGroup = { [key: string]: TokenValue | TokenGroup };
|
||||
|
||||
function resolve(path: string, group: TokenGroup, root: TokenGroup = group): string | number | undefined {
|
||||
const parts = path.split(".");
|
||||
let current: TokenGroup | TokenValue = group;
|
||||
for (const part of parts) {
|
||||
if (typeof current === "object" && "$value" in current) return undefined;
|
||||
current = (current as TokenGroup)[part];
|
||||
if (!current) return undefined;
|
||||
}
|
||||
if (typeof current === "object" && "$value" in current) {
|
||||
const val = (current as TokenValue).$value;
|
||||
if (typeof val === "string" && val.startsWith("{") && val.endsWith("}")) {
|
||||
const refPath = val.slice(1, -1);
|
||||
return resolve(refPath, root, root);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function cssVar(path: string): string {
|
||||
const varName = path
|
||||
.replace(/\./g, "-")
|
||||
.replace(/([A-Z])/g, "-$1")
|
||||
.toLowerCase();
|
||||
return `var(--${varName})`;
|
||||
}
|
||||
|
||||
export function color(path: string): string {
|
||||
const resolved = resolve(path, tokens.color as TokenGroup, tokens as unknown as TokenGroup);
|
||||
if (resolved !== undefined) return String(resolved);
|
||||
return cssVar(`color.${path}`);
|
||||
}
|
||||
|
||||
export function spacing(path: string): string {
|
||||
const resolved = resolve(path, tokens.spacing as TokenGroup);
|
||||
if (resolved !== undefined) return String(resolved);
|
||||
return cssVar(`spacing.${path}`);
|
||||
}
|
||||
|
||||
export function fontSize(path: string): string {
|
||||
const resolved = resolve(path, tokens.typography as TokenGroup);
|
||||
if (resolved !== undefined) return String(resolved);
|
||||
return cssVar(`typography.${path}`);
|
||||
}
|
||||
|
||||
export function lineHeight(path: string): string {
|
||||
const parts = path.split(".");
|
||||
let current: TokenGroup | TokenValue = tokens.typography as TokenGroup;
|
||||
for (const part of parts) {
|
||||
if (typeof current === "object" && "$value" in current) return "1.5";
|
||||
current = (current as TokenGroup)[part];
|
||||
if (!current) return "1.5";
|
||||
}
|
||||
if (typeof current === "object" && "$line-height" in current) {
|
||||
return (current as TokenValue)["$line-height"]!;
|
||||
}
|
||||
return "1.5";
|
||||
}
|
||||
|
||||
export function fontWeight(path: string): string {
|
||||
const resolved = resolve(path, tokens.typography as TokenGroup);
|
||||
if (resolved !== undefined) return String(resolved);
|
||||
return cssVar(`typography.${path}`);
|
||||
}
|
||||
|
||||
export function fontFamily(path: string): string {
|
||||
const resolved = resolve(path, tokens.typography as TokenGroup);
|
||||
if (resolved !== undefined) return String(resolved);
|
||||
return cssVar(`typography.${path}`);
|
||||
}
|
||||
|
||||
export function letterSpacing(path: string): string {
|
||||
const resolved = resolve(path, tokens.typography as TokenGroup);
|
||||
if (resolved !== undefined) return String(resolved);
|
||||
return cssVar(`typography.${path}`);
|
||||
}
|
||||
|
||||
export function borderRadius(path: string): string {
|
||||
const resolved = resolve(path, tokens["border-radius"] as TokenGroup);
|
||||
if (resolved !== undefined) return String(resolved);
|
||||
return cssVar(`border-radius.${path}`);
|
||||
}
|
||||
|
||||
export function shadow(path: string): string {
|
||||
const resolved = resolve(path, tokens.shadow as TokenGroup);
|
||||
if (resolved !== undefined) return String(resolved);
|
||||
return cssVar(`shadow.${path}`);
|
||||
}
|
||||
|
||||
export function breakpoint(path: string): string {
|
||||
const resolved = resolve(path, tokens.breakpoint as TokenGroup);
|
||||
if (resolved !== undefined) return String(resolved);
|
||||
return cssVar(`breakpoint.${path}`);
|
||||
}
|
||||
|
||||
export function zIndex(path: string): string {
|
||||
const resolved = resolve(path, tokens["z-index"] as TokenGroup);
|
||||
if (resolved !== undefined) return String(resolved);
|
||||
return cssVar(`z-index.${path}`);
|
||||
}
|
||||
|
||||
export function transition(path: string): string {
|
||||
const resolved = resolve(path, tokens.transition as TokenGroup);
|
||||
if (resolved !== undefined) return String(resolved);
|
||||
return cssVar(`transition.${path}`);
|
||||
}
|
||||
|
||||
export type { TokenValue, TokenGroup };
|
||||
|
||||
export { tokens };
|
||||
@@ -0,0 +1,215 @@
|
||||
@layer components {
|
||||
.status-message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border-radius: var(--border-radius-md);
|
||||
border: 1px solid transparent;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
.status-message--success {
|
||||
background-color: var(--color-semantic-success-light);
|
||||
border-color: var(--color-semantic-success);
|
||||
color: var(--color-neutral-gray-900);
|
||||
}
|
||||
|
||||
.status-message--error {
|
||||
background-color: var(--color-semantic-error-light);
|
||||
border-color: var(--color-semantic-error);
|
||||
color: var(--color-neutral-gray-900);
|
||||
}
|
||||
|
||||
.status-message--warning {
|
||||
background-color: var(--color-semantic-warning-light);
|
||||
border-color: var(--color-semantic-warning);
|
||||
color: var(--color-neutral-gray-900);
|
||||
}
|
||||
|
||||
.status-message--info {
|
||||
background-color: var(--color-brand-primary-light);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-neutral-gray-900);
|
||||
}
|
||||
|
||||
.status-message__icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--spacing-5);
|
||||
height: var(--spacing-5);
|
||||
}
|
||||
|
||||
.status-message__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-message__title {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-message__description {
|
||||
color: var(--color-neutral-gray-600);
|
||||
margin: var(--spacing-1) 0 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.status-message__dismiss {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-xl);
|
||||
line-height: var(--line-height-xl);
|
||||
color: var(--color-neutral-gray-500);
|
||||
padding: var(--spacing-0_5);
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: color var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.status-message__dismiss:hover {
|
||||
color: var(--color-neutral-gray-700);
|
||||
background-color: var(--color-neutral-gray-100);
|
||||
}
|
||||
|
||||
.status-message__dismiss:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-8);
|
||||
color: var(--color-neutral-gray-500);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.loading__spinner {
|
||||
border-radius: var(--border-radius-full);
|
||||
border: 2px solid var(--color-neutral-gray-200);
|
||||
border-top-color: var(--color-brand-primary);
|
||||
animation: loading-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading--sm .loading__spinner {
|
||||
width: var(--spacing-4);
|
||||
height: var(--spacing-4);
|
||||
}
|
||||
|
||||
.loading--md .loading__spinner {
|
||||
width: var(--spacing-8);
|
||||
height: var(--spacing-8);
|
||||
}
|
||||
|
||||
.loading--lg .loading__spinner {
|
||||
width: var(--spacing-12);
|
||||
height: var(--spacing-12);
|
||||
}
|
||||
|
||||
.loading__label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-neutral-gray-500);
|
||||
}
|
||||
|
||||
@keyframes loading-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-12) var(--spacing-6);
|
||||
text-align: center;
|
||||
color: var(--color-neutral-gray-500);
|
||||
font-family: var(--font-family-sans);
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
width: var(--spacing-12);
|
||||
height: var(--spacing-12);
|
||||
color: var(--color-neutral-gray-400);
|
||||
}
|
||||
|
||||
.empty-state__title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-neutral-gray-700);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state__description {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-neutral-gray-500);
|
||||
margin: 0;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.empty-state__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
background-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary-on);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-default);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.empty-state__action:hover {
|
||||
background-color: var(--color-brand-primary-hover);
|
||||
}
|
||||
|
||||
.empty-state__action:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-0_5) var(--spacing-2);
|
||||
border-radius: var(--border-radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-xs);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-badge--inbox {
|
||||
background-color: var(--color-brand-primary-light);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.status-badge--archived {
|
||||
background-color: var(--color-neutral-gray-100);
|
||||
color: var(--color-neutral-gray-500);
|
||||
}
|
||||
|
||||
.status-badge--processed {
|
||||
background-color: var(--color-semantic-success-light);
|
||||
color: var(--color-semantic-success);
|
||||
}
|
||||
|
||||
.status-badge--error {
|
||||
background-color: var(--color-semantic-error-light);
|
||||
color: var(--color-semantic-error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
StatusMessage,
|
||||
Loading,
|
||||
EmptyState,
|
||||
StatusBadge,
|
||||
} from "./StatusMessage";
|
||||
import "./StatusMessage.css";
|
||||
import "../design-system/tokens.css";
|
||||
|
||||
const meta: Meta<typeof StatusMessage> = {
|
||||
title: "Feedback/StatusMessage",
|
||||
component: StatusMessage,
|
||||
parameters: {
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [
|
||||
{ id: "color-contrast", enabled: true },
|
||||
{ id: "button-name", enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Success: StoryObj<typeof StatusMessage> = {
|
||||
args: {
|
||||
variant: "success",
|
||||
title: "Email archived successfully",
|
||||
description: "The email has been moved to your archive.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: StoryObj<typeof StatusMessage> = {
|
||||
args: {
|
||||
variant: "error",
|
||||
title: "Failed to process email",
|
||||
description: "An error occurred while processing. Please try again.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: StoryObj<typeof StatusMessage> = {
|
||||
args: {
|
||||
variant: "warning",
|
||||
title: "Rate limit approaching",
|
||||
description: "You have processed 45 out of 50 emails this hour.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Info: StoryObj<typeof StatusMessage> = {
|
||||
args: {
|
||||
variant: "info",
|
||||
title: "System update scheduled",
|
||||
description: "Maintenance will occur at 2:00 AM UTC.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Dismissable: StoryObj<typeof StatusMessage> = {
|
||||
args: {
|
||||
variant: "success",
|
||||
title: "Changes saved",
|
||||
onDismiss: () => alert("Dismissed"),
|
||||
},
|
||||
};
|
||||
|
||||
const LoadingMeta: Meta<typeof Loading> = {
|
||||
title: "Feedback/Loading",
|
||||
component: Loading,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export const LoadingDefault: StoryObj<typeof Loading> = {
|
||||
args: {
|
||||
size: "md",
|
||||
label: "Loading",
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingSmall: StoryObj<typeof Loading> = {
|
||||
args: {
|
||||
size: "sm",
|
||||
label: "Loading emails...",
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingLarge: StoryObj<typeof Loading> = {
|
||||
args: {
|
||||
size: "lg",
|
||||
label: "Fetching inbox...",
|
||||
},
|
||||
};
|
||||
|
||||
const EmptyStateMeta: Meta<typeof EmptyState> = {
|
||||
title: "Feedback/EmptyState",
|
||||
component: EmptyState,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export const NoEmails: StoryObj<typeof EmptyState> = {
|
||||
args: {
|
||||
title: "No emails yet",
|
||||
description: "New mail is checked every 5 minutes.",
|
||||
},
|
||||
};
|
||||
|
||||
export const NoSearchResults: StoryObj<typeof EmptyState> = {
|
||||
args: {
|
||||
title: "No emails match your search",
|
||||
action: { label: "Clear search", onClick: () => alert("Search cleared") },
|
||||
},
|
||||
};
|
||||
|
||||
export const AllProcessed: StoryObj<typeof EmptyState> = {
|
||||
args: {
|
||||
title: "All caught up!",
|
||||
description: "No inbox items remaining.",
|
||||
},
|
||||
};
|
||||
|
||||
const StatusBadgeMeta: Meta<typeof StatusBadge> = {
|
||||
title: "Feedback/StatusBadge",
|
||||
component: StatusBadge,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export const BadgeInbox: StoryObj<typeof StatusBadge> = {
|
||||
args: { status: "inbox" },
|
||||
};
|
||||
|
||||
export const BadgeArchived: StoryObj<typeof StatusBadge> = {
|
||||
args: { status: "archived" },
|
||||
};
|
||||
|
||||
export const BadgeProcessed: StoryObj<typeof StatusBadge> = {
|
||||
args: { status: "processed" },
|
||||
};
|
||||
|
||||
export const BadgeError: StoryObj<typeof StatusBadge> = {
|
||||
args: { status: "error" },
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import {
|
||||
StatusMessage,
|
||||
Loading,
|
||||
EmptyState,
|
||||
StatusBadge,
|
||||
} from "./StatusMessage";
|
||||
|
||||
describe("StatusMessage", () => {
|
||||
it("renders success variant", () => {
|
||||
render(
|
||||
<StatusMessage variant="success" title="Saved successfully" />
|
||||
);
|
||||
expect(screen.getByText("Saved successfully")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error variant with role=alert", () => {
|
||||
render(
|
||||
<StatusMessage variant="error" title="Something went wrong" />
|
||||
);
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders description when provided", () => {
|
||||
render(
|
||||
<StatusMessage
|
||||
variant="info"
|
||||
title="Heads up"
|
||||
description="This is additional context"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("This is additional context")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders dismiss button when onDismiss is provided", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<StatusMessage variant="warning" title="Warning" onDismiss={onDismiss} />
|
||||
);
|
||||
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
|
||||
expect(dismissButton).toBeInTheDocument();
|
||||
fireEvent.click(dismissButton);
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not render dismiss button when onDismiss is not provided", () => {
|
||||
render(<StatusMessage variant="success" title="Done" />);
|
||||
expect(screen.queryByRole("button", { name: /dismiss/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies correct aria-label for each variant", () => {
|
||||
const { rerender } = render(
|
||||
<StatusMessage variant="success" title="OK" />
|
||||
);
|
||||
expect(screen.getByLabelText("success: OK")).toBeInTheDocument();
|
||||
|
||||
rerender(<StatusMessage variant="error" title="Fail" />);
|
||||
expect(screen.getByLabelText("error: Fail")).toBeInTheDocument();
|
||||
|
||||
rerender(<StatusMessage variant="warning" title="Careful" />);
|
||||
expect(screen.getByLabelText("warning: Careful")).toBeInTheDocument();
|
||||
|
||||
rerender(<StatusMessage variant="info" title="Note" />);
|
||||
expect(screen.getByLabelText("info: Note")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Loading", () => {
|
||||
it("renders with default size and label", () => {
|
||||
render(<Loading />);
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom label", () => {
|
||||
render(<Loading label="Fetching emails..." />);
|
||||
expect(screen.getByText("Fetching emails...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with aria-label for accessibility", () => {
|
||||
render(<Loading label="Loading data" />);
|
||||
expect(screen.getByRole("status")).toHaveAttribute("aria-label", "Loading data");
|
||||
});
|
||||
|
||||
it("applies size class variants", () => {
|
||||
const { rerender } = render(<Loading size="sm" />);
|
||||
expect(document.querySelector(".loading--sm")).toBeInTheDocument();
|
||||
|
||||
rerender(<Loading size="md" />);
|
||||
expect(document.querySelector(".loading--md")).toBeInTheDocument();
|
||||
|
||||
rerender(<Loading size="lg" />);
|
||||
expect(document.querySelector(".loading--lg")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmptyState", () => {
|
||||
it("renders title and description", () => {
|
||||
render(
|
||||
<EmptyState title="No emails" description="New mail is checked every 5 minutes." />
|
||||
);
|
||||
expect(screen.getByText("No emails")).toBeInTheDocument();
|
||||
expect(screen.getByText("New mail is checked every 5 minutes.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders action button when provided", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<EmptyState title="No results" action={{ label: "Clear search", onClick }} />
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /clear search/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not render action button when not provided", () => {
|
||||
render(<EmptyState title="No data" />);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with role=status", () => {
|
||||
render(<EmptyState title="Empty" />);
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
it("renders inbox badge", () => {
|
||||
render(<StatusBadge status="inbox" />);
|
||||
expect(screen.getByText("inbox")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Status: inbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all status variants", () => {
|
||||
const { rerender } = render(<StatusBadge status="inbox" />);
|
||||
expect(screen.getByText("inbox")).toBeInTheDocument();
|
||||
|
||||
rerender(<StatusBadge status="archived" />);
|
||||
expect(screen.getByText("archived")).toBeInTheDocument();
|
||||
|
||||
rerender(<StatusBadge status="processed" />);
|
||||
expect(screen.getByText("processed")).toBeInTheDocument();
|
||||
|
||||
rerender(<StatusBadge status="error" />);
|
||||
expect(screen.getByText("error")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
|
||||
export interface StatusMessageProps {
|
||||
variant: "success" | "error" | "warning" | "info";
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantToAriaRole: Record<string, string> = {
|
||||
success: "status",
|
||||
error: "alert",
|
||||
warning: "alert",
|
||||
info: "status",
|
||||
};
|
||||
|
||||
export function StatusMessage({
|
||||
variant,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
onDismiss,
|
||||
className,
|
||||
}: StatusMessageProps) {
|
||||
return (
|
||||
<div
|
||||
className={["status-message", `status-message--${variant}`, className]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role={variantToAriaRole[variant] ?? "status"}
|
||||
aria-label={`${variant}: ${title}`}
|
||||
>
|
||||
{icon && (
|
||||
<span className="status-message__icon" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="status-message__content">
|
||||
<p className="status-message__title">{title}</p>
|
||||
{description && (
|
||||
<p className="status-message__description">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="status-message__dismiss"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss message"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface LoadingProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Loading({
|
||||
size = "md",
|
||||
label = "Loading",
|
||||
className,
|
||||
}: LoadingProps) {
|
||||
return (
|
||||
<div
|
||||
className={["loading", `loading--${size}`, className]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role="status"
|
||||
aria-label={label}
|
||||
>
|
||||
<div className="loading__spinner" aria-hidden="true" />
|
||||
<span className="loading__label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: { label: string; onClick: () => void };
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
icon,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={["empty-state", className].filter(Boolean).join(" ")}
|
||||
role="status"
|
||||
>
|
||||
{icon && (
|
||||
<div className="empty-state__icon" aria-hidden="true">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="empty-state__title">{title}</h3>
|
||||
{description && (
|
||||
<p className="empty-state__description">{description}</p>
|
||||
)}
|
||||
{action && (
|
||||
<button
|
||||
className="empty-state__action"
|
||||
onClick={action.onClick}
|
||||
type="button"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface StatusBadgeProps {
|
||||
status: "inbox" | "archived" | "processed" | "error";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, className }: StatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={["status-badge", `status-badge--${status}`, className]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
aria-label={`Status: ${status}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { StatusMessage, Loading, EmptyState, StatusBadge } from "./StatusMessage";
|
||||
export type { StatusMessageProps, LoadingProps, EmptyStateProps, StatusBadgeProps } from "./StatusMessage";
|
||||
@@ -0,0 +1,5 @@
|
||||
@import url("./design-system/tokens.css");
|
||||
@import url("./layout/AppShell.css");
|
||||
@import url("./layout/ResponsiveGrid.css");
|
||||
@import url("./navigation/SidebarNav.css");
|
||||
@import url("./feedback/StatusMessage.css");
|
||||
@@ -0,0 +1,97 @@
|
||||
@layer components {
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: var(--spacing-16) 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-surface-background);
|
||||
font-family: var(--font-family-sans);
|
||||
}
|
||||
|
||||
.app-shell__sidebar {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
background-color: var(--color-surface-card);
|
||||
border-right: 1px solid var(--color-neutral-gray-200);
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4);
|
||||
z-index: var(--z-index-sticky);
|
||||
}
|
||||
|
||||
.app-shell__main {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.app-shell__detail {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4);
|
||||
border-left: 1px solid var(--color-neutral-gray-200);
|
||||
background-color: var(--color-surface-card);
|
||||
}
|
||||
|
||||
@responsive(sm, max: md) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-shell__sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-shell__detail {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@responsive(md, max: lg) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.app-shell__sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@responsive(lg) {
|
||||
.app-shell {
|
||||
grid-template-columns: var(--spacing-16) 1fr;
|
||||
}
|
||||
|
||||
.app-shell--with-detail {
|
||||
grid-template-columns: var(--spacing-16) 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@responsive(xl) {
|
||||
.app-shell--with-detail {
|
||||
grid-template-columns: var(--spacing-16) 2fr 3fr;
|
||||
}
|
||||
}
|
||||
|
||||
.skip-to-content {
|
||||
position: absolute;
|
||||
top: -100%;
|
||||
left: var(--spacing-4);
|
||||
z-index: var(--z-index-toast);
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
background-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary-on);
|
||||
border-radius: var(--border-radius-default);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skip-to-content:focus {
|
||||
top: var(--spacing-4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { AppShell } from "./AppShell";
|
||||
import { SidebarNav } from "../navigation/SidebarNav";
|
||||
import type { NavItem } from "../navigation/SidebarNav";
|
||||
import { StatusBadge } from "../feedback/StatusMessage";
|
||||
import "./AppShell.css";
|
||||
import "../navigation/SidebarNav.css";
|
||||
import "../design-system/tokens.css";
|
||||
|
||||
const meta: Meta<typeof AppShell> = {
|
||||
title: "Layout/AppShell",
|
||||
component: AppShell,
|
||||
parameters: {
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [
|
||||
{ id: "color-contrast", enabled: true },
|
||||
{ id: "aria-valid-attr-value", enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AppShell>;
|
||||
|
||||
const inboxItems: NavItem[] = [
|
||||
{ id: "inbox", label: "Inbox", href: "/mailbox", badge: 5 },
|
||||
{ id: "archive", label: "Archive", href: "/mailbox/archive" },
|
||||
{ id: "processed", label: "Processed", href: "/mailbox/processed" },
|
||||
{ id: "settings", label: "Settings", href: "/settings" },
|
||||
{ id: "help", label: "Help", href: "/help" },
|
||||
];
|
||||
|
||||
export const ThreeColumnLayout: Story = {
|
||||
args: {
|
||||
sidebar: (
|
||||
<SidebarNav
|
||||
items={inboxItems}
|
||||
activeId="inbox"
|
||||
onNavigate={() => {}}
|
||||
/>
|
||||
),
|
||||
main: (
|
||||
<div>
|
||||
<h2 style={{ fontSize: "var(--font-size-2xl)", fontWeight: 600, margin: 0 }}>
|
||||
Mailbox
|
||||
</h2>
|
||||
<p style={{ color: "var(--color-neutral-gray-500)", fontSize: "var(--font-size-sm)" }}>
|
||||
5 unread messages
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
detail: (
|
||||
<div>
|
||||
<h3 style={{ fontSize: "var(--font-size-xl)", fontWeight: 600 }}>
|
||||
Email Subject
|
||||
</h3>
|
||||
<p style={{ color: "var(--color-neutral-gray-500)", fontSize: "var(--font-size-sm)" }}>
|
||||
From: sender@example.com · Jun 10
|
||||
</p>
|
||||
<p style={{ fontSize: "var(--font-size-base)", lineHeight: 1.5 }}>
|
||||
Email body preview...
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "var(--spacing-2)", marginTop: "var(--spacing-4)" }}>
|
||||
<button
|
||||
style={{
|
||||
backgroundColor: "var(--color-brand-primary)",
|
||||
color: "var(--color-brand-primary-on)",
|
||||
border: "none",
|
||||
borderRadius: "var(--border-radius-default)",
|
||||
padding: "var(--spacing-2) var(--spacing-4)",
|
||||
fontSize: "var(--font-size-sm)",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
backgroundColor: "var(--color-neutral-gray-100)",
|
||||
color: "var(--color-neutral-gray-700)",
|
||||
border: "none",
|
||||
borderRadius: "var(--border-radius-default)",
|
||||
padding: "var(--spacing-2) var(--spacing-4)",
|
||||
fontSize: "var(--font-size-sm)",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Mark Processed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const TwoColumnLayout: Story = {
|
||||
args: {
|
||||
sidebar: (
|
||||
<SidebarNav
|
||||
items={inboxItems}
|
||||
activeId="inbox"
|
||||
onNavigate={() => {}}
|
||||
/>
|
||||
),
|
||||
main: (
|
||||
<div>
|
||||
<h2 style={{ fontSize: "var(--font-size-2xl)", fontWeight: 600, margin: 0 }}>
|
||||
Dashboard
|
||||
</h2>
|
||||
<div style={{ display: "flex", gap: "var(--spacing-4)", marginTop: "var(--spacing-4)" }}>
|
||||
<StatusBadge status="inbox" />
|
||||
<StatusBadge status="processed" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AppShell } from "./AppShell";
|
||||
|
||||
describe("AppShell", () => {
|
||||
it("renders sidebar and main content", () => {
|
||||
render(
|
||||
<AppShell
|
||||
sidebar={<nav data-testid="sidebar">Sidebar</nav>}
|
||||
main={<div data-testid="main">Main content</div>}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("sidebar")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders detail panel when provided", () => {
|
||||
render(
|
||||
<AppShell
|
||||
sidebar={<nav>Sidebar</nav>}
|
||||
main={<div>Main</div>}
|
||||
detail={<div data-testid="detail">Detail</div>}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("detail")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render detail panel when not provided", () => {
|
||||
render(
|
||||
<AppShell
|
||||
sidebar={<nav>Sidebar</nav>}
|
||||
main={<div>Main</div>}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByLabelText("Detail panel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with role=application", () => {
|
||||
render(
|
||||
<AppShell
|
||||
sidebar={<nav>Sidebar</nav>}
|
||||
main={<div>Main</div>}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("application")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("main content has id for skip-to-content", () => {
|
||||
render(
|
||||
<AppShell
|
||||
sidebar={<nav>Sidebar</nav>}
|
||||
main={<div>Main</div>}
|
||||
/>
|
||||
);
|
||||
expect(document.getElementById("main-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sidebar has role=complementary", () => {
|
||||
render(
|
||||
<AppShell
|
||||
sidebar={<nav>Sidebar</nav>}
|
||||
main={<div>Main</div>}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { breakpoints, spacing, zIndex } from "../design-system";
|
||||
|
||||
export interface AppShellProps {
|
||||
sidebar: ReactNode;
|
||||
main: ReactNode;
|
||||
detail?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AppShell({ sidebar, main, detail, className }: AppShellProps) {
|
||||
return (
|
||||
<div
|
||||
className={["app-shell", className].filter(Boolean).join(" ")}
|
||||
role="application"
|
||||
>
|
||||
<aside className="app-shell__sidebar" role="complementary">
|
||||
{sidebar}
|
||||
</aside>
|
||||
<main className="app-shell__main" id="main-content" tabIndex={-1}>
|
||||
{main}
|
||||
</main>
|
||||
{detail && (
|
||||
<section className="app-shell__detail" aria-label="Detail panel">
|
||||
{detail}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
@layer components {
|
||||
.responsive-grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--spacing-4);
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.responsive-grid--cols-4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.responsive-grid--cols-8 {
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
}
|
||||
|
||||
.responsive-grid--cols-12 {
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
}
|
||||
|
||||
@responsive(sm) {
|
||||
.responsive-grid {
|
||||
padding-inline: var(--spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
@responsive(md) {
|
||||
.responsive-grid {
|
||||
padding-inline: var(--spacing-6);
|
||||
}
|
||||
}
|
||||
|
||||
@responsive(lg) {
|
||||
.responsive-grid {
|
||||
padding-inline: var(--spacing-8);
|
||||
}
|
||||
}
|
||||
|
||||
@responsive(xl) {
|
||||
.responsive-grid {
|
||||
padding-inline: var(--spacing-8);
|
||||
}
|
||||
}
|
||||
|
||||
.responsive-grid__item--span-1 { grid-column: span 1; }
|
||||
.responsive-grid__item--span-2 { grid-column: span 2; }
|
||||
.responsive-grid__item--span-3 { grid-column: span 3; }
|
||||
.responsive-grid__item--span-4 { grid-column: span 4; }
|
||||
.responsive-grid__item--span-6 { grid-column: span 6; }
|
||||
.responsive-grid__item--span-8 { grid-column: span 8; }
|
||||
.responsive-grid__item--span-12 { grid-column: span 12; }
|
||||
|
||||
@responsive(max: sm) {
|
||||
.responsive-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.responsive-grid__item {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@responsive(sm, max: md) {
|
||||
.responsive-grid {
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
}
|
||||
|
||||
.responsive-grid__item--sm-1 { grid-column: span 1; }
|
||||
.responsive-grid__item--sm-2 { grid-column: span 2; }
|
||||
.responsive-grid__item--sm-3 { grid-column: span 3; }
|
||||
.responsive-grid__item--sm-4 { grid-column: span 4; }
|
||||
.responsive-grid__item--sm-6 { grid-column: span 6; }
|
||||
.responsive-grid__item--sm-8 { grid-column: span 8; }
|
||||
}
|
||||
|
||||
@responsive(md, max: lg) {
|
||||
.responsive-grid {
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
}
|
||||
|
||||
.responsive-grid__item--md-1 { grid-column: span 1; }
|
||||
.responsive-grid__item--md-2 { grid-column: span 2; }
|
||||
.responsive-grid__item--md-3 { grid-column: span 3; }
|
||||
.responsive-grid__item--md-4 { grid-column: span 4; }
|
||||
.responsive-grid__item--md-6 { grid-column: span 6; }
|
||||
.responsive-grid__item--md-8 { grid-column: span 8; }
|
||||
.responsive-grid__item--md-12 { grid-column: span 12; }
|
||||
}
|
||||
|
||||
@responsive(lg) {
|
||||
.responsive-grid {
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
}
|
||||
|
||||
.responsive-grid__item--lg-1 { grid-column: span 1; }
|
||||
.responsive-grid__item--lg-2 { grid-column: span 2; }
|
||||
.responsive-grid__item--lg-3 { grid-column: span 3; }
|
||||
.responsive-grid__item--lg-4 { grid-column: span 4; }
|
||||
.responsive-grid__item--lg-6 { grid-column: span 6; }
|
||||
.responsive-grid__item--lg-8 { grid-column: span 8; }
|
||||
.responsive-grid__item--lg-12 { grid-column: span 12; }
|
||||
}
|
||||
|
||||
@responsive(xl) {
|
||||
.responsive-grid__item--xl-1 { grid-column: span 1; }
|
||||
.responsive-grid__item--xl-2 { grid-column: span 2; }
|
||||
.responsive-grid__item--xl-3 { grid-column: span 3; }
|
||||
.responsive-grid__item--xl-4 { grid-column: span 4; }
|
||||
.responsive-grid__item--xl-6 { grid-column: span 6; }
|
||||
.responsive-grid__item--xl-8 { grid-column: span 8; }
|
||||
.responsive-grid__item--xl-12 { grid-column: span 12; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { CSSProperties, Meta, StoryObj } from "react";
|
||||
import { ResponsiveGrid, GridItem } from "./ResponsiveGrid";
|
||||
import "./ResponsiveGrid.css";
|
||||
import "../design-system/tokens.css";
|
||||
|
||||
const meta: Meta<typeof ResponsiveGrid> = {
|
||||
title: "Layout/ResponsiveGrid",
|
||||
component: ResponsiveGrid,
|
||||
parameters: {
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [
|
||||
{ id: "color-contrast", enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ResponsiveGrid>;
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
backgroundColor: "var(--color-surface-card)",
|
||||
borderRadius: "var(--border-radius-md)",
|
||||
boxShadow: "var(--shadow-md)",
|
||||
padding: "var(--spacing-4)",
|
||||
minHeight: "120px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--color-neutral-gray-600)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
};
|
||||
|
||||
export const Default12Column: Story = {
|
||||
args: {
|
||||
columns: 12,
|
||||
children: (
|
||||
<>
|
||||
<GridItem lgSpan={8}>
|
||||
<div style={cardStyle}>Main content (8/12)</div>
|
||||
</GridItem>
|
||||
<GridItem lgSpan={4}>
|
||||
<div style={cardStyle}>Sidebar (4/12)</div>
|
||||
</GridItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const MetricCards: Story = {
|
||||
args: {
|
||||
columns: 12,
|
||||
children: (
|
||||
<>
|
||||
<GridItem mdSpan={6} lgSpan={3}>
|
||||
<div style={{ ...cardStyle, borderTop: "3px solid var(--color-brand-primary)" }}>
|
||||
Ingestion Rate
|
||||
</div>
|
||||
</GridItem>
|
||||
<GridItem mdSpan={6} lgSpan={3}>
|
||||
<div style={{ ...cardStyle, borderTop: "3px solid var(--color-semantic-success)" }}>
|
||||
Error Count
|
||||
</div>
|
||||
</GridItem>
|
||||
<GridItem mdSpan={6} lgSpan={3}>
|
||||
<div style={{ ...cardStyle, borderTop: "3px solid var(--color-semantic-warning)" }}>
|
||||
Latency p99
|
||||
</div>
|
||||
</GridItem>
|
||||
<GridItem mdSpan={6} lgSpan={3}>
|
||||
<div style={{ ...cardStyle, borderTop: "3px solid var(--color-semantic-info)" }}>
|
||||
Uptime
|
||||
</div>
|
||||
</GridItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const EmailListAndDetail: Story = {
|
||||
args: {
|
||||
columns: 12,
|
||||
children: (
|
||||
<>
|
||||
<GridItem mdSpan={6} lgSpan={4}>
|
||||
<div style={{ ...cardStyle, minHeight: "200px" }}>Email list</div>
|
||||
</GridItem>
|
||||
<GridItem mdSpan={6} lgSpan={8}>
|
||||
<div style={{ ...cardStyle, minHeight: "200px" }}>Email detail</div>
|
||||
</GridItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ResponsiveGrid, GridItem } from "./ResponsiveGrid";
|
||||
|
||||
describe("ResponsiveGrid", () => {
|
||||
it("renders children", () => {
|
||||
render(
|
||||
<ResponsiveGrid>
|
||||
<div data-testid="child">Child</div>
|
||||
</ResponsiveGrid>
|
||||
);
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies default 12-column grid class", () => {
|
||||
render(
|
||||
<ResponsiveGrid>
|
||||
<div>Child</div>
|
||||
</ResponsiveGrid>
|
||||
);
|
||||
const grid = document.querySelector(".responsive-grid") as HTMLElement;
|
||||
expect(grid).toBeInTheDocument();
|
||||
expect(grid.classList.contains("responsive-grid--cols-12")).toBe(true);
|
||||
});
|
||||
|
||||
it("applies custom column count class", () => {
|
||||
render(
|
||||
<ResponsiveGrid columns={8}>
|
||||
<div>Child</div>
|
||||
</ResponsiveGrid>
|
||||
);
|
||||
const grid = document.querySelector(".responsive-grid") as HTMLElement;
|
||||
expect(grid.classList.contains("responsive-grid--cols-8")).toBe(true);
|
||||
});
|
||||
|
||||
it("applies 4-column class", () => {
|
||||
render(
|
||||
<ResponsiveGrid columns={4}>
|
||||
<div>Child</div>
|
||||
</ResponsiveGrid>
|
||||
);
|
||||
const grid = document.querySelector(".responsive-grid") as HTMLElement;
|
||||
expect(grid.classList.contains("responsive-grid--cols-4")).toBe(true);
|
||||
});
|
||||
|
||||
it("supports custom element type via as prop", () => {
|
||||
render(
|
||||
<ResponsiveGrid as="section">
|
||||
<div>Child</div>
|
||||
</ResponsiveGrid>
|
||||
);
|
||||
expect(document.querySelector("section.responsive-grid")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies custom className", () => {
|
||||
render(
|
||||
<ResponsiveGrid className="custom-class">
|
||||
<div>Child</div>
|
||||
</ResponsiveGrid>
|
||||
);
|
||||
const grid = document.querySelector(".responsive-grid") as HTMLElement;
|
||||
expect(grid.classList.contains("custom-class")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GridItem", () => {
|
||||
it("renders children", () => {
|
||||
render(
|
||||
<GridItem>
|
||||
<div data-testid="item">Item</div>
|
||||
</GridItem>
|
||||
);
|
||||
expect(screen.getByTestId("item")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies span class when span is provided", () => {
|
||||
render(
|
||||
<GridItem span={6}>
|
||||
<div>Item</div>
|
||||
</GridItem>
|
||||
);
|
||||
const item = document.querySelector(".responsive-grid__item") as HTMLElement;
|
||||
expect(item.classList.contains("responsive-grid__item--span-6")).toBe(true);
|
||||
});
|
||||
|
||||
it("applies responsive span classes", () => {
|
||||
render(
|
||||
<GridItem smSpan={4} mdSpan={6} lgSpan={8} xlSpan={12}>
|
||||
<div>Item</div>
|
||||
</GridItem>
|
||||
);
|
||||
const item = document.querySelector(".responsive-grid__item") as HTMLElement;
|
||||
expect(item.classList.contains("responsive-grid__item--sm-4")).toBe(true);
|
||||
expect(item.classList.contains("responsive-grid__item--md-6")).toBe(true);
|
||||
expect(item.classList.contains("responsive-grid__item--lg-8")).toBe(true);
|
||||
expect(item.classList.contains("responsive-grid__item--xl-12")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export interface ResponsiveGridProps {
|
||||
children: ReactNode;
|
||||
columns?: number;
|
||||
className?: string;
|
||||
as?: "div" | "section" | "main" | "article" | "aside";
|
||||
}
|
||||
|
||||
const COLUMN_CLASS_MAP: Record<number, string> = {
|
||||
4: "responsive-grid--cols-4",
|
||||
8: "responsive-grid--cols-8",
|
||||
12: "responsive-grid--cols-12",
|
||||
};
|
||||
|
||||
const SPAN_CLASS_MAP: Record<number, string> = {
|
||||
1: "responsive-grid__item--span-1",
|
||||
2: "responsive-grid__item--span-2",
|
||||
3: "responsive-grid__item--span-3",
|
||||
4: "responsive-grid__item--span-4",
|
||||
6: "responsive-grid__item--span-6",
|
||||
8: "responsive-grid__item--span-8",
|
||||
12: "responsive-grid__item--span-12",
|
||||
};
|
||||
|
||||
export function ResponsiveGrid({
|
||||
children,
|
||||
columns = 12,
|
||||
className,
|
||||
as: Component = "div",
|
||||
}: ResponsiveGridProps) {
|
||||
const colClass = COLUMN_CLASS_MAP[columns] ?? "responsive-grid--cols-12";
|
||||
return (
|
||||
<Component
|
||||
className={["responsive-grid", colClass, className].filter(Boolean).join(" ")}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
export interface GridItemProps {
|
||||
children: ReactNode;
|
||||
span?: number;
|
||||
smSpan?: number;
|
||||
mdSpan?: number;
|
||||
lgSpan?: number;
|
||||
xlSpan?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GridItem({
|
||||
children,
|
||||
span,
|
||||
smSpan,
|
||||
mdSpan,
|
||||
lgSpan,
|
||||
xlSpan,
|
||||
className,
|
||||
}: GridItemProps) {
|
||||
const spanClass = span ? SPAN_CLASS_MAP[span] : undefined;
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"responsive-grid__item",
|
||||
smSpan && `responsive-grid__item--sm-${smSpan}`,
|
||||
mdSpan && `responsive-grid__item--md-${mdSpan}`,
|
||||
lgSpan && `responsive-grid__item--lg-${lgSpan}`,
|
||||
xlSpan && `responsive-grid__item--xl-${xlSpan}`,
|
||||
spanClass,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { AppShell } from "./AppShell";
|
||||
export type { AppShellProps } from "./AppShell";
|
||||
export { ResponsiveGrid, GridItem } from "./ResponsiveGrid";
|
||||
export type { ResponsiveGridProps, GridItemProps } from "./ResponsiveGrid";
|
||||
@@ -0,0 +1,359 @@
|
||||
@layer components {
|
||||
.mailbox-dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
font-family: var(--font-family-sans);
|
||||
background-color: var(--color-surface-background);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-neutral-gray-200);
|
||||
background-color: var(--color-surface-card);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__filter-group {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__filter-select {
|
||||
appearance: none;
|
||||
padding: var(--spacing-1) var(--spacing-3) var(--spacing-1) var(--spacing-2);
|
||||
border: 1px solid var(--color-neutral-gray-300);
|
||||
border-radius: var(--border-radius-default);
|
||||
background-color: var(--color-surface-card);
|
||||
color: var(--color-neutral-gray-700);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family-sans);
|
||||
line-height: var(--line-height-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__filter-select:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border-radius: var(--border-radius-full);
|
||||
background-color: var(--color-brand-primary-light);
|
||||
color: var(--color-brand-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__filter-chip:hover {
|
||||
background-color: var(--color-brand-primary-hover);
|
||||
color: var(--color-brand-primary-on);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__filter-chip:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-neutral-gray-100);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__item:hover {
|
||||
background-color: var(--color-neutral-gray-50);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__item:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__item--active {
|
||||
background-color: var(--color-brand-primary-light);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__item-subject {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-neutral-gray-900);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__item-subject--unread {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__item-date {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-neutral-gray-500);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__item-from {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-neutral-gray-600);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__item-preview {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-neutral-gray-500);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__item-modules {
|
||||
display: flex;
|
||||
gap: var(--spacing-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__module-badge {
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__module-badge--finance {
|
||||
background-color: var(--color-semantic-success-light);
|
||||
color: var(--color-semantic-success);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__module-badge--crm {
|
||||
background-color: var(--color-brand-primary-light);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__module-badge--intel {
|
||||
background-color: var(--color-semantic-warning-light);
|
||||
color: var(--color-semantic-warning);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__module-badge--promotions {
|
||||
background-color: var(--color-neutral-gray-100);
|
||||
color: var(--color-neutral-gray-500);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border-top: 1px solid var(--color-neutral-gray-200);
|
||||
background-color: var(--color-surface-card);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__pagination-button {
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
border: 1px solid var(--color-neutral-gray-300);
|
||||
border-radius: var(--border-radius-default);
|
||||
background-color: var(--color-surface-card);
|
||||
color: var(--color-neutral-gray-700);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family-sans);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__pagination-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__pagination-button:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__pagination-button:hover:not(:disabled) {
|
||||
background-color: var(--color-neutral-gray-50);
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: var(--spacing-4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__detail-header {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__detail-subject {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-neutral-gray-900);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__detail-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-2);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-neutral-gray-500);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__detail-body {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--color-neutral-gray-800);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__detail-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-4);
|
||||
padding-top: var(--spacing-4);
|
||||
border-top: 1px solid var(--color-neutral-gray-200);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__action-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-default);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-family: var(--font-family-sans);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__action-button--primary {
|
||||
background-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary-on);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__action-button--primary:hover {
|
||||
background-color: var(--color-brand-primary-hover);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__action-button--primary:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__action-button--secondary {
|
||||
background-color: var(--color-neutral-gray-100);
|
||||
color: var(--color-neutral-gray-700);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__action-button--secondary:hover {
|
||||
background-color: var(--color-neutral-gray-200);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__action-button--secondary:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__action-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__search-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
border: 1px solid var(--color-neutral-gray-300);
|
||||
border-radius: var(--border-radius-default);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family-sans);
|
||||
color: var(--color-neutral-gray-800);
|
||||
background-color: var(--color-surface-card);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.mailbox-dashboard__search-input:focus {
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__search-input::placeholder {
|
||||
color: var(--color-neutral-gray-400);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mailbox-dashboard__toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__filter-group {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mailbox-dashboard__detail {
|
||||
padding: var(--spacing-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { MailboxDashboard } from "./MailboxDashboard";
|
||||
import type { MessageList, MessageDetail } from "../api/mailbox";
|
||||
import "./MailboxDashboard.css";
|
||||
import "../feedback/StatusMessage.css";
|
||||
import "../design-system/tokens.css";
|
||||
|
||||
const mockMessages: MessageList = {
|
||||
data: [
|
||||
{
|
||||
message_id: "msg-1",
|
||||
subject: "Your June invoice from Acme Corp",
|
||||
from_address: "billing@acme.example",
|
||||
from_name: "Acme Billing",
|
||||
date: "2026-06-10T09:42:00Z",
|
||||
body_preview: "Invoice #INV-2042 for $120.00 due 2026-06-30...",
|
||||
modules: ["finance"],
|
||||
status: "new",
|
||||
raw_s3_link: null,
|
||||
},
|
||||
{
|
||||
message_id: "msg-2",
|
||||
subject: "Weekly digest — June 7–13",
|
||||
from_address: "noreply@example.com",
|
||||
from_name: "Digest Bot",
|
||||
date: "2026-06-09T08:00:00Z",
|
||||
body_preview: "Here is your weekly summary of mailbox activity...",
|
||||
modules: ["promotions"],
|
||||
status: "processed",
|
||||
raw_s3_link: null,
|
||||
},
|
||||
{
|
||||
message_id: "msg-3",
|
||||
subject: "CRM lead: New contact from website",
|
||||
from_address: "crm-system@company.io",
|
||||
from_name: null,
|
||||
date: "2026-06-08T15:30:00Z",
|
||||
body_preview: "A new lead has been captured from the contact form...",
|
||||
modules: ["crm"],
|
||||
status: "new",
|
||||
raw_s3_link: null,
|
||||
},
|
||||
{
|
||||
message_id: "msg-4",
|
||||
subject: "Intelligence briefing #42",
|
||||
from_address: "intel@secure.net",
|
||||
from_name: "Intel Service",
|
||||
date: "2026-06-07T12:00:00Z",
|
||||
body_preview: "Key findings from the latest threat landscape analysis...",
|
||||
modules: ["intel"],
|
||||
status: "archived",
|
||||
raw_s3_link: null,
|
||||
},
|
||||
],
|
||||
pagination: { limit: 25, next_cursor: null, has_more: false },
|
||||
};
|
||||
|
||||
const mockDetail: MessageDetail = {
|
||||
...mockMessages.data[0],
|
||||
extracts: {
|
||||
finance: {
|
||||
Direction: "expense",
|
||||
Counterparty: "Acme Corp",
|
||||
Amount: 120.0,
|
||||
Currency: "USD",
|
||||
},
|
||||
},
|
||||
papra_link: null,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof MailboxDashboard> = {
|
||||
title: "Mailbox/MailboxDashboard",
|
||||
component: MailboxDashboard,
|
||||
parameters: {
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [
|
||||
{ id: "color-contrast", enabled: true },
|
||||
{ id: "aria-valid-attr-value", enabled: true },
|
||||
{ id: "list", enabled: true },
|
||||
{ id: "label", enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MailboxDashboard>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
mockData: [
|
||||
{
|
||||
url: "/api/v1/mailbox/messages*",
|
||||
response: mockMessages,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDetail: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
mockData: [
|
||||
{
|
||||
url: "/api/v1/mailbox/messages*",
|
||||
response: (url: string) => {
|
||||
if (url.includes("/status")) return { ...mockDetail, status: "archived" };
|
||||
if (url.match(/\/mailbox\/messages\/[^/]+$/)) return mockDetail;
|
||||
return mockMessages;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyState: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
mockData: [
|
||||
{
|
||||
url: "/api/v1/mailbox/messages*",
|
||||
response: { data: [], pagination: { limit: 25, next_cursor: null, has_more: false } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithPagination: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
mockData: [
|
||||
{
|
||||
url: "/api/v1/mailbox/messages*",
|
||||
response: {
|
||||
...mockMessages,
|
||||
pagination: { limit: 25, next_cursor: "cursor-abc", has_more: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MailboxDashboard } from "./MailboxDashboard";
|
||||
import type { MessageList, MessageDetail } from "../api/mailbox";
|
||||
|
||||
const mockMessages: MessageList = {
|
||||
data: [
|
||||
{
|
||||
message_id: "msg-1",
|
||||
subject: "Your June invoice from Acme Corp",
|
||||
from_address: "billing@acme.example",
|
||||
from_name: "Acme Billing",
|
||||
date: "2026-06-10T09:42:00Z",
|
||||
body_preview: "Invoice #INV-2042 for $120.00 due 2026-06-30...",
|
||||
modules: ["finance"],
|
||||
status: "new",
|
||||
raw_s3_link: null,
|
||||
},
|
||||
{
|
||||
message_id: "msg-2",
|
||||
subject: "Weekly digest",
|
||||
from_address: "noreply@example.com",
|
||||
from_name: null,
|
||||
date: "2026-06-09T08:00:00Z",
|
||||
body_preview: "Here is your weekly summary...",
|
||||
modules: ["promotions"],
|
||||
status: "processed",
|
||||
raw_s3_link: null,
|
||||
},
|
||||
],
|
||||
pagination: { limit: 25, next_cursor: null, has_more: false },
|
||||
};
|
||||
|
||||
const mockDetail: MessageDetail = {
|
||||
...mockMessages.data[0],
|
||||
extracts: {
|
||||
finance: {
|
||||
Direction: "expense",
|
||||
Counterparty: "Acme Corp",
|
||||
Amount: 120.0,
|
||||
Currency: "USD",
|
||||
},
|
||||
},
|
||||
papra_link: null,
|
||||
};
|
||||
|
||||
describe("MailboxDashboard", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders loading state initially", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
() => new Promise(() => {})
|
||||
);
|
||||
render(<MailboxDashboard />);
|
||||
expect(screen.getByText("Loading messages…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders message list after fetch", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockMessages),
|
||||
} as Response);
|
||||
render(<MailboxDashboard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Your June invoice from Acme Corp")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders empty state when no messages", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [], pagination: { limit: 25, next_cursor: null, has_more: false } }),
|
||||
} as Response);
|
||||
render(<MailboxDashboard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No messages")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders search input", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockMessages),
|
||||
} as Response);
|
||||
render(<MailboxDashboard />);
|
||||
expect(screen.getByLabelText("Search messages")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders filter by status dropdown", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockMessages),
|
||||
} as Response);
|
||||
render(<MailboxDashboard />);
|
||||
expect(screen.getByLabelText("Filter by status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders sort dropdown", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockMessages),
|
||||
} as Response);
|
||||
render(<MailboxDashboard />);
|
||||
expect(screen.getByLabelText("Sort messages")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders module badges for finance", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockMessages),
|
||||
} as Response);
|
||||
render(<MailboxDashboard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("finance")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders status badges", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockMessages),
|
||||
} as Response);
|
||||
render(<MailboxDashboard />);
|
||||
await waitFor(() => {
|
||||
const badges = screen.getAllByText("inbox");
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches message detail on click", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation((url: string | URL | Request) => {
|
||||
const urlStr = typeof url === "string" ? url : url.toString();
|
||||
if (urlStr.includes("/mailbox/messages/msg-1")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockDetail),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockMessages),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
render(<MailboxDashboard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Your June invoice from Acme Corp")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: /Your June invoice from Acme Corp/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("article")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls patchMessageStatus when Archive is clicked", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation((url: string | URL | Request) => {
|
||||
const urlStr = typeof url === "string" ? url : url.toString();
|
||||
if (urlStr.includes("/mailbox/messages/msg-1/status")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ ...mockDetail, status: "archived" }),
|
||||
} as Response);
|
||||
}
|
||||
if (urlStr.includes("/mailbox/messages/msg-1") && !urlStr.includes("/status")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockDetail),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockMessages),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
render(<MailboxDashboard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Your June invoice from Acme Corp")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: /Your June invoice from Acme Corp/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Archive")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const archiveButton = screen.getByText("Archive");
|
||||
fireEvent.click(archiveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/mailbox/messages/msg-1/status"),
|
||||
expect.objectContaining({ method: "PATCH" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("has role=application for accessibility", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockMessages),
|
||||
} as Response);
|
||||
render(<MailboxDashboard />);
|
||||
expect(screen.getByRole("application")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("search input has aria-label", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockMessages),
|
||||
} as Response);
|
||||
render(<MailboxDashboard />);
|
||||
expect(screen.getByLabelText("Search messages")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("message list has role=list", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockMessages),
|
||||
} as Response);
|
||||
render(<MailboxDashboard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("list", { name: "Email inbox" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state on API failure", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.resolve({ error: "Internal error", code: "INTERNAL_ERROR" }),
|
||||
} as Response);
|
||||
render(<MailboxDashboard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("error", { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears search on Escape key", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockMessages),
|
||||
} as Response);
|
||||
render(<MailboxDashboard />);
|
||||
const searchInput = screen.getByLabelText("Search messages");
|
||||
fireEvent.change(searchInput, { target: { value: "test" } });
|
||||
expect(searchInput).toHaveValue("test");
|
||||
fireEvent.keyDown(searchInput, { key: "Escape" });
|
||||
expect(searchInput).toHaveValue("");
|
||||
});
|
||||
|
||||
it("renders load more button when has_more is true", async () => {
|
||||
const mockWithMore: MessageList = {
|
||||
data: mockMessages.data,
|
||||
pagination: { limit: 25, next_cursor: "cursor-abc", has_more: true },
|
||||
};
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockWithMore),
|
||||
} as Response);
|
||||
render(<MailboxDashboard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Load more")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import React, { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import {
|
||||
listMailboxMessages,
|
||||
getMailboxMessage,
|
||||
patchMessageStatus,
|
||||
MailboxApiError,
|
||||
} from "../api/mailbox";
|
||||
import type { MessageSummary, MessageDetail, MailboxListParams, MessageStatusUpdate } from "../api/mailbox";
|
||||
import { useErrorReporting, usePerformanceMetrics } from "../observability";
|
||||
import { Loading, EmptyState, StatusBadge } from "../feedback/StatusMessage";
|
||||
import "./MailboxDashboard.css";
|
||||
|
||||
export interface MailboxDashboardProps {
|
||||
onNavigate?: (route: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type FilterStatus = "all" | "new" | "processed" | "archived";
|
||||
type SortOption = "-date" | "date" | "subject" | "-subject" | "from" | "-from";
|
||||
|
||||
export function MailboxDashboard({ onNavigate, className }: MailboxDashboardProps) {
|
||||
const { reportError } = useErrorReporting("MailboxDashboard");
|
||||
const { reportRenderTime } = usePerformanceMetrics("MailboxDashboard");
|
||||
|
||||
const [messages, setMessages] = useState<MessageSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<MessageDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<FilterStatus>("all");
|
||||
const [sort, setSort] = useState<SortOption>("-date");
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const fetchMessages = useCallback(
|
||||
async (params?: Partial<MailboxListParams> & { append?: boolean }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const listParams: MailboxListParams = {
|
||||
q: search || undefined,
|
||||
status: filterStatus === "all" ? undefined : [filterStatus as "new" | "processed" | "archived"],
|
||||
sort,
|
||||
limit: 25,
|
||||
cursor: params?.cursor || undefined,
|
||||
...params,
|
||||
};
|
||||
const result = await listMailboxMessages(listParams);
|
||||
if (params?.append) {
|
||||
setMessages((prev) => [...prev, ...result.data]);
|
||||
} else {
|
||||
setMessages(result.data);
|
||||
}
|
||||
setNextCursor(result.pagination.next_cursor);
|
||||
setHasMore(result.pagination.has_more);
|
||||
reportRenderTime("fetch-messages");
|
||||
} catch (err) {
|
||||
if (err instanceof MailboxApiError) {
|
||||
reportError(err, { code: err.body.code });
|
||||
} else {
|
||||
reportError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to load messages");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[search, filterStatus, sort, reportError, reportRenderTime]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessages();
|
||||
}, [filterStatus, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
fetchMessages();
|
||||
}, 300);
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [search]);
|
||||
|
||||
const handleSelectMessage = useCallback(
|
||||
async (messageId: string) => {
|
||||
setSelectedId(messageId);
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const result = await getMailboxMessage(messageId);
|
||||
setDetail(result);
|
||||
reportRenderTime("fetch-detail");
|
||||
} catch (err) {
|
||||
if (err instanceof MailboxApiError) {
|
||||
reportError(err, { code: err.body.code });
|
||||
} else {
|
||||
reportError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
setDetail(null);
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
},
|
||||
[reportError, reportRenderTime]
|
||||
);
|
||||
|
||||
const handleStatusUpdate = useCallback(
|
||||
async (messageId: string, newStatus: MessageStatusUpdate) => {
|
||||
try {
|
||||
const result = await patchMessageStatus(messageId, newStatus);
|
||||
setDetail(result);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.message_id === messageId ? { ...m, status: newStatus } : m))
|
||||
);
|
||||
reportRenderTime(`status-update-${newStatus}`);
|
||||
} catch (err) {
|
||||
if (err instanceof MailboxApiError) {
|
||||
reportError(err, { code: err.body.code });
|
||||
} else {
|
||||
reportError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
},
|
||||
[reportError, reportRenderTime]
|
||||
);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (nextCursor) {
|
||||
fetchMessages({ cursor: nextCursor, append: true });
|
||||
}
|
||||
}, [nextCursor, fetchMessages]);
|
||||
|
||||
const handleSearchKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
setSearch("");
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const sidebarItems = useMemo(
|
||||
() => [
|
||||
{ id: "inbox", label: "Inbox", href: "/mailbox", badge: messages.filter((m) => m.status === "new").length },
|
||||
{ id: "archive", label: "Archive", href: "/mailbox/archive" },
|
||||
{ id: "processed", label: "Processed", href: "/mailbox/processed" },
|
||||
],
|
||||
[messages]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={["mailbox-dashboard", className].filter(Boolean).join(" ")}
|
||||
role="application"
|
||||
aria-label="Mailbox Owner Dashboard"
|
||||
>
|
||||
<div className="mailbox-dashboard__toolbar" role="search">
|
||||
<div className="mailbox-dashboard__search">
|
||||
<input
|
||||
type="search"
|
||||
className="mailbox-dashboard__search-input"
|
||||
placeholder="Search messages… (press / to focus)"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
aria-label="Search messages"
|
||||
/>
|
||||
</div>
|
||||
<div className="mailbox-dashboard__filter-group">
|
||||
<select
|
||||
className="mailbox-dashboard__filter-select"
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as FilterStatus)}
|
||||
aria-label="Filter by status"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="new">Inbox</option>
|
||||
<option value="archived">Archived</option>
|
||||
<option value="processed">Processed</option>
|
||||
</select>
|
||||
<select
|
||||
className="mailbox-dashboard__filter-select"
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as SortOption)}
|
||||
aria-label="Sort messages"
|
||||
>
|
||||
<option value="-date">Newest first</option>
|
||||
<option value="date">Oldest first</option>
|
||||
<option value="subject">Subject A–Z</option>
|
||||
<option value="-subject">Subject Z–A</option>
|
||||
<option value="from">From A–Z</option>
|
||||
<option value="-from">From Z–A</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mailbox-dashboard__content">
|
||||
{error && (
|
||||
<StatusBadge status="error" />
|
||||
)}
|
||||
{loading && messages.length === 0 ? (
|
||||
<Loading label="Loading messages…" />
|
||||
) : messages.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No messages"
|
||||
description={search ? `No results for "${search}"` : "Your mailbox is empty"}
|
||||
/>
|
||||
) : (
|
||||
<ul className="mailbox-dashboard__list" role="list" aria-label="Email inbox" ref={listRef}>
|
||||
{messages.map((msg) => (
|
||||
<li key={msg.message_id} className="mailbox-dashboard__list-item" role="listitem">
|
||||
<button
|
||||
className={[
|
||||
"mailbox-dashboard__item",
|
||||
selectedId === msg.message_id && "mailbox-dashboard__item--active",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onClick={() => handleSelectMessage(msg.message_id)}
|
||||
aria-label={`${msg.subject} from ${msg.from_name || msg.from_address}, ${new Date(msg.date).toLocaleDateString()}`}
|
||||
type="button"
|
||||
>
|
||||
<div className="mailbox-dashboard__item-row">
|
||||
<span
|
||||
className={[
|
||||
"mailbox-dashboard__item-subject",
|
||||
msg.status === "new" && "mailbox-dashboard__item-subject--unread",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{msg.subject || "(no subject)"}
|
||||
</span>
|
||||
<span className="mailbox-dashboard__item-date">
|
||||
{new Date(msg.date).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mailbox-dashboard__item-meta">
|
||||
<span className="mailbox-dashboard__item-from">
|
||||
{msg.from_name || msg.from_address}
|
||||
</span>
|
||||
{msg.body_preview && (
|
||||
<span className="mailbox-dashboard__item-preview">
|
||||
{msg.body_preview}
|
||||
</span>
|
||||
)}
|
||||
<span className="mailbox-dashboard__item-modules">
|
||||
{msg.modules.map((mod) => (
|
||||
<span
|
||||
key={mod}
|
||||
className={`mailbox-dashboard__module-badge mailbox-dashboard__module-badge--${mod}`}
|
||||
>
|
||||
{mod}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<StatusBadge status={msg.status === "new" ? "inbox" : msg.status} />
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{hasMore && (
|
||||
<div className="mailbox-dashboard__pagination">
|
||||
<button
|
||||
className="mailbox-dashboard__pagination-button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedId && detail && (
|
||||
<aside className="mailbox-dashboard__detail" aria-label="Email detail" role="article">
|
||||
{loadingDetail ? (
|
||||
<Loading label="Loading message…" />
|
||||
) : (
|
||||
<>
|
||||
<div className="mailbox-dashboard__detail-header">
|
||||
<h2 className="mailbox-dashboard__detail-subject">{detail.subject || "(no subject)"}</h2>
|
||||
<div className="mailbox-dashboard__detail-meta">
|
||||
<span>From: {detail.from_name || detail.from_address}</span>
|
||||
<span>{new Date(detail.date).toLocaleString()}</span>
|
||||
<StatusBadge status={detail.status === "new" ? "inbox" : detail.status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mailbox-dashboard__detail-body">{detail.body_preview}</div>
|
||||
<div className="mailbox-dashboard__detail-actions">
|
||||
{detail.status !== "archived" && (
|
||||
<button
|
||||
className="mailbox-dashboard__action-button mailbox-dashboard__action-button--primary"
|
||||
onClick={() => handleStatusUpdate(detail.message_id, "archived")}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
)}
|
||||
{detail.status !== "processed" && (
|
||||
<button
|
||||
className="mailbox-dashboard__action-button mailbox-dashboard__action-button--secondary"
|
||||
onClick={() => handleStatusUpdate(detail.message_id, "processed")}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
Mark Processed
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MailboxDashboard;
|
||||
@@ -0,0 +1,13 @@
|
||||
export { MailboxDashboard } from "./MailboxDashboard";
|
||||
export { MailboxApiError } from "../api/mailbox";
|
||||
export type {
|
||||
MailboxDashboardProps,
|
||||
} from "./MailboxDashboard";
|
||||
export type {
|
||||
MessageSummary,
|
||||
MessageList,
|
||||
MessageDetail,
|
||||
MailboxListParams,
|
||||
ApiError,
|
||||
MessageStatusUpdate,
|
||||
} from "../api/mailbox";
|
||||
@@ -0,0 +1,261 @@
|
||||
@layer components {
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: var(--spacing-4);
|
||||
background-color: var(--color-surface-card);
|
||||
font-family: var(--font-family-sans);
|
||||
}
|
||||
|
||||
.sidebar-nav__header {
|
||||
margin-bottom: var(--spacing-6);
|
||||
padding-bottom: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-neutral-gray-200);
|
||||
}
|
||||
|
||||
.sidebar-nav__list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.sidebar-nav__item {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
border-radius: var(--border-radius-default);
|
||||
color: var(--color-neutral-gray-700);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-base);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sidebar-nav__link:hover {
|
||||
background-color: var(--color-neutral-gray-50);
|
||||
color: var(--color-neutral-gray-900);
|
||||
}
|
||||
|
||||
.sidebar-nav__link:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--border-radius-default);
|
||||
}
|
||||
|
||||
.sidebar-nav__link--active {
|
||||
background-color: var(--color-brand-primary-light);
|
||||
color: var(--color-brand-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.sidebar-nav__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--spacing-5);
|
||||
height: var(--spacing-5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-nav__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: var(--spacing-5);
|
||||
height: var(--spacing-5);
|
||||
padding: 0 var(--spacing-1);
|
||||
border-radius: var(--border-radius-full);
|
||||
background-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary-on);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-xs);
|
||||
}
|
||||
|
||||
.sidebar-nav__footer {
|
||||
margin-top: auto;
|
||||
padding-top: var(--spacing-4);
|
||||
border-top: 1px solid var(--color-neutral-gray-200);
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-nav__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--spacing-10);
|
||||
height: var(--spacing-10);
|
||||
border-radius: var(--border-radius-default);
|
||||
background: none;
|
||||
border: 1px solid var(--color-neutral-gray-200);
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-2);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-nav__toggle:hover {
|
||||
background-color: var(--color-neutral-gray-50);
|
||||
}
|
||||
|
||||
.mobile-nav__toggle:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mobile-nav__hamburger {
|
||||
display: block;
|
||||
width: var(--spacing-5);
|
||||
height: var(--spacing-0_5);
|
||||
background-color: var(--color-neutral-gray-700);
|
||||
border-radius: var(--border-radius-sm);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-nav__hamburger::before,
|
||||
.mobile-nav__hamburger::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: var(--spacing-0_5);
|
||||
background-color: var(--color-neutral-gray-700);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.mobile-nav__hamburger::before {
|
||||
top: calc(var(--spacing-1) * -1 - var(--spacing-0_5));
|
||||
}
|
||||
|
||||
.mobile-nav__hamburger::after {
|
||||
top: calc(var(--spacing-1) + var(--spacing-0_5));
|
||||
}
|
||||
|
||||
.mobile-nav__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: var(--color-surface-overlay);
|
||||
z-index: var(--z-index-overlay);
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.mobile-nav__drawer {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
width: 280px;
|
||||
background-color: var(--color-surface-card);
|
||||
box-shadow: var(--shadow-xl);
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--transition-normal);
|
||||
z-index: var(--z-index-modal);
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.mobile-nav__drawer--open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.mobile-nav__list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.mobile-nav__item {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mobile-nav__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border-radius: var(--border-radius-default);
|
||||
color: var(--color-neutral-gray-700);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-base);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-nav__link:hover {
|
||||
background-color: var(--color-neutral-gray-50);
|
||||
color: var(--color-neutral-gray-900);
|
||||
}
|
||||
|
||||
.mobile-nav__link:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--border-radius-default);
|
||||
}
|
||||
|
||||
.mobile-nav__link--active {
|
||||
background-color: var(--color-brand-primary-light);
|
||||
color: var(--color-brand-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.mobile-nav__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--spacing-5);
|
||||
height: var(--spacing-5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-nav__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-nav__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: var(--spacing-5);
|
||||
height: var(--spacing-5);
|
||||
padding: 0 var(--spacing-1);
|
||||
border-radius: var(--border-radius-full);
|
||||
background-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary-on);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-xs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { SidebarNav, MobileNav } from "./SidebarNav";
|
||||
import type { NavItem } from "./SidebarNav";
|
||||
import "./SidebarNav.css";
|
||||
import "../design-system/tokens.css";
|
||||
|
||||
const meta: Meta<typeof SidebarNav> = {
|
||||
title: "Navigation/SidebarNav",
|
||||
component: SidebarNav,
|
||||
parameters: {
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [
|
||||
{ id: "color-contrast", enabled: true },
|
||||
{ id: "aria-allowed-attr", enabled: true },
|
||||
{ id: "aria-valid-attr-value", enabled: true },
|
||||
{ id: "button-name", enabled: true },
|
||||
{ id: "link-name", enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SidebarNav>;
|
||||
|
||||
const inboxItems: NavItem[] = [
|
||||
{ id: "inbox", label: "Inbox", href: "/mailbox", badge: 5 },
|
||||
{ id: "archive", label: "Archive", href: "/mailbox/archive" },
|
||||
{ id: "processed", label: "Processed", href: "/mailbox/processed" },
|
||||
{ id: "settings", label: "Settings", href: "/settings" },
|
||||
{ id: "help", label: "Help", href: "/help" },
|
||||
];
|
||||
|
||||
const adminItems: NavItem[] = [
|
||||
{ id: "dashboard", label: "Dashboard", href: "/admin" },
|
||||
{ id: "logs", label: "Logs", href: "/admin/logs" },
|
||||
{ id: "config", label: "Config", href: "/admin/config" },
|
||||
{ id: "users", label: "Users", href: "/admin/users" },
|
||||
{ id: "settings", label: "Settings", href: "/settings" },
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: inboxItems,
|
||||
activeId: "inbox",
|
||||
onNavigate: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const ActiveArchive: Story = {
|
||||
args: {
|
||||
items: inboxItems,
|
||||
activeId: "archive",
|
||||
onNavigate: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const AdminNavigation: Story = {
|
||||
args: {
|
||||
items: adminItems,
|
||||
activeId: "dashboard",
|
||||
onNavigate: () => {},
|
||||
"aria-label": "Admin navigation",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHeaderAndFooter: Story = {
|
||||
args: {
|
||||
items: inboxItems,
|
||||
activeId: "inbox",
|
||||
onNavigate: () => {},
|
||||
header: <div style={{ padding: "8px", fontWeight: 600, fontSize: "14px" }}>InboxOS</div>,
|
||||
footer: <div style={{ padding: "8px", fontSize: "12px", color: "#6b7280" }}>v1.5</div>,
|
||||
},
|
||||
};
|
||||
|
||||
export const MobileDrawer: StoryObj<typeof MobileNav> = {
|
||||
render: (args) => <MobileNav {...args} />,
|
||||
args: {
|
||||
items: inboxItems,
|
||||
activeId: "inbox",
|
||||
onNavigate: () => {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { SidebarNav, MobileNav } from "./SidebarNav";
|
||||
import type { NavItem } from "./SidebarNav";
|
||||
|
||||
const mockItems: NavItem[] = [
|
||||
{ id: "inbox", label: "Inbox", href: "/mailbox", badge: 3 },
|
||||
{ id: "archive", label: "Archive", href: "/mailbox/archive" },
|
||||
{ id: "processed", label: "Processed", href: "/mailbox/processed" },
|
||||
{ id: "settings", label: "Settings", href: "/settings" },
|
||||
];
|
||||
|
||||
describe("SidebarNav", () => {
|
||||
it("renders navigation items", () => {
|
||||
render(<SidebarNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
|
||||
expect(screen.getByText("Inbox")).toBeInTheDocument();
|
||||
expect(screen.getByText("Archive")).toBeInTheDocument();
|
||||
expect(screen.getByText("Processed")).toBeInTheDocument();
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks active item with aria-current", () => {
|
||||
render(<SidebarNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
|
||||
const activeLink = screen.getByText("Inbox").closest("a");
|
||||
expect(activeLink).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
it("does not set aria-current on inactive items", () => {
|
||||
render(<SidebarNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
|
||||
const archiveLink = screen.getByText("Archive").closest("a");
|
||||
expect(archiveLink).not.toHaveAttribute("aria-current");
|
||||
});
|
||||
|
||||
it("renders badge for items with unread count", () => {
|
||||
render(<SidebarNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
|
||||
expect(screen.getByLabelText("3 unread")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onNavigate when item is clicked", () => {
|
||||
const onNavigate = vi.fn();
|
||||
render(<SidebarNav items={mockItems} activeId="inbox" onNavigate={onNavigate} />);
|
||||
fireEvent.click(screen.getByText("Archive"));
|
||||
expect(onNavigate).toHaveBeenCalledWith("archive");
|
||||
});
|
||||
|
||||
it("renders with navigation role and aria-label", () => {
|
||||
render(<SidebarNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
|
||||
expect(screen.getByRole("navigation")).toHaveAttribute("aria-label", "Main navigation");
|
||||
});
|
||||
|
||||
it("renders header and footer when provided", () => {
|
||||
render(
|
||||
<SidebarNav
|
||||
items={mockItems}
|
||||
activeId="inbox"
|
||||
onNavigate={vi.fn()}
|
||||
header={<div data-testid="header">Header</div>}
|
||||
footer={<div data-testid="footer">Footer</div>}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("footer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom aria-label", () => {
|
||||
render(
|
||||
<SidebarNav
|
||||
items={mockItems}
|
||||
activeId="inbox"
|
||||
onNavigate={vi.fn()}
|
||||
aria-label="Admin navigation"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("navigation")).toHaveAttribute("aria-label", "Admin navigation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MobileNav", () => {
|
||||
it("renders hamburger toggle button", () => {
|
||||
render(<MobileNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /open navigation/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens drawer on toggle click", () => {
|
||||
render(<MobileNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
|
||||
const toggle = screen.getByRole("button", { name: /open navigation/i });
|
||||
fireEvent.click(toggle);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes drawer on Escape key", async () => {
|
||||
render(<MobileNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
|
||||
const toggle = screen.getByRole("button", { name: /open navigation/i });
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
});
|
||||
|
||||
it("closes drawer on overlay click", async () => {
|
||||
render(<MobileNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
|
||||
const toggle = screen.getByRole("button", { name: /open navigation/i });
|
||||
fireEvent.click(toggle);
|
||||
const overlay = document.querySelector(".mobile-nav__overlay");
|
||||
expect(overlay).toBeInTheDocument();
|
||||
fireEvent.click(overlay!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /open navigation/i })).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onNavigate and closes drawer when item is clicked", async () => {
|
||||
const onNavigate = vi.fn();
|
||||
render(<MobileNav items={mockItems} activeId="inbox" onNavigate={onNavigate} />);
|
||||
const toggle = screen.getByRole("button", { name: /open navigation/i });
|
||||
fireEvent.click(toggle);
|
||||
const archiveLink = screen.getByText("Archive");
|
||||
fireEvent.click(archiveLink);
|
||||
expect(onNavigate).toHaveBeenCalledWith("archive");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /open navigation/i })).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
});
|
||||
|
||||
it("marks active item in mobile nav", () => {
|
||||
render(<MobileNav items={mockItems} activeId="inbox" onNavigate={vi.fn()} />);
|
||||
const toggle = screen.getByRole("button", { name: /open navigation/i });
|
||||
fireEvent.click(toggle);
|
||||
const inboxLink = screen.getByText("Inbox").closest("a");
|
||||
expect(inboxLink).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState, useCallback, useRef, useEffect, type ReactNode, type MouseEvent } from "react";
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
href?: string;
|
||||
icon?: ReactNode;
|
||||
badge?: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export interface SidebarNavProps {
|
||||
items: NavItem[];
|
||||
activeId: string;
|
||||
onNavigate: (id: string) => void;
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
export function SidebarNav({
|
||||
items,
|
||||
activeId,
|
||||
onNavigate,
|
||||
header,
|
||||
footer,
|
||||
className,
|
||||
"aria-label": ariaLabel = "Main navigation",
|
||||
}: SidebarNavProps) {
|
||||
return (
|
||||
<nav
|
||||
className={["sidebar-nav", className].filter(Boolean).join(" ")}
|
||||
role="navigation"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{header && <div className="sidebar-nav__header">{header}</div>}
|
||||
<ul className="sidebar-nav__list" role="list">
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeId;
|
||||
const Tag = item.href ? "a" : "button";
|
||||
return (
|
||||
<li key={item.id} className="sidebar-nav__item" role="listitem">
|
||||
<Tag
|
||||
{...(item.href ? { href: item.href } : { type: "button" })}
|
||||
className={[
|
||||
"sidebar-nav__link",
|
||||
isActive && "sidebar-nav__link--active",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={
|
||||
item.onClick ??
|
||||
((e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onNavigate(item.id);
|
||||
})
|
||||
}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="sidebar-nav__icon" aria-hidden="true">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="sidebar-nav__label">{item.label}</span>
|
||||
{item.badge !== undefined && item.badge > 0 && (
|
||||
<span
|
||||
className="sidebar-nav__badge"
|
||||
aria-label={`${item.badge} unread`}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Tag>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{footer && <div className="sidebar-nav__footer">{footer}</div>}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export interface MobileNavProps {
|
||||
items: NavItem[];
|
||||
activeId: string;
|
||||
onNavigate: (id: string) => void;
|
||||
className?: string;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
export function MobileNav({
|
||||
items,
|
||||
activeId,
|
||||
onNavigate,
|
||||
className,
|
||||
"aria-label": ariaLabel = "Mobile navigation",
|
||||
}: MobileNavProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const navRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggleMenu = useCallback(() => {
|
||||
setOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setOpen(false);
|
||||
toggleRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, closeMenu]);
|
||||
|
||||
return (
|
||||
<div className={["mobile-nav", className].filter(Boolean).join(" ")}>
|
||||
<button
|
||||
ref={toggleRef}
|
||||
className="mobile-nav__toggle"
|
||||
onClick={toggleMenu}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-nav-menu"
|
||||
aria-label={open ? "Close navigation menu" : "Open navigation menu"}
|
||||
>
|
||||
<span className="mobile-nav__hamburger" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="mobile-nav__overlay"
|
||||
onClick={closeMenu}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={navRef}
|
||||
id="mobile-nav-menu"
|
||||
className={[
|
||||
"mobile-nav__drawer",
|
||||
open && "mobile-nav__drawer--open",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<ul className="mobile-nav__list" role="list">
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeId;
|
||||
return (
|
||||
<li key={item.id} className="mobile-nav__item" role="listitem">
|
||||
<a
|
||||
href={item.href ?? "#"}
|
||||
className={[
|
||||
"mobile-nav__link",
|
||||
isActive && "mobile-nav__link--active",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onNavigate(item.id);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="mobile-nav__icon" aria-hidden="true">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="mobile-nav__label">{item.label}</span>
|
||||
{item.badge !== undefined && item.badge > 0 && (
|
||||
<span
|
||||
className="mobile-nav__badge"
|
||||
aria-label={`${item.badge} unread`}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SidebarNav, MobileNav } from "./SidebarNav";
|
||||
export type { NavItem, SidebarNavProps, MobileNavProps } from "./SidebarNav";
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import {
|
||||
useErrorReporting,
|
||||
usePerformanceMetrics,
|
||||
useNavigationReporting,
|
||||
reportToObservability,
|
||||
reportMetric,
|
||||
} from "./index";
|
||||
|
||||
describe("useErrorReporting", () => {
|
||||
it("provides reportError function", () => {
|
||||
const { result } = renderHook(() => useErrorReporting("TestComponent"));
|
||||
expect(result.current.reportError).toBeDefined();
|
||||
expect(result.current.reportWarning).toBeDefined();
|
||||
});
|
||||
|
||||
it("reportError dispatches custom event", () => {
|
||||
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
|
||||
const { result } = renderHook(() => useErrorReporting("TestComponent"));
|
||||
act(() => {
|
||||
result.current.reportError(new Error("test error"), { context: "unit-test" });
|
||||
});
|
||||
expect(dispatchSpy).toHaveBeenCalled();
|
||||
const event = dispatchSpy.mock.calls[0][0] as CustomEvent;
|
||||
expect(event.type).toBe("inboxos:report");
|
||||
expect(event.detail.component).toBe("TestComponent");
|
||||
expect(event.detail.action).toBe("error");
|
||||
expect(event.detail.metadata.errorMessage).toBe("test error");
|
||||
dispatchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("reportWarning dispatches custom event", () => {
|
||||
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
|
||||
const { result } = renderHook(() => useErrorReporting("TestComponent"));
|
||||
act(() => {
|
||||
result.current.reportWarning("test warning");
|
||||
});
|
||||
expect(dispatchSpy).toHaveBeenCalled();
|
||||
const event = dispatchSpy.mock.calls[0][0] as CustomEvent;
|
||||
expect(event.detail.action).toBe("warning");
|
||||
dispatchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePerformanceMetrics", () => {
|
||||
it("provides reportRenderTime function", () => {
|
||||
const { result } = renderHook(() => usePerformanceMetrics("TestComponent"));
|
||||
expect(result.current.reportRenderTime).toBeDefined();
|
||||
});
|
||||
|
||||
it("dispatches mount metric on mount", () => {
|
||||
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
|
||||
renderHook(() => usePerformanceMetrics("TestComponent"));
|
||||
expect(dispatchSpy).toHaveBeenCalled();
|
||||
const mountEvents = dispatchSpy.mock.calls.filter(
|
||||
(call) => (call[0] as CustomEvent).type === "inboxos:metric"
|
||||
);
|
||||
const mountEvent = mountEvents.find(
|
||||
(call) => (call[0] as CustomEvent).detail.name === "component.mount"
|
||||
);
|
||||
expect(mountEvent).toBeDefined();
|
||||
dispatchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("reportRenderTime dispatches metric event", () => {
|
||||
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
|
||||
const { result } = renderHook(() => usePerformanceMetrics("TestComponent"));
|
||||
dispatchSpy.mockClear();
|
||||
act(() => {
|
||||
result.current.reportRenderTime("initial");
|
||||
});
|
||||
const renderEvents = dispatchSpy.mock.calls.filter(
|
||||
(call) => (call[0] as CustomEvent).type === "inboxos:metric"
|
||||
&& (call[0] as CustomEvent).detail.name === "component.render"
|
||||
);
|
||||
expect(renderEvents.length).toBeGreaterThan(0);
|
||||
dispatchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useNavigationReporting", () => {
|
||||
it("provides reportNavigation function", () => {
|
||||
const { result } = renderHook(() => useNavigationReporting("Sidebar"));
|
||||
expect(result.current.reportNavigation).toBeDefined();
|
||||
});
|
||||
|
||||
it("dispatches navigation event", () => {
|
||||
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
|
||||
const { result } = renderHook(() => useNavigationReporting("Sidebar"));
|
||||
act(() => {
|
||||
result.current.reportNavigation("/inbox", "/archive");
|
||||
});
|
||||
expect(dispatchSpy).toHaveBeenCalled();
|
||||
const event = dispatchSpy.mock.calls[0][0] as CustomEvent;
|
||||
expect(event.detail.action).toBe("navigate");
|
||||
expect(event.detail.metadata.from).toBe("/inbox");
|
||||
expect(event.detail.metadata.to).toBe("/archive");
|
||||
dispatchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportToObservability (standalone)", () => {
|
||||
it("includes source and timestamp", () => {
|
||||
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
|
||||
reportToObservability({ component: "Test", action: "test-action" });
|
||||
const event = dispatchSpy.mock.calls[0][0] as CustomEvent;
|
||||
expect(event.detail.source).toBe("inboxos-ui");
|
||||
expect(event.detail.timestamp).toBeDefined();
|
||||
dispatchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportMetric (standalone)", () => {
|
||||
it("includes source and timestamp", () => {
|
||||
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
|
||||
reportMetric({ name: "test-metric", value: 42, tags: { component: "Test" } });
|
||||
const event = dispatchSpy.mock.calls[0][0] as CustomEvent;
|
||||
expect(event.detail.source).toBe("inboxos-ui-metric");
|
||||
expect(event.detail.value).toBe(42);
|
||||
dispatchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface ReportOptions {
|
||||
component: string;
|
||||
action: string;
|
||||
metadata?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
function reportToObservability(options: ReportOptions): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const payload = {
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "inboxos-ui",
|
||||
...options,
|
||||
};
|
||||
|
||||
if (typeof window.dispatchEvent === "function") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("inboxos:report", { detail: payload })
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof console !== "undefined" && typeof console.debug === "function") {
|
||||
console.debug("[inboxos-ui]", payload);
|
||||
}
|
||||
}
|
||||
|
||||
interface MetricOptions {
|
||||
name: string;
|
||||
value: number;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
function reportMetric(options: MetricOptions): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const payload = {
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "inboxos-ui-metric",
|
||||
...options,
|
||||
};
|
||||
|
||||
if (typeof window.dispatchEvent === "function") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("inboxos:metric", { detail: payload })
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof console !== "undefined" && typeof console.debug === "function") {
|
||||
console.debug("[inboxos-metric]", payload);
|
||||
}
|
||||
}
|
||||
|
||||
export function useErrorReporting(componentName: string) {
|
||||
const reportError = useCallback(
|
||||
(error: Error, metadata?: Record<string, string>) => {
|
||||
reportToObservability({
|
||||
component: componentName,
|
||||
action: "error",
|
||||
metadata: {
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack ?? "",
|
||||
...metadata,
|
||||
},
|
||||
});
|
||||
},
|
||||
[componentName]
|
||||
);
|
||||
|
||||
const reportWarning = useCallback(
|
||||
(message: string, metadata?: Record<string, string>) => {
|
||||
reportToObservability({
|
||||
component: componentName,
|
||||
action: "warning",
|
||||
metadata: { message, ...metadata },
|
||||
});
|
||||
},
|
||||
[componentName]
|
||||
);
|
||||
|
||||
return { reportError, reportWarning };
|
||||
}
|
||||
|
||||
export function usePerformanceMetrics(componentName: string) {
|
||||
const mountTimeRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mountTimeRef.current = performance.now();
|
||||
reportMetric({
|
||||
name: "component.mount",
|
||||
value: performance.now(),
|
||||
tags: { component: componentName },
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (mountTimeRef.current !== null) {
|
||||
const duration = performance.now() - mountTimeRef.current;
|
||||
reportMetric({
|
||||
name: "component.unmount",
|
||||
value: duration,
|
||||
tags: { component: componentName },
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [componentName]);
|
||||
|
||||
const reportRenderTime = useCallback(
|
||||
(label: string) => {
|
||||
const now = performance.now();
|
||||
reportMetric({
|
||||
name: "component.render",
|
||||
value: now,
|
||||
tags: { component: componentName, label },
|
||||
});
|
||||
},
|
||||
[componentName]
|
||||
);
|
||||
|
||||
return { reportRenderTime };
|
||||
}
|
||||
|
||||
export function useNavigationReporting(componentName: string) {
|
||||
const reportNavigation = useCallback(
|
||||
(from: string, to: string) => {
|
||||
reportToObservability({
|
||||
component: componentName,
|
||||
action: "navigate",
|
||||
metadata: { from, to },
|
||||
});
|
||||
},
|
||||
[componentName]
|
||||
);
|
||||
|
||||
return { reportNavigation };
|
||||
}
|
||||
|
||||
export { reportToObservability, reportMetric };
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
conditions: ["development"],
|
||||
},
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify("development"),
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/test-setup.ts"],
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: ["src/**/*.test.{ts,tsx}", "src/**/*.stories.{ts,tsx}", "src/**/*.d.ts"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user