Files
crewli/dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives.md
bert.hausmans 280776fcda docs(plan): add Plan 3 (Tier-1 primitives + DraggableBlock)
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>
2026-05-17 22:49:58 +02:00

1772 lines
82 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 45 (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 19 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 10191039). **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 10191039 → 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 78 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 466478 + responsive 13551360). **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 466478 (+ <=640px stack 13551360) 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 78: Parity-check (Bert, record) + `@visual` baseline** — Task 2 Steps 78 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 45 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 45 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 19821991, 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 19821991 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 78: Parity-check (Bert, record) + `@visual` baseline** — Task 2 Steps 78 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 78: Parity-check (Bert, record) + `@visual` baseline** — Task 2 Steps 78 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:0022: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:0022: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 45). 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.