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

38 KiB
Raw Blame History

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):

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.vuepages/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.

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).