RFC + bootable /v2/ vertical slice (spec §9 deliverable 1). TDD task breakdown: v2RouteName guard, routesFolder wiring, boundary zones, definePage ESLint rule, useShellUiStore, useRightDrawer, OrganizerLayoutV2 + AppShellV2 skeleton, /v2/dashboard boot proof. Plans 2-5 outlined. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1187 lines
46 KiB
Markdown
1187 lines
46 KiB
Markdown
# Crewli GUI Redesign — Foundation Plan 1 (RFC + bootable /v2/ slice)
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Land the new project RFC (superseding F4a–F4d) and the structural foundation so that `/v2/dashboard` boots through a new `OrganizerLayoutV2` + `AppShellV2` skeleton, with route-name collision prevention, boundary zones, layout-meta enforcement, and the v2 UI state layer — all green under lint/typecheck/test/build.
|
||
|
||
**Architecture:** Parallel `/v2/*` route tree (own `routesFolder` with a `v2-` route-name prefix), a new `OrganizerLayoutV2` selected via `definePage({ meta: { layout: 'OrganizerLayoutV2' } })` (enforced by a custom ESLint rule), a Tailwind-grid `AppShellV2` *skeleton* with named slot regions (PrimeVue shell pieces arrive in Plan 2), a single `useShellUiStore` for sidebar/theme/density/right-drawer state, and a thin `useRightDrawer()` facade over it. No v1 code is touched except additive config.
|
||
|
||
**Tech Stack:** Vue 3 `<script setup lang="ts">`, unplugin-vue-router, vite-plugin-vue-meta-layouts, Pinia, eslint-plugin-boundaries 6.0.2, eslint-plugin-local-rules, Vitest (unit/component projects), Playwright Component Testing, Tailwind v4.
|
||
|
||
**Plan location note:** saved under `dev-docs/superpowers/plans/` (not the skill default `docs/superpowers/plans/`) because `docs/` is Crewli's VitePress user-docs site; `dev-docs/` is the developer-doc convention and matches where the spec lives.
|
||
|
||
**Source spec:** `dev-docs/superpowers/specs/2026-05-15-crewli-starter-gui-redesign-design.md` (approved, review rounds 1–2 applied).
|
||
|
||
**Scope:** This is Plan 1 of 5. It delivers the RFC + spec §9 deliverable 1. Plans 2–5 (shell pieces, Tier-1 primitives + DraggableBlock, template layer, full Storybook catalog) are outlined at the end and authored after Plan 1 lands.
|
||
|
||
---
|
||
|
||
## File Structure (Plan 1)
|
||
|
||
**Created:**
|
||
- `dev-docs/RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md` — new project RFC, supersedes F4a–F4d
|
||
- `apps/app/src/plugins/1.router/v2RouteName.ts` — pure route-name prefix helper
|
||
- `apps/app/src/plugins/1.router/__tests__/v2RouteName.spec.ts` — its unit test
|
||
- `apps/app/eslint-local-rules.cjs` — local ESLint plugin entry
|
||
- `apps/app/eslint-rules/require-v2-layout-meta.cjs` — the custom rule
|
||
- `apps/app/eslint-rules/__tests__/require-v2-layout-meta.spec.ts` — RuleTester test
|
||
- `apps/app/tests/unit/boundaries-v2.spec.ts` — ESLint-API test proving the new zones
|
||
- `apps/app/src/stores/useShellUiStore.ts` — v2 shell UI state
|
||
- `apps/app/src/stores/__tests__/useShellUiStore.spec.ts`
|
||
- `apps/app/src/composables/useRightDrawer.ts` — facade over the store
|
||
- `apps/app/src/composables/__tests__/useRightDrawer.spec.ts`
|
||
- `apps/app/src/layouts/OrganizerLayoutV2.vue` — v2 layout (MetaLayouts target dir)
|
||
- `apps/app/src/layouts/components/AppShellV2.vue` — Tailwind-grid skeleton
|
||
- `apps/app/tests/component/layouts/AppShellV2.spec.ts` — mount test
|
||
- `apps/app/src/pages-v2/dashboard.vue` — empty boot-proof page
|
||
- `apps/app/tests/playwright-ct/v2/appshell-boot.spec.ts` — CT smoke
|
||
|
||
**Modified:**
|
||
- `apps/app/vite.config.ts:23-30` — `routesFolder` array + `getRouteName` extension
|
||
- `apps/app/.eslintrc.cjs` — `boundaries/elements` + `boundaries/element-types` + `plugins` + `overrides`
|
||
- `apps/app/package.json` — add `eslint-plugin-local-rules` devDependency
|
||
- `dev-docs/RFC-WS-FRONTEND-PRIMEVUE.md` — supersession banner on F4a–F4d
|
||
- `dev-docs/PRIMEVUE_COMPONENTS.md` — pointer to the new RFC
|
||
|
||
---
|
||
|
||
## Task 1: New project RFC + supersession pointers
|
||
|
||
**Files:**
|
||
- Create: `dev-docs/RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md`
|
||
- Modify: `dev-docs/RFC-WS-FRONTEND-PRIMEVUE.md` (add banner above the `### F4 — Component migration` heading)
|
||
- Modify: `dev-docs/PRIMEVUE_COMPONENTS.md` (add pointer near the top status block)
|
||
|
||
- [ ] **Step 1: Create the RFC file**
|
||
|
||
Create `dev-docs/RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md` with this content:
|
||
|
||
```markdown
|
||
# RFC-WS-GUI-REDESIGN-CREWLI-STARTER — crewli-starter as design source
|
||
|
||
| Field | Value |
|
||
|---|---|
|
||
| **Status** | Approved (2026-05-16) |
|
||
| **Supersedes** | F4a–F4d component-migration sub-packages of RFC-WS-FRONTEND-PRIMEVUE |
|
||
| **Design spec** | `dev-docs/superpowers/specs/2026-05-15-crewli-starter-gui-redesign-design.md` |
|
||
| **Impl plans** | `dev-docs/superpowers/plans/2026-05-16-gui-redesign-foundation.md` (Plan 1 of 5) |
|
||
|
||
## 1. What changes vs RFC-WS-FRONTEND-PRIMEVUE
|
||
|
||
The F4a–F4d strategy ("translate legacy Vuetify pages 1:1 to PrimeVue,
|
||
preserve UX") is **superseded**. The new strategy: `crewli-starter/` is
|
||
the design source of truth; v2 pages are built fresh under `/v2/*`
|
||
parallel routes and migrated page-by-page; v1 stays inert until per-page
|
||
cutover. All F4 *architectural* decisions (AD-1..AD-12: PrimeVue + Aura +
|
||
Tailwind + FormField + DataTable conventions) remain binding.
|
||
|
||
## 2. Binding architectural decisions (carried from the spec)
|
||
|
||
- **AD-G1 — Parallel routes.** `pages-v2/` mounts at `/v2/*` via a second
|
||
`routesFolder`; v2 route NAMES are `v2-` prefixed (collision guard).
|
||
- **AD-G2 — Layout.** `OrganizerLayoutV2` wraps `AppShellV2`; every
|
||
`pages-v2/**` page declares `definePage({ meta: { layout:
|
||
'OrganizerLayoutV2' } })`, enforced by a custom ESLint rule.
|
||
- **AD-G3 — Fidelity.** PrimeVue-first; custom CSS only for genuinely
|
||
bespoke visuals (DraggableBlock layout, WorkspaceSwitcher visual,
|
||
shell chrome). Generic elements accept the PrimeVue look.
|
||
- **AD-G4 — State.** No `useWorkspaceStore`. Org/context data reuses
|
||
`useAuthStore`/`useOrganisationStore`. One new `useShellUiStore` holds
|
||
only sidebar/theme/density + right-drawer state. `provide`/`inject`
|
||
from crewli-starter is replaced per-port (no `inject()` survives).
|
||
- **AD-G5 — Boundaries.** New `components-v2`/`pages-v2` zones; the only
|
||
v1→v2 bridge is a narrow `components-foundation` zone (FormField,
|
||
Icon). No back-porting (structurally enforced).
|
||
- **AD-G6 — Testing.** TEST-INFRA-001 (✅ Resolved) Playwright-CT +
|
||
visual foundation is kept as the CI gate; Storybook a11y is
|
||
complementary. v2 visual baselines are captured from the v2 component
|
||
after human parity-check vs crewli-starter. CT specs are standalone
|
||
(no `@storybook/test-runner`).
|
||
- **AD-G7 — Portal.** Frontend SPA prefix is `/portal/*` (already true
|
||
in repo); observability binds on `route.meta.context==='portal'`, not
|
||
path; `/api/v1/p/*` is a separate untouched backend layer. Portal v2
|
||
is a later sprint with its own `PortalLayoutV2`.
|
||
- **AD-G8 — Cutover.** Per page: move `pages-v2/X`→`pages/X`, rewrite
|
||
links, strip `v2-` name prefix, delete dead v1. Final cutover: folder
|
||
renames + revert router/boundaries config + delete v1 shell + Vuetify.
|
||
|
||
## 3. Sequencing
|
||
|
||
Plan 1: RFC + structural foundation (bootable `/v2/` slice).
|
||
Plan 2: shell pieces (AppSidebar/AppTopbar/SidebarNav/WorkspaceSwitcher/
|
||
RightDrawer/AppDialog).
|
||
Plan 3: Tier-1 primitives + DraggableBlock + their Storybook stories.
|
||
Plan 4: template layer (List/Form/Detail/Dashboard/StateBlock).
|
||
Plan 5: full PrimeVue standard catalog stories + theme/density toolbar.
|
||
Then: Smart-Filter sub-sprint → Page-1 (events list) → subsequent trees.
|
||
|
||
## 4. Out of scope
|
||
|
||
Backend changes; v1 deletion before cutover; back-porting; domain
|
||
modules (Timetable/Cue/SectionBuilder migrate with their owning page);
|
||
Flatpickr/vue-i18n/DatePicker (inherit RFC-WS-FRONTEND-PRIMEVUE).
|
||
```
|
||
|
||
- [ ] **Step 2: Add the supersession banner to the old RFC**
|
||
|
||
In `dev-docs/RFC-WS-FRONTEND-PRIMEVUE.md`, find the line `### F4 — Component migration (5–6 days, 4 sub-packages)` and insert immediately **above** it:
|
||
|
||
```markdown
|
||
> **⚠️ SUPERSEDED (2026-05-16):** F4a–F4d below are superseded by
|
||
> `dev-docs/RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md`. The page-migration
|
||
> strategy changed (crewli-starter is now the design source, parallel
|
||
> `/v2/*` routes, page-by-page cutover). AD-1..AD-12 remain binding.
|
||
> F2/F3/F5/F6 are unaffected.
|
||
```
|
||
|
||
- [ ] **Step 3: Add the pointer to PRIMEVUE_COMPONENTS.md**
|
||
|
||
In `dev-docs/PRIMEVUE_COMPONENTS.md`, directly under the `**Status:**` line near the top, add:
|
||
|
||
```markdown
|
||
> **GUI redesign:** the page-migration phase is governed by
|
||
> `dev-docs/RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md` (supersedes F4a–F4d).
|
||
> This component reference still applies to all v2 work.
|
||
```
|
||
|
||
- [ ] **Step 4: Verify and commit**
|
||
|
||
Run: `git -C /Users/berthausmans/Documents/Development/crewli diff --stat`
|
||
Expected: 3 files changed (1 new RFC, 2 modified docs).
|
||
|
||
```bash
|
||
cd /Users/berthausmans/Documents/Development/crewli
|
||
git add dev-docs/RFC-WS-GUI-REDESIGN-CREWLI-STARTER.md dev-docs/RFC-WS-FRONTEND-PRIMEVUE.md dev-docs/PRIMEVUE_COMPONENTS.md
|
||
git commit -m "docs: add GUI-redesign RFC superseding F4a-F4d"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: `v2RouteName` pure helper (route-name collision guard)
|
||
|
||
**Files:**
|
||
- Create: `apps/app/src/plugins/1.router/v2RouteName.ts`
|
||
- Test: `apps/app/src/plugins/1.router/__tests__/v2RouteName.spec.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `apps/app/src/plugins/1.router/__tests__/v2RouteName.spec.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it } from 'vitest'
|
||
import { v2RouteName } from '@/plugins/1.router/v2RouteName'
|
||
|
||
describe('v2RouteName', () => {
|
||
it('prefixes names for routes under the v2 path', () => {
|
||
expect(v2RouteName('dashboard', '/v2/dashboard')).toBe('v2-dashboard')
|
||
expect(v2RouteName('events-id', '/v2/events/:id')).toBe('v2-events-id')
|
||
})
|
||
|
||
it('prefixes the bare v2 root', () => {
|
||
expect(v2RouteName('v2', '/v2')).toBe('v2-v2')
|
||
expect(v2RouteName('index', 'v2/')).toBe('v2-index')
|
||
})
|
||
|
||
it('leaves v1 route names untouched', () => {
|
||
expect(v2RouteName('dashboard', '/dashboard')).toBe('dashboard')
|
||
expect(v2RouteName('events', '/events')).toBe('events')
|
||
})
|
||
|
||
it('does not match a v1 path that merely starts with the letters v2', () => {
|
||
expect(v2RouteName('v2x-thing', '/v2x-thing')).toBe('v2x-thing')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to verify it fails**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run src/plugins/1.router/__tests__/v2RouteName.spec.ts`
|
||
Expected: FAIL — `Failed to resolve import "@/plugins/1.router/v2RouteName"`.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Create `apps/app/src/plugins/1.router/v2RouteName.ts`:
|
||
|
||
```ts
|
||
/**
|
||
* Route-NAME collision guard for the parallel /v2/* tree.
|
||
*
|
||
* unplugin-vue-router derives the route name from the file path relative
|
||
* to its routesFolder, so `src/pages/events/index.vue` and
|
||
* `src/pages-v2/events/index.vue` would BOTH yield name `events` — a
|
||
* silent runtime collision (router.push({ name: 'events' }) becomes
|
||
* ambiguous). The pages-v2 routesFolder carries `path: 'v2/'`, so every
|
||
* v2 route's URL path is under `/v2`. Prefix the NAME with `v2-` for
|
||
* those, leaving v1 names untouched. Stripped at final cutover.
|
||
*
|
||
* @param baseName the kebab name already computed by getRouteName
|
||
* @param routePath the node's URL path (leading slash optional)
|
||
*/
|
||
export function v2RouteName(baseName: string, routePath: string): string {
|
||
const normalized = routePath.replace(/^\//, '')
|
||
const isV2 = normalized === 'v2' || normalized.startsWith('v2/')
|
||
|
||
return isV2 ? `v2-${baseName}` : baseName
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run src/plugins/1.router/__tests__/v2RouteName.spec.ts`
|
||
Expected: PASS — 4 tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /Users/berthausmans/Documents/Development/crewli
|
||
git add apps/app/src/plugins/1.router/v2RouteName.ts apps/app/src/plugins/1.router/__tests__/v2RouteName.spec.ts
|
||
git commit -m "feat(router): add v2RouteName collision-guard helper"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Wire `routesFolder` + `getRouteName` in vite.config.ts
|
||
|
||
**Files:**
|
||
- Modify: `apps/app/vite.config.ts:23-30`
|
||
|
||
- [ ] **Step 1: Replace the VueRouter() block**
|
||
|
||
In `apps/app/vite.config.ts`, replace exactly this block (lines 23-30):
|
||
|
||
```ts
|
||
VueRouter({
|
||
getRouteName: routeNode => {
|
||
// Convert pascal case to kebab case
|
||
return getPascalCaseRouteName(routeNode)
|
||
.replace(/([a-z\d])([A-Z])/g, '$1-$2')
|
||
.toLowerCase()
|
||
},
|
||
}),
|
||
```
|
||
|
||
with:
|
||
|
||
```ts
|
||
VueRouter({
|
||
// Parallel /v2/* tree (RFC-WS-GUI-REDESIGN AD-G1). The second
|
||
// routesFolder prefixes every v2 URL with /v2/; v2RouteName then
|
||
// prefixes the route NAME with `v2-` so file-name twins across
|
||
// pages/ and pages-v2/ cannot collide. Reverted at final cutover.
|
||
routesFolder: [
|
||
{ src: 'src/pages' },
|
||
{ src: 'src/pages-v2', path: 'v2/' },
|
||
],
|
||
getRouteName: routeNode => {
|
||
// Convert pascal case to kebab case
|
||
const base = getPascalCaseRouteName(routeNode)
|
||
.replace(/([a-z\d])([A-Z])/g, '$1-$2')
|
||
.toLowerCase()
|
||
|
||
// Defensive path read: unplugin-vue-router 0.8.8 TreeNode exposes
|
||
// `.fullPath`; fall back to `.value.path` then '' so a future
|
||
// plugin bump can't silently drop the v2- prefix. Step 5 below
|
||
// empirically verifies the emitted name.
|
||
const nodePath
|
||
= (routeNode.fullPath
|
||
?? routeNode.value?.path
|
||
?? '') as string
|
||
|
||
return v2RouteName(base, nodePath)
|
||
},
|
||
}),
|
||
```
|
||
|
||
- [ ] **Step 2: Add the helper import**
|
||
|
||
In `apps/app/vite.config.ts`, the imports end at line 16 (`import svgLoader from 'vite-svg-loader'`). Add directly below it:
|
||
|
||
```ts
|
||
import { v2RouteName } from './src/plugins/1.router/v2RouteName'
|
||
```
|
||
|
||
- [ ] **Step 3: Create the boot-proof page so the dev server has a v2 route**
|
||
|
||
Create `apps/app/src/pages-v2/dashboard.vue` (fleshed out further in Task 9; minimal here so the route exists):
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
definePage({ meta: { layout: 'OrganizerLayoutV2', public: true } })
|
||
</script>
|
||
|
||
<template>
|
||
<div data-testid="v2-dashboard-placeholder">v2 dashboard</div>
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 4: Typecheck the config change**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vue-tsc --noEmit -p tsconfig.json 2>&1 | head -20`
|
||
Expected: no new errors referencing `vite.config.ts` or `v2RouteName`. (`routeNode.fullPath` is a documented `TreeNode` field in unplugin-vue-router 0.8.x.)
|
||
|
||
- [ ] **Step 5: Verify the route is generated with the prefixed name**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vite build 2>&1 | tail -5`
|
||
Expected: build succeeds. Then run: `grep -rn "v2-dashboard\|/v2/dashboard" "$(find /Users/berthausmans/Documents/Development/crewli/apps/app -name 'typed-router.d.ts' | head -1)"`
|
||
Expected: an entry mapping path `/v2/dashboard` to route name `v2-dashboard`.
|
||
|
||
**If the name is `dashboard` (no `v2-` prefix), the defensive path read
|
||
returned empty** — the installed unplugin-vue-router exposes the node
|
||
path differently. Remediate before continuing: temporarily add
|
||
`console.log(JSON.stringify({ k: Object.keys(routeNode), fp: routeNode.fullPath, vp: routeNode.value?.path }))`
|
||
inside `getRouteName`, run `pnpm exec vite build 2>&1 | grep v2`, read
|
||
the actual field carrying `/v2/...`, update the `nodePath` expression to
|
||
read that field, remove the log, rebuild, re-grep. Do not proceed to
|
||
Task 4 until `typed-router.d.ts` shows `v2-dashboard`.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /Users/berthausmans/Documents/Development/crewli
|
||
git add apps/app/vite.config.ts apps/app/src/pages-v2/dashboard.vue
|
||
git commit -m "feat(router): mount pages-v2 at /v2/* with v2- name prefix"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: eslint-plugin-boundaries — v2 zones + matrix
|
||
|
||
**Files:**
|
||
- Modify: `apps/app/.eslintrc.cjs` (`boundaries/elements` and `boundaries/element-types`)
|
||
- Test: `apps/app/tests/unit/boundaries-v2.spec.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test (ESLint Node API on fixtures)**
|
||
|
||
Create `apps/app/tests/unit/boundaries-v2.spec.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it } from 'vitest'
|
||
import { ESLint } from 'eslint'
|
||
|
||
const eslint = new ESLint({ cwd: `${process.cwd()}` })
|
||
|
||
async function boundaryErrors(filePath: string, code: string) {
|
||
const [result] = await eslint.lintText(code, { filePath })
|
||
|
||
return result.messages.filter(m => m.ruleId === 'boundaries/element-types')
|
||
}
|
||
|
||
describe('boundaries — v2 zones', () => {
|
||
it('allows pages-v2 → components-v2', async () => {
|
||
const errs = await boundaryErrors(
|
||
'src/pages-v2/dashboard.vue',
|
||
`<script setup lang="ts">import X from '@/components-v2/shared/X.vue'</script><template><X /></template>`,
|
||
)
|
||
expect(errs).toHaveLength(0)
|
||
})
|
||
|
||
it('allows components-v2 → components-foundation (FormField bridge)', async () => {
|
||
const errs = await boundaryErrors(
|
||
'src/components-v2/forms/Demo.vue',
|
||
`<script setup lang="ts">import FormField from '@/components/forms/FormField.vue'</script><template><FormField /></template>`,
|
||
)
|
||
expect(errs).toHaveLength(0)
|
||
})
|
||
|
||
it('forbids v1 components → components-v2 (no back-porting)', async () => {
|
||
const errs = await boundaryErrors(
|
||
'src/components/organizer/Legacy.vue',
|
||
`<script setup lang="ts">import X from '@/components-v2/shared/X.vue'</script><template><X /></template>`,
|
||
)
|
||
expect(errs.length).toBeGreaterThan(0)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to verify it fails**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run tests/unit/boundaries-v2.spec.ts`
|
||
Expected: FAIL — the "forbids v1 → components-v2" case currently produces 0 errors (no zone exists, so the import is unclassified and allowed), so `expect(errs.length).toBeGreaterThan(0)` fails.
|
||
|
||
- [ ] **Step 3: Add the element zones**
|
||
|
||
In `apps/app/.eslintrc.cjs`, find this exact line inside `'boundaries/elements'`:
|
||
|
||
```js
|
||
{ type: 'components', pattern: 'src/components/**' },
|
||
```
|
||
|
||
Insert **directly above** it (so the narrow zones win — first-match-wins, order matters):
|
||
|
||
```js
|
||
// GUI-redesign v2 zones (RFC-WS-GUI-REDESIGN AD-G5). Declared
|
||
// before the generic `components` catch-all. `components-foundation`
|
||
// is the ONLY sanctioned v1→v2 bridge (FormField + Icon — audited
|
||
// to live in the generic `components` zone, not components-shared).
|
||
// eslint-plugin-boundaries 6.0.2 micromatch supports the brace
|
||
// form below; if a future bump breaks it, split into two entries
|
||
// with the same `type` (see RFC §14 fallback).
|
||
{ type: 'components-foundation', pattern: 'src/components/{forms/**,Icon.vue}' },
|
||
{ type: 'components-v2', pattern: 'src/components-v2/**' },
|
||
```
|
||
|
||
Then find this exact line:
|
||
|
||
```js
|
||
{ type: 'pages', pattern: 'src/pages/**' },
|
||
```
|
||
|
||
Insert **directly above** it:
|
||
|
||
```js
|
||
{ type: 'pages-v2', pattern: 'src/pages-v2/**' },
|
||
```
|
||
|
||
- [ ] **Step 4: Add the matrix rows**
|
||
|
||
In `apps/app/.eslintrc.cjs`, find this exact line inside `'boundaries/element-types'` `rules`:
|
||
|
||
```js
|
||
{ from: 'components', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components', 'components-shared', 'components-organizer'] },
|
||
```
|
||
|
||
Insert **directly above** it:
|
||
|
||
```js
|
||
// v2 zones. components-v2 may use the FormField/Icon bridge
|
||
// (components-foundation) but NOT any other v1 component zone.
|
||
// No v1 `from` rule lists components-v2/pages-v2 → back-porting
|
||
// is structurally impossible (RFC-WS-GUI-REDESIGN AD-G5).
|
||
{ from: 'components-foundation', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components-foundation'] },
|
||
{ 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'] },
|
||
```
|
||
|
||
- [ ] **Step 5: Run the test to verify it passes**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run tests/unit/boundaries-v2.spec.ts`
|
||
Expected: PASS — 3 tests. (If the brace-glob misbehaves on 6.0.2, replace the `components-foundation` element line with two lines — `pattern: 'src/components/forms/**'` and `pattern: 'src/components/Icon.vue'` — same `type`; re-run.)
|
||
|
||
- [ ] **Step 6: Confirm no regression on existing zones**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec eslint 'src/components/organizer/**/*.vue' --rule '{}' 2>&1 | tail -3`
|
||
Expected: no new `boundaries/element-types` errors introduced by the additions (exit status unchanged from baseline).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd /Users/berthausmans/Documents/Development/crewli
|
||
git add apps/app/.eslintrc.cjs apps/app/tests/unit/boundaries-v2.spec.ts
|
||
git commit -m "feat(lint): add components-v2/pages-v2 boundary zones (no back-port)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Custom ESLint rule — `require-v2-layout-meta`
|
||
|
||
**Files:**
|
||
- Create: `apps/app/eslint-rules/require-v2-layout-meta.cjs`
|
||
- Create: `apps/app/eslint-local-rules.cjs`
|
||
- Create: `apps/app/eslint-rules/__tests__/require-v2-layout-meta.spec.ts`
|
||
- Modify: `apps/app/package.json` (add `eslint-plugin-local-rules` dev dep)
|
||
- Modify: `apps/app/.eslintrc.cjs` (`plugins` + new `overrides` entry)
|
||
|
||
- [ ] **Step 1: Add the local-rules plugin dependency**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm add -D eslint-plugin-local-rules@3.0.2`
|
||
Expected: `package.json` devDependencies gains `"eslint-plugin-local-rules": "3.0.2"`.
|
||
|
||
- [ ] **Step 2: Write the failing rule test (RuleTester)**
|
||
|
||
Create `apps/app/eslint-rules/__tests__/require-v2-layout-meta.spec.ts`:
|
||
|
||
```ts
|
||
import { createRequire } from 'node:module'
|
||
import { describe, it } from 'vitest'
|
||
import { RuleTester } from 'eslint'
|
||
|
||
// Project is ESLint 8.57 — RuleTester uses the legacy `parser` +
|
||
// `parserOptions` shape (NOT ESLint-9 `languageOptions`). createRequire
|
||
// gives us a CJS `require` inside this ESM test for the .cjs rule + the
|
||
// parser path.
|
||
const require = createRequire(import.meta.url)
|
||
const rule = require('../require-v2-layout-meta.cjs')
|
||
|
||
const ruleTester = new RuleTester({
|
||
parser: require.resolve('vue-eslint-parser'),
|
||
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
|
||
})
|
||
|
||
describe('require-v2-layout-meta', () => {
|
||
it('passes valid and rejects invalid', () => {
|
||
ruleTester.run('require-v2-layout-meta', rule, {
|
||
valid: [
|
||
{
|
||
filename: 'src/pages-v2/dashboard.vue',
|
||
code: `<script setup lang="ts">definePage({ meta: { layout: 'OrganizerLayoutV2' } })</script><template><div/></template>`,
|
||
},
|
||
{
|
||
// non-v2 file is ignored entirely
|
||
filename: 'src/pages/dashboard.vue',
|
||
code: `<script setup lang="ts">const x = 1</script><template><div/></template>`,
|
||
},
|
||
],
|
||
invalid: [
|
||
{
|
||
filename: 'src/pages-v2/dashboard.vue',
|
||
code: `<script setup lang="ts">const x = 1</script><template><div/></template>`,
|
||
errors: [{ messageId: 'missing' }],
|
||
},
|
||
{
|
||
filename: 'src/pages-v2/events/index.vue',
|
||
code: `<script setup lang="ts">definePage({ meta: { layout: 'default' } })</script><template><div/></template>`,
|
||
errors: [{ messageId: 'wrongLayout' }],
|
||
},
|
||
],
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Run it to verify it fails**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run eslint-rules/__tests__/require-v2-layout-meta.spec.ts`
|
||
Expected: FAIL — `Cannot find module '../require-v2-layout-meta.cjs'`.
|
||
|
||
- [ ] **Step 4: Write the rule**
|
||
|
||
Create `apps/app/eslint-rules/require-v2-layout-meta.cjs`:
|
||
|
||
```js
|
||
/**
|
||
* Enforces that every src/pages-v2/**.vue page declares
|
||
* definePage({ meta: { layout: 'OrganizerLayoutV2' } })
|
||
* (or 'PortalLayoutV2' for src/pages-v2/portal/**). Without this a v2
|
||
* page silently falls back to the `default` layout — a no-error
|
||
* wrong-shell bug. RFC-WS-GUI-REDESIGN AD-G2.
|
||
*/
|
||
'use strict'
|
||
|
||
module.exports = {
|
||
meta: {
|
||
type: 'problem',
|
||
docs: { description: 'require definePage layout meta on pages-v2' },
|
||
messages: {
|
||
missing: 'pages-v2 page must call definePage({ meta: { layout: ... } }).',
|
||
wrongLayout: 'pages-v2 layout must be {{expected}} (got {{actual}}).',
|
||
},
|
||
schema: [],
|
||
},
|
||
create(context) {
|
||
const filename = (context.filename || context.getFilename() || '').replace(/\\/g, '/')
|
||
if (!filename.includes('src/pages-v2/'))
|
||
return {}
|
||
|
||
const expected = filename.includes('src/pages-v2/portal/')
|
||
? 'PortalLayoutV2'
|
||
: 'OrganizerLayoutV2'
|
||
|
||
let sawDefinePage = false
|
||
|
||
return {
|
||
CallExpression(node) {
|
||
if (node.callee.type !== 'Identifier' || node.callee.name !== 'definePage')
|
||
return
|
||
sawDefinePage = true
|
||
|
||
const arg = node.arguments[0]
|
||
const metaProp = arg && arg.type === 'ObjectExpression'
|
||
? arg.properties.find(p => p.key && p.key.name === 'meta')
|
||
: null
|
||
const layoutProp = metaProp && metaProp.value.type === 'ObjectExpression'
|
||
? metaProp.value.properties.find(p => p.key && p.key.name === 'layout')
|
||
: null
|
||
|
||
if (!layoutProp || layoutProp.value.value !== expected) {
|
||
context.report({
|
||
node,
|
||
messageId: 'wrongLayout',
|
||
data: { expected, actual: layoutProp ? String(layoutProp.value.value) : 'none' },
|
||
})
|
||
}
|
||
},
|
||
'Program:exit': function (node) {
|
||
if (!sawDefinePage)
|
||
context.report({ node, messageId: 'missing' })
|
||
},
|
||
}
|
||
},
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Create the local-rules entry**
|
||
|
||
Create `apps/app/eslint-local-rules.cjs`:
|
||
|
||
```js
|
||
'use strict'
|
||
|
||
module.exports = {
|
||
'require-v2-layout-meta': require('./eslint-rules/require-v2-layout-meta.cjs'),
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run the rule test to verify it passes**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run eslint-rules/__tests__/require-v2-layout-meta.spec.ts`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: Wire the rule into .eslintrc.cjs**
|
||
|
||
In `apps/app/.eslintrc.cjs`, find the top-level `plugins: [` array (≈ line 30) and add `'local-rules'` as an element. Then add this entry to the top-level `overrides:` array:
|
||
|
||
```js
|
||
{
|
||
files: ['src/pages-v2/**/*.vue'],
|
||
rules: {
|
||
'local-rules/require-v2-layout-meta': 'error',
|
||
},
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 8: Verify the rule is active end-to-end**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && printf '%s' "<script setup lang=\"ts\">const x=1</script><template><div/></template>" > /tmp/bad.vue && cp /tmp/bad.vue src/pages-v2/__rule_probe.vue && pnpm exec eslint src/pages-v2/__rule_probe.vue ; rm src/pages-v2/__rule_probe.vue`
|
||
Expected: ESLint reports `local-rules/require-v2-layout-meta` `missing`. (The probe file is removed by the same command.)
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
cd /Users/berthausmans/Documents/Development/crewli
|
||
git add apps/app/eslint-rules apps/app/eslint-local-rules.cjs apps/app/.eslintrc.cjs apps/app/package.json apps/app/pnpm-lock.yaml
|
||
git commit -m "feat(lint): enforce definePage layout meta on pages-v2"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: `useShellUiStore` (sidebar / theme / density / right-drawer)
|
||
|
||
**Files:**
|
||
- Create: `apps/app/src/stores/useShellUiStore.ts`
|
||
- Test: `apps/app/src/stores/__tests__/useShellUiStore.spec.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `apps/app/src/stores/__tests__/useShellUiStore.spec.ts`:
|
||
|
||
```ts
|
||
import { beforeEach, describe, expect, it } from 'vitest'
|
||
import { createPinia, setActivePinia } from 'pinia'
|
||
import { useShellUiStore } from '@/stores/useShellUiStore'
|
||
|
||
describe('useShellUiStore', () => {
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia())
|
||
document.documentElement.removeAttribute('data-theme')
|
||
document.documentElement.removeAttribute('data-density')
|
||
document.documentElement.classList.remove('dark')
|
||
})
|
||
|
||
it('defaults: expanded sidebar, comfortable density, light theme, closed drawer', () => {
|
||
const s = useShellUiStore()
|
||
expect(s.sidebarCollapsed).toBe(false)
|
||
expect(s.density).toBe('comfortable')
|
||
expect(s.theme).toBe('light')
|
||
expect(s.drawer.isOpen).toBe(false)
|
||
})
|
||
|
||
it('toggleSidebar flips collapsed', () => {
|
||
const s = useShellUiStore()
|
||
s.toggleSidebar()
|
||
expect(s.sidebarCollapsed).toBe(true)
|
||
})
|
||
|
||
it('applyDomAttributes writes data-theme/data-density and .dark', () => {
|
||
const s = useShellUiStore()
|
||
s.setTheme('dark')
|
||
s.setDensity('compact')
|
||
s.applyDomAttributes()
|
||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
|
||
expect(document.documentElement.getAttribute('data-density')).toBe('compact')
|
||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||
})
|
||
|
||
it('openDrawer/closeDrawer mutate drawer state', () => {
|
||
const s = useShellUiStore()
|
||
s.openDrawer('PersonCard', { id: '01H' })
|
||
expect(s.drawer).toEqual({ isOpen: true, component: 'PersonCard', props: { id: '01H' } })
|
||
s.closeDrawer()
|
||
expect(s.drawer.isOpen).toBe(false)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to verify it fails**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run src/stores/__tests__/useShellUiStore.spec.ts`
|
||
Expected: FAIL — cannot resolve `@/stores/useShellUiStore`.
|
||
|
||
- [ ] **Step 3: Write the store**
|
||
|
||
Create `apps/app/src/stores/useShellUiStore.ts`:
|
||
|
||
```ts
|
||
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
|
||
// v2 shell UI state ONLY (RFC-WS-GUI-REDESIGN AD-G4). No tenant/org
|
||
// state — that stays in useAuthStore/useOrganisationStore. Owns the
|
||
// writes to <html data-theme>/<html data-density>/.dark (composes with
|
||
// Aura darkModeSelector '.dark'); v2 bypasses Vuexy useSkins.ts.
|
||
|
||
export type ShellTheme = 'light' | 'dark'
|
||
export type ShellDensity = 'comfortable' | 'compact'
|
||
|
||
export interface ShellDrawerState {
|
||
isOpen: boolean
|
||
component: string | null
|
||
props: Record<string, unknown>
|
||
}
|
||
|
||
export const useShellUiStore = defineStore('shellUi', () => {
|
||
const sidebarCollapsed = ref(false)
|
||
const density = ref<ShellDensity>('comfortable')
|
||
const theme = ref<ShellTheme>('light')
|
||
const drawer = ref<ShellDrawerState>({ isOpen: false, component: null, props: {} })
|
||
|
||
function toggleSidebar(): void {
|
||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||
}
|
||
|
||
function setTheme(next: ShellTheme): void {
|
||
theme.value = next
|
||
}
|
||
|
||
function setDensity(next: ShellDensity): void {
|
||
density.value = next
|
||
}
|
||
|
||
function applyDomAttributes(): void {
|
||
const el = document.documentElement
|
||
el.setAttribute('data-theme', theme.value)
|
||
el.setAttribute('data-density', density.value)
|
||
el.classList.toggle('dark', theme.value === 'dark')
|
||
}
|
||
|
||
function openDrawer(component: string, props: Record<string, unknown> = {}): void {
|
||
drawer.value = { isOpen: true, component, props }
|
||
}
|
||
|
||
function closeDrawer(): void {
|
||
drawer.value = { isOpen: false, component: null, props: {} }
|
||
}
|
||
|
||
return {
|
||
sidebarCollapsed,
|
||
density,
|
||
theme,
|
||
drawer,
|
||
toggleSidebar,
|
||
setTheme,
|
||
setDensity,
|
||
applyDomAttributes,
|
||
openDrawer,
|
||
closeDrawer,
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run src/stores/__tests__/useShellUiStore.spec.ts`
|
||
Expected: PASS — 5 tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /Users/berthausmans/Documents/Development/crewli
|
||
git add apps/app/src/stores/useShellUiStore.ts apps/app/src/stores/__tests__/useShellUiStore.spec.ts
|
||
git commit -m "feat(stores): add useShellUiStore for v2 shell UI state"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: `useRightDrawer()` facade composable
|
||
|
||
**Files:**
|
||
- Create: `apps/app/src/composables/useRightDrawer.ts`
|
||
- Test: `apps/app/src/composables/__tests__/useRightDrawer.spec.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `apps/app/src/composables/__tests__/useRightDrawer.spec.ts`:
|
||
|
||
```ts
|
||
import { beforeEach, describe, expect, it } from 'vitest'
|
||
import { createPinia, setActivePinia } from 'pinia'
|
||
import { useRightDrawer } from '@/composables/useRightDrawer'
|
||
import { useShellUiStore } from '@/stores/useShellUiStore'
|
||
|
||
describe('useRightDrawer', () => {
|
||
beforeEach(() => setActivePinia(createPinia()))
|
||
|
||
it('open() writes through to the store', () => {
|
||
const { open } = useRightDrawer()
|
||
open('ArtistCard', { id: '01H' })
|
||
const s = useShellUiStore()
|
||
expect(s.drawer).toEqual({ isOpen: true, component: 'ArtistCard', props: { id: '01H' } })
|
||
})
|
||
|
||
it('close() clears the store drawer', () => {
|
||
const { open, close, isOpen } = useRightDrawer()
|
||
open('ArtistCard')
|
||
close()
|
||
expect(isOpen.value).toBe(false)
|
||
expect(useShellUiStore().drawer.isOpen).toBe(false)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to verify it fails**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run src/composables/__tests__/useRightDrawer.spec.ts`
|
||
Expected: FAIL — cannot resolve `@/composables/useRightDrawer`.
|
||
|
||
- [ ] **Step 3: Write the composable**
|
||
|
||
Create `apps/app/src/composables/useRightDrawer.ts`:
|
||
|
||
```ts
|
||
import { storeToRefs } from 'pinia'
|
||
import type { ComputedRef } from 'vue'
|
||
import { computed } from 'vue'
|
||
import { useShellUiStore } from '@/stores/useShellUiStore'
|
||
|
||
// Thin facade over useShellUiStore.drawer (RFC-WS-GUI-REDESIGN AD-G4,
|
||
// issue 5). NOT a module-level ref singleton: state lives in the Pinia
|
||
// store so Playwright CT can drive the drawer via @pinia/testing
|
||
// without rendering the shell, and tests don't leak state across cases.
|
||
|
||
export interface UseRightDrawer {
|
||
isOpen: ComputedRef<boolean>
|
||
component: ComputedRef<string | null>
|
||
props: ComputedRef<Record<string, unknown>>
|
||
open: (component: string, props?: Record<string, unknown>) => void
|
||
close: () => void
|
||
}
|
||
|
||
export function useRightDrawer(): UseRightDrawer {
|
||
const store = useShellUiStore()
|
||
const { drawer } = storeToRefs(store)
|
||
|
||
return {
|
||
isOpen: computed(() => drawer.value.isOpen),
|
||
component: computed(() => drawer.value.component),
|
||
props: computed(() => drawer.value.props),
|
||
open: (component, props = {}) => store.openDrawer(component, props),
|
||
close: () => store.closeDrawer(),
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run src/composables/__tests__/useRightDrawer.spec.ts`
|
||
Expected: PASS — 2 tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /Users/berthausmans/Documents/Development/crewli
|
||
git add apps/app/src/composables/useRightDrawer.ts apps/app/src/composables/__tests__/useRightDrawer.spec.ts
|
||
git commit -m "feat(composables): add useRightDrawer facade over useShellUiStore"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: `OrganizerLayoutV2` + `AppShellV2` skeleton
|
||
|
||
**Files:**
|
||
- Create: `apps/app/src/layouts/components/AppShellV2.vue`
|
||
- Create: `apps/app/src/layouts/OrganizerLayoutV2.vue`
|
||
- Test: `apps/app/tests/component/layouts/AppShellV2.spec.ts`
|
||
|
||
> Skeleton only: a Tailwind 12-col-ish grid with **named slot regions**
|
||
> (`sidebar`, `topbar`, default=content, `drawer`) + `RouterView`. No
|
||
> PrimeVue parts yet (spec: outer container is custom Tailwind grid;
|
||
> PrimeVue shell pieces arrive in Plan 2). Per spec §13, the skeleton
|
||
> gets a Vitest mount test now; Playwright-CT visual baselines are
|
||
> captured in Plan 2 when the real shell pieces land.
|
||
|
||
- [ ] **Step 1: Write the failing mount test**
|
||
|
||
Create `apps/app/tests/component/layouts/AppShellV2.spec.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it } from 'vitest'
|
||
import { mount } from '@vue/test-utils'
|
||
import { createPinia } from 'pinia'
|
||
import AppShellV2 from '@/layouts/components/AppShellV2.vue'
|
||
|
||
describe('AppShellV2 (skeleton)', () => {
|
||
it('renders the grid regions and default slot content', () => {
|
||
const wrapper = mount(AppShellV2, {
|
||
global: { plugins: [createPinia()] },
|
||
slots: {
|
||
sidebar: '<nav data-testid="sb">SB</nav>',
|
||
topbar: '<header data-testid="tb">TB</header>',
|
||
default: '<main data-testid="content">CONTENT</main>',
|
||
drawer: '<aside data-testid="dr">DR</aside>',
|
||
},
|
||
})
|
||
expect(wrapper.find('[data-testid="appshell-v2"]').exists()).toBe(true)
|
||
expect(wrapper.find('[data-testid="sb"]').exists()).toBe(true)
|
||
expect(wrapper.find('[data-testid="tb"]').exists()).toBe(true)
|
||
expect(wrapper.find('[data-testid="content"]').text()).toBe('CONTENT')
|
||
expect(wrapper.find('[data-testid="dr"]').exists()).toBe(true)
|
||
})
|
||
|
||
it('applies the collapsed modifier from useShellUiStore', async () => {
|
||
const pinia = createPinia()
|
||
const wrapper = mount(AppShellV2, { global: { plugins: [pinia] } })
|
||
const { useShellUiStore } = await import('@/stores/useShellUiStore')
|
||
useShellUiStore().toggleSidebar()
|
||
await wrapper.vm.$nextTick()
|
||
expect(wrapper.find('[data-testid="appshell-v2"]').classes()).toContain('is-collapsed')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to verify it fails**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run --project component tests/component/layouts/AppShellV2.spec.ts`
|
||
Expected: FAIL — cannot resolve `@/layouts/components/AppShellV2.vue`.
|
||
|
||
- [ ] **Step 3: Write `AppShellV2.vue`**
|
||
|
||
Create `apps/app/src/layouts/components/AppShellV2.vue`:
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
// AppShellV2 — Tailwind-grid SKELETON (RFC-WS-GUI-REDESIGN AD-G2/G3,
|
||
// Plan 1). Named slot regions; PrimeVue shell pieces (AppSidebar,
|
||
// AppTopbar, WorkspaceSwitcher, RightDrawer) are injected via slots in
|
||
// Plan 2. Outer container is intentionally custom Tailwind (no PrimeVue
|
||
// equivalent for a permanent rail), matching crewli-starter `.app`.
|
||
|
||
import { computed, onMounted, watch } from 'vue'
|
||
import { storeToRefs } from 'pinia'
|
||
import { useShellUiStore } from '@/stores/useShellUiStore'
|
||
|
||
const shell = useShellUiStore()
|
||
const { sidebarCollapsed } = storeToRefs(shell)
|
||
|
||
const rootClass = computed(() => ({ 'is-collapsed': sidebarCollapsed.value }))
|
||
|
||
onMounted(() => shell.applyDomAttributes())
|
||
watch(
|
||
() => [shell.theme, shell.density],
|
||
() => shell.applyDomAttributes(),
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
data-testid="appshell-v2"
|
||
class="grid min-h-screen grid-cols-[auto_1fr]"
|
||
:class="rootClass"
|
||
>
|
||
<div class="row-span-2">
|
||
<slot name="sidebar" />
|
||
</div>
|
||
|
||
<div class="flex min-h-screen flex-col">
|
||
<slot name="topbar" />
|
||
|
||
<main class="flex-1 overflow-auto p-6">
|
||
<slot />
|
||
</main>
|
||
</div>
|
||
|
||
<slot name="drawer" />
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vitest run --project component tests/component/layouts/AppShellV2.spec.ts`
|
||
Expected: PASS — 2 tests.
|
||
|
||
- [ ] **Step 5: Write `OrganizerLayoutV2.vue`**
|
||
|
||
Create `apps/app/src/layouts/OrganizerLayoutV2.vue` (must live in `src/layouts/` — MetaLayouts `target`):
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
// OrganizerLayoutV2 — v2 layout file selected by
|
||
// definePage({ meta: { layout: 'OrganizerLayoutV2' } }) on pages-v2/**
|
||
// (RFC-WS-GUI-REDESIGN AD-G2). Plan 1: wires the skeleton + RouterView.
|
||
// Plan 2 fills the sidebar/topbar/drawer slots with ported PrimeVue
|
||
// shell pieces.
|
||
import AppShellV2 from '@/layouts/components/AppShellV2.vue'
|
||
</script>
|
||
|
||
<template>
|
||
<AppShellV2>
|
||
<template #sidebar>
|
||
<nav class="w-72 border-r border-surface-200 p-4 dark:border-surface-800">
|
||
<span class="text-sm text-surface-500">Crewli v2</span>
|
||
</nav>
|
||
</template>
|
||
|
||
<template #topbar>
|
||
<header class="flex h-14 items-center border-b border-surface-200 px-6 dark:border-surface-800">
|
||
<span class="text-sm text-surface-500">v2 shell (skeleton)</span>
|
||
</header>
|
||
</template>
|
||
|
||
<RouterView />
|
||
</AppShellV2>
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 6: Typecheck**
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && pnpm exec vue-tsc --noEmit -p tsconfig.json 2>&1 | grep -E 'AppShellV2|OrganizerLayoutV2' | head`
|
||
Expected: no output (no type errors in the new files).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd /Users/berthausmans/Documents/Development/crewli
|
||
git add apps/app/src/layouts/OrganizerLayoutV2.vue apps/app/src/layouts/components/AppShellV2.vue apps/app/tests/component/layouts/AppShellV2.spec.ts
|
||
git commit -m "feat(layouts): add OrganizerLayoutV2 + AppShellV2 skeleton"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Boot proof — `/v2/dashboard` + full gate
|
||
|
||
**Files:**
|
||
- Modify: `apps/app/src/pages-v2/dashboard.vue` (flesh out from the Task 3 stub)
|
||
- Test: `apps/app/tests/playwright-ct/v2/appshell-boot.spec.ts`
|
||
|
||
- [ ] **Step 1: Flesh out the page**
|
||
|
||
Replace the contents of `apps/app/src/pages-v2/dashboard.vue`:
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
// First v2 page — boot proof (RFC-WS-GUI-REDESIGN Plan 1). `public`
|
||
// keeps it reachable without auth wiring during the foundation slice;
|
||
// it is removed when the real dashboard lands in a later plan.
|
||
definePage({ meta: { layout: 'OrganizerLayoutV2', public: true } })
|
||
</script>
|
||
|
||
<template>
|
||
<section data-testid="v2-dashboard">
|
||
<h1 class="text-xl font-semibold text-surface-900 dark:text-surface-0">
|
||
v2 foundation OK
|
||
</h1>
|
||
<p class="mt-2 text-surface-600 dark:text-surface-300">
|
||
AppShellV2 skeleton renders this route at /v2/dashboard.
|
||
</p>
|
||
</section>
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing CT smoke (mounts the skeleton + page body)**
|
||
|
||
Create `apps/app/tests/playwright-ct/v2/appshell-boot.spec.ts`:
|
||
|
||
```ts
|
||
import { expect, test } from '@playwright/experimental-ct-vue'
|
||
import { createTestingPinia } from '@pinia/testing'
|
||
import AppShellV2 from '@/layouts/components/AppShellV2.vue'
|
||
|
||
test('AppShellV2 mounts and renders content in the CT runner', async ({ mount }) => {
|
||
const component = await mount(AppShellV2, {
|
||
global: { plugins: [createTestingPinia({ stubActions: false })] },
|
||
slots: { default: '<section data-testid="v2-dashboard">v2 foundation OK</section>' },
|
||
})
|
||
|
||
await expect(component.getByTestId('appshell-v2')).toBeVisible()
|
||
await expect(component.getByTestId('v2-dashboard')).toContainText('v2 foundation OK')
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Run the smoke (integration test — depends on Task 8)**
|
||
|
||
This is an integration smoke, not unit TDD: it proves the CT runner can
|
||
mount the Plan-1 skeleton built in Task 8. There is no artificial
|
||
red phase — the test file is the new artifact; if `AppShellV2` were
|
||
absent the import would fail.
|
||
|
||
Run: `cd /Users/berthausmans/Documents/Development/crewli/apps/app && grep '@pinia/testing' package.json && pnpm exec playwright test --config=playwright-ct.config.ts tests/playwright-ct/v2/appshell-boot.spec.ts 2>&1 | tail -6`
|
||
Expected: `@pinia/testing` is present in package.json (it is, `^1.0.3`); result `1 passed`. If the first CT run reports the Chromium browser is missing, run `pnpm exec playwright install chromium` once and re-run.
|
||
|
||
- [ ] **Step 4: Run the full foundation gate**
|
||
|
||
Run each; all must pass:
|
||
|
||
```bash
|
||
cd /Users/berthausmans/Documents/Development/crewli/apps/app
|
||
pnpm exec vue-tsc --noEmit -p tsconfig.json # typecheck: 0 new errors
|
||
pnpm exec eslint src/pages-v2 src/components-v2 src/stores/useShellUiStore.ts src/composables/useRightDrawer.ts # boundaries + v2 layout rule clean
|
||
pnpm exec vitest run src/stores/__tests__/useShellUiStore.spec.ts src/composables/__tests__/useRightDrawer.spec.ts src/plugins/1.router/__tests__/v2RouteName.spec.ts tests/unit/boundaries-v2.spec.ts
|
||
pnpm exec vitest run --project component tests/component/layouts/AppShellV2.spec.ts
|
||
pnpm exec vite build # production build succeeds
|
||
```
|
||
|
||
Expected: typecheck 0 new errors; eslint 0 errors; all vitest specs pass; build succeeds with a `/v2/dashboard` → `v2-dashboard` entry in `typed-router.d.ts`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /Users/berthausmans/Documents/Development/crewli
|
||
git add apps/app/src/pages-v2/dashboard.vue apps/app/tests/playwright-ct/v2/appshell-boot.spec.ts
|
||
git commit -m "feat(v2): boot /v2/dashboard through OrganizerLayoutV2 + AppShellV2"
|
||
```
|
||
|
||
---
|
||
|
||
## Definition of Done (Plan 1)
|
||
|
||
- New RFC committed; F4a–F4d banner + PRIMEVUE_COMPONENTS pointer added.
|
||
- `/v2/dashboard` renders via `OrganizerLayoutV2` → `AppShellV2`; its
|
||
route name is `v2-dashboard` (no collision with v1 `dashboard`).
|
||
- `useShellUiStore` + `useRightDrawer()` unit-tested; drawer state is
|
||
store-backed (CT-drivable).
|
||
- New boundary zones active; back-port v1→`components-v2` is an error.
|
||
- A `pages-v2/**` page missing the `OrganizerLayoutV2` layout meta is an
|
||
ESLint error.
|
||
- `pnpm exec vue-tsc --noEmit`, `pnpm lint`, `pnpm test`, the CT smoke,
|
||
and `pnpm exec vite build` all pass. Existing test count not reduced.
|
||
- No `src/components-v2/forms/` directory created (FormField reused via
|
||
the `components-foundation` bridge).
|
||
|
||
---
|
||
|
||
## Subsequent plans (authored after Plan 1 lands)
|
||
|
||
- **Plan 2 — Shell pieces:** `AppSidebar`, `SidebarHeader`, `SidebarNav`,
|
||
`WorkspaceSwitcher` (PrimeVue `Popover` + computed over
|
||
`useAuthStore`/`useOrganisationStore`), `AppTopbar` (PrimeVue
|
||
`Breadcrumb`/`Button`/`Avatar`/`Menu`/`OverlayBadge`), `RightDrawer`
|
||
(PrimeVue `Drawer` + scaffold, driven by `useRightDrawer()`),
|
||
`AppDialog` (PrimeVue `Dialog` + scaffold). Each: Vitest mount + a
|
||
Playwright-CT `@visual` baseline captured after parity-check vs
|
||
crewli-starter. Fills the `OrganizerLayoutV2` slots.
|
||
- **Plan 3 — Tier-1 primitives + DraggableBlock:** `StatusTag`
|
||
(+ `statusSeverity.ts` map seeded from `src/types/` enums), `StatCard`,
|
||
`StateBlock`, `PageHead`, `TagsInput`, `EnergyDots`, `EnergyPicker`,
|
||
and `DraggableBlock` (foundation despite Tier-4 deferring the
|
||
Timetable/Cue pages — spec §8/§9). Co-located `.stories.ts` each.
|
||
- **Plan 4 — Template layer:** `ListTemplate`, `FormTemplate`,
|
||
`DetailTemplate`, `DashboardTemplate`, `StateBlock` integration.
|
||
- **Plan 5 — Storybook catalog + toolbar:** global theme/density toolbar
|
||
decorators in `.storybook/preview.ts`; the ~80-component PrimeVue
|
||
standard catalog (grouped per crewli-starter `ComponentsPage.vue`);
|
||
Foundations stories. CT specs stay standalone (no
|
||
`@storybook/test-runner`), per spec §13.
|
||
|
||
Then the Smart-Filter sub-sprint, then Page-1 (events list), then the
|
||
remaining page trees, per spec §10.
|
||
```
|