# 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 `` / `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: '' } })` (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 (` + Tailwind (custom) | Permanent rail; no PrimeVue primitive | | Sidebar mobile | PrimeVue `Drawer` | Correct off-canvas primitive | | Sidebar nav rows | `