Files
crewli/dev-docs/superpowers/specs/2026-05-15-crewli-starter-gui-redesign-design.md
bert.hausmans ae0bd2da6c docs(spec): amend §8 severity-map + §6 stories-location + §8.X enforcement
- §8 severity table now covers all 21 values of the 5 mirrored enums
  (ShiftAssignment/ArtistEngagement/Payment/Person/MatchStatus); drops
  3 phantom rows (active/inactive/expired, in no enum). Closes the
  silent grey-fallback gap for 11 production values found in the
  Plan 3 brainstorm self-audit.
- §6 stories-placement reworded: custom/wrapper components (incl.
  PrimeVue wrappers) co-locate; only the ~80 PrimeVue catalog +
  Foundations centralize. Plan 2 misread this.
- New §8.X: bidirectional Vitest consistency test
  (apps/app/tests/unit/utils/statusSeverity.consistency.spec.ts),
  added in Plan 3 — fails on any unmapped enum value OR orphan key.

Plan 3 cleanup tasks (tracked in the Plan 3 plan doc, not here):
  (a) migrate Plan 2's 6 centralized stories to co-located.
  (b) refactor AppTopbar to wrap PrimeVue Menubar per RFC AD-3.

Background: discovered during Plan 3 (Tier-1 primitives) brainstorm
self-audit. Fixes spec-vs-reality drift and two Plan 2 deviations
from binding spec/RFC text; prevents recurrence for future enums.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 19:28:03 +02:00

660 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design — Crewli SPA GUI Redesign on the crewli-starter Design System
| Field | Value |
|---|---|
| **Status** | Design approved; spec review rounds 12 corrections applied — pending re-approval |
| **Date** | 2026-05-15 |
| **Author** | Brainstorming session (Bert + Claude Code) |
| **Supersedes** | The F4aF4d component-migration sub-packages of `dev-docs/RFC-WS-FRONTEND-PRIMEVUE.md` |
| **Next artifact** | Implementation plan (writing-plans), then a new project RFC in `dev-docs/` |
| **Source design system** | `crewli-starter/` (sibling working directory) |
> **Spec review round 1 (2026-05-15) — corrections applied, all audited against the codebase:**
> 1. **TEST-INFRA-001** (blocking): audited → ✅ Resolved; new §13 Testing strategy added (Playwright CT gate kept, re-baselined against crewli-starter, Storybook a11y complementary).
> 2. **Layout-selection mechanism** (blocking): §3 now specifies the exact `definePage({ meta: { layout: 'OrganizerLayoutV2' } })` convention + `routesFolder` vite config (both audited).
> 3. **eslint-plugin-boundaries** (blocking): §5 corrected (was factually wrong); new §14 specifies the `components-v2`/`pages-v2`/`components-foundation` zones + no-back-port asymmetry.
> 4. `useWorkspaceStore` → reuse `useAuthStore`/`useOrganisationStore` for org data + new `useShellUiStore` for sidebar/theme/density only (§4); `provide`/`inject` substitution is now a mandatory per-port rule.
> 5. Portal scope made explicit (§12, in scope, own later sprint, `/portal/*` not `/p/*`).
> 6. Smart Filter promoted to its own sub-sprint (§10).
>
> **Spec review round 2 (2026-05-16) — corrections applied, all audited against the codebase:**
> 1. **`useWorkspaceStore` ghost** (blocking): §7.4 contradiction removed — WorkspaceSwitcher data now explicitly derived via computed over `useAuthStore.organisations`/`currentOrganisation` + `useOrganisationStore` branding; no store.
> 2. **Portal `/p/*` vs `/portal/*`** (blocking): audited — frontend SPA *already* uses `/portal/*` (`src/pages/portal/...`); observability binds on `route.meta.context` not path (`contextBinding.ts:51`); `/api/v1/p/*` is a separate untouched backend layer. §12 rewritten; **no cross-doc commit needed** (feared conflict dissolved by audit).
> 3. **Route name collision** (blocking): §3 specifies the `getRouteName` `v2-` name-prefix convention for the second `routesFolder` (prevents `events` name clash).
> 4. Theme/density parallel-mode: explicit AD (§4) — v1/v2 *not* synchronised during parallel-mode (accepted, temporary; bridge explicitly rejected).
> 5. `useRightDrawer()` state: decided (§4) — lives in `useShellUiStore` (CT-testable), composable is a thin facade.
> 6. `DraggableBlock`: disambiguated (§8/§9) — it **is** a foundation deliverable; Tier-4 defers only the Timetable/Cue pages.
> 7. `definePage` enforcement: single mechanism chosen (§3) — custom ESLint rule on `pages-v2/**`, no "or".
> 8. StatusTag severity map: documented table + single-source-of-truth rule (§8), seeded from audited `src/types/` enums.
> 9. `components-foundation` brace-glob: verification note + two-pattern fallback (§14).
> 10. Storybook ↔ Playwright CT interplay: decided (§13) — independent surfaces, standalone CT specs, no `@storybook/test-runner` (matches TEST-INFRA-001 infra).
---
## 1. Context & problem
The Crewli SPA (`apps/app/`) is mid-migration from Vuetify+Vuexy to
PrimeVue 4.5 (Aura) + Tailwind v4. F3 (PrimeVue foundation, parallel-mode)
and F3.5 (a mockup-parity `AppShell`) have landed. Storybook 10 is wired
(PrimeVue + Tailwind) but contains only two smoke stories. The original
F4 plan was "translate existing Vuetify pages 1:1 to PrimeVue, preserve
UX."
A complete working prototype design system now exists in `crewli-starter/`:
~40 components, ~20 pages, a full app shell (sidebar / topbar / right
drawer / workspace switcher), and several rich modules (Timetable, Cue
editor, Section Builder). It already uses the **same stack**: PrimeVue 4.5,
Aura preset via `@primeuix/themes`, the Crewli teal token set, Tailwind v4,
Iconify-Tabler. It is pure JavaScript (no TypeScript).
**Decision: pivot the redesign to use `crewli-starter` as the design
source of truth**, building a new GUI in parallel with the existing one
and migrating page-by-page, rather than translating legacy Vuetify pages.
## 2. Decisions taken (this session)
1. **Cohabitation:** parallel routes under `/v2/*`. Old pages stay on
existing routes. Cutover is route-by-route.
2. **Fidelity rule:** PrimeVue-first. Default to a stock PrimeVue
component styled with Tailwind + `pt` API + Aura tokens. Port custom
CSS *only* when no PrimeVue primitive fits or the visual is genuinely
bespoke. Generic elements (KPI cards, error/empty states, status
badges, etc.) are **rebuilt on PrimeVue and accept the PrimeVue look**
— restyle freely. Pixel-perfect 1:1 is reserved for the genuinely
bespoke set only (see §7).
3. **Plan relationship:** a new project RFC supersedes F4aF4d. The F4
*architectural* decisions (PrimeVue + Tailwind + Aura + FormField +
DataTable conventions) all still stand; only the page-migration
strategy and design source change.
4. **Shell strategy:** a new `AppShellV2.vue` (and `OrganizerLayoutV2.vue`)
ports the crewli-starter shell 1:1. The existing `AppShell.vue` /
`OrganizerLayout.vue` stay untouched until `/v2/` supersedes them.
5. **Folder convention:** `pages-v2/` + `components-v2/` mirror the
existing structure. No per-file `V2` suffix inside those folders
(the folder name carries the distinction); the suffix is only on the
two shell/layout files that sit beside their v1 siblings.
6. **Sequencing approach:** Approach A — foundation-first vertical slice.
Build the minimum end-to-end first, then iterate page-by-page.
## 3. Folder structure & routing
```
apps/app/src/
├── pages-v2/ # NEW parallel page tree → /v2/*
├── components-v2/
│ ├── layout/ # AppSidebar, AppTopbar, SidebarNav, WorkspaceSwitcher, RightDrawer
│ ├── shared/ # PageHead, StatCard, StatusTag, StateBlock, EnergyDots, EnergyPicker, TagsInput, DraggableBlock
│ ├── templates/ # ListTemplate, FormTemplate, DetailTemplate, DashboardTemplate
│ ├── filters/ # SmartFilterBar + chip + popover + 5 editors + SmartListsRow
│ ├── timetable/ # (deferred — migrate with feature)
│ └── music/ # (deferred — migrate with feature)
│ # NOTE: no components-v2/forms/ — v2 reuses the existing
│ # apps/app/src/components/forms/FormField.vue. This folder is
│ # created ONLY if FormField provably forks (see §5).
├── layouts/
│ ├── OrganizerLayout.vue # EXISTING — untouched
│ ├── OrganizerLayoutV2.vue # NEW — wraps AppShellV2
│ └── components/
│ ├── AppShell.vue # EXISTING — untouched
│ └── AppShellV2.vue # NEW — ports crewli-starter shell 1:1
├── stories/ # EXPANDED — see §6
├── stores/ # + useShellUiStore (sidebar/theme/density ONLY —
│ # org/context data reuses existing stores, §4)
├── composables/ # + useRightDrawer
└── types/v2/ # shared v2 types
```
**Routing (exact mechanism — audited 2026-05-15).** `vite.config.ts`
currently calls `VueRouter({ getRouteName: … })` with **no `routesFolder`**,
so it defaults to `src/pages` only — `pages-v2/` would generate zero
routes without a config change. Required change (foundation deliverable
1, §9):
```ts
VueRouter({
routesFolder: [
{ src: 'src/pages' }, // unchanged, no prefix
{ src: 'src/pages-v2', path: 'v2/' }, // new → all routes prefixed /v2/
],
// CHANGED (issue 3 — route NAME collision): the existing getRouteName
// derives the name from the file path *relative to its routesFolder*,
// so pages/events/index.vue and pages-v2/events/index.vue would BOTH
// generate name `events` → silent runtime collision (router.push({
// name: 'events' }) becomes ambiguous; one wins). The v2 routesFolder
// node carries `path: 'v2/'`; getRouteName must detect that origin and
// prefix the name with `v2-`. Binding convention:
// pages-v2/events/index.vue → name `v2-events`
// pages-v2/events/[id].vue → name `v2-events-id`
// Every v2 `<RouterLink :to="{ name }">` / `router.push({ name })`
// uses the `v2-` prefix. At final cutover the prefix is stripped in
// the same commit as the folder rename (mechanical find/replace).
getRouteName: routeNode => { /* existing kebab logic + v2- prefix when
routeNode originates from src/pages-v2 */ },
})
```
**Layout selection (exact convention — audited).** The project uses
`MetaLayouts({ target: './src/layouts', defaultLayout: 'default' })` and
`setupLayouts` in `src/plugins/1.router/index.ts`. Pages opt into a
layout via `definePage({ meta: { layout: '<LayoutFileName>' } })`
(verified: `login.vue``'blank'`, `forbidden.vue``'PublicLayout'`).
**Binding convention for v2:** every `pages-v2/**` page declares
`definePage({ meta: { layout: 'OrganizerLayoutV2' } })`, and
`src/layouts/OrganizerLayoutV2.vue` must exist (MetaLayouts `target` is
`./src/layouts`). No conditional logic inside layout files — each page
tree pins its own layout. The old layout stays inert (zero regression
risk on un-migrated pages).
**Enforcement (issue 7 — single chosen mechanism, no "or"):** a custom
ESLint rule scoped to `src/pages-v2/**/*.vue` that fails the build
unless the file contains a `definePage({ meta: { layout:
'OrganizerLayoutV2' } })` call with exactly that layout value (portal v2
pages: `'PortalLayoutV2'`). ~15-line AST rule (or `ast-grep`),
foundation deliverable 1. Chosen over a runtime wrapper because a
missing meta-key is otherwise a silent wrong-shell bug (no error, just
the `default` layout) — an ESLint error fails CI loudly at author time.
**Cutover convention (per page):** when a v2 page is approved to replace
its v1 counterpart, move `pages-v2/X.vue``pages/X.vue` (overwrite),
rewrite internal links `/v2/X``/X`, delete now-unused v1 components,
commit. Router auto-updates.
## 4. AppShellV2 composition
```
layouts/components/AppShellV2.vue # Tailwind grid: sidebar | (topbar + content) | rightDrawer
# (V2 suffix: sits beside v1 AppShell.vue)
└── composes components-v2/layout/:
├── AppSidebar.vue # permanent rail (≥lg); PrimeVue Drawer overlay (<lg)
│ ├── SidebarHeader.vue # logo + collapse toggle
│ ├── SidebarNav.vue # grouped nav + active highlight
│ └── WorkspaceSwitcher.vue # bottom switcher (custom visual + PrimeVue Popover)
├── AppTopbar.vue # Breadcrumb (PrimeVue) + actions (Button/Avatar/Menu/OverlayBadge)
└── RightDrawer.vue # PrimeVue Drawer position=right + slot scaffold
```
(No `V2` suffix on the `components-v2/layout/` files — the folder carries
the distinction per decision 5. Only `AppShellV2.vue` and
`OrganizerLayoutV2.vue` carry the suffix, since they sit beside their v1
siblings in `layouts/`.)
| Piece | Implementation | Rationale |
|---|---|---|
| Outer container | Tailwind grid (custom) | Matches crewli-starter `.app`; no PrimeVue equivalent |
| Sidebar desktop | `<aside>` + Tailwind (custom) | Permanent rail; no PrimeVue primitive |
| Sidebar mobile | PrimeVue `Drawer` | Correct off-canvas primitive |
| Sidebar nav rows | `<button>` + Tailwind + Icon | crewli-starter spec too bespoke for `PanelMenu` |
| Breadcrumb | PrimeVue `Breadcrumb` | Standard fits |
| Topbar actions | PrimeVue `Button` (text) + `OverlayBadge` | Standard |
| User menu | PrimeVue `Avatar` + `Menu` | Standard |
| RightDrawer | PrimeVue `Drawer` + custom slot scaffold | Plumbing free, content bespoke |
| WorkspaceSwitcher | custom visual + PrimeVue `Popover` | See §7.4 |
**State contract** (replaces crewli-starter `provide`/`inject`). Audited
2026-05-15: existing stores are `useAuthStore`, `useOrganisationStore`,
`useImpersonationStore`, `useNotificationStore` — there is **no**
workspace/theme/density store today (theme/density currently via Vuexy
`useSkins.ts`). crewli-starter's "workspace" concept maps to Crewli's
**organisation/context**, which already lives in `useAuthStore` +
`useOrganisationStore`. To avoid duplicating tenant state:
- **Org / context / workspace data:** read from existing
`useAuthStore` (`availableContexts`, current org) +
`useOrganisationStore` (org list, branding). `WorkspaceSwitcher`
consumes these — it does **not** own org data. Switching context
goes through the existing auth/org flow.
- **`useShellUiStore` (NEW — the only new store):** holds *only* genuinely
new v2-shell UI state with no existing home — `sidebarCollapsed`,
`density`, `theme` (v2 light/dark), **and the RightDrawer state**
(`activeComponent`, `props`, `isOpen`). Nothing tenant-related.
- **`useRightDrawer()` (issue 5 — state location decided):** a thin
composable **facade over `useShellUiStore`** (not a module-level
`ref()` singleton). `open(component, props)` / `close()` mutate the
store. Rationale: Playwright CT can drive the drawer by setting store
state directly (via `@pinia/testing`) without rendering the shell — a
module-level ref would force importing the composable and leak state
across tests. Decision is binding, not deferred.
- `vue-router`: replaces `provide('navigate', …)`.
- `<html data-theme>` / `<html data-density>` mechanism retained
(composes with Aura `darkModeSelector`); v2 bypasses Vuexy
`useSkins.ts`. `useShellUiStore` owns the writes to these attributes.
**AD — theme/density during parallel-mode (issue 4, decided not
deferred):** v1 (Vuexy `useSkins.ts`) and v2 (`useShellUiStore`) have
**separate sources of truth** and are **not synchronised** while both
ship. Crossing a v1↔v2 boundary mid-session may not carry a
just-changed theme. This is an **accepted, documented limitation**: it
is temporary (gone at final cutover), the cross-boundary-after-toggle
path is rare, and a bridge would couple new code to the Vuexy module
being deleted. Explicitly rejected alternative: a
`useShellUiStore.$subscribe` → Vuexy skin-key writer (rejected — adds
coupling to dead-end code for a transient edge case). If user testing
later shows this is jarring, the one-way bridge is the pre-identified
fallback, but it is **not** built by default.
**`provide`/`inject` substitution is a mandatory per-port rule:**
crewli-starter uses `provide`/`inject` pervasively (`workspace`,
`navigate`, `openDrawer`, `theme`, `density`, …). Every ported component
must replace these with the store/composable/router equivalents above —
no `inject()` survives the port. This is an architectural decision in
the RFC, applied to every component without exception.
F3.5's `AppShell` features (notification bell, help, breadcrumb,
org-switcher card) are not lost — the underlying state wires up
identically; only the visual treatment moves to the crewli-starter look.
## 5. Component conventions
Decision tree: (1) PrimeVue component exists → use it raw + Tailwind +
`pt`. (2) Same composition 3+ times → wrap it. (3) Genuinely outside
PrimeVue → custom in `components-v2/`.
| Category | Examples | Rule |
|---|---|---|
| Raw PrimeVue | Button, InputText, Tag, Card, Divider, Drawer, Dialog, Avatar, Breadcrumb, DataTable, Toast, Skeleton, Message | Import directly; Tailwind + `pt`; Aura tokens carry brand |
| Wrappers | FormField (reuse existing), Icon (reuse existing), AppDialog, DataTableLazy | Wrap when composition repeats 3+ times |
| Custom | WorkspaceSwitcher, DraggableBlock, EnergyDots, EnergyPicker, RightDrawer scaffold, the timetable/music modules | Hand-built; scoped CSS only when Tailwind can't express it |
- **No `V2` suffix** on files inside `pages-v2/` / `components-v2/` (folder
carries it). Suffix only on `AppShellV2.vue` / `OrganizerLayoutV2.vue`.
- **Forms:** reuse existing `apps/app/src/components/forms/FormField.vue`
(already PrimeVue + Zod). No fork unless it provably diverges.
- **Icons:** reuse existing `apps/app/src/components/Icon.vue`.
crewli-starter `<Icon icon="tabler:x" />` → project `<Icon name="tabler-x" />`
(colon→hyphen is the only delta).
- **TypeScript conversion:** `<script setup>``<script setup lang="ts">`;
`defineProps({...})``defineProps<{...}>()` + `withDefaults`;
`defineEmits([...])` → typed `defineEmits<{...}>()`; `inject(...)`
composable/store; component-local types inline, shared types in
`types/v2/`.
- **Boundaries (CORRECTED — audited 2026-05-15):** the matrix does **not**
apply to v2 folders automatically. `boundaries/elements` uses explicit
path patterns (`src/components/**`, `src/components/{shared,auth,settings}/**`,
`src/pages/{events,members,…}/**`, etc.); **nothing matches
`src/components-v2/**` or `src/pages-v2/**`** — they fall through to no
element type. New zones + matrix rows are a required foundation
deliverable. Specification in §14.
## 6. Storybook organization
Custom components are first-class in Storybook exactly like PrimeVue
ones. crewli-starter `ComponentsPage.vue` (~80 PrimeVue components) is
the **source of truth for the standard-component catalog**.
**Story placement (binding — two disjoint classes):**
1. **Custom/wrapper components.** Every component authored in this repo
— shell pieces (`AppSidebar`, `AppTopbar`, `WorkspaceSwitcher`,
`RightDrawer`, …), Tier-1 primitives (`StatusTag`, `StatCard`,
`StateBlock`, `PageHead`, `TagsInput`, `EnergyDots`, `EnergyPicker`),
`DraggableBlock`, and the template layer — **including components
that internally wrap a PrimeVue component**. Their `.stories.ts` is
**co-located next to the `.vue`** so it moves with the component at
cutover and is deleted with it. This is the default for essentially
all `components-v2/**` work.
2. **PrimeVue standard catalog + Foundations.** The ~80-component
pure-PrimeVue demo gallery (source of truth: crewli-starter
`ComponentsPage.vue`) and the Foundations stories
(Color/Typography/Spacing/Icons/Dark/Density) — neither has an owning
`.vue`. These are **centralized under `stories/`**.
"Wraps a PrimeVue component" does **not** reclassify a component into
the catalog — a wrapper is still custom and co-locates. (Plan 2 misread
the prior wording and centralized 6 shell stories under
`src/stories/v2/`; Plan 3 carries a cleanup task to migrate them — see
commit body.)
**Story tree:** `Foundations/` (Color, Typography, Spacing, Icons, Dark
mode, Density) · `PrimeVue/` (~80, grouped as in ComponentsPage) ·
`Layout/` (AppSidebar, AppTopbar, WorkspaceSwitcher, RightDrawer,
AppShell) · `Shared/` (StatusTag, EnergyDots, StatCard, PageHead,
StateBlock, DraggableBlock) · `Forms/` (FormField) · `Templates/`
(List, Form, Detail, Dashboard, StateBlock) · `Modules/` (later:
Timetable, Cue, SectionBuilder).
**Conventions (binding):** CSF3; `tags: ['autodocs']`; every prop wired
as an `argTypes` control; each meaningful state is its own named export
(not a kitchen-sink story); a11y addon runs per story.
**Global toolbar** (`.storybook/preview.ts`): theme toggle (light/dark,
sets `data-theme`) + density toggle (comfortable/compact, sets
`data-density`) — every story verifiable in all 4 theme×density combos.
## 7. Bespoke component specs (1:1 fidelity)
### 7.1 DraggableBlock — fully custom
Extracted from inline `TimetableGrid` markup so timetable + cue editor
render identically.
```ts
defineProps<{
line1Left: { tag?: { label: string; severity?: TagSeverity }; text?: string }
line1Right?: { tag?: { label: string; severity?: TagSeverity }; pill?: string }
line2Left?: string
line2Right?: { progress: number } | null
selected?: boolean
dragging?: boolean
density?: 'compact' | 'regular' | 'comfy' // 56 / 64 / 76 px row
}>()
defineEmits<{
click: []
dragstart: [e: PointerEvent]
dragend: [delta: { x: number; y: number }]
}>()
```
Internals: PrimeVue `Tag` + PrimeVue `ProgressBar`; 2-line flex grid in
scoped CSS (no Tailwind utility expresses crewli-starter spacing — the
justified "port custom CSS" case). Drag = crewli-starter native pointer
drag (coupled to timeline px/min math); component emits
`dragstart`/`dragend`, parent owns positioning. `vuedraggable` not used
(wrong abstraction for free-position blocks). Stories: ArtistBlock,
CueBlock, WithProgress, Selected, Dragging, DarkMode, density variants.
### 7.2 AppDialog — PrimeVue Dialog + slot scaffold
PrimeVue `Dialog` provides scrim / focus-trap / escape / teleport / a11y
(replaces crewli-starter `AppModal` hand-rolled plumbing). Slot contract
preserved: `title`, `sub`, `#tabs` (optional), default = scrollable body,
`#footer` (rendered only if provided). `:pt` matches crewli-starter
header/body/footer padding + body-scroll behaviour.
### 7.3 RightDrawer — PrimeVue Drawer + scaffold
PrimeVue `Drawer position="right"`. Slot scaffold: header (close +
title + `#actions`), scrollable default body, `flush` prop (removes body
padding for edge-to-edge content). Driven by `useRightDrawer()`.
### 7.4 WorkspaceSwitcher — custom visual + PrimeVue Popover
Bespoke visual (gradient-square logo with initials, name/sub stack,
chevron) stays 1:1 custom CSS. Dropdown plumbing (open/close,
click-outside, positioning) replaced by PrimeVue `Popover` (drops manual
`mousedown` listener). Panel content (Workspaces header + Manage link,
list with gradient logos + current checkmark, footer New workspace /
Invite) stays custom markup.
**Data source (corrected — consistent with §4, no `useWorkspaceStore`):**
the switcher reads existing stores only:
- org list ← `useAuthStore().organisations`
- current org ← `useAuthStore().currentOrganisation` (existing computed)
- context switching ← existing auth/org flow (not a new action)
The crewli-starter shape `{ initials, name, sub, gradient }` is **derived
via computed properties over that store data**, not stored:
`name` = org name; `initials` = computed from `name`; `gradient` =
derived from `useOrganisationStore` branding (`primaryColor` per
RFC-WS-FRONTEND-PRIMEVUE AD-2) → a two-stop gradient; `sub` = org
metadata line. No new store, no duplicated tenant state.
**Pattern established:** overlay/focus/escape/click-outside behaviour
comes free from PrimeVue `Dialog`/`Drawer`/`Popover`; only bespoke
*visual content* keeps custom CSS.
## 8. Additional secured elements
**Tier-1 primitives (PrimeVue-based, secure now):**
| Element | v2 implementation | Custom CSS |
|---|---|---|
| StatCard | PrimeVue `Card` + Icon + trend `Tag` | none (replaces AppKpiCard) |
| StatusTag | PrimeVue `Tag` + `severity` map (+ dot via `pt`) | none |
| StateBlock | `Skeleton` (loading) · `Message`+retry (error) · `Card`+`Button` (empty) | none (replaces ErrorHeader / AppLoadingIndicator) |
| TagsInput | PrimeVue `AutoComplete` `multiple` + `typeahead` | none (drop custom) |
| PageHead | thin Tailwind flex (title/sub/`#actions`) | none (pure layout) |
| EnergyDots / EnergyPicker | 5-dot meter — no PrimeVue primitive (`Rating` is stars, wrong visual) | minimal, justified |
**StatusTag severity map (issue 8 — documented, single source of
truth).** Audited backend-mirrored enums in `src/types/` today:
`ShiftAssignmentStatus`, `ArtistEngagementStatus`, `PaymentStatus`,
`PersonStatus`, `MatchStatus`. Status→PrimeVue-`severity` is **project
knowledge, not a per-page decision** — it lives in one map
`components-v2/shared/statusSeverity.ts`, consumed by `StatusTag`:
| Status value(s) | `severity` | Semantics |
|---|---|---|
| `approved`, `completed`, `confirmed`, `contracted`, `paid_in_full` | `success` | terminal-good / fully settled |
| `pending_approval`, `pending`, `applied`, `option`, `offered`, `reverted` | `warn` | organizer action required |
| `invited`, `requested`, `deposit_paid` | `info` | awaiting external party / in-progress — no viewer action |
| `none`, `draft`, `dismissed` | `secondary` | muted — absent / not-yet-live / archived |
| `rejected`, `cancelled`, `declined`, `no_show` | `danger` | terminal-bad |
| (unmapped at runtime) | renders `info` + dev console warn | unreachable in a passing build — §8.X consistency test fails on any gap |
Rule: **every** backend status enum mirrored into `src/types/` gets a
row here in the same PR that adds the enum (extends the existing
"mirror backend PHP enums" project rule). `StatusTag` never inlines a
severity; it always resolves through this map.
### 8.X Enforcement (binding)
The §8 severity map is mechanically enforced by a single Vitest unit
test, `apps/app/tests/unit/utils/statusSeverity.consistency.spec.ts`,
added in Plan 3 alongside `statusSeverity.ts`. It imports the live enum
modules (`ShiftAssignmentStatus`, `ArtistEngagementStatus`,
`PaymentStatus`, `PersonStatus`, `MatchStatus`) and asserts **both**
directions:
1. **Completeness** — every value of every listed enum resolves to an
explicit severity in `statusSeverity.ts`, never the dev-fallback.
Guards the failure mode that left 11 values silent-falling to grey
`info`.
2. **No phantoms** — every key in `statusSeverity.ts` corresponds to a
value present in at least one listed enum. Guards the inverse mode
(the original table mapped `active`/`inactive`/`expired`, which exist
in no enum).
Adding a new mirrored enum, or a new value on an existing one, requires
extending **both** `statusSeverity.ts` and this test's enum-list in the
**same commit**. A silent fallback or an orphan key is a test failure
that blocks CI, not a convention. This extends the existing "mirror
backend PHP enums" rule (CLAUDE.md) with a mechanical gate.
**Tier-2 — Smart Filter subsystem (secure generic version now):**
`SmartFilterBar` + `FilterChip` + `FilterPopover` + `AddFilterMenu` +
5 editors (Text / NumberRange / MultiSelect / EnumGrid / Tag) +
`SmartListsRow` + `useSmartFilters`. Music-specific `QueryChipsBar` is a
variant — deferred with the music module.
**Tier-3 — Template layer (highest consistency lever):**
`ListTemplate` (PageHead + SmartFilterBar + DataTable + states),
`FormTemplate` (PageHead + Form/FormField + footer actions),
`DetailTemplate` (PageHead + Tabs + RightDrawer hook),
`DashboardTemplate` (StatCard grid + widget slots),
`StateBlock` (the CLAUDE.md mandatory loading/error/empty three-state in
one place). Pages-v2 compose a template instead of hand-rolling layout.
**Tier-4 — Deferred (migrate with owning page):** the Timetable suite
(`TimetableGrid` 947, `TimetableTab` 687, `TimetableModals`,
`ParkingColumn`, `TimetablePopover`) and Music/Cue suite
(`CueTimelineEditor` 889, `SectionBuilderPro` 2084, `SongDrawerBody`,
`SongFilesPanel`) — these large feature *modules and their pages* are
deferred.
**`DraggableBlock` is explicitly NOT deferred (issue 6 resolved).** It is
a **foundation-sprint deliverable** (built in §9 deliverable 3, listed
in `components-v2/shared/`, Storybook stories required early per the
original brief). Tier-4 defers the Timetable/Cue *pages*, not this
shared primitive. It is non-trivial (pointer drag, 3 density modes,
scoped CSS) and intentionally built first precisely because two future
features depend on it being stable and visually locked.
**Reconciliation:** v2 uses crewli-starter-derived versions; existing
`AppKpiCard` / `AppSearchHeader` / `AppLoadingIndicator` / `ErrorHeader`
stay frozen on v1 surfaces and die at cutover. No back-porting either
direction.
## 9. Foundation sprint deliverables
1. Routing + layout scaffold: **`vite.config.ts` `routesFolder` change**
(§3), `OrganizerLayoutV2`, `AppShellV2`, `/v2/*` mount via the
`definePage` meta-key convention (§3), `useShellUiStore`,
`useRightDrawer()`, theme/density wiring, **`eslint-plugin-boundaries`
zone + matrix extension (§14)**, one empty `pages-v2/dashboard.vue`
proving boot + a passing lint run.
2. Shell pieces ported 1:1 (TS): AppSidebar, AppTopbar, SidebarNav,
WorkspaceSwitcher, RightDrawer, AppDialog.
3. Tier-1 primitives (PrimeVue-based per §8) **plus `DraggableBlock`**
(custom, §7.1 — foundation despite Tier-4 deferring the
Timetable/Cue pages; see §8).
4. Template layer: List / Form / Detail / Dashboard / StateBlock.
5. Storybook: global theme/density toolbar; story tree; stories for
every Tier-1 primitive + 4 shell components + each template +
foundations. PrimeVue standard catalog (~80) split across foundation
+ Page-1 sprints.
6. New project RFC in `dev-docs/` superseding F4aF4d; pointer added
from `PRIMEVUE_COMPONENTS.md`.
## 10. Sequencing after foundation
- **Smart Filter sub-sprint:** the Smart Filter subsystem (5 editors +
`useSmartFilters` composable + bar/chip/popover, ~600 lines) is its
**own sub-sprint**, not "in parallel" work bundled into Page-1. It
lands immediately before Page-1 because Page-1 is its first consumer.
- **Page-1 sprint:** events list end-to-end via `ListTemplate`
(exercises the now-secured SmartFilterBar + lazy DataTable +
RightDrawer — highest pattern-validation value). Finish PrimeVue
standard catalog stories here.
- **Subsequent sprints:** one page tree at a time (dashboard → settings
→ events detail → members → platform → **portal**). Each: build under
`pages-v2/`, add only needed components/stories, sign-off, cutover
commit. Portal is in scope (§12) but is its own later sprint with its
own `PortalLayoutV2` (token-auth, different chrome than the organizer
shell).
- **Domain modules** migrate with their owning page.
- **Final cutover:** rename `pages-v2/``pages/`,
`components-v2/``components/`, drop `*V2` suffixes, **strip the `v2-`
route-name prefix and revert the `routesFolder`/`getRouteName` change
(§3)**, collapse the boundaries zones (§14), delete dead v1 shell +
Vuetify, one closure commit.
## 11. Quality gates (per sprint)
`pnpm test`, `pnpm typecheck`, `pnpm lint` (with the §14 boundaries
extension active — v2 folders are zoned, not unmatched), Playwright CT +
visual baselines per §13, Storybook a11y panel clean on new stories,
visual sign-off against crewli-starter. No `any`. Existing test count
must not regress.
## 12. Non-goals
- No backend changes.
- No deletion of v1 code until per-page cutover (or final cutover).
- No back-porting v2 components into v1 surfaces (and vice versa).
- Domain modules (Timetable, Cue, SectionBuilder) are *not* foundation
work — they migrate with their owning page.
- Flatpickr / vue-i18n / DatePicker decisions inherit from
RFC-WS-FRONTEND-PRIMEVUE unchanged.
- No PrimeVue Pro / template purchase.
**Portal scope (explicit decision — audited 2026-05-15, issue 2
resolved).** The review flagged a feared cross-doc `/p/*` vs `/portal/*`
conflict touching observability + API. **Audit dissolves it — there is
no conflict, because two different layers were being conflated:**
- **Frontend SPA route prefix = `/portal/*`** (verified in the repo, not
just claimed by a doc): `src/pages/portal/{advance,evenementen,
profiel,registreren,shifts,…}`; router tests assert
`/portal/evenementen`, `/portal/advance/:token`, `/portal/profiel`.
This is the layer the v2 redesign touches.
- **Backend API path = `/api/v1/p/...`** (e.g.
`/api/v1/p/artist/{token}` in RFC-TIMETABLE). This is a **different
layer**; it already coexists with the `/portal/*` SPA routes today
and is **out of scope** (this redesign is frontend-only, §12).
- **Frontend observability is NOT path-prefix bound.**
`src/observability/contextBinding.ts:51` keys on
`route.meta.public === true && route.meta.context === 'portal'`
**route meta, not the URL path**. Moving/prefixing SPA routes does not
affect Sentry actor-scope tagging as long as v2 portal routes carry
the same `meta.context: 'portal'` (binding requirement, added to the
portal sub-sprint). RFC-WS-7-OBSERVABILITY's `/p/*` prose describes
the backend; no observability-config change is triggered by this
redesign, and **no cross-doc commit is required.**
**Decision:** portal is **in scope** (crewli-starter ships
`PortalPage`/`PortalVolunteerPage`/`PortalCustomerPage`/
`PortalVolunteerFormPage`), as a dedicated **later sprint** (not
foundation) with its own `PortalLayoutV2` (token-auth chrome differs
from the organizer shell). During parallel mode it lives at
`/v2/portal/*` **with `meta.context: 'portal'` preserved**; cutover
folds it to `/portal/*`. The backend `/api/v1/p/*` layer is untouched.
## 13. Testing strategy (audited 2026-05-15)
The review correctly flagged that RFC Amendment A-1 made TEST-INFRA-001
a prerequisite to F2. **Audit result: TEST-INFRA-001, TEST-CONTRACT-001,
and TEST-VISUAL-001 are all ✅ Resolved** (`BACKLOG.md` §§ at lines
2307 / 2371 / 2424; closed in branch `chore/test-infra-001`, commits
`b8d18e6` / `2dfb1e8` / `f6509d9`). The Playwright Component Testing +
visual-regression foundation **exists in the repo today**
(`playwright-ct.config.ts`, `pnpm test:component`, `pnpm test:visual`).
The precondition is therefore satisfied — but RFC R-11's rationale
("jsdom does not detect visual regression in a multi-component
migration") is **more** relevant here, not less, because v2 builds
components from scratch rather than translating them. Explicit decision:
| Tier | Tool | v2 application |
|---|---|---|
| Logic / props / emits | Vitest + jsdom | Unchanged. Component-local logic, store/composable units. |
| Component render + a11y | Playwright CT (`test:component`) | **Required** for every bespoke component (§7) and every template (§8 Tier-3). Reuses the TEST-INFRA-001 foundation. |
| Visual regression | Playwright CT `@visual` (`test:visual`) | **Required** baselines for bespoke components + templates + the shell. Workflow: the v2 component is visually signed off against crewli-starter (human parity check), *then* its rendered output is captured as the committed baseline screenshot; thereafter CI flags any drift from that approved baseline. (Not an automated cross-repo diff against crewli-starter, and *not* the legacy Vuexy prototype HTML that TEST-VISUAL-001 baselined.) Sub-package closure requires green baselines. |
| Accessibility (authoring aid) | Storybook `addon-a11y` | **Complementary, not a replacement** for Playwright a11y. Storybook is the authoring/QA surface; CI correctness gate stays Playwright CT + `vitest-axe`. |
Storybook + manual sign-off does **not** replace Playwright CT — it
augments it. This is the deliberate, bidirectional-valid choice the
review asked for, made explicitly: keep the Playwright gate, add
Storybook as the authoring/review surface, re-baseline visuals against
crewli-starter.
**Storybook ↔ Playwright CT interplay (issue 10 — audited, matches
TEST-INFRA-001 infra).** Verified: `playwright-ct.config.ts` uses
`testDir: './tests/playwright-ct'`, `testMatch: /.*\.spec\.ts$/`, and
the existing CT suite does **not** mount Storybook stories (no
`.stories` reference in the CT config or specs). **Decision (consistent
with the established infra):** the two surfaces are **independent and
share only the component under test** — CT tests are standalone
`tests/playwright-ct/**/*.spec.ts` that `mount()` the component
directly; Storybook stories are separate authoring/QA artifacts. v2
follows this exactly: do **not** wire Playwright to render Storybook
stories (no `@storybook/test-runner`); a component has both a
`.stories.ts` (authoring) and a `tests/playwright-ct/.../*.spec.ts`
(gate), each owning its own fixtures. Rationale: matches TEST-INFRA-001,
avoids coupling the CI gate to Storybook's build.
## 14. eslint-plugin-boundaries extension (audited 2026-05-15)
My original §5 claim ("matrix applies identically") was **factually
wrong** and is corrected here. `.eslintrc.cjs` `boundaries/elements`
uses explicit, order-sensitive path patterns; none match
`src/components-v2/**` or `src/pages-v2/**`. Required foundation change
(deliverable 1):
1. **New element zones** (added *before* the generic `components`
catch-all, since first-match wins):
- `{ type: 'components-v2', pattern: 'src/components-v2/**' }`
- `{ type: 'pages-v2', pattern: 'src/pages-v2/**' }`
- A narrow whitelist zone for the two intentional cross-imports:
`{ type: 'components-foundation', pattern: 'src/components/{forms/**,Icon.vue}' }`
(FormField + Icon — audited to live in the generic `components`
zone today, not `components-shared`).
**Issue 9 — brace-expansion caveat:** `eslint-plugin-boundaries`
resolves `pattern` via micromatch; single-level brace expansion is
supported in current versions, but deliverable 1 must **verify it
against the project's installed `eslint-plugin-boundaries` version**
(currently `6.0.2`) with a one-line test. **Safe fallback if brace
expansion misbehaves:** declare the zone as two explicit element
entries instead — `{ type: 'components-foundation', pattern:
'src/components/forms/**' }` and `{ type: 'components-foundation',
pattern: 'src/components/Icon.vue' }` (same `type`, two patterns is
valid and unambiguous).
2. **New matrix rows** (`boundaries/element-types`):
- `{ from: 'components-v2', allow: ['types','utils','lib','composables','composables-forms','stores','components-v2','components-foundation'] }`
- `{ from: 'pages-v2', allow: ['types','utils','lib','composables','composables-forms','stores','navigation','components-v2','components-foundation','layouts','plugins'] }`
3. **Asymmetry rule:** `components-v2` / `pages-v2` may import
`components-foundation` (FormField, Icon) — the *only* permitted
v1→v2 bridge. v1 zones get **no** `allow` entry for `components-v2`
(no back-porting, enforced structurally, matching §12).
Exact ESLint object syntax is an implementation-plan task; this section
fixes the *contract* (zones, allowed edges, the no-back-port asymmetry).