ADR-0410: UI engineering and quality gates¶
Monorepo
ui/with React 19 + Vite 6, a frontend test pyramid mirroring ADR-0706, CI bundle budget enforcement, and Kollect branding — so the read-only console ships with the same quality bar as the operator.
Theme: 07 · Project & meta (frontend) · Status: Current (accepted 2026-06-05)
Context¶
ADR-0408 accepts a read-only SPA fed by the Read API. Maintainer locked decisions (OQ-5–8, OQ-12) specify monorepo layout, Tailwind v4, bundle budget CI failure, nightly visual regression, and MVP scope (observability only — no Target create forms, no frontend RBAC shell).
Backend quality gates live in ADR-0706. Frontend gates must be explicit so UI PRs do not rely on ad-hoc checks.
Decision¶
1. Monorepo layout (OQ-5)¶
The SPA lives in the kollect monorepo, not a separate repository:
ui/ # React SPA
src/
public/
e2e/
openapi/ # symlink or copy from repo root openapi/v1alpha1/
package.json
pnpm-lock.yaml # committed
vite.config.ts
charts/kollect-ui/ # static Deployment subchart ([ADR-0409](0409-kollect-ui-deployment.md))
hack/ci/ui-verify.sh # contract drift + bundle budget
.github/workflows/ui-ci.yaml
Taskfile targets: task ui-dev, task build-ui, task ui-ci, task ui-mock-prism, task ui-visual.
Mock stack (Phase 1): MSW intercepts /v1alpha1/* in dev and Vitest by default
(VITE_MOCK_API=true); optional Prism on port 4010 for real HTTP e2e — see
ADR-0412.
OpenAPI changes require regenerated TS types — task verify (or hack/verify-ui-contract.sh) fails
on drift, matching backend codegen discipline.
2. Stack (locked)¶
| Layer | Choice | Rationale |
|---|---|---|
| Framework | React 19 | Prior art (Argo CD UI); ADR-0408 assumption |
| Build | Vite 6 | Fast dev; content-hashed assets |
| Language | TypeScript | OpenAPI codegen; typed routes |
| Routing | @tanstack/react-router |
Code-splitting, typed loaders |
| Server state | @tanstack/react-query |
Cache, retry, SSE invalidation |
| Client UI state | Zustand (ui/src/store/) |
Filter prefs, column visibility, drawer/selection — unit-tested slices; not XState |
| Tables | @tanstack/react-table + @tanstack/react-virtual |
10k-row virtualization (NFR-PERF-1) |
| Styling | Tailwind v4 (OQ-6) | Ops-density layouts; repo-wide |
| Primitives | Radix UI | a11y-friendly dialogs, menus |
| Icons | Lucide | Tree-shakeable |
| Forms (deferred) | react-hook-form + zod |
Target create forms post-MVP |
| API client | openapi-typescript or Orval from openapi/v1alpha1/inventory.yaml |
Contract-first |
Non-goals (v0.2): i18n, Service Worker offline, heavy charting libraries, mobile-native apps, XState/state-machine client layer (deferred — Zustand is the default global store).
Client state split (v0.2): TanStack Query owns server cache; Zustand owns UI prefs and ephemeral
selection (connection, inventory column/filter prefs, selection drawer state). URL search params
are primary for inventory filters (ADR-0408).
3. Testing pyramid (mirrors ADR-0706 spirit)¶
┌─────────────────────────────────────┐
L4 │ E2E — Playwright (kind + Helm) │
│ operator + kollect-ui │
└──────────────────┬──────────────────┘
│
┌──────────────────▼──────────────────┐
L3 │ Contract — MSW + OpenAPI diff │
└──────────────────┬──────────────────┘
│
┌──────────────────────────────────▼──────────────────────────────────┐
L2 │ Component — RTL + user-event; badges, virtualized table │
└──────────────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────────────────▼──────────────────────────────────┐
L1 │ Unit — Vitest: parsers, sort keys, formatters, route loaders │
└──────────────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────────────────▼──────────────────────────────────┐
L0 │ Static — ESLint, tsc, stylelint, knip │
└──────────────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────────────────▼──────────────────────────────────┐
L4+ │ Visual regression — Percy/Chromatic (nightly, OQ-8) │
│ a11y — @axe-core/playwright (PR gate) │
└─────────────────────────────────────────────────────────────────────┘
| Tier | Tooling | CI |
|---|---|---|
| L0 | pnpm lint, pnpm typecheck |
ui-ci.yaml PR gate |
| L1 | pnpm test:unit (Vitest) |
PR gate |
| L2 | pnpm test:component (RTL) |
PR gate |
| L3 | pnpm test:contract vs openapi/v1alpha1/inventory.yaml |
PR gate |
| L4 | pnpm test:e2e (Playwright + kind + Helm) |
Nightly / workflow_dispatch |
| Visual | Percy or Chromatic — Overview, Inventory, Target detail, Sink list, onboarding | Nightly required (OQ-8) |
| a11y | @axe-core/playwright on Overview + Inventory |
PR gate |
Coverage floors: 80% lines on ui/src/lib/** and formatters; 60% overall.
E2E MVP injects SA token for Read API auth; OIDC flows added when oauth2-proxy lands (ADR-0409).
4. Bundle budget (OQ-7)¶
| Metric | Target (v0.2) |
|---|---|
| Initial JS (shell route) | ≤ 200 KB gzip |
| Lazy route chunk | ≤ 80 KB gzip each |
| Time to interactive (kind, warm) | ≤ 2.5 s throttled Fast 3G |
pnpm build fails CI when the shell route exceeds 200 KB gzip (vite-plugin-bundle-stats or
equivalent). Lazy routes for Inventory and Exports via React.lazy.
5. CI workflow ui-ci.yaml¶
Proposed jobs (implementation tracked in ROADMAP):
| Job | Trigger | Blocks merge |
|---|---|---|
ui |
PR + push main |
Yes — lint, typecheck, unit, component, contract, build (bundle budget), a11y |
ui-nightly |
cron + manual | No on PR — e2e + visual regression |
Supply chain: pnpm install --frozen-lockfile; pnpm audit + OSV scanner block high/critical;
Renovate groups ui/ deps separately.
6. Branding integration¶
Canonical public assets: docs/assets/ (logo SVG, favicons, symbol variants). UI copies or imports
from docs/assets/branding/ at build time.
| Token | Hex | Role |
|---|---|---|
| Kollekt Blue | #326CE5 |
Primary actions, links |
| Deep Navy | #081A4B |
Nav bar (dark), wordmark on light |
| Inventory Teal | #18B6A3 |
Export/sync success, healthy chips |
| Sky Accent | #7FB3FF |
Info states, decorative |
| Stone / Graphite | #E5E7EB / #1F2937 |
Borders, body text |
Typography: Inter (fallbacks: IBM Plex Sans, Geist). Ops-console density.
Naming: Product chrome uses Kollect (CRDs, page titles). Optional wordmark lockup may render Kollekt in SVG paths — do not rename CRDs or API paths.
Themes: prefers-color-scheme; light #FFFFFF + Deep Navy; dark #081A4B + white wordmark.
7. MVP scope and deferred features¶
| In v0.2 MVP | Deferred |
|---|---|
| Read-only Overview, Inventory, Target/Sink lists + detail drawers | Target create/apply forms (OQ-12) |
| Onboarding = copy-YAML + docs links | Frontend RBAC-aware nav masking |
| Export health per sink on inventory | Cross-cluster portal auth (OQ-9) |
| Virtualized catalog + filters (when Read API ships) | Login shell / OIDC UI |
| Kollect branding in nav and empty states | i18n / RTL |
| Simplified "catalog" mode for non-K8s stakeholders (v0.3) | |
| Optional BFF (Phase 3) |
8. Security checklist (pre-release)¶
- No kube tokens in
localStorage(production) - CSP enforced on UI routes (ADR-0409)
- React default escaping; no raw
condition.messageas HTML dangerouslySetInnerHTMLforbidden except sanitizer allowlist module- Dependency audit gate in
ui-ci
Consequences¶
Positive¶
- Frontend quality bar is documented and enforceable before
ui/scaffold lands. - OpenAPI-driven contract tests prevent Read API / UI drift.
- Bundle budget keeps the ops console lightweight on slow networks.
Negative¶
- Nightly visual + e2e infra cost (Percy/Chromatic license, kind runners).
- Monorepo increases repo size and CI matrix surface.
- Hybrid K8s API for CRD status requires separate client tests not covered by OpenAPI contract alone.