- §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>
38 KiB
Design — Crewli SPA GUI Redesign on the crewli-starter Design System
| Field | Value |
|---|---|
| Status | Design approved; spec review rounds 1–2 corrections applied — pending re-approval |
| Date | 2026-05-15 |
| Author | Brainstorming session (Bert + Claude Code) |
| Supersedes | The F4a–F4d 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:
- TEST-INFRA-001 (blocking): audited → ✅ Resolved; new §13 Testing strategy added (Playwright CT gate kept, re-baselined against crewli-starter, Storybook a11y complementary).
- Layout-selection mechanism (blocking): §3 now specifies the exact
definePage({ meta: { layout: 'OrganizerLayoutV2' } })convention +routesFoldervite config (both audited).- eslint-plugin-boundaries (blocking): §5 corrected (was factually wrong); new §14 specifies the
components-v2/pages-v2/components-foundationzones + no-back-port asymmetry.useWorkspaceStore→ reuseuseAuthStore/useOrganisationStorefor org data + newuseShellUiStorefor sidebar/theme/density only (§4);provide/injectsubstitution is now a mandatory per-port rule.- Portal scope made explicit (§12, in scope, own later sprint,
/portal/*not/p/*).- Smart Filter promoted to its own sub-sprint (§10).
Spec review round 2 (2026-05-16) — corrections applied, all audited against the codebase:
useWorkspaceStoreghost (blocking): §7.4 contradiction removed — WorkspaceSwitcher data now explicitly derived via computed overuseAuthStore.organisations/currentOrganisation+useOrganisationStorebranding; no store.- Portal
/p/*vs/portal/*(blocking): audited — frontend SPA already uses/portal/*(src/pages/portal/...); observability binds onroute.meta.contextnot 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).- Route name collision (blocking): §3 specifies the
getRouteNamev2-name-prefix convention for the secondroutesFolder(preventseventsname clash).- Theme/density parallel-mode: explicit AD (§4) — v1/v2 not synchronised during parallel-mode (accepted, temporary; bridge explicitly rejected).
useRightDrawer()state: decided (§4) — lives inuseShellUiStore(CT-testable), composable is a thin facade.DraggableBlock: disambiguated (§8/§9) — it is a foundation deliverable; Tier-4 defers only the Timetable/Cue pages.definePageenforcement: single mechanism chosen (§3) — custom ESLint rule onpages-v2/**, no "or".- StatusTag severity map: documented table + single-source-of-truth rule (§8), seeded from audited
src/types/enums.components-foundationbrace-glob: verification note + two-pattern fallback (§14).- 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)
- Cohabitation: parallel routes under
/v2/*. Old pages stay on existing routes. Cutover is route-by-route. - Fidelity rule: PrimeVue-first. Default to a stock PrimeVue
component styled with Tailwind +
ptAPI + 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). - Plan relationship: a new project RFC supersedes F4a–F4d. The F4 architectural decisions (PrimeVue + Tailwind + Aura + FormField + DataTable conventions) all still stand; only the page-migration strategy and design source change.
- Shell strategy: a new
AppShellV2.vue(andOrganizerLayoutV2.vue) ports the crewli-starter shell 1:1. The existingAppShell.vue/OrganizerLayout.vuestay untouched until/v2/supersedes them. - Folder convention:
pages-v2/+components-v2/mirror the existing structure. No per-fileV2suffix 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. - 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):
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).WorkspaceSwitcherconsumes 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 overuseShellUiStore(not a module-levelref()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: replacesprovide('navigate', …).<html data-theme>/<html data-density>mechanism retained (composes with AuradarkModeSelector); v2 bypasses VuexyuseSkins.ts.useShellUiStoreowns 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
V2suffix on files insidepages-v2//components-v2/(folder carries it). Suffix only onAppShellV2.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([...])→ typeddefineEmits<{...}>();inject(...)→ composable/store; component-local types inline, shared types intypes/v2/. - Boundaries (CORRECTED — audited 2026-05-15): the matrix does not
apply to v2 folders automatically.
boundaries/elementsuses explicit path patterns (src/components/**,src/components/{shared,auth,settings}/**,src/pages/{events,members,…}/**, etc.); nothing matchessrc/components-v2/**orsrc/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):
- 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.tsis co-located next to the.vueso it moves with the component at cutover and is deleted with it. This is the default for essentially allcomponents-v2/**work. - 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 understories/.
"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.
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:
- 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 greyinfo. - No phantoms — every key in
statusSeverity.tscorresponds to a value present in at least one listed enum. Guards the inverse mode (the original table mappedactive/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
- Routing + layout scaffold:
vite.config.tsroutesFolderchange (§3),OrganizerLayoutV2,AppShellV2,/v2/*mount via thedefinePagemeta-key convention (§3),useShellUiStore,useRightDrawer(), theme/density wiring,eslint-plugin-boundarieszone + matrix extension (§14), one emptypages-v2/dashboard.vueproving boot + a passing lint run. - Shell pieces ported 1:1 (TS): AppSidebar, AppTopbar, SidebarNav, WorkspaceSwitcher, RightDrawer, AppDialog.
- Tier-1 primitives (PrimeVue-based per §8) plus
DraggableBlock(custom, §7.1 — foundation despite Tier-4 deferring the Timetable/Cue pages; see §8). - Template layer: List / Form / Detail / Dashboard / StateBlock.
- 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.
- New project RFC in
dev-docs/superseding F4a–F4d; pointer added fromPRIMEVUE_COMPONENTS.md.
10. Sequencing after foundation
- Smart Filter sub-sprint: the Smart Filter subsystem (5 editors +
useSmartFilterscomposable + 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 ownPortalLayoutV2(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*V2suffixes, strip thev2-route-name prefix and revert theroutesFolder/getRouteNamechange (§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:51keys onroute.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 samemeta.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):
- New element zones (added before the generic
componentscatch-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 genericcomponentszone today, notcomponents-shared). Issue 9 — brace-expansion caveat:eslint-plugin-boundariesresolvespatternvia micromatch; single-level brace expansion is supported in current versions, but deliverable 1 must verify it against the project's installedeslint-plugin-boundariesversion (currently6.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' }(sametype, two patterns is valid and unambiguous).
- 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'] }
- Asymmetry rule:
components-v2/pages-v2may importcomponents-foundation(FormField, Icon) — the only permitted v1→v2 bridge. v1 zones get noallowentry forcomponents-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).