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>
82 KiB
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.StateBlockhas no source.DraggableBlockis inline markup intimetable/TimetableGrid.vue, not a component. - Only
DraggableBlockhas 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.tschange). components-v2is one boundaries zone (noshared/sub-zone) already allowedtypes,utils,lib,composables,composables-forms,stores,components-v2,components-foundationand 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.vueis referenced in twoboundaries-v2.spec.tscases (L35allows pages-v2 → components-v2; L65forbids v1 components → components-v2). Both repoint toStatusTag.vue.AppTopbar.vueimportsAvatar,Breadcrumb,InputText,Menu,OverlayBadge,Popover— noMenubar(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.vueprops:name: string(required),size?: number|string→ maps to@iconify/vue<Icon :icon :width :height>. Crewli-starter uses@iconify/vuedirectly withicon="tabler:x"; v2 ports use@/components/Icon.vuewith the dash formname="tabler-x"(AppDialog precedent).- Vitest mount pattern:
@vue/test-utilsmount, Pinia viaglobal.plugins:[createPinia()], PrimeVue +Iconstubbed, 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 forStatCardtrend andEnergyDotsenergy 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 testapps/app/src/components-v2/shared/StatusTag.vue+__tests__/StatusTag.spec.ts+StatusTag.stories.tsapps/app/src/components-v2/shared/StatCard.vue+__tests__/StatCard.spec.ts+StatCard.stories.tsapps/app/src/components-v2/shared/PageHead.vue+__tests__/PageHead.spec.ts+PageHead.stories.tsapps/app/src/components-v2/shared/StateBlock.vue+__tests__/StateBlock.spec.ts+StateBlock.stories.tsapps/app/src/components-v2/shared/TagsInput.vue+__tests__/TagsInput.spec.ts+TagsInput.stories.tsapps/app/src/components-v2/shared/EnergyDots.vue+__tests__/EnergyDots.spec.ts+EnergyDots.stories.tsapps/app/src/components-v2/shared/EnergyPicker.vue+__tests__/EnergyPicker.spec.ts+EnergyPicker.stories.tsapps/app/src/components-v2/shared/DraggableBlock.vue+__tests__/DraggableBlock.spec.ts+DraggableBlock.stories.tsapps/app/tests/playwright-ct/v2/draggableblock.ct.spec.ts— Tier-2 drag-emit interaction test (@visualbaseline 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 casesapps/app/src/components-v2/layout/AppTopbar.vue— refactor to wrap PrimeVueMenubar(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.tsrewritten to the unchanged@/stories/v2/_helpers.tspath
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 consumevar(--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#1eafb1vs{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
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→windowmousemove/mouseup, 3px move threshold, parent computes snap/lane/stage fromclientX/Yand px/min; emitsdispatch({type}). -
CueTimelineEditor(onCueDragStart, line 200): native HTML5 drag (draggable="true",dataTransfer.setData),.draggingclass, drop via slotdragover; emitsupdate. -
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.
DraggableBlockis presentational. Onpointerdown(primary button) it callssetPointerCapture, tracks a 3px move threshold (TimetableGrid parity), emitsdragstart: [e: PointerEvent]once threshold is crossed, and onpointerup/lostpointercaptureemitsdragend: [delta: { x: number; y: number }](clientX/Yminus start). It performs zero snap/lane/px-min math.TimetableGridkeepsstartDragMove's math but is driven by@dragstart/@dragendinstead of its ownmousedown.CueTimelineEditordrops HTML5 drag and adopts the same emits.clickis emitted only when no drag occurred (threshold not crossed).vuedraggableis 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.tsMUST include anArtistBlockstory (TimetableGrid usage expressed in the §7.1 contract) and aCueBlockstory (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
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
// 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.tsseeded verbatim from amended spec §8
// 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.
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
// 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
<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
// 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.
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
@visualbaseline (after Step 7 passes)
Run: pnpm test:visual:update -- StatusTag then pnpm test:visual -- StatusTag → expect PASS.
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
// 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
<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
// 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).
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
@visualbaseline — identical workflow to Task 2 Steps 7–8 withStatCard.
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
// 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
<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
// 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
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) +
@visualbaseline — Task 2 Steps 7–8 workflow withPageHead.
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)
// 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
<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)
// 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
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
@visualstep 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
// 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)
<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
// 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
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
@visualbaseline (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).
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
// 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)
<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
// 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
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) +
@visualbaseline — Task 2 Steps 7–8 withEnergyDots.
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
// 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
<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
// 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
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) +
@visualbaseline — Task 2 Steps 7–8 withEnergyPicker.
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)
// 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)
<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)
// 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)
// 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
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) +
@visualbaseline (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.
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/)
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
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
_helpersimports 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
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.vueBEFORE 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):
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:
<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
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: replaceimport X from '@/components-v2/shared/X.vue'/<X />withimport StatusTag from '@/components-v2/shared/StatusTag.vue'/<StatusTag status="approved" />. -
Case
forbids v1 components → components-v2 (no back-porting): same substitution (keep theexpect(errs.length).toBeGreaterThan(0)assertion). -
Step 2: Add shared/ regression-lock cases*
Append inside the describe('boundaries — v2 zones') block:
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
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.
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.tsexists, seeded verbatim from amended spec §8 (no reinterpretation).tests/unit/utils/statusSeverity.consistency.spec.tspasses 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.tsand 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
dragenddelta; CT@cttest proves real-pointer drag emits;ArtistBlock+CueBlockretrofit-prove stories exist (constraint #4). - StateBlock has an exhaustive Vitest spec (3 states + 2 transitions) and no
@visualbaseline (constraint #5); its story notes this. - TagsInput is the
AutoCompletere-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
.darkvs[data-theme="dark"](constraint #6). - Parity-check performed by Bert and recorded (screenshot evidence committed) per component before its
@visualbaseline; StateBlock exempt (constraint #8). X.vuedeleted and bothboundaries-v2.spec.tsreferences (L35 + L65) repointed toStatusTag.vue; 3 shared/* regression-lock cases added (constraint #7).- Cleanup (a): the 6 Plan 2 stories are co-located;
_helpers.tsunchanged atsrc/stories/v2/_helpers.ts; no relative_helpersimports remain;src/stories/v2/contains only_helpers.ts. - Cleanup (b):
AppTopbar.vuerenders a PrimeVue<Menubar>; its Plan 2@visualbaseline updated with a recorded parity-pass (visually identical). pnpm exec vue-tsc --noEmitclean; noanyanywhere in new code.pnpm lintclean (scoped — whole-codebase formatter OOM is the known Plan 1 constraint; lint the touched paths).pnpm testgreen; existing test count not reduced (new specs are additive).pnpm test:componentgreen (DraggableBlock CT).pnpm test:visualgreen for every component with a baseline (all except StateBlock).pnpm exec vite buildgreen 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
secondaryis 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@visualbaseline (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 understories/per amended §6 class 2); Foundations stories. CT specs stay standalone. - Optional follow-up (decision #3): relocate
src/stories/v2/_helpers.tsto 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.