Eight components under apps/app/src/components-v2/shared/ plus a
severity-map utility, seeded from amended spec §8 and enforced via the
bidirectional Vitest consistency test required by §8.X.
Carries forward two Plan 2-deviation cleanup tasks from spec-amend
commit ae0bd2da:
- (a) migrate 6 centralized stories from src/stories/v2/ to co-located
- (b) refactor AppTopbar to wrap PrimeVue Menubar per RFC AD-3
Also deletes the X.vue boundary-test stub and repoints
boundaries-v2.spec.ts at a real shared component.
Plan format follows Plan 1 precedent (REQUIRED SUB-SKILL header,
- [ ] task syntax, Definition of Done, Plans 4-5 outline). Execution
will happen in a fresh Claude Code session via
superpowers:subagent-driven-development.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1772 lines
82 KiB
Markdown
1772 lines
82 KiB
Markdown
# Crewli GUI Redesign — Plan 3 (Tier-1 primitives + DraggableBlock)
|
||
|
||
> **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 eight Tier-1 shared primitives (`StatusTag`, `StatCard`, `PageHead`, `StateBlock`, `TagsInput`, `EnergyDots`, `EnergyPicker`, `DraggableBlock`) plus the `statusSeverity.ts` single-source-of-truth map under `apps/app/src/components-v2/shared/`, each with a co-located Storybook story and the right test tier, so Plan 4's template layer and all subsequent v2 pages have a stable, visually-locked component vocabulary — all green under typecheck/lint/test/CT/build.
|
||
|
||
**Architecture:** Six primitives are 1:1 ports of crewli-starter sources translated to TypeScript + Tailwind + PrimeVue Aura tokens following the Plan 2 `AppDialog.vue` precedent (documented CSS-translation JSDoc, `var(--p-*)` tokens, `Icon` via the `components-foundation` bridge). `StatusTag` resolves severity exclusively through `statusSeverity.ts` (spec §8/§8.X). `StateBlock` is built fresh (no crewli-starter source). `TagsInput` is a re-implementation onto PrimeVue `AutoComplete` (spec §8), not a port. `DraggableBlock` is a designed abstraction over two crewli-starter consumers with different drag models — its canonical API (spec §7.1) is reconciled in a Phase A gate **before any code**. No v1 code is touched except the two carried-forward Plan 2 cleanups and the X.vue boundary-stub removal.
|
||
|
||
**Tech Stack:** Vue 3 `<script setup lang="ts">`, PrimeVue 4 (`Tag`, `Card`, `Skeleton`, `Message`, `Button`, `AutoComplete`, `ProgressBar`) + Aura preset (`@/plugins/primevue/theme.ts`), Tailwind v4, `@vue/test-utils` + Vitest (Tier-1), Playwright Component Testing (Tier-2/Tier-4 `@visual`), Storybook CSF3 (co-located, auto-discovered via `../src/**/*.stories.ts`), `eslint-plugin-boundaries` 6.0.2, pnpm.
|
||
|
||
**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 and Plan 1 live.
|
||
|
||
**Source spec:** `dev-docs/superpowers/specs/2026-05-15-crewli-starter-gui-redesign-design.md` (amended commit `ae0bd2da` — §6 stories-placement, §8 severity table, §8.X enforcement).
|
||
|
||
**Scope:** Plan 3 of 5. Delivers spec §9 deliverable 3 (Tier-1 primitives + DraggableBlock) plus §6/§8.X enforcement and two carried-forward Plan 2 cleanups from commit `ae0bd2da`. Plans 4–5 (template layer, Storybook catalog + toolbar) are outlined at the end.
|
||
|
||
**Ground-truth inputs (Phase A audit, confirmed 2026-05-17):**
|
||
- crewli-starter is the sibling working dir `../crewli-starter` (abs `/Users/berthausmans/Documents/Development/crewli-starter`).
|
||
- Primitive sources exist: `shared/{StatusTag,StatCard,PageHead,EnergyDots}.vue`, `music/{EnergyPicker,TagsInput}.vue`. `StateBlock` has **no** source. `DraggableBlock` is **inline markup** in `timetable/TimetableGrid.vue`, not a component.
|
||
- Only `DraggableBlock` has a §7-pinned prop contract (§7.1). The other six derive 1:1 from their crewli-starter source (TagsInput excepted — re-impl per §8).
|
||
- Storybook glob is `../src/**/*.stories.ts` → co-located stories are auto-discovered (cleanup (a) needs no `.storybook/main.ts` change).
|
||
- `components-v2` is **one** boundaries zone (no `shared/` sub-zone) already allowed `types,utils,lib,composables,composables-forms,stores,components-v2,components-foundation` and forbidden v1/pages/layouts. Constraint #7 is therefore satisfied by the existing zone; Task 12 adds **regression-lock tests**, not a new zone (audit-before-assume deviation from the prompt's framing — documented in Task 12).
|
||
- `X.vue` is referenced in **two** `boundaries-v2.spec.ts` cases (L35 `allows pages-v2 → components-v2`; L65 `forbids v1 components → components-v2`). Both repoint to `StatusTag.vue`.
|
||
- `AppTopbar.vue` imports `Avatar,Breadcrumb,InputText,Menu,OverlayBadge,Popover` — no `Menubar` (RFC AD-3 deviation, cleanup (b)).
|
||
- Theme divergence: crewli-starter dark selector `[data-theme="dark"]` + hardcoded dark primary `#1eafb1`; apps/app dark selector `.dark` (RFC AD-2) + Aura token ramp `{primary.400}`. Reconciled in Task A1.
|
||
- Test commands (pnpm): `pnpm test` (Vitest run), `pnpm test:component` (Playwright CT), `pnpm test:visual` (CT `@visual`), `pnpm test:visual:update` (update baselines), `pnpm exec vue-tsc --noEmit` (typecheck), `pnpm lint` (scoped — whole-codebase formatter OOM is a known Plan 1 constraint), `pnpm exec vite build`.
|
||
- `Icon.vue` props: `name: string` (required), `size?: number|string` → maps to `@iconify/vue` `<Icon :icon :width :height>`. Crewli-starter uses `@iconify/vue` directly with `icon="tabler:x"`; v2 ports use `@/components/Icon.vue` with the dash form `name="tabler-x"` (AppDialog precedent).
|
||
- Vitest mount pattern: `@vue/test-utils` `mount`, Pinia via `global.plugins:[createPinia()]`, PrimeVue + `Icon` stubbed, test files co-located in `__tests__/`.
|
||
|
||
**CSS-translation token map (crewli-starter `main.css` var → apps/app Aura token, per `AppDialog.vue` precedent):**
|
||
|
||
| crewli-starter | apps/app PrimeVue token |
|
||
|---|---|
|
||
| `--fg` | `--p-text-color` |
|
||
| `--fg-muted` | `--p-text-muted-color` |
|
||
| `--surface` | `--p-content-background` |
|
||
| `--surface-alt` | `--p-content-hover-background` |
|
||
| `--border` | `--p-content-border-color` |
|
||
| `--p-primary` | `--p-primary-color` |
|
||
| `--radius-sm` / `--radius` / `--radius-lg` | `--p-border-radius-sm` / `--p-border-radius` / `--p-border-radius-lg` |
|
||
| `--success` / `--danger` / `--warn` / `--info` (text) | `--p-green-600` / `--p-red-600` / `--p-amber-600` / `--p-sky-600` (Aura palette; verify shade against `theme.ts`) |
|
||
| `--p-primary-tint` | `color-mix(in srgb, var(--p-primary-color) 12%, transparent)` |
|
||
|
||
> Semantic status colours are **never** hand-mapped on `StatusTag` — it delegates to PrimeVue `<Tag :severity>`, which themes correctly in both modes. The palette tokens above are only for `StatCard` trend and `EnergyDots` energy levels.
|
||
|
||
---
|
||
|
||
## File Structure (Plan 3)
|
||
|
||
**Created:**
|
||
- `apps/app/src/components-v2/shared/statusSeverity.ts` — status→severity SoT map (spec §8)
|
||
- `apps/app/tests/unit/utils/statusSeverity.consistency.spec.ts` — bidirectional §8.X enforcement test
|
||
- `apps/app/src/components-v2/shared/StatusTag.vue` + `__tests__/StatusTag.spec.ts` + `StatusTag.stories.ts`
|
||
- `apps/app/src/components-v2/shared/StatCard.vue` + `__tests__/StatCard.spec.ts` + `StatCard.stories.ts`
|
||
- `apps/app/src/components-v2/shared/PageHead.vue` + `__tests__/PageHead.spec.ts` + `PageHead.stories.ts`
|
||
- `apps/app/src/components-v2/shared/StateBlock.vue` + `__tests__/StateBlock.spec.ts` + `StateBlock.stories.ts`
|
||
- `apps/app/src/components-v2/shared/TagsInput.vue` + `__tests__/TagsInput.spec.ts` + `TagsInput.stories.ts`
|
||
- `apps/app/src/components-v2/shared/EnergyDots.vue` + `__tests__/EnergyDots.spec.ts` + `EnergyDots.stories.ts`
|
||
- `apps/app/src/components-v2/shared/EnergyPicker.vue` + `__tests__/EnergyPicker.spec.ts` + `EnergyPicker.stories.ts`
|
||
- `apps/app/src/components-v2/shared/DraggableBlock.vue` + `__tests__/DraggableBlock.spec.ts` + `DraggableBlock.stories.ts`
|
||
- `apps/app/tests/playwright-ct/v2/draggableblock.ct.spec.ts` — Tier-2 drag-emit interaction test (`@visual` baseline for static states only)
|
||
- `dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives-DRAGGABLEBLOCK-CONTRACT.md` — Task A2 reconciliation deliverable
|
||
|
||
**Modified:**
|
||
- `apps/app/tests/unit/boundaries-v2.spec.ts` — X.vue → StatusTag.vue (L35 & L65) + shared/* regression-lock cases
|
||
- `apps/app/src/components-v2/layout/AppTopbar.vue` — refactor to wrap PrimeVue `Menubar` (RFC AD-3)
|
||
- `apps/app/src/components-v2/layout/__tests__/AppTopbar.spec.ts` — assertions updated for Menubar
|
||
- Story relocations (cleanup (a)): `src/stories/v2/{AppDialog,AppSidebar,AppTopbar,RightDrawer,SidebarNav,WorkspaceSwitcher}.stories.ts` → co-located beside their `.vue`; imports of `_helpers.ts` rewritten to the unchanged `@/stories/v2/_helpers.ts` path
|
||
|
||
**Deleted:**
|
||
- `apps/app/src/components-v2/shared/X.vue` (boundary-test stub, `TODO TECH-WS-GUI-REDESIGN`)
|
||
|
||
---
|
||
|
||
## Phase A — Reconciliation gates (NO component code yet)
|
||
|
||
### Task A1: Theme-alignment decision (constraint #6)
|
||
|
||
**Files:** Create section in the contract doc; no code.
|
||
|
||
- [ ] **Step 1: Document the divergence and the decision**
|
||
|
||
Append to `dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives-DRAGGABLEBLOCK-CONTRACT.md` a "Theme alignment" section stating verbatim:
|
||
|
||
> crewli-starter: `darkModeSelector: '[data-theme="dark"]'`, dark primary hardcoded `#1eafb1`, bespoke surface block.
|
||
> apps/app: `darkModeSelector: '.dark'` (RFC-WS-FRONTEND-PRIMEVUE AD-2, matches Vuexy), dark primary `{primary.400}` Aura ramp, RFC Appendix B surface tokens.
|
||
> **Decision (option b — normalise at the harness):** v2 components NEVER hardcode a dark selector or a semantic hex; they consume `var(--p-*)` Aura tokens only, which resolve correctly under apps/app's `.dark`. The parity harness renders the crewli-starter reference under `[data-theme="dark"]` and the v2 component under `.dark`; the human parity-check compares **rendered pixels**, not selector strings. The `#1eafb1` vs `{primary.400}` ramp delta is **accepted** (same teal family; RFC AD-2 owns the apps/app ramp) and explicitly recorded so a dark-mode parity diff is read as theme-by-design, not a component bug.
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives-DRAGGABLEBLOCK-CONTRACT.md
|
||
git commit -m "docs(plan): Plan 3 Task A1 — theme-alignment decision (accept .dark vs [data-theme] delta)"
|
||
```
|
||
|
||
### Task A2: DraggableBlock dual-consumer contract reconciliation (constraint #4)
|
||
|
||
**Files:** `dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives-DRAGGABLEBLOCK-CONTRACT.md`
|
||
|
||
Spec §7.1 is canonical. The two consumers use **different** drag models (Phase A facts):
|
||
- `TimetableGrid` (`startDragMove`, line 241): `mousedown` → `window` `mousemove`/`mouseup`, 3px move threshold, parent computes snap/lane/stage from `clientX/Y` and px/min; emits `dispatch({type})`.
|
||
- `CueTimelineEditor` (`onCueDragStart`, line 200): native HTML5 drag (`draggable="true"`, `dataTransfer.setData`), `.dragging` class, drop via slot `dragover`; emits `update`.
|
||
|
||
- [ ] **Step 1: Write the field→slot reconciliation tables**
|
||
|
||
Add to the contract doc two tables mapping each consumer's per-block data to the §7.1 generic slots:
|
||
|
||
```
|
||
TimetableGrid performance block → §7.1
|
||
artist.name → line1Left.text
|
||
status tag (engagement) → line1Left.tag {label, severity}
|
||
genre → line1Right.pill
|
||
capacity/conflict warn → line1Right.tag {label:'!', severity:'danger'}
|
||
blockTime(start,end) → line2Left
|
||
advanceCount done/total → line2Right.progress (0..1 → 0..100)
|
||
selected (selectedId) → selected
|
||
drag.value!=null → dragging
|
||
baseRowHeight 56/64/76 → density compact|regular|comfy
|
||
|
||
CueTimelineEditor cue block → §7.1
|
||
cue.label → line1Left.text
|
||
cue.kind tag → line1Left.tag {label, severity}
|
||
cue.dest pill → line1Right.pill
|
||
(none) → line1Right.tag
|
||
cue.time → line2Left
|
||
(none) → line2Right (null — no progress on cues)
|
||
selectedCueId===cue.id → selected
|
||
drag?.id===cue.id → dragging
|
||
fixed 'regular' → density
|
||
```
|
||
|
||
- [ ] **Step 2: Write the unified drag-model decision**
|
||
|
||
Add verbatim:
|
||
|
||
> **Drag model = PointerEvents, parent owns positioning.** `DraggableBlock` is presentational. On `pointerdown` (primary button) it calls `setPointerCapture`, tracks a 3px move threshold (TimetableGrid parity), emits `dragstart: [e: PointerEvent]` once threshold is crossed, and on `pointerup`/`lostpointercapture` emits `dragend: [delta: { x: number; y: number }]` (`clientX/Y` minus start). It performs **zero** snap/lane/px-min math. `TimetableGrid` keeps `startDragMove`'s math but is driven by `@dragstart`/`@dragend` instead of its own `mousedown`. `CueTimelineEditor` drops HTML5 drag and adopts the same emits. `click` is emitted only when no drag occurred (threshold not crossed). `vuedraggable` is not used (wrong abstraction for free-position blocks; spec §7.1).
|
||
|
||
- [ ] **Step 3: Write the retrofit-prove requirement**
|
||
|
||
Add verbatim:
|
||
|
||
> Plan 3 is incomplete without a retrofit proof per consumer: `DraggableBlock.stories.ts` MUST include an `ArtistBlock` story (TimetableGrid usage expressed in the §7.1 contract) and a `CueBlock` story (CueTimelineEditor usage in the §7.1 contract). These prove the abstraction expresses both consumers without either consumer's page existing yet (Tier-4 defers the pages, not this proof).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives-DRAGGABLEBLOCK-CONTRACT.md
|
||
git commit -m "docs(plan): Plan 3 Task A2 — DraggableBlock canonical API reconciled from 2 consumers"
|
||
```
|
||
|
||
> **Gate:** Tasks 1–9 may start in parallel with A1/A2 EXCEPT Task 9 (DraggableBlock), which MUST NOT begin until A2 is committed.
|
||
|
||
---
|
||
|
||
## Task 1: `statusSeverity.ts` + bidirectional consistency test (constraints #1, #2; spec §8/§8.X)
|
||
|
||
**Files:**
|
||
- Create: `apps/app/src/components-v2/shared/statusSeverity.ts`
|
||
- Test: `apps/app/tests/unit/utils/statusSeverity.consistency.spec.ts`
|
||
|
||
- [ ] **Step 1: Write the failing consistency test**
|
||
|
||
```ts
|
||
// apps/app/tests/unit/utils/statusSeverity.consistency.spec.ts
|
||
import { describe, expect, it } from 'vitest'
|
||
import { ShiftAssignmentStatus } from '@/types/shiftAssignment'
|
||
import { PersonStatus } from '@/types/person'
|
||
import { MatchStatus } from '@/types/identityMatch'
|
||
import { ArtistEngagementStatus, PaymentStatus } from '@/types/timetable'
|
||
import { STATUS_SEVERITY, statusSeverity } from '@/components-v2/shared/statusSeverity'
|
||
|
||
const ENUMS = { ShiftAssignmentStatus, ArtistEngagementStatus, PaymentStatus, PersonStatus, MatchStatus }
|
||
const ALL_VALUES = Object.values(ENUMS).flatMap(e => Object.values(e)) as string[]
|
||
|
||
describe('statusSeverity §8.X enforcement', () => {
|
||
it('completeness: every live enum value resolves to an explicit severity (no dev-fallback)', () => {
|
||
const missing = ALL_VALUES.filter(v => !(v in STATUS_SEVERITY))
|
||
expect(missing, `unmapped enum values: ${missing.join(', ')}`).toEqual([])
|
||
})
|
||
|
||
it('no phantoms: every map key exists in at least one live enum', () => {
|
||
const orphans = Object.keys(STATUS_SEVERITY).filter(k => !ALL_VALUES.includes(k))
|
||
expect(orphans, `orphan keys: ${orphans.join(', ')}`).toEqual([])
|
||
})
|
||
|
||
it('resolver returns the mapped severity and never throws on a known value', () => {
|
||
expect(statusSeverity('approved')).toBe('success')
|
||
expect(statusSeverity('no_show')).toBe('danger')
|
||
expect(statusSeverity('deposit_paid')).toBe('info')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run it — expect FAIL (module missing)**
|
||
|
||
Run: `pnpm test -- statusSeverity.consistency`
|
||
Expected: FAIL — `Cannot find module '@/components-v2/shared/statusSeverity'`.
|
||
|
||
- [ ] **Step 3: Implement `statusSeverity.ts` seeded verbatim from amended spec §8**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/statusSeverity.ts
|
||
// Single source of truth for status → PrimeVue Tag severity.
|
||
// Seeded VERBATIM from design spec §8 (commit ae0bd2da). Do NOT
|
||
// reinterpret here — a wrong severity is a spec-amendment process,
|
||
// not an edit to this file. Enforced bidirectionally by
|
||
// tests/unit/utils/statusSeverity.consistency.spec.ts (spec §8.X).
|
||
|
||
export type TagSeverity = 'success' | 'warn' | 'info' | 'secondary' | 'danger'
|
||
|
||
export const STATUS_SEVERITY: Readonly<Record<string, TagSeverity>> = Object.freeze({
|
||
// success — terminal-good / fully settled
|
||
approved: 'success',
|
||
completed: 'success',
|
||
confirmed: 'success',
|
||
contracted: 'success',
|
||
paid_in_full: 'success',
|
||
// warn — organizer action required
|
||
pending_approval: 'warn',
|
||
pending: 'warn',
|
||
applied: 'warn',
|
||
option: 'warn',
|
||
offered: 'warn',
|
||
reverted: 'warn',
|
||
// info — awaiting external party / in-progress, no viewer action
|
||
invited: 'info',
|
||
requested: 'info',
|
||
deposit_paid: 'info',
|
||
// secondary — muted: absent / not-yet-live / archived
|
||
none: 'secondary',
|
||
draft: 'secondary',
|
||
dismissed: 'secondary',
|
||
// danger — terminal-bad
|
||
rejected: 'danger',
|
||
cancelled: 'danger',
|
||
declined: 'danger',
|
||
no_show: 'danger',
|
||
})
|
||
|
||
const FALLBACK: TagSeverity = 'info'
|
||
|
||
/**
|
||
* Resolve a status string to a Tag severity. The §8.X consistency
|
||
* test guarantees every live enum value is mapped, so the fallback
|
||
* is unreachable in a passing build; it exists only as defence in
|
||
* depth and emits a dev-only console warning so any gap is loud.
|
||
*/
|
||
export function statusSeverity(status: string): TagSeverity {
|
||
const hit = STATUS_SEVERITY[status]
|
||
if (hit)
|
||
return hit
|
||
if (import.meta.env.DEV)
|
||
console.warn(`[statusSeverity] unmapped status "${status}" — falling back to "${FALLBACK}". Add a §8 row + extend the §8.X test.`)
|
||
return FALLBACK
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run it — expect PASS**
|
||
|
||
Run: `pnpm test -- statusSeverity.consistency`
|
||
Expected: PASS (3 tests). If `completeness` fails, a `src/types` enum changed since the spec amend — STOP and amend spec §8 first (constraint #1: never reinterpret here).
|
||
|
||
- [ ] **Step 5: Typecheck + commit**
|
||
|
||
Run: `pnpm exec vue-tsc --noEmit` → expect no new errors.
|
||
|
||
```bash
|
||
git add apps/app/src/components-v2/shared/statusSeverity.ts apps/app/tests/unit/utils/statusSeverity.consistency.spec.ts
|
||
git commit -m "feat(gui-v2): statusSeverity SoT map + bidirectional §8.X consistency test"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: `StatusTag.vue` (consumes Task 1) + spec + story
|
||
|
||
**Source:** `../crewli-starter/src/components/shared/StatusTag.vue` (13 lines: `tone`, `dot` props, `.tag`/`.dot` CSS). **v2 implementation per spec §8:** PrimeVue `<Tag :severity>` resolved via `statusSeverity.ts`, optional leading dot via `:pt`.
|
||
|
||
**Files:**
|
||
- Create: `apps/app/src/components-v2/shared/StatusTag.vue`
|
||
- Test: `apps/app/src/components-v2/shared/__tests__/StatusTag.spec.ts`
|
||
- Story: `apps/app/src/components-v2/shared/StatusTag.stories.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/__tests__/StatusTag.spec.ts
|
||
import { mount } from '@vue/test-utils'
|
||
import { describe, expect, it } from 'vitest'
|
||
import { defineComponent } from 'vue'
|
||
import StatusTag from '@/components-v2/shared/StatusTag.vue'
|
||
|
||
const TagStub = defineComponent({
|
||
name: 'TagStub',
|
||
props: { value: { type: String, default: '' }, severity: { type: String, default: '' }, pt: { type: Object, default: () => ({}) } },
|
||
template: `<span class="tag-stub" :data-severity="severity">{{ value }}<slot /></span>`,
|
||
})
|
||
const mountTag = (props: Record<string, unknown>) =>
|
||
mount(StatusTag, { props, global: { stubs: { Tag: TagStub } } })
|
||
|
||
describe('StatusTag', () => {
|
||
it('resolves severity from statusSeverity for the status prop', () => {
|
||
expect(mountTag({ status: 'approved' }).get('.tag-stub').attributes('data-severity')).toBe('success')
|
||
expect(mountTag({ status: 'no_show' }).get('.tag-stub').attributes('data-severity')).toBe('danger')
|
||
expect(mountTag({ status: 'deposit_paid' }).get('.tag-stub').attributes('data-severity')).toBe('info')
|
||
})
|
||
|
||
it('renders the label prop, defaulting to the humanised status', () => {
|
||
expect(mountTag({ status: 'pending_approval' }).text()).toContain('pending approval')
|
||
expect(mountTag({ status: 'approved', label: 'Goedgekeurd' }).text()).toContain('Goedgekeurd')
|
||
})
|
||
|
||
it('adds the dot passthrough only when dot=true', () => {
|
||
expect(mountTag({ status: 'draft' }).get('.tag-stub').attributes('data-severity')).toBe('secondary')
|
||
const pt = mountTag({ status: 'draft', dot: true }).getComponent(TagStub).props('pt') as Record<string, unknown>
|
||
expect(pt).toHaveProperty('root')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL** (`Cannot find module StatusTag.vue`). Run: `pnpm test -- StatusTag`.
|
||
|
||
- [ ] **Step 3: Implement `StatusTag.vue`**
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
/**
|
||
* StatusTag — PrimeVue <Tag> whose severity ALWAYS resolves through
|
||
* statusSeverity.ts (spec §8). Never inline a severity here.
|
||
*
|
||
* crewli-starter port: .tag/.dot visual is delegated to PrimeVue Tag
|
||
* (themes in both modes via Aura). The optional leading dot reproduces
|
||
* crewli-starter's `<span class="dot">` via :pt.root (a ::before-style
|
||
* inline dot) rather than scoped CSS — no bespoke spacing needed.
|
||
*/
|
||
import { computed } from 'vue'
|
||
import Tag from 'primevue/tag'
|
||
import { statusSeverity } from '@/components-v2/shared/statusSeverity'
|
||
|
||
const props = defineProps<{
|
||
status: string
|
||
label?: string
|
||
dot?: boolean
|
||
}>()
|
||
|
||
const severity = computed(() => statusSeverity(props.status))
|
||
|
||
const text = computed(() =>
|
||
props.label ?? props.status.replace(/_/g, ' '),
|
||
)
|
||
|
||
const pt = computed(() =>
|
||
props.dot
|
||
? {
|
||
root: {
|
||
class: 'before:content-[""] before:inline-block before:w-2 before:h-2 before:rounded-full before:bg-current before:mr-1.5 before:align-middle',
|
||
},
|
||
}
|
||
: {},
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<Tag
|
||
:value="text"
|
||
:severity="severity"
|
||
:pt="pt"
|
||
/>
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 4: Run — expect PASS** (3 tests). Run: `pnpm test -- StatusTag`.
|
||
|
||
- [ ] **Step 5: Write the co-located story**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/StatusTag.stories.ts
|
||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||
import StatusTag from '@/components-v2/shared/StatusTag.vue'
|
||
|
||
const meta: Meta<typeof StatusTag> = {
|
||
title: 'Shared/StatusTag',
|
||
component: StatusTag,
|
||
tags: ['autodocs'],
|
||
argTypes: {
|
||
status: { control: 'text' },
|
||
label: { control: 'text' },
|
||
dot: { control: 'boolean' },
|
||
},
|
||
}
|
||
export default meta
|
||
type Story = StoryObj<typeof StatusTag>
|
||
|
||
export const Success: Story = { args: { status: 'approved' } }
|
||
export const Warn: Story = { args: { status: 'pending_approval' } }
|
||
export const Info: Story = { args: { status: 'invited' } }
|
||
export const Secondary: Story = { args: { status: 'draft' } }
|
||
export const Danger: Story = { args: { status: 'no_show' } }
|
||
export const WithDot: Story = { args: { status: 'confirmed', dot: true } }
|
||
export const CustomLabel: Story = { args: { status: 'rejected', label: 'Afgewezen' } }
|
||
```
|
||
|
||
- [ ] **Step 6: Lint, typecheck, commit**
|
||
|
||
Run: `pnpm exec vue-tsc --noEmit` and `pnpm lint -- src/components-v2/shared/StatusTag.vue src/components-v2/shared/StatusTag.stories.ts` → expect clean.
|
||
|
||
```bash
|
||
git add apps/app/src/components-v2/shared/StatusTag.vue apps/app/src/components-v2/shared/__tests__/StatusTag.spec.ts apps/app/src/components-v2/shared/StatusTag.stories.ts
|
||
git commit -m "feat(gui-v2): StatusTag (PrimeVue Tag + statusSeverity map) + story"
|
||
```
|
||
|
||
- [ ] **Step 7: Parity-check (constraint #8) — RECORD, do not self-pass**
|
||
|
||
Bert performs side-by-side: crewli-starter `StatusTag` (rendered under `[data-theme="dark"]` and light) vs v2 Storybook `Shared/StatusTag` (under `.dark` and light). Capture two screenshots into `apps/app/tests/playwright-ct/v2/__screenshots__/parity/StatusTag.{light,dark}.png` as evidence. Tick this box only after Bert records "parity pass" in the PR/commit trailer. **WHO:** Bert. **WHEN:** now, before any `@visual` baseline. **HOW:** browser side-by-side, screenshots committed.
|
||
|
||
- [ ] **Step 8: Capture the Tier-4 `@visual` baseline (after Step 7 passes)**
|
||
|
||
Run: `pnpm test:visual:update -- StatusTag` then `pnpm test:visual -- StatusTag` → expect PASS.
|
||
|
||
```bash
|
||
git add apps/app/tests/playwright-ct/v2/__screenshots__
|
||
git commit -m "test(gui-v2): StatusTag @visual baseline (parity-pass recorded)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `StatCard.vue` + spec + story
|
||
|
||
**Source:** `../crewli-starter/src/components/shared/StatCard.vue` (26 lines: `icon,label,value,trend,trendDir`; `.stat-card` CSS 1019–1039). **v2:** PrimeVue `<Card>` shell + `Icon` bridge + trend row; spec §8 "replaces AppKpiCard".
|
||
|
||
**Files:** Create `StatCard.vue`; Test `__tests__/StatCard.spec.ts`; Story `StatCard.stories.ts`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/__tests__/StatCard.spec.ts
|
||
import { mount } from '@vue/test-utils'
|
||
import { describe, expect, it } from 'vitest'
|
||
import { defineComponent } from 'vue'
|
||
import StatCard from '@/components-v2/shared/StatCard.vue'
|
||
|
||
const CardStub = defineComponent({ name: 'CardStub', template: `<div class="card-stub"><slot name="content" /></div>` })
|
||
const IconStub = defineComponent({ name: 'Icon', props: ['name', 'size'], template: `<i class="icon-stub" :data-icon="name" />` })
|
||
const mountCard = (props: Record<string, unknown>) =>
|
||
mount(StatCard, { props, global: { stubs: { Card: CardStub, Icon: IconStub } } })
|
||
|
||
describe('StatCard', () => {
|
||
it('renders label, value and the leading icon', () => {
|
||
const w = mountCard({ icon: 'tabler-users', label: 'Vrijwilligers', value: 128 })
|
||
expect(w.text()).toContain('Vrijwilligers')
|
||
expect(w.text()).toContain('128')
|
||
expect(w.get('.icon-stub').attributes('data-icon')).toBe('tabler-users')
|
||
})
|
||
|
||
it('shows the trend row with direction class only when trend is set', () => {
|
||
expect(mountCard({ icon: 'tabler-users', label: 'X', value: 1 }).find('[data-trend]').exists()).toBe(false)
|
||
const up = mountCard({ icon: 'tabler-users', label: 'X', value: 1, trend: '+12%', trendDir: 'up' })
|
||
expect(up.get('[data-trend]').attributes('data-trend')).toBe('up')
|
||
expect(up.text()).toContain('+12%')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL.** Run: `pnpm test -- StatCard`.
|
||
|
||
- [ ] **Step 3: Implement `StatCard.vue`**
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
/**
|
||
* StatCard — KPI tile. crewli-starter port (replaces v1 AppKpiCard).
|
||
* CSS translation (main.css .stat-card 1019–1039 → Tailwind + Aura tokens):
|
||
* .stat-card .lbl → text-[11.5px] font-semibold uppercase tracking-[0.06em] text-[var(--p-text-muted-color)]
|
||
* .stat-card .val → text-[28px] font-bold tracking-[-0.01em] tabular-nums
|
||
* .trend.up/.down → text-[var(--p-green-600)] / text-[var(--p-red-600)]
|
||
*/
|
||
import Card from 'primevue/card'
|
||
import Icon from '@/components/Icon.vue'
|
||
|
||
defineProps<{
|
||
icon: string
|
||
label: string
|
||
value: string | number
|
||
trend?: string
|
||
trendDir?: 'up' | 'down'
|
||
}>()
|
||
</script>
|
||
|
||
<template>
|
||
<Card
|
||
:pt="{
|
||
root: 'border border-[var(--p-content-border-color)] rounded-[var(--p-border-radius-lg)] shadow-none',
|
||
body: 'p-[18px]',
|
||
content: 'flex flex-col gap-1.5',
|
||
}"
|
||
>
|
||
<template #content>
|
||
<div class="flex items-center gap-2 text-[11.5px] font-semibold uppercase tracking-[0.06em] text-[var(--p-text-muted-color)]">
|
||
<Icon :name="icon" :size="16" class="text-[var(--p-primary-color)]" />
|
||
{{ label }}
|
||
</div>
|
||
<div class="text-[28px] font-bold tracking-[-0.01em] tabular-nums">
|
||
{{ value }}
|
||
</div>
|
||
<div
|
||
v-if="trend"
|
||
:data-trend="trendDir"
|
||
class="flex items-center gap-1 text-xs text-[var(--p-text-muted-color)]"
|
||
:class="{
|
||
'text-[var(--p-green-600)]!': trendDir === 'up',
|
||
'text-[var(--p-red-600)]!': trendDir === 'down',
|
||
}"
|
||
>
|
||
<Icon v-if="trendDir === 'up'" name="tabler-trending-up" :size="14" />
|
||
<Icon v-if="trendDir === 'down'" name="tabler-trending-down" :size="14" />
|
||
{{ trend }}
|
||
</div>
|
||
</template>
|
||
</Card>
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 4: Run — expect PASS.** Run: `pnpm test -- StatCard`.
|
||
|
||
- [ ] **Step 5: Co-located story**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/StatCard.stories.ts
|
||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||
import StatCard from '@/components-v2/shared/StatCard.vue'
|
||
|
||
const meta: Meta<typeof StatCard> = {
|
||
title: 'Shared/StatCard',
|
||
component: StatCard,
|
||
tags: ['autodocs'],
|
||
argTypes: {
|
||
icon: { control: 'text' },
|
||
label: { control: 'text' },
|
||
value: { control: 'text' },
|
||
trend: { control: 'text' },
|
||
trendDir: { control: 'inline-radio', options: ['', 'up', 'down'] },
|
||
},
|
||
}
|
||
export default meta
|
||
type Story = StoryObj<typeof StatCard>
|
||
|
||
export const Plain: Story = { args: { icon: 'tabler-users', label: 'Vrijwilligers', value: 128 } }
|
||
export const TrendUp: Story = { args: { icon: 'tabler-calendar-event', label: 'Diensten', value: 42, trend: '+12% vs vorige week', trendDir: 'up' } }
|
||
export const TrendDown: Story = { args: { icon: 'tabler-alert-triangle', label: 'No-shows', value: 3, trend: '-2 vs vorige week', trendDir: 'down' } }
|
||
```
|
||
|
||
- [ ] **Step 6: Typecheck, lint, commit** (same commands as Task 2 Step 6, paths for StatCard).
|
||
|
||
```bash
|
||
git add apps/app/src/components-v2/shared/StatCard.vue apps/app/src/components-v2/shared/__tests__/StatCard.spec.ts apps/app/src/components-v2/shared/StatCard.stories.ts
|
||
git commit -m "feat(gui-v2): StatCard (PrimeVue Card KPI tile, replaces AppKpiCard) + story"
|
||
```
|
||
|
||
- [ ] **Step 7: Parity-check (Bert, record) then Step 8 `@visual` baseline** — identical workflow to Task 2 Steps 7–8 with `StatCard`.
|
||
|
||
---
|
||
|
||
## Task 4: `PageHead.vue` + spec + story
|
||
|
||
**Source:** `../crewli-starter/src/components/shared/PageHead.vue` (18 lines: `title,sub`, `#actions` slot; `.page-head` CSS 466–478 + responsive 1355–1360). **v2:** pure Tailwind flex, no PrimeVue (spec §8 "thin Tailwind flex").
|
||
|
||
**Files:** Create `PageHead.vue`; Test `__tests__/PageHead.spec.ts`; Story `PageHead.stories.ts`.
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/__tests__/PageHead.spec.ts
|
||
import { mount } from '@vue/test-utils'
|
||
import { describe, expect, it } from 'vitest'
|
||
import PageHead from '@/components-v2/shared/PageHead.vue'
|
||
|
||
describe('PageHead', () => {
|
||
it('renders the title and optional sub', () => {
|
||
const w = mount(PageHead, { props: { title: 'Evenementen', sub: '3 actief' } })
|
||
expect(w.get('h1').text()).toBe('Evenementen')
|
||
expect(w.text()).toContain('3 actief')
|
||
})
|
||
|
||
it('omits the sub element when not provided', () => {
|
||
const w = mount(PageHead, { props: { title: 'Evenementen' } })
|
||
expect(w.find('[data-sub]').exists()).toBe(false)
|
||
})
|
||
|
||
it('renders the #actions slot', () => {
|
||
const w = mount(PageHead, { props: { title: 'X' }, slots: { actions: '<button>New</button>' } })
|
||
expect(w.get('button').text()).toBe('New')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL.** Run: `pnpm test -- PageHead`.
|
||
|
||
- [ ] **Step 3: Implement `PageHead.vue`**
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
/**
|
||
* PageHead — title/sub/#actions. Pure layout (spec §8). CSS translation
|
||
* of main.css .page-head 466–478 (+ <=640px stack 1355–1360) to Tailwind.
|
||
*/
|
||
defineProps<{ title: string, sub?: string }>()
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flex flex-wrap items-end justify-between gap-6 mb-5 max-[640px]:flex-col max-[640px]:items-stretch max-[640px]:gap-3.5">
|
||
<div class="min-w-0 flex-[1_1_240px] max-[640px]:flex-[0_0_auto]">
|
||
<h1 class="m-0 text-2xl font-bold tracking-[-0.01em] text-[var(--p-text-color)] text-balance max-[640px]:text-[22px]">
|
||
{{ title }}
|
||
</h1>
|
||
<div v-if="sub" data-sub class="mt-1 text-[13.5px] text-[var(--p-text-muted-color)]">
|
||
{{ sub }}
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-2 max-[640px]:w-full max-[640px]:justify-start">
|
||
<slot name="actions" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 4: Run — expect PASS.** Run: `pnpm test -- PageHead`.
|
||
|
||
- [ ] **Step 5: Co-located story**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/PageHead.stories.ts
|
||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||
import Button from 'primevue/button'
|
||
import PageHead from '@/components-v2/shared/PageHead.vue'
|
||
|
||
const meta: Meta<typeof PageHead> = {
|
||
title: 'Shared/PageHead',
|
||
component: PageHead,
|
||
tags: ['autodocs'],
|
||
argTypes: { title: { control: 'text' }, sub: { control: 'text' } },
|
||
}
|
||
export default meta
|
||
type Story = StoryObj<typeof PageHead>
|
||
|
||
export const TitleOnly: Story = { args: { title: 'Evenementen' } }
|
||
export const WithSub: Story = { args: { title: 'Evenementen', sub: '3 actieve evenementen' } }
|
||
export const WithActions: Story = {
|
||
args: { title: 'Evenementen', sub: '3 actief' },
|
||
render: args => ({
|
||
components: { PageHead, Button },
|
||
setup: () => ({ args }),
|
||
template: `<PageHead :title="args.title" :sub="args.sub"><template #actions><Button label="Nieuw evenement" /></template></PageHead>`,
|
||
}),
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Typecheck, lint, commit**
|
||
|
||
```bash
|
||
git add apps/app/src/components-v2/shared/PageHead.vue apps/app/src/components-v2/shared/__tests__/PageHead.spec.ts apps/app/src/components-v2/shared/PageHead.stories.ts
|
||
git commit -m "feat(gui-v2): PageHead (Tailwind flex title/sub/#actions) + story"
|
||
```
|
||
|
||
- [ ] **Step 7–8: Parity-check (Bert, record) + `@visual` baseline** — Task 2 Steps 7–8 workflow with `PageHead`.
|
||
|
||
---
|
||
|
||
## Task 5: `StateBlock.vue` (built fresh) + spec + story — constraint #5
|
||
|
||
**No crewli-starter source.** Fresh composition (spec §8 + CLAUDE.md three-state): `Skeleton` (loading) · `Message` + retry `Button` (error) · empty `Card` + action `Button` (empty) · default slot (success). **Constraint #5: NO `@visual` baseline day one** — a self-baseline is tautological. Coverage = exhaustive Vitest across all states + transitions; visual baseline deferred to a Plans 4–5 follow-up after first real usage.
|
||
|
||
**Files:** Create `StateBlock.vue`; Test `__tests__/StateBlock.spec.ts`; Story `StateBlock.stories.ts`.
|
||
|
||
- [ ] **Step 1: Write the exhaustive failing test (all 3 states + transitions)**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/__tests__/StateBlock.spec.ts
|
||
import { mount } from '@vue/test-utils'
|
||
import { describe, expect, it } from 'vitest'
|
||
import { defineComponent } from 'vue'
|
||
import StateBlock from '@/components-v2/shared/StateBlock.vue'
|
||
|
||
const stubs = {
|
||
Skeleton: defineComponent({ name: 'Skeleton', template: `<div class="skeleton-stub" />` }),
|
||
Message: defineComponent({ name: 'Message', props: ['severity'], template: `<div class="message-stub"><slot /></div>` }),
|
||
Button: defineComponent({ name: 'Button', props: ['label'], emits: ['click'], template: `<button class="btn-stub" @click="$emit('click')">{{ label }}</button>` }),
|
||
Card: defineComponent({ name: 'Card', template: `<div class="card-stub"><slot name="content" /></div>` }),
|
||
}
|
||
const mountSB = (props: Record<string, unknown>, slots: Record<string, string> = {}) =>
|
||
mount(StateBlock, { props, slots, global: { stubs } })
|
||
|
||
describe('StateBlock', () => {
|
||
it('loading: renders Skeleton, nothing else', () => {
|
||
const w = mountSB({ state: 'loading' }, { default: '<p>data</p>' })
|
||
expect(w.find('.skeleton-stub').exists()).toBe(true)
|
||
expect(w.text()).not.toContain('data')
|
||
})
|
||
|
||
it('error: renders Message + retry Button; emits retry on click', async () => {
|
||
const w = mountSB({ state: 'error', errorMessage: 'Mislukt' })
|
||
expect(w.get('.message-stub').text()).toContain('Mislukt')
|
||
await w.get('.btn-stub').trigger('click')
|
||
expect(w.emitted('retry')).toHaveLength(1)
|
||
})
|
||
|
||
it('empty: renders empty Card + action Button; emits action on click', async () => {
|
||
const w = mountSB({ state: 'empty', emptyMessage: 'Niets hier', actionLabel: 'Maak aan' })
|
||
expect(w.text()).toContain('Niets hier')
|
||
await w.get('.btn-stub').trigger('click')
|
||
expect(w.emitted('action')).toHaveLength(1)
|
||
})
|
||
|
||
it('success: renders the default slot, no state chrome', () => {
|
||
const w = mountSB({ state: 'success' }, { default: '<p>real content</p>' })
|
||
expect(w.text()).toContain('real content')
|
||
expect(w.find('.skeleton-stub').exists()).toBe(false)
|
||
expect(w.find('.message-stub').exists()).toBe(false)
|
||
})
|
||
|
||
it('transition loading→success swaps chrome for slot content', async () => {
|
||
const w = mountSB({ state: 'loading' }, { default: '<p>loaded</p>' })
|
||
expect(w.find('.skeleton-stub').exists()).toBe(true)
|
||
await w.setProps({ state: 'success' })
|
||
expect(w.find('.skeleton-stub').exists()).toBe(false)
|
||
expect(w.text()).toContain('loaded')
|
||
})
|
||
|
||
it('transition error→loading clears the message', async () => {
|
||
const w = mountSB({ state: 'error', errorMessage: 'Mislukt' })
|
||
expect(w.text()).toContain('Mislukt')
|
||
await w.setProps({ state: 'loading' })
|
||
expect(w.find('.message-stub').exists()).toBe(false)
|
||
expect(w.find('.skeleton-stub').exists()).toBe(true)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL.** Run: `pnpm test -- StateBlock`.
|
||
|
||
- [ ] **Step 3: Implement `StateBlock.vue`**
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
/**
|
||
* StateBlock — the CLAUDE.md mandatory three-state wrapper (loading /
|
||
* error / empty) + success passthrough. Built fresh (no crewli-starter
|
||
* source). Per Plan 3 constraint #5 this component intentionally has
|
||
* NO @visual baseline yet (self-baseline is tautological); correctness
|
||
* is locked by the exhaustive Vitest spec. Visual baseline is a
|
||
* Plans 4–5 follow-up after first real page usage.
|
||
*/
|
||
import Skeleton from 'primevue/skeleton'
|
||
import Message from 'primevue/message'
|
||
import Button from 'primevue/button'
|
||
import Card from 'primevue/card'
|
||
|
||
defineProps<{
|
||
state: 'loading' | 'error' | 'empty' | 'success'
|
||
errorMessage?: string
|
||
emptyMessage?: string
|
||
actionLabel?: string
|
||
retryLabel?: string
|
||
}>()
|
||
|
||
const emit = defineEmits<{ retry: [], action: [] }>()
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="state === 'loading'" class="flex flex-col gap-3" data-state="loading">
|
||
<Skeleton height="2rem" />
|
||
<Skeleton height="2rem" width="80%" />
|
||
<Skeleton height="2rem" width="60%" />
|
||
</div>
|
||
|
||
<Message
|
||
v-else-if="state === 'error'"
|
||
severity="error"
|
||
:closable="false"
|
||
data-state="error"
|
||
>
|
||
<div class="flex items-center justify-between gap-4 w-full">
|
||
<span>{{ errorMessage ?? 'Er ging iets mis.' }}</span>
|
||
<Button :label="retryLabel ?? 'Opnieuw proberen'" size="small" @click="emit('retry')" />
|
||
</div>
|
||
</Message>
|
||
|
||
<Card
|
||
v-else-if="state === 'empty'"
|
||
data-state="empty"
|
||
:pt="{ root: 'border border-dashed border-[var(--p-content-border-color)] shadow-none', content: 'flex flex-col items-center gap-3 py-10 text-center' }"
|
||
>
|
||
<template #content>
|
||
<p class="m-0 text-[var(--p-text-muted-color)]">
|
||
{{ emptyMessage ?? 'Nog niets om te tonen.' }}
|
||
</p>
|
||
<Button v-if="actionLabel" :label="actionLabel" @click="emit('action')" />
|
||
</template>
|
||
</Card>
|
||
|
||
<slot v-else />
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 4: Run — expect PASS (6 tests).** Run: `pnpm test -- StateBlock`.
|
||
|
||
- [ ] **Step 5: Co-located story** (note in story description: no @visual baseline by Plan 3 constraint #5)
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/StateBlock.stories.ts
|
||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||
import StateBlock from '@/components-v2/shared/StateBlock.vue'
|
||
|
||
/**
|
||
* StateBlock stories. NOTE: per Plan 3 constraint #5 this component has
|
||
* NO Tier-4 @visual baseline yet (self-baseline is tautological). These
|
||
* stories exist for autodocs + a11y + manual review only.
|
||
*/
|
||
const meta: Meta<typeof StateBlock> = {
|
||
title: 'Shared/StateBlock',
|
||
component: StateBlock,
|
||
tags: ['autodocs'],
|
||
argTypes: { state: { control: 'inline-radio', options: ['loading', 'error', 'empty', 'success'] } },
|
||
}
|
||
export default meta
|
||
type Story = StoryObj<typeof StateBlock>
|
||
|
||
export const Loading: Story = { args: { state: 'loading' } }
|
||
export const Error: Story = { args: { state: 'error', errorMessage: 'Kon evenementen niet laden.' } }
|
||
export const Empty: Story = { args: { state: 'empty', emptyMessage: 'Nog geen evenementen.', actionLabel: 'Nieuw evenement' } }
|
||
export const Success: Story = {
|
||
args: { state: 'success' },
|
||
render: args => ({ components: { StateBlock }, setup: () => ({ args }), template: `<StateBlock v-bind="args"><p>Echte inhoud.</p></StateBlock>` }),
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Typecheck, lint, commit**
|
||
|
||
```bash
|
||
git add apps/app/src/components-v2/shared/StateBlock.vue apps/app/src/components-v2/shared/__tests__/StateBlock.spec.ts apps/app/src/components-v2/shared/StateBlock.stories.ts
|
||
git commit -m "feat(gui-v2): StateBlock 3-state wrapper (exhaustive Vitest, no @visual per constraint #5)"
|
||
```
|
||
|
||
> **No parity-check, no `@visual` step for StateBlock** (constraint #5). The exhaustive Vitest spec is the lock.
|
||
|
||
---
|
||
|
||
## Task 6: `TagsInput.vue` (re-implementation on PrimeVue AutoComplete) — constraint #2
|
||
|
||
**NOT a port.** crewli-starter source is **behavioural reference only**. Spec §8: PrimeVue `AutoComplete` `multiple` + `typeahead`. The prompt's "wraps PrimeVue Chip" note was incorrect — dropped. The five behavioural rules from the crewli-starter source become a Vitest checklist.
|
||
|
||
**Files:** Create `TagsInput.vue`; Test `__tests__/TagsInput.spec.ts`; Story `TagsInput.stories.ts`.
|
||
|
||
- [ ] **Step 1: Write the failing test — the 5 behavioural rules as a checklist**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/__tests__/TagsInput.spec.ts
|
||
import { mount } from '@vue/test-utils'
|
||
import { describe, expect, it } from 'vitest'
|
||
import { defineComponent } from 'vue'
|
||
import TagsInput from '@/components-v2/shared/TagsInput.vue'
|
||
|
||
/**
|
||
* AutoCompleteStub mirrors the PrimeVue AutoComplete contract TagsInput
|
||
* relies on: v-model (array in multiple mode), `complete` event, and
|
||
* exposing the typed query so we can drive add/dedupe logic.
|
||
*/
|
||
const AutoCompleteStub = defineComponent({
|
||
name: 'AutoComplete',
|
||
props: { modelValue: { type: Array, default: () => [] }, suggestions: { type: Array, default: () => [] }, multiple: Boolean, typeahead: Boolean },
|
||
emits: ['update:modelValue', 'complete'],
|
||
methods: {
|
||
addRaw(raw: string) { this.$emit('update:modelValue', [...(this.modelValue as string[]), raw]) },
|
||
},
|
||
template: `<div class="ac-stub" :data-count="modelValue.length" />`,
|
||
})
|
||
const mountTI = (props: Record<string, unknown> = {}) =>
|
||
mount(TagsInput, { props, global: { stubs: { AutoComplete: AutoCompleteStub } } })
|
||
|
||
describe('TagsInput — 5 behavioural rules (crewli-starter reference)', () => {
|
||
it('(a) array model: modelValue is an array, update:modelValue emits an array', async () => {
|
||
const w = mountTI({ modelValue: ['rock'] })
|
||
expect(w.get('.ac-stub').attributes('data-count')).toBe('1')
|
||
await w.vm.normalizeAndEmit(['rock', 'jazz'])
|
||
expect(w.emitted('update:modelValue')![0][0]).toEqual(['rock', 'jazz'])
|
||
})
|
||
|
||
it('(b) lowercase-dedupe: mixed-case duplicates collapse to one lowercase entry', async () => {
|
||
const w = mountTI({ modelValue: ['rock'] })
|
||
await w.vm.normalizeAndEmit(['rock', 'ROCK', 'Rock', 'jazz'])
|
||
expect(w.emitted('update:modelValue')!.at(-1)![0]).toEqual(['rock', 'jazz'])
|
||
})
|
||
|
||
it('(c) Enter or comma adds (separator handling in onComplete query)', async () => {
|
||
const w = mountTI({ modelValue: [] })
|
||
expect(w.vm.splitQuery('rock,jazz')).toEqual(['rock', 'jazz'])
|
||
expect(w.vm.splitQuery('rock\n')).toEqual(['rock'])
|
||
})
|
||
|
||
it('(d) Backspace-remove last is delegated to AutoComplete multiple (chip removal) — model shrinks', async () => {
|
||
const w = mountTI({ modelValue: ['rock', 'jazz'] })
|
||
await w.vm.normalizeAndEmit(['rock'])
|
||
expect(w.emitted('update:modelValue')!.at(-1)![0]).toEqual(['rock'])
|
||
})
|
||
|
||
it('(e) 5-suggestion cap: visibleSuggestions never exceeds 5 filtered, dedup-against-model', () => {
|
||
const w = mountTI({ modelValue: ['rock'], suggestions: ['rock', 'rockabilly', 'rocksteady', 'rock-n-roll', 'rockpool', 'rockford', 'rocketry'] })
|
||
const out = w.vm.filterSuggestions('rock')
|
||
expect(out.length).toBeLessThanOrEqual(5)
|
||
expect(out).not.toContain('rock') // already in model
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL.** Run: `pnpm test -- TagsInput`.
|
||
|
||
- [ ] **Step 3: Implement `TagsInput.vue` (AutoComplete multiple + typeahead)**
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
/**
|
||
* TagsInput — RE-IMPLEMENTATION (not a port) onto PrimeVue AutoComplete
|
||
* `multiple` + `typeahead` per spec §8. crewli-starter's hand-rolled
|
||
* <input> is behavioural reference only. The 5 reference rules:
|
||
* (a) array model (b) lowercase-dedupe
|
||
* (c) Enter/comma adds (d) Backspace removes last
|
||
* (e) 5-suggestion cap
|
||
* Visual parity criterion: "coherent in Aura/teal aesthetic", NOT a
|
||
* pixel match against crewli-starter (constraint #2).
|
||
*/
|
||
import { ref } from 'vue'
|
||
import AutoComplete from 'primevue/autocomplete'
|
||
|
||
const props = withDefaults(defineProps<{
|
||
modelValue?: string[]
|
||
suggestions?: string[]
|
||
placeholder?: string
|
||
}>(), { modelValue: () => [], suggestions: () => [], placeholder: 'Tag toevoegen…' })
|
||
|
||
const emit = defineEmits<{ 'update:modelValue': [string[]] }>()
|
||
|
||
const filtered = ref<string[]>([])
|
||
|
||
/** (b) lowercase + dedupe, order-preserving. */
|
||
function normalizeAndEmit(next: string[]): void {
|
||
const seen = new Set<string>()
|
||
const out: string[] = []
|
||
for (const raw of next) {
|
||
const t = raw.trim().toLowerCase()
|
||
if (t && !seen.has(t)) {
|
||
seen.add(t)
|
||
out.push(t)
|
||
}
|
||
}
|
||
emit('update:modelValue', out)
|
||
}
|
||
|
||
/** (c) split a typed query on comma / newline (Enter). */
|
||
function splitQuery(q: string): string[] {
|
||
return q.split(/[,\n]/).map(s => s.trim()).filter(Boolean)
|
||
}
|
||
|
||
/** (e) filter suggestions: exclude already-selected, cap at 5. */
|
||
function filterSuggestions(q: string): string[] {
|
||
const t = q.toLowerCase()
|
||
const model = props.modelValue.map(s => s.toLowerCase())
|
||
return props.suggestions
|
||
.filter(s => !model.includes(s.toLowerCase()) && s.toLowerCase().includes(t))
|
||
.slice(0, 5)
|
||
}
|
||
|
||
function onComplete(e: { query: string }): void {
|
||
filtered.value = filterSuggestions(e.query)
|
||
}
|
||
|
||
function onModelUpdate(next: unknown): void {
|
||
// AutoComplete multiple emits the full array (chips + typed token).
|
||
const arr = Array.isArray(next) ? next.flatMap(v => typeof v === 'string' ? splitQuery(v) : [String(v)]) : []
|
||
normalizeAndEmit(arr)
|
||
}
|
||
|
||
defineExpose({ normalizeAndEmit, splitQuery, filterSuggestions })
|
||
</script>
|
||
|
||
<template>
|
||
<AutoComplete
|
||
:model-value="props.modelValue"
|
||
:suggestions="filtered"
|
||
multiple
|
||
typeahead
|
||
:placeholder="props.placeholder"
|
||
class="w-full"
|
||
@complete="onComplete"
|
||
@update:model-value="onModelUpdate"
|
||
/>
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 4: Run — expect PASS (5 tests).** Run: `pnpm test -- TagsInput`.
|
||
|
||
- [ ] **Step 5: Co-located story**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/TagsInput.stories.ts
|
||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||
import { ref } from 'vue'
|
||
import TagsInput from '@/components-v2/shared/TagsInput.vue'
|
||
|
||
const meta: Meta<typeof TagsInput> = {
|
||
title: 'Shared/TagsInput',
|
||
component: TagsInput,
|
||
tags: ['autodocs'],
|
||
}
|
||
export default meta
|
||
type Story = StoryObj<typeof TagsInput>
|
||
|
||
function model(initial: string[], suggestions: string[]): Story['render'] {
|
||
return () => ({
|
||
components: { TagsInput },
|
||
setup() { const tags = ref(initial); return { tags, suggestions } },
|
||
template: `<div class="max-w-md"><TagsInput v-model="tags" :suggestions="suggestions" /><pre class="mt-3 text-xs">{{ tags }}</pre></div>`,
|
||
})
|
||
}
|
||
export const Empty: Story = { render: model([], ['rock', 'jazz', 'techno', 'house', 'ambient', 'drum-n-bass']) }
|
||
export const Prefilled: Story = { render: model(['rock', 'jazz'], ['rock', 'jazz', 'techno', 'house', 'ambient']) }
|
||
```
|
||
|
||
- [ ] **Step 6: Typecheck, lint, commit**
|
||
|
||
```bash
|
||
git add apps/app/src/components-v2/shared/TagsInput.vue apps/app/src/components-v2/shared/__tests__/TagsInput.spec.ts apps/app/src/components-v2/shared/TagsInput.stories.ts
|
||
git commit -m "feat(gui-v2): TagsInput re-impl on PrimeVue AutoComplete (5 behavioural rules) + story"
|
||
```
|
||
|
||
- [ ] **Step 7: Parity-check (Bert) — criterion is "coherent in Aura/teal", NOT pixel-match** (constraint #2). Record pass. Then Step 8 `@visual` baseline (the v2 rendering is the baseline; this is allowed here because the component is intentionally a re-design, unlike StateBlock which has no stable target at all).
|
||
|
||
```bash
|
||
git add apps/app/tests/playwright-ct/v2/__screenshots__
|
||
git commit -m "test(gui-v2): TagsInput @visual baseline (Aura-coherence parity recorded)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: `EnergyDots.vue` + spec + story
|
||
|
||
**Source:** `../crewli-starter/src/components/shared/EnergyDots.vue` (17 lines: `value,lg`; `.energy-dots` CSS 1982–1991, level colours). **v2:** minimal scoped CSS justified (spec §8 — no PrimeVue primitive; `Rating` is stars).
|
||
|
||
**Files:** Create `EnergyDots.vue`; Test `__tests__/EnergyDots.spec.ts`; Story `EnergyDots.stories.ts`.
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/__tests__/EnergyDots.spec.ts
|
||
import { mount } from '@vue/test-utils'
|
||
import { describe, expect, it } from 'vitest'
|
||
import EnergyDots from '@/components-v2/shared/EnergyDots.vue'
|
||
|
||
describe('EnergyDots', () => {
|
||
it('always renders 5 dots; `on` count equals value', () => {
|
||
const w = mount(EnergyDots, { props: { value: 3 } })
|
||
expect(w.findAll('.d')).toHaveLength(5)
|
||
expect(w.findAll('.d.on')).toHaveLength(3)
|
||
})
|
||
it('exposes data-energy for level colouring and lg class when lg', () => {
|
||
const w = mount(EnergyDots, { props: { value: 5, lg: true } })
|
||
expect(w.get('[data-energy]').attributes('data-energy')).toBe('5')
|
||
expect(w.get('.energy-dots').classes()).toContain('lg')
|
||
})
|
||
it('clamps value into 0..5', () => {
|
||
expect(mount(EnergyDots, { props: { value: 9 } }).findAll('.d.on')).toHaveLength(5)
|
||
expect(mount(EnergyDots, { props: { value: -2 } }).findAll('.d.on')).toHaveLength(0)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL.** Run: `pnpm test -- EnergyDots`.
|
||
|
||
- [ ] **Step 3: Implement `EnergyDots.vue`** (scoped CSS justified; level colours via Aura tokens)
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
/**
|
||
* EnergyDots — 5-dot meter (spec §8: no PrimeVue primitive; Rating is
|
||
* stars/wrong visual → minimal scoped CSS is the justified bespoke case).
|
||
* crewli-starter main.css 1982–1991 ported; crewli vars → Aura tokens.
|
||
*/
|
||
import { computed } from 'vue'
|
||
|
||
const props = withDefaults(defineProps<{ value?: number, lg?: boolean }>(), { value: 0, lg: false })
|
||
const clamped = computed(() => Math.max(0, Math.min(5, Math.round(props.value))))
|
||
</script>
|
||
|
||
<template>
|
||
<div class="energy-dots" :class="{ lg }" :data-energy="clamped">
|
||
<span v-for="i in 5" :key="i" class="d" :class="{ on: i <= clamped }" />
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* Justified per spec §8 — no Tailwind/PrimeVue expression of this meter. */
|
||
.energy-dots { display: inline-flex; gap: 3px; align-items: center; }
|
||
.d { width: 8px; height: 8px; border-radius: 50%; background: var(--p-content-border-color); }
|
||
.d.on { background: var(--p-primary-color); }
|
||
.energy-dots[data-energy="1"] .d.on { background: var(--p-sky-600); }
|
||
.energy-dots[data-energy="4"] .d.on { background: oklch(65% 0.15 35); }
|
||
.energy-dots[data-energy="5"] .d.on { background: var(--p-red-600); }
|
||
.energy-dots.lg .d { width: 11px; height: 11px; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 4: Run — expect PASS (3 tests).** Run: `pnpm test -- EnergyDots`.
|
||
|
||
- [ ] **Step 5: Co-located story**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/EnergyDots.stories.ts
|
||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||
import EnergyDots from '@/components-v2/shared/EnergyDots.vue'
|
||
|
||
const meta: Meta<typeof EnergyDots> = {
|
||
title: 'Shared/EnergyDots',
|
||
component: EnergyDots,
|
||
tags: ['autodocs'],
|
||
argTypes: { value: { control: { type: 'range', min: 0, max: 5, step: 1 } }, lg: { control: 'boolean' } },
|
||
}
|
||
export default meta
|
||
type Story = StoryObj<typeof EnergyDots>
|
||
export const Level3: Story = { args: { value: 3 } }
|
||
export const Level5: Story = { args: { value: 5 } }
|
||
export const Large: Story = { args: { value: 4, lg: true } }
|
||
```
|
||
|
||
- [ ] **Step 6: Typecheck, lint, commit**
|
||
|
||
```bash
|
||
git add apps/app/src/components-v2/shared/EnergyDots.vue apps/app/src/components-v2/shared/__tests__/EnergyDots.spec.ts apps/app/src/components-v2/shared/EnergyDots.stories.ts
|
||
git commit -m "feat(gui-v2): EnergyDots 5-dot meter (scoped CSS justified per §8) + story"
|
||
```
|
||
|
||
- [ ] **Step 7–8: Parity-check (Bert, record) + `@visual` baseline** — Task 2 Steps 7–8 with `EnergyDots`.
|
||
|
||
---
|
||
|
||
## Task 8: `EnergyPicker.vue` + spec + story
|
||
|
||
**Source:** `../crewli-starter/src/components/music/EnergyPicker.vue` (22 lines: `modelValue`, `update:modelValue`, click-to-toggle-zero). **v2:** interactive sibling of EnergyDots, same scoped-CSS justification.
|
||
|
||
**Files:** Create `EnergyPicker.vue`; Test `__tests__/EnergyPicker.spec.ts`; Story `EnergyPicker.stories.ts`.
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/__tests__/EnergyPicker.spec.ts
|
||
import { mount } from '@vue/test-utils'
|
||
import { describe, expect, it } from 'vitest'
|
||
import EnergyPicker from '@/components-v2/shared/EnergyPicker.vue'
|
||
|
||
describe('EnergyPicker', () => {
|
||
it('renders 5 buttons; clicking i emits i', async () => {
|
||
const w = mount(EnergyPicker, { props: { modelValue: 0 } })
|
||
const btns = w.findAll('button')
|
||
expect(btns).toHaveLength(5)
|
||
await btns[2].trigger('click')
|
||
expect(w.emitted('update:modelValue')![0]).toEqual([3])
|
||
})
|
||
it('clicking the current value toggles back to 0 (crewli-starter parity)', async () => {
|
||
const w = mount(EnergyPicker, { props: { modelValue: 3 } })
|
||
await w.findAll('button')[2].trigger('click')
|
||
expect(w.emitted('update:modelValue')![0]).toEqual([0])
|
||
})
|
||
it('marks buttons up to modelValue as on', () => {
|
||
const w = mount(EnergyPicker, { props: { modelValue: 2 } })
|
||
expect(w.findAll('button.on')).toHaveLength(2)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL.** Run: `pnpm test -- EnergyPicker`.
|
||
|
||
- [ ] **Step 3: Implement `EnergyPicker.vue`**
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
/**
|
||
* EnergyPicker — interactive 5-step picker (crewli-starter music/
|
||
* EnergyPicker.vue port). Click current value → reset to 0. Scoped CSS
|
||
* justified per spec §8 (same rationale as EnergyDots).
|
||
*/
|
||
const props = withDefaults(defineProps<{ modelValue?: number }>(), { modelValue: 0 })
|
||
const emit = defineEmits<{ 'update:modelValue': [number] }>()
|
||
|
||
function pick(i: number): void {
|
||
emit('update:modelValue', i === props.modelValue ? 0 : i)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="energy-picker">
|
||
<button
|
||
v-for="i in 5"
|
||
:key="i"
|
||
type="button"
|
||
:class="{ on: i <= modelValue }"
|
||
@click="pick(i)"
|
||
>{{ i }}</button>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.energy-picker { display: inline-flex; gap: 4px; }
|
||
.energy-picker button {
|
||
width: 28px; height: 28px; border-radius: 50%;
|
||
border: 1px solid var(--p-content-border-color);
|
||
background: var(--p-content-background); color: var(--p-text-muted-color);
|
||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||
transition: background .15s, color .15s, border-color .15s;
|
||
}
|
||
.energy-picker button.on {
|
||
background: var(--p-primary-color); color: var(--p-primary-contrast-color);
|
||
border-color: var(--p-primary-color);
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 4: Run — expect PASS (3 tests).** Run: `pnpm test -- EnergyPicker`.
|
||
|
||
- [ ] **Step 5: Co-located story**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/EnergyPicker.stories.ts
|
||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||
import { ref } from 'vue'
|
||
import EnergyPicker from '@/components-v2/shared/EnergyPicker.vue'
|
||
|
||
const meta: Meta<typeof EnergyPicker> = { title: 'Shared/EnergyPicker', component: EnergyPicker, tags: ['autodocs'] }
|
||
export default meta
|
||
type Story = StoryObj<typeof EnergyPicker>
|
||
export const Interactive: Story = {
|
||
render: () => ({
|
||
components: { EnergyPicker },
|
||
setup() { const v = ref(0); return { v } },
|
||
template: `<div class="flex items-center gap-3"><EnergyPicker v-model="v" /><span class="text-sm">value: {{ v }}</span></div>`,
|
||
}),
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Typecheck, lint, commit**
|
||
|
||
```bash
|
||
git add apps/app/src/components-v2/shared/EnergyPicker.vue apps/app/src/components-v2/shared/__tests__/EnergyPicker.spec.ts apps/app/src/components-v2/shared/EnergyPicker.stories.ts
|
||
git commit -m "feat(gui-v2): EnergyPicker interactive 5-step (crewli-starter port) + story"
|
||
```
|
||
|
||
- [ ] **Step 7–8: Parity-check (Bert, record) + `@visual` baseline** — Task 2 Steps 7–8 with `EnergyPicker`.
|
||
|
||
---
|
||
|
||
## Task 9: `DraggableBlock.vue` (abstraction; gated on Task A2) + spec + stories + CT
|
||
|
||
**MUST NOT start until Task A2 is committed.** Implements the spec §7.1 canonical contract using the A2-reconciled PointerEvent drag model. Internals: PrimeVue `Tag` + `ProgressBar`; 2-line flex in scoped CSS (spec §7.1 — justified bespoke). Parent owns all positioning.
|
||
|
||
**Files:** Create `DraggableBlock.vue`; Test `__tests__/DraggableBlock.spec.ts`; CT `apps/app/tests/playwright-ct/v2/draggableblock.ct.spec.ts`; Story `DraggableBlock.stories.ts`.
|
||
|
||
- [ ] **Step 1: Confirm the A2 gate**
|
||
|
||
Run: `git log --oneline --grep "Task A2" -1` → expect the A2 reconciliation commit. If absent, STOP — Task 9 is gated.
|
||
|
||
- [ ] **Step 2: Write the failing Vitest spec (props/render/click-vs-drag discrimination)**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/__tests__/DraggableBlock.spec.ts
|
||
import { mount } from '@vue/test-utils'
|
||
import { describe, expect, it } from 'vitest'
|
||
import { defineComponent } from 'vue'
|
||
import DraggableBlock from '@/components-v2/shared/DraggableBlock.vue'
|
||
|
||
const stubs = {
|
||
Tag: defineComponent({ name: 'Tag', props: ['value', 'severity'], template: `<span class="tag-stub" :data-sev="severity">{{ value }}</span>` }),
|
||
ProgressBar: defineComponent({ name: 'ProgressBar', props: ['value'], template: `<div class="pb-stub" :data-val="value" />` }),
|
||
}
|
||
const mountDB = (props: Record<string, unknown>) =>
|
||
mount(DraggableBlock, { props, global: { stubs }, attachTo: document.body })
|
||
|
||
describe('DraggableBlock', () => {
|
||
it('renders line1Left text + tag and line2Right progress', () => {
|
||
const w = mountDB({
|
||
line1Left: { text: 'DJ Foo', tag: { label: 'confirmed', severity: 'success' } },
|
||
line2Right: { progress: 42 },
|
||
})
|
||
expect(w.text()).toContain('DJ Foo')
|
||
expect(w.get('.tag-stub').attributes('data-sev')).toBe('success')
|
||
expect(w.get('.pb-stub').attributes('data-val')).toBe('42')
|
||
})
|
||
|
||
it('density prop maps to the row-height data attribute', () => {
|
||
expect(mountDB({ line1Left: { text: 'x' }, density: 'compact' }).get('[data-density]').attributes('data-density')).toBe('compact')
|
||
expect(mountDB({ line1Left: { text: 'x' }, density: 'comfy' }).get('[data-density]').attributes('data-density')).toBe('comfy')
|
||
})
|
||
|
||
it('pointer without movement → click emitted, no dragstart/dragend', async () => {
|
||
const w = mountDB({ line1Left: { text: 'x' } })
|
||
const el = w.get('[data-density]')
|
||
await el.trigger('pointerdown', { button: 0, clientX: 10, clientY: 10, pointerId: 1 })
|
||
await el.trigger('pointerup', { clientX: 10, clientY: 10, pointerId: 1 })
|
||
expect(w.emitted('click')).toHaveLength(1)
|
||
expect(w.emitted('dragstart')).toBeUndefined()
|
||
expect(w.emitted('dragend')).toBeUndefined()
|
||
})
|
||
|
||
it('pointer past 3px threshold → dragstart once, dragend with delta, no click', async () => {
|
||
const w = mountDB({ line1Left: { text: 'x' } })
|
||
const el = w.get('[data-density]')
|
||
await el.trigger('pointerdown', { button: 0, clientX: 0, clientY: 0, pointerId: 1 })
|
||
await el.trigger('pointermove', { clientX: 20, clientY: 8, pointerId: 1 })
|
||
await el.trigger('pointerup', { clientX: 20, clientY: 8, pointerId: 1 })
|
||
expect(w.emitted('dragstart')).toHaveLength(1)
|
||
expect(w.emitted('dragend')![0]).toEqual([{ x: 20, y: 8 }])
|
||
expect(w.emitted('click')).toBeUndefined()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Run — expect FAIL.** Run: `pnpm test -- DraggableBlock`.
|
||
|
||
- [ ] **Step 4: Implement `DraggableBlock.vue` (PointerEvent model from A2; parent owns positioning)**
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
/**
|
||
* DraggableBlock — spec §7.1 canonical contract. ABSTRACTION over two
|
||
* crewli-starter consumers (TimetableGrid mousedown+px/min;
|
||
* CueTimelineEditor HTML5 drag) reconciled in Task A2. This component
|
||
* is presentational + emits normalised pointer drag; it performs NO
|
||
* snap/lane/px-min math — the parent owns all positioning.
|
||
*
|
||
* Scoped CSS is the spec §7.1-justified bespoke case (crewli-starter
|
||
* 2-line block spacing has no Tailwind/PrimeVue expression).
|
||
*/
|
||
import { ref } from 'vue'
|
||
import Tag from 'primevue/tag'
|
||
import ProgressBar from 'primevue/progressbar'
|
||
import type { TagSeverity } from '@/components-v2/shared/statusSeverity'
|
||
|
||
interface TagBit { label: string, severity?: TagSeverity }
|
||
|
||
const props = withDefaults(defineProps<{
|
||
line1Left: { tag?: TagBit, text?: string }
|
||
line1Right?: { tag?: TagBit, pill?: string }
|
||
line2Left?: string
|
||
line2Right?: { progress: number } | null
|
||
selected?: boolean
|
||
dragging?: boolean
|
||
density?: 'compact' | 'regular' | 'comfy'
|
||
}>(), { density: 'regular', selected: false, dragging: false, line2Right: null })
|
||
|
||
const emit = defineEmits<{
|
||
click: []
|
||
dragstart: [e: PointerEvent]
|
||
dragend: [delta: { x: number, y: number }]
|
||
}>()
|
||
|
||
const THRESHOLD = 3
|
||
const startX = ref(0)
|
||
const startY = ref(0)
|
||
const moved = ref(false)
|
||
const active = ref(false)
|
||
|
||
function onPointerDown(e: PointerEvent): void {
|
||
if (e.button !== 0)
|
||
return
|
||
active.value = true
|
||
moved.value = false
|
||
startX.value = e.clientX
|
||
startY.value = e.clientY
|
||
;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
|
||
}
|
||
|
||
function onPointerMove(e: PointerEvent): void {
|
||
if (!active.value)
|
||
return
|
||
if (!moved.value && (Math.abs(e.clientX - startX.value) > THRESHOLD || Math.abs(e.clientY - startY.value) > THRESHOLD)) {
|
||
moved.value = true
|
||
emit('dragstart', e)
|
||
}
|
||
}
|
||
|
||
function onPointerUp(e: PointerEvent): void {
|
||
if (!active.value)
|
||
return
|
||
active.value = false
|
||
if (moved.value)
|
||
emit('dragend', { x: e.clientX - startX.value, y: e.clientY - startY.value })
|
||
else
|
||
emit('click')
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
class="db"
|
||
:data-density="density"
|
||
:class="{ 'is-selected': selected, 'is-dragging': dragging }"
|
||
@pointerdown="onPointerDown"
|
||
@pointermove="onPointerMove"
|
||
@pointerup="onPointerUp"
|
||
@lostpointercapture="onPointerUp"
|
||
>
|
||
<div class="db-row">
|
||
<span class="db-left">
|
||
<Tag v-if="line1Left.tag" :value="line1Left.tag.label" :severity="line1Left.tag.severity" />
|
||
<span v-if="line1Left.text" class="db-text">{{ line1Left.text }}</span>
|
||
</span>
|
||
<span v-if="line1Right" class="db-right">
|
||
<Tag v-if="line1Right.tag" :value="line1Right.tag.label" :severity="line1Right.tag.severity" />
|
||
<span v-if="line1Right.pill" class="db-pill">{{ line1Right.pill }}</span>
|
||
</span>
|
||
</div>
|
||
<div v-if="line2Left || line2Right" class="db-row db-row2">
|
||
<span v-if="line2Left" class="db-time">{{ line2Left }}</span>
|
||
<ProgressBar v-if="line2Right" :value="line2Right.progress" :show-value="false" class="db-progress" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* spec §7.1 justified bespoke — crewli-starter block spacing. */
|
||
.db {
|
||
display: flex; flex-direction: column; justify-content: center; gap: 2px;
|
||
padding: 4px 8px; border-radius: var(--p-border-radius);
|
||
border: 1px solid var(--p-content-border-color);
|
||
background: var(--p-content-background);
|
||
user-select: none; cursor: grab; overflow: hidden;
|
||
}
|
||
.db[data-density="compact"] { min-height: 56px; }
|
||
.db[data-density="regular"] { min-height: 64px; }
|
||
.db[data-density="comfy"] { min-height: 76px; }
|
||
.db.is-selected { border-color: var(--p-primary-color); box-shadow: 0 0 0 2px color-mix(in srgb, var(--p-primary-color) 30%, transparent); }
|
||
.db.is-dragging { cursor: grabbing; opacity: .85; }
|
||
.db-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
|
||
.db-left, .db-right { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
|
||
.db-text { font-size: 13px; font-weight: 600; color: var(--p-text-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.db-pill { font-size: 11px; color: var(--p-text-muted-color); }
|
||
.db-row2 { gap: 6px; }
|
||
.db-time { font-size: 11px; color: var(--p-text-muted-color); }
|
||
.db-progress { flex: 1; height: 4px; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 5: Run — expect PASS (4 tests).** Run: `pnpm test -- DraggableBlock`.
|
||
|
||
- [ ] **Step 6: Write the Tier-2 CT drag-emit interaction test (real Chromium pointer)**
|
||
|
||
```ts
|
||
// apps/app/tests/playwright-ct/v2/draggableblock.ct.spec.ts
|
||
import { expect, test } from '@playwright/experimental-ct-vue'
|
||
import DraggableBlock from '@/components-v2/shared/DraggableBlock.vue'
|
||
|
||
test('drag past threshold emits dragstart then dragend with delta @ct', async ({ mount }) => {
|
||
const events: string[] = []
|
||
const component = await mount(DraggableBlock, {
|
||
props: { line1Left: { text: 'DJ Foo' } },
|
||
on: {
|
||
dragstart: () => events.push('dragstart'),
|
||
dragend: (d: { x: number, y: number }) => events.push(`dragend:${d.x},${d.y}`),
|
||
click: () => events.push('click'),
|
||
},
|
||
})
|
||
const box = (await component.boundingBox())!
|
||
await component.page().mouse.move(box.x + 10, box.y + 10)
|
||
await component.page().mouse.down()
|
||
await component.page().mouse.move(box.x + 40, box.y + 22)
|
||
await component.page().mouse.up()
|
||
expect(events[0]).toBe('dragstart')
|
||
expect(events.some(e => e.startsWith('dragend:'))).toBe(true)
|
||
expect(events).not.toContain('click')
|
||
})
|
||
|
||
test('static states render for @visual baseline @visual', async ({ mount }) => {
|
||
const component = await mount(DraggableBlock, {
|
||
props: {
|
||
line1Left: { text: 'DJ Foo', tag: { label: 'confirmed', severity: 'success' } },
|
||
line1Right: { pill: 'Main' },
|
||
line2Left: '21:00–22:00',
|
||
line2Right: { progress: 60 },
|
||
selected: true,
|
||
},
|
||
})
|
||
await expect(component).toHaveScreenshot('draggableblock-selected.png')
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 7: Run CT — expect PASS.** Run: `pnpm test:component -- draggableblock`.
|
||
|
||
- [ ] **Step 8: Write the co-located stories (incl. A2 retrofit-prove ArtistBlock + CueBlock)**
|
||
|
||
```ts
|
||
// apps/app/src/components-v2/shared/DraggableBlock.stories.ts
|
||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||
import DraggableBlock from '@/components-v2/shared/DraggableBlock.vue'
|
||
|
||
const meta: Meta<typeof DraggableBlock> = {
|
||
title: 'Shared/DraggableBlock',
|
||
component: DraggableBlock,
|
||
tags: ['autodocs'],
|
||
}
|
||
export default meta
|
||
type Story = StoryObj<typeof DraggableBlock>
|
||
|
||
/** A2 retrofit-prove: TimetableGrid performance block via the §7.1 contract. */
|
||
export const ArtistBlock: Story = {
|
||
args: {
|
||
line1Left: { text: 'DJ Foo', tag: { label: 'confirmed', severity: 'success' } },
|
||
line1Right: { pill: 'Main Stage' },
|
||
line2Left: '21:00–22:00',
|
||
line2Right: { progress: 60 },
|
||
},
|
||
}
|
||
/** A2 retrofit-prove: CueTimelineEditor cue block via the §7.1 contract. */
|
||
export const CueBlock: Story = {
|
||
args: {
|
||
line1Left: { text: 'Intro VT', tag: { label: 'video', severity: 'info' } },
|
||
line1Right: { pill: 'PGM' },
|
||
line2Left: '00:30',
|
||
line2Right: null,
|
||
},
|
||
}
|
||
export const WithProgress: Story = { args: { line1Left: { text: 'Set' }, line2Right: { progress: 80 } } }
|
||
export const Selected: Story = { args: { line1Left: { text: 'Selected' }, selected: true } }
|
||
export const Dragging: Story = { args: { line1Left: { text: 'Dragging' }, dragging: true } }
|
||
export const Compact: Story = { args: { line1Left: { text: 'Compact' }, density: 'compact' } }
|
||
export const Comfy: Story = { args: { line1Left: { text: 'Comfy' }, density: 'comfy' } }
|
||
```
|
||
|
||
- [ ] **Step 9: Typecheck, lint, commit**
|
||
|
||
```bash
|
||
git add apps/app/src/components-v2/shared/DraggableBlock.vue apps/app/src/components-v2/shared/__tests__/DraggableBlock.spec.ts apps/app/tests/playwright-ct/v2/draggableblock.ct.spec.ts apps/app/src/components-v2/shared/DraggableBlock.stories.ts
|
||
git commit -m "feat(gui-v2): DraggableBlock §7.1 abstraction (PointerEvent drag, A2-reconciled) + CT + stories"
|
||
```
|
||
|
||
- [ ] **Step 10: Parity-check (Bert, record) + `@visual` baseline (static states only — drag is covered by the CT @ct test, not a screenshot)**
|
||
|
||
Run: `pnpm test:visual:update -- draggableblock` then `pnpm test:visual -- draggableblock` → PASS.
|
||
|
||
```bash
|
||
git add apps/app/tests/playwright-ct/v2/__screenshots__
|
||
git commit -m "test(gui-v2): DraggableBlock @visual baseline (ArtistBlock+CueBlock parity recorded)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Cleanup (a) — migrate Plan 2's 6 stories to co-located
|
||
|
||
`_helpers.ts` stays at `apps/app/src/stories/v2/_helpers.ts` (decision #3 — it's a story-util, not a story; relocating it is a separate refactor listed in Plans 4–5). The 6 `.stories.ts` move beside their `.vue`; their `_helpers` import is rewritten to the absolute alias `@/stories/v2/_helpers` so it survives the move.
|
||
|
||
**Files moved:** `src/stories/v2/{AppDialog→components-v2/shared, AppSidebar→components-v2/layout, AppTopbar→components-v2/layout, RightDrawer→components-v2/layout, SidebarNav→components-v2/layout, WorkspaceSwitcher→components-v2/layout}.stories.ts`
|
||
|
||
- [ ] **Step 1: Move AppDialog story (the one in `shared/`)**
|
||
|
||
```bash
|
||
git mv apps/app/src/stories/v2/AppDialog.stories.ts apps/app/src/components-v2/shared/AppDialog.stories.ts
|
||
```
|
||
|
||
Edit the import in `apps/app/src/components-v2/shared/AppDialog.stories.ts`: `import AppDialog from '@/components-v2/shared/AppDialog.vue'` already alias-based — confirm no relative `_helpers` import (AppDialog story has none). Run: `pnpm test -- AppDialog` and `pnpm exec vue-tsc --noEmit` → expect PASS / clean.
|
||
|
||
- [ ] **Step 2: Move the 5 layout stories**
|
||
|
||
```bash
|
||
git mv apps/app/src/stories/v2/AppSidebar.stories.ts apps/app/src/components-v2/layout/AppSidebar.stories.ts
|
||
git mv apps/app/src/stories/v2/AppTopbar.stories.ts apps/app/src/components-v2/layout/AppTopbar.stories.ts
|
||
git mv apps/app/src/stories/v2/RightDrawer.stories.ts apps/app/src/components-v2/layout/RightDrawer.stories.ts
|
||
git mv apps/app/src/stories/v2/SidebarNav.stories.ts apps/app/src/components-v2/layout/SidebarNav.stories.ts
|
||
git mv apps/app/src/stories/v2/WorkspaceSwitcher.stories.ts apps/app/src/components-v2/layout/WorkspaceSwitcher.stories.ts
|
||
```
|
||
|
||
- [ ] **Step 3: Rewrite `_helpers` imports to the stable alias**
|
||
|
||
In each moved layout story that imports helpers, change any `from './_helpers'` to `from '@/stories/v2/_helpers'`. Verify none remain relative:
|
||
|
||
Run: `grep -rn "_helpers" apps/app/src/components-v2/` → every hit must read `@/stories/v2/_helpers`. Expected: no `./_helpers` or `../`.
|
||
|
||
- [ ] **Step 4: Verify discovery + green suite**
|
||
|
||
Run: `pnpm exec vue-tsc --noEmit` → clean. Run: `pnpm test` → existing count not reduced. Run: `pnpm build-storybook 2>&1 | tail -5` (or `pnpm exec storybook build`) → the 6 stories still resolve under the `../src/**/*.stories.ts` glob (now via the co-located path).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/app/src/components-v2 apps/app/src/stories/v2
|
||
git commit -m "refactor(gui-v2): cleanup(a) — co-locate Plan 2's 6 stories per amended §6 (_helpers stays)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Cleanup (b) — AppTopbar wraps PrimeVue `Menubar` (RFC AD-3)
|
||
|
||
RFC-WS-FRONTEND-PRIMEVUE AD-3: *"…Menubar for the top bar…"*. Current `AppTopbar.vue` is hand-rolled (Breadcrumb+Menu+Popover, no Menubar). Refactor so the top-bar chrome is a `<Menubar>` whose `#start`/`#end` slots host the existing breadcrumb (start) and the existing search/notifications/user-menu cluster (end). Behaviour and existing tests' intent are preserved.
|
||
|
||
**Files:** Modify `apps/app/src/components-v2/layout/AppTopbar.vue`; Modify `apps/app/src/components-v2/layout/__tests__/AppTopbar.spec.ts`.
|
||
|
||
- [ ] **Step 1: FULL read of `apps/app/src/components-v2/layout/AppTopbar.vue` BEFORE any edit**
|
||
|
||
This is a *wrap, don't rewrite* refactor — the plan deliberately does not inline AppTopbar's ~200 lines (fabricating line ranges unread would be a worse placeholder than this instruction). Therefore Step 1 is mandatory and blocking: open and read `apps/app/src/components-v2/layout/AppTopbar.vue` in full, top to bottom, before touching anything. Identify the three regions that will move into Menubar slots: the `<Breadcrumb>` block (→ `#start`), and the search `InputText` / notifications `OverlayBadge` / user `Avatar`+`Menu` cluster (→ `#end`). Note every `<script setup>` ref/handler (`breadcrumbItems`, `userMenuRef`, `toggleUserMenu`, logout, etc.) — **none of these change**; only the template wrapper does. Then run `pnpm test -- AppTopbar` and record the current green assertions as the behaviour baseline to preserve. The exact Menubar shape (slots + `:pt` overrides required to match current appearance) is concretely pinned in Step 4 — do not improvise it.
|
||
|
||
- [ ] **Step 2: Update the spec first (TDD) — assert a Menubar is the chrome root**
|
||
|
||
Add to `__tests__/AppTopbar.spec.ts` (keep all existing assertions):
|
||
|
||
```ts
|
||
import MenubarReal from 'primevue/menubar'
|
||
// ... within describe:
|
||
it('renders the top bar inside a PrimeVue Menubar (RFC AD-3)', () => {
|
||
const w = mountTopbar()
|
||
expect(w.findComponent(MenubarReal).exists()).toBe(true)
|
||
})
|
||
it('breadcrumb lives in Menubar #start, user cluster in #end', () => {
|
||
const w = mountTopbar()
|
||
const bar = w.findComponent(MenubarReal)
|
||
expect(bar.find('[data-tb="breadcrumb"]').exists()).toBe(true)
|
||
expect(bar.find('[data-tb="user"]').exists()).toBe(true)
|
||
})
|
||
```
|
||
|
||
(If the existing spec stubs PrimeVue globally, add a `MenubarStub` exposing `#start`/`#end` slots mirroring the AppDialog DialogStub pattern, and assert via the stub instead of the real component — match the file's existing stubbing convention.)
|
||
|
||
- [ ] **Step 3: Run — expect FAIL** (no Menubar yet). Run: `pnpm test -- AppTopbar`.
|
||
|
||
- [ ] **Step 4: Refactor `AppTopbar.vue` — wrap content in `<Menubar>`**
|
||
|
||
Add `import Menubar from 'primevue/menubar'`. Replace the outer hand-rolled chrome container with:
|
||
|
||
```vue
|
||
<Menubar
|
||
:model="[]"
|
||
:pt="{
|
||
root: 'border-0 border-b border-[var(--p-content-border-color)] rounded-none px-4 py-0 bg-[var(--p-content-background)]',
|
||
start: 'flex items-center min-w-0',
|
||
end: 'flex items-center gap-2',
|
||
}"
|
||
>
|
||
<template #start>
|
||
<div data-tb="breadcrumb"><!-- existing <Breadcrumb> block, unchanged --></div>
|
||
</template>
|
||
<template #end>
|
||
<div data-tb="search"><!-- existing InputText search, unchanged --></div>
|
||
<div data-tb="notifications"><!-- existing OverlayBadge bell, unchanged --></div>
|
||
<div data-tb="user"><!-- existing Avatar + Menu user cluster, unchanged --></div>
|
||
</template>
|
||
</Menubar>
|
||
```
|
||
|
||
Keep every existing script-block ref/handler (`breadcrumbItems`, `userMenuRef`, `toggleUserMenu`, logout, etc.) — only the template wrapper changes. `:model="[]"` because the Crewli top bar has no Menubar menu items; it uses Menubar purely as the AD-3-mandated chrome shell with custom `#start`/`#end`.
|
||
|
||
- [ ] **Step 5: Run — expect PASS** (new + all pre-existing AppTopbar assertions). Run: `pnpm test -- AppTopbar`.
|
||
|
||
- [ ] **Step 6: Typecheck, lint, visual re-baseline (AppTopbar already had a Plan 2 baseline → update it)**
|
||
|
||
Run: `pnpm exec vue-tsc --noEmit` → clean. Run: `pnpm test:visual:update -- AppTopbar` then `pnpm test:visual -- AppTopbar` → PASS (Bert records parity-pass: the Menubar refactor must be visually identical to the Plan 2 AppTopbar).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add apps/app/src/components-v2/layout/AppTopbar.vue apps/app/src/components-v2/layout/__tests__/AppTopbar.spec.ts apps/app/tests/playwright-ct/v2/__screenshots__
|
||
git commit -m "refactor(gui-v2): cleanup(b) — AppTopbar wraps PrimeVue Menubar per RFC AD-3"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Delete `X.vue`, repoint both boundary refs, add shared/* regression locks (constraint #7)
|
||
|
||
**Deliberate, approved deviation from the prompt's constraint #7 wording (read this, future Plan reviewer):** the originating prompt's constraint #7 literally asked to "add import rules to the boundaries config so `shared/*` may import types/utils/composables and may NOT import pages-v2/layouts-v2". This plan **intentionally does not do that**, and the deviation was reviewed and **approved by Bert at the Phase C gate (2026-05-17)**. Rationale: Phase A established that `components-v2` is **one** boundaries zone already allowed `types,utils,lib,composables,composables-forms,stores,components-v2,components-foundation` and (by omission) already forbidden `pages-v2`/`layouts`/v1. Adding a `shared/` *sub-zone* would (a) be redundant — the desired edges already hold — and (b) actively contradict the existing single-zone architecture set in Plan 1, creating a maintenance trap. The constraint's *intent* (lock the shared/* edges against future drift) is better served by **regression-lock tests** than by a structural change. So Task 12 instead (1) removes the X.vue stub, (2) repoints both boundary-spec refs to a real shared component, and (3) adds **regression-lock test cases** asserting the already-correct shared/* edges so future drift is caught. This paragraph is the audit trail: the deviation is a considered architectural choice, not an oversight.
|
||
|
||
**Files:** Modify `apps/app/tests/unit/boundaries-v2.spec.ts`; Delete `apps/app/src/components-v2/shared/X.vue`.
|
||
|
||
- [ ] **Step 1: Repoint both X.vue references to `StatusTag.vue` (L35 + L65)**
|
||
|
||
In `apps/app/tests/unit/boundaries-v2.spec.ts`:
|
||
- Case `allows pages-v2 → components-v2`: replace
|
||
`import X from '@/components-v2/shared/X.vue'` / `<X />` with
|
||
`import StatusTag from '@/components-v2/shared/StatusTag.vue'` / `<StatusTag status="approved" />`.
|
||
- Case `forbids v1 components → components-v2 (no back-porting)`: same substitution (keep the `expect(errs.length).toBeGreaterThan(0)` assertion).
|
||
|
||
- [ ] **Step 2: Add shared/* regression-lock cases**
|
||
|
||
Append inside the `describe('boundaries — v2 zones')` block:
|
||
|
||
```ts
|
||
it('allows components-v2/shared → types (statusSeverity consumes enums)', async () => {
|
||
const errs = await boundaryErrors(
|
||
'src/components-v2/shared/StatusTag.vue',
|
||
'<script setup lang="ts">import { ShiftAssignmentStatus } from \'@/types/shiftAssignment\'</script><template><span>{{ ShiftAssignmentStatus.APPROVED }}</span></template>',
|
||
)
|
||
expect(errs).toHaveLength(0)
|
||
})
|
||
|
||
it('forbids components-v2/shared → pages-v2 (no upward import)', async () => {
|
||
const errs = await boundaryErrors(
|
||
'src/components-v2/shared/StatusTag.vue',
|
||
'<script setup lang="ts">import Dash from \'@/pages-v2/dashboard.vue\'</script><template><Dash /></template>',
|
||
)
|
||
expect(errs.length).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('forbids components-v2/shared → layouts (no upward import)', async () => {
|
||
const errs = await boundaryErrors(
|
||
'src/components-v2/shared/StatusTag.vue',
|
||
'<script setup lang="ts">import L from \'@/layouts/OrganizerLayoutV2.vue\'</script><template><L /></template>',
|
||
)
|
||
expect(errs.length).toBeGreaterThan(0)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Run — expect PASS** (repointed + 3 new cases). Run: `pnpm test -- boundaries-v2`.
|
||
|
||
- [ ] **Step 4: Delete the stub**
|
||
|
||
```bash
|
||
git rm apps/app/src/components-v2/shared/X.vue
|
||
```
|
||
|
||
- [ ] **Step 5: Full green gate + commit**
|
||
|
||
Run: `pnpm test -- boundaries-v2` → PASS (no X.vue resolution error — the resolver now hits real StatusTag.vue). Run: `pnpm exec vue-tsc --noEmit` → clean.
|
||
|
||
```bash
|
||
git add apps/app/tests/unit/boundaries-v2.spec.ts
|
||
git commit -m "refactor(gui-v2): delete X.vue stub, repoint 2 boundary refs to StatusTag, add shared/* regression locks"
|
||
```
|
||
|
||
---
|
||
|
||
## Definition of Done (Plan 3)
|
||
|
||
- [ ] `apps/app/src/components-v2/shared/statusSeverity.ts` exists, seeded **verbatim** from amended spec §8 (no reinterpretation).
|
||
- [ ] `tests/unit/utils/statusSeverity.consistency.spec.ts` passes **both** directions (completeness + no-phantoms) against the 5 live enums (spec §8.X).
|
||
- [ ] All 8 components exist under `apps/app/src/components-v2/shared/` with a co-located `__tests__/*.spec.ts` and a co-located `*.stories.ts` (amended §6).
|
||
- [ ] DraggableBlock built only after Task A2's reconciliation doc committed; its Vitest spec proves click-vs-drag discrimination and `dragend` delta; CT `@ct` test proves real-pointer drag emits; `ArtistBlock` + `CueBlock` retrofit-prove stories exist (constraint #4).
|
||
- [ ] StateBlock has an exhaustive Vitest spec (3 states + 2 transitions) and **no `@visual` baseline** (constraint #5); its story notes this.
|
||
- [ ] TagsInput is the `AutoComplete` re-impl; the 5 behavioural rules pass as a Vitest checklist; parity criterion recorded as "Aura-coherent", not pixel-match (constraint #2).
|
||
- [ ] Theme-alignment decision (Task A1) is documented; parity harness normalises `.dark` vs `[data-theme="dark"]` (constraint #6).
|
||
- [ ] Parity-check performed by Bert and **recorded** (screenshot evidence committed) per component before its `@visual` baseline; StateBlock exempt (constraint #8).
|
||
- [ ] `X.vue` deleted **and both** `boundaries-v2.spec.ts` references (L35 + L65) repointed to `StatusTag.vue`; 3 shared/* regression-lock cases added (constraint #7).
|
||
- [ ] Cleanup (a): the 6 Plan 2 stories are co-located; `_helpers.ts` unchanged at `src/stories/v2/_helpers.ts`; no relative `_helpers` imports remain; `src/stories/v2/` contains only `_helpers.ts`.
|
||
- [ ] Cleanup (b): `AppTopbar.vue` renders a PrimeVue `<Menubar>`; its Plan 2 `@visual` baseline updated with a recorded parity-pass (visually identical).
|
||
- [ ] `pnpm exec vue-tsc --noEmit` clean; **no `any`** anywhere in new code.
|
||
- [ ] `pnpm lint` clean (scoped — whole-codebase formatter OOM is the known Plan 1 constraint; lint the touched paths).
|
||
- [ ] `pnpm test` green; existing test count **not reduced** (new specs are additive).
|
||
- [ ] `pnpm test:component` green (DraggableBlock CT).
|
||
- [ ] `pnpm test:visual` green for every component with a baseline (all except StateBlock).
|
||
- [ ] `pnpm exec vite build` green **and the JS bundle-size delta reported** in the final commit/PR body (expected small; measured, not assumed — constraint #9).
|
||
|
||
---
|
||
|
||
## Open questions
|
||
|
||
> Open question (decided during implementation, not now):
|
||
> Spec §8 specifies runtime fallback = info + dev-warn (this plan
|
||
> implements that verbatim per constraint #1). Claude Chat suggested
|
||
> secondary + dev-warn for visual distinguishability between "real
|
||
> info" and "missing mapping". Non-blocking — §8.X bidirectional
|
||
> test makes fallback unreachable in a passing build. Decide during
|
||
> Plan 3 implementation. If `secondary` is chosen, a §8 spec
|
||
> amendment precedes the map change.
|
||
|
||
---
|
||
|
||
## Subsequent plans (authored after Plan 3 lands)
|
||
|
||
- **Plan 4 — Template layer:** `ListTemplate` (PageHead + SmartFilterBar + DataTable + StateBlock), `FormTemplate` (PageHead + Form/FormField + footer), `DetailTemplate` (PageHead + Tabs + RightDrawer hook), `DashboardTemplate` (StatCard grid + widget slots), with StateBlock integrated. Includes the **deferred StateBlock `@visual` baseline** (constraint #5) once it has first real template usage.
|
||
- **Plan 5 — Storybook catalog + toolbar:** global theme/density toolbar decorators in `.storybook/preview.ts`; the ~80-component PrimeVue standard catalog (centralised under `stories/` per amended §6 class 2); Foundations stories. CT specs stay standalone.
|
||
- **Optional follow-up (decision #3):** relocate `src/stories/v2/_helpers.ts` to a non-story test-support location (e.g. `tests/support/storyHelpers.ts`) and repoint the 6 co-located stories. Out of Plan 3 scope deliberately — it is a pure refactor with no user-facing or contract change and would bloat the cleanup.
|
||
|
||
Then the Smart-Filter sub-sprint, then Page-1 (events list), then the remaining page trees, per spec §10.
|