- §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>
660 lines
38 KiB
Markdown
660 lines
38 KiB
Markdown
# 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:**
|
||
> 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 F4a–F4d. 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 F4a–F4d; 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).
|