Replaces the Vuetify VForm + AppTextField + VBtn stack with the F3
form pattern: @primevue/forms' <Form> with a Zod resolver, the
project-owned <FormField> wrapper from B5, and PrimeVue InputText /
Password / Checkbox / Button at the input layer. Surrounding chrome
(VRow / VCol illustration column, VCard, VAlert reset-success banner,
auth-logo link, MfaChallengeCard) stays Vuetify until F4b migrates
the auth surface in full.
Zod schema:
- email: required, valid email format
- password: required
Both messages are Dutch (per F3 sprint plan convention).
422 error handling routes through useFormError() from B5. The Laravel
response shape (errors.<field>: string[]) feeds applyApiErrors directly.
rate_limited and other reason-only failures are synthesized into the
email field's error map so they surface visually under the email input,
preserving the existing UX.
The remember-me checkbox is rendered with PrimeVue Checkbox (no schema
coverage — it's UI state, not validated input). The password visibility
toggle is delegated to PrimeVue's Password component's built-in
toggle-mask prop (replaces the previous manual isPasswordVisible ref
and append-inner-icon plumbing).
Verification:
- pnpm typecheck — clean.
- pnpm test — 402 tests pass unchanged.
- pnpm build — succeeds; login chunk grew from ~21 KB to ~84 KB raw
due to @primevue/forms + Password/Checkbox component code (gzip 22 KB).
Will normalize during F4 as more pages share these modules.
- Manual browser test deferred to Phase C brand-review screenshot
capture.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layout-shell rewrite per RFC AD-3, B7-option-B. R-10 isolation invariant
honored — this single commit is revertible to roll back the layout
change without losing B1–B6 progress.
New component (PrimeVue-only, no Vuetify imports per F3 hard constraint):
- apps/app/src/layouts/components/AppShell.vue (~210 lines)
- Desktop sidebar (Tailwind grid, lg+ breakpoint) renders nav items
as PrimeVue Buttons + Icons. Mobile (<lg) hides sidebar; PrimeVue
Drawer slides in on hamburger toggle.
- Top bar (Tailwind) has hamburger + title (mobile) and an Avatar +
Menu (PrimeVue) for the user dropdown with "Mijn Profiel" and
"Uitloggen" actions.
- Nav items accept the existing { title, to: { name }, icon: { icon } }
shape from src/navigation/vertical so call-sites stay terse.
Five top-level layouts delegate to AppShell (filename preserved per
AD-3 so vite-plugin-vue-meta-layouts continues to resolve routes
unchanged):
- default.vue — org + (super-admin) platform nav
- OrganizerLayout — same nav as default; matches authenticated org UX
- PortalLayout — portal-specific 2-item nav ("Mijn evenementen",
"Mijn Profiel")
- blank.vue — minimal chrome-less wrapper for login etc.
- PublicLayout — minimal wrapper for public form-fill routes;
uses <main> for semantic structure
F3 functional regressions (intentional — F4 sub-packages reintroduce
each item through PrimeVue):
- NavSearchBar (Vuetify-heavy combobox/overlay) — absent from top bar
- ContextSwitcher (Vuetify VBtn + VMenu) — absent
- NavbarThemeSwitcher (Vuetify IconBtn) — absent; dark mode driven by
PrimeVue's darkModeSelector: '.dark' continues to work via the
existing @core skin classes until F6 cleanup
- NavbarShortcuts (Vuetify-heavy) — absent
- NavBarNotifications (Vuetify-heavy) — absent
- UserProfile from @/layouts/components/ (Vuetify-heavy menu) — replaced
with the minimal Avatar + Menu dropdown described above; rich profile
panel returns in F4
- ImpersonationBanner — absent; super-admin impersonation UX is F4 work
- PortalLayout event-mode vs platform-mode topbar (route.meta.navMode
driven) — absent; F4 reintroduces via AppShell prop or slot
- Suspense + AppLoadingIndicator wrapping pages — dropped; pages handle
their own loading via PrimeVue ProgressSpinner
VApp at App.vue level still wraps everything, so Vuetify components
inside still-Vuetify pages continue to render correctly during the
parallel-mode window.
Test updates (no Vuetify in layout structure to assert against anymore):
- OrganizerLayout.spec.ts — mocks AppShell instead of the deleted
DefaultLayoutWithVerticalNav reference; provides Pinia.
- PortalLayout.spec.ts — same mock pattern; new structural assertions
go through AppShell stub; the new third test verifies
PortalLayout forwards portal nav items + title to AppShell.
- PublicLayout.vue — uses <main> for semantics; PublicLayout.spec.ts
still passes unchanged.
Auto-generated component/auto-import dts files refreshed for the new
AppShell component (committed for stable dev workflow).
Verification:
- pnpm typecheck — clean.
- pnpm test — 402 tests pass (test count unchanged after spec rewrites).
- pnpm build — succeeds in 14.05s; AppShell chunk is ~57 KB raw.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two additions complete the F3 runtime scaffolding:
apps/app/src/components/Icon.vue — generic Iconify renderer wrapping
@iconify/vue's <Icon> component. F4 migration substitutes
<VIcon icon="tabler-X" /> with <Icon name="tabler-X" /> at call-sites,
producing real SVG output and using the existing Crewli "tabler-*"
naming convention. Props: name (required, e.g. "tabler-eye"), optional
size. The component avoids @iconify/vue's auto-import for clarity at
call-sites.
apps/app/src/App.vue — mounts <Toast /> and <ConfirmDialog /> at the
template root inside VLocaleProvider. Both render alongside the
existing VSnackbar and VDialog confirm patterns during the F3–F4
parallel-mode window. F4 sub-packages migrate call-sites to PrimeVue's
useToast() / useConfirm() composables.
UnoCSS-style i-tabler-* utility-class rendering (RFC AD-5 v1.0 wording)
is not adopted — UnoCSS is not installed in the Crewli stack. The
RFC will be aligned in B9.
Verification:
- pnpm typecheck — clean.
- pnpm test — 402 tests pass unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new artifacts that together provide the F4 form-migration target:
apps/app/src/components/forms/FormField.vue — project-owned wrapper
around @primevue/forms' built-in FormField. The default slot accepts
the actual input (e.g. <InputText name="email" />); the wrapper renders
label (with optional required asterisk), error Message, and hint chrome
around it. Reads field state from the parent <Form> via the built-in
FormField's scoped slot, so call-sites do not need to thread $form
manually.
apps/app/src/composables/useFormError.ts — the API 422 bridge. Parent
component calls useFormError() once; the composable provides an
apiErrors ref through Vue inject. Each FormField in the component
reads its own field name from that map. applyApiErrors() reads the
Crewli backend's { errors: { field: string[] } } shape and surfaces
the first message per field; clearApiErrors() resets between submits.
Error precedence per RFC Appendix A: explicit apiError prop > inject
apiErrors map > Zod resolver error from $field.
Signature note: RFC's useFormError(formRef) is implemented as
useFormError() — the formRef parameter is unused in the provide/inject
implementation, and Crewli convention avoids unused parameters. RFC
will be aligned in B9 if it remains a meaningful spec gap during F4.
Verification:
- pnpm typecheck — clean.
- pnpm test — 402 tests pass unchanged.
- B8 will exercise the components end-to-end on the login page; F4d
validates against the public-registration multi-step form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
installPrimeVue(app) runs AFTER registerPlugins(app) (which registers
Vuetify + router + Pinia via the Vuexy @core machine). Placing the
PrimeVue install outside @core/utils/plugins is deliberate — it keeps
PrimeVue free of the Vuexy plugin loader so F6 can remove @core/
without disturbing PrimeVue registration.
Both frameworks are now active at runtime. Existing Vuetify pages
continue to render unchanged; PrimeVue components become available
for the layout-shell rewrite (B7) and the FormField wrapper (B5).
Verification:
- pnpm typecheck — clean.
- pnpm build — succeeds in 14.26s, no PrimeVue or theme-related errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three additions wire Tailwind v4 into the SPA without disturbing the
existing Vuetify pipeline:
- apps/app/src/assets/styles/tailwind.css — Tailwind v4 CSS-first entry.
Uses @import "tailwindcss"; @plugin "tailwindcss-primeui"; and
@source pointing at apps/app/src/ to scan template content.
- apps/app/vite.config.ts — adds the @tailwindcss/vite plugin between
vue() and vuetify(). After vue() so it sees compiled template
content; before vuetify() so Vuetify's SCSS pipeline runs unimpeded.
- apps/app/src/main.ts — imports tailwind.css before the Vuetify/Vuexy
SCSS so utility classes are available alongside Vuetify's cascade.
optimizeDeps.exclude remains ['vuetify'] (no PrimeVue addition) — HMR
behaves correctly in dev with the current config; revisit if needed.
Verification:
- pnpm typecheck — clean.
- pnpm build — succeeds in 13.97s; CSS emitted per-route as expected.
- pnpm test — 402 tests pass unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scaffolds apps/app/src/plugins/primevue/ as three files mirroring the
Vuetify plugin structure at apps/app/src/plugins/vuetify/:
- theme.ts — CrewliPreset extends Aura via definePreset(). Primary
palette (50–950) is the exact token plan from RFC Appendix B,
centered on Crewli teal #0D9394 (light primary, primary.500) and
#0B7F80 (dark primary, primary.600). Surface tokens use Aura
defaults. colorScheme.light/dark map primary.color, hover, active,
and contrastColor per Appendix B.
- defaults.ts — empty pt (PassThrough) defaults object. F3 ships this
scaffold; F4 sub-packages populate component-level defaults as each
Vuetify surface migrates.
- index.ts — installPrimeVue(app) registers PrimeVue with the preset,
Dutch locale (primelocale/nl.json → nl.nl), darkModeSelector: '.dark'
(matches Vuexy convention per AD-2), and the three services Toast,
Confirmation, Dialog.
Theme imports use @primeuix/themes (the maintained successor PrimeVue's
official docs prescribe), not RFC v1.0's @primevue/themes. See B1 commit
for substitution rationale. RFC will be aligned in B9.
The plugin is not yet registered in main.ts — that lands in B4 after
Tailwind v4 wiring (B3).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Packages installed:
- primevue@4.5.5
- @primeuix/themes@2.0.3 (substitutes @primevue/themes per ecosystem
state — see rationale below)
- @primevue/forms@4.5.5
- primelocale@1.6.0 (pinned to ^1 per RFC)
- tailwindcss@4.3.0
- @tailwindcss/vite@4.3.0
- tailwindcss-primeui@0.6.1
Package substitution: @primevue/themes → @primeuix/themes
RFC v1.0 §6 F3 specifies @primevue/themes@^4.5, but during install pnpm
reported this package as deprecated by its maintainers (PrimeFaces) with
explicit guidance to migrate to @primeuix/themes. Web verification confirms
that the official PrimeVue 4 install documentation at primevue.org/vite/
now specifies `@primeuix/themes` directly, not the deprecated path:
pnpm add primevue @primeuix/themes
import Aura from '@primeuix/themes/aura';
@primeuix/themes is maintained by the same maintainers (mert.sincan,
cagatay.civici), has the same API surface (Aura preset, definePreset,
semantic tokens), and is the path PrimeVue 4's documentation now
prescribes. The substitution is not a deviation from PrimeVue v4
conventions — it IS the current PrimeVue v4 convention.
The RFC will be amended in B9 to align AD-2 and Appendix B with this
ecosystem state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both comments in apps/app/playwright/index.ts (header block lines 38-45
and inline at line 66) state that the Vuetify provider gets replaced by
PrimeVue in F3. This predates the RFC clarification that test-runtime
flip is F5, not F3 (per ARCH-TESTING.md §7).
F3 builds the PrimeVue runtime in main.ts but keeps the test runtime
on Vuetify. Component tests continue to mount with the Vuetify provider
until F5 deliberately swaps it. This commit aligns the comments with
that decision so no future contributor wonders whether the F3 sprint
should have touched this file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lefthook v2 runs `git lfs pre-push` internally for pre-push hooks (per
docs/usage/features/git-lfs.md; confirmed in internal/run/controller/
lfs.go where the internal handler invokes `git lfs pre-push <remote>
<url>` with a buffered `cachedStdin`). Our manual `git-lfs:` command
in lefthook.yml was a second invocation against the same remote; the
duplicate is directly visible in `LEFTHOOK_VERBOSE=1` output as
`[git-lfs] executing hook` (internal) followed by `[lefthook] run:
git lfs pre-push` (manual).
The previous fix attempt (piped: true, commit 1b06804) was based on a
wrong understanding of `piped`'s semantics — `piped` controls
fail-fast behavior, not stdin routing or sequencing. Default lefthook
behavior is already sequential per docs/configuration/parallel.md.
That "fix" was placebo; incident 2 (F2 push, zero LFS objects, commit
99eedb6) proved it.
Phase A investigation: documentary + source confirmation that lefthook
owns the LFS pre-push call. Phase B sandbox test against a filesystem
remote confirmed the duplicate execution in logs but did NOT reproduce
the production hang — likely because the duplicate manual call against
a local remote has no LFS server to interact with. A network-y remote
(Gitea over SSH/HTTPS) appears to be part of the trigger. Two
mechanisms remain plausible (H1: PTY-stdin without EOF in
`while read` loop per docs/configuration/use_stdin.md; H4: server-side
LFS interaction on the duplicate call). Both are eliminated by the
same fix: remove the manual command. LFS uploads continue to work via
lefthook's internal handler (verified in sandbox post-fix).
Regression coverage: scripts/test-lefthook-pre-push.sh asserts exactly
one internal LFS invocation, zero manual ones, and `Uploading LFS
objects: 100%` present, against a disposable sandbox.
See dev-docs/ADR-LEFTHOOK-LFS-INTEGRATION.md for full context, both
misconceptions to prevent regression, and the alternative-scenarios
playbook if Phase E ever regresses.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Closes B5 of F2 (RFC-WS-FRONTEND-PRIMEVUE). PRIMEVUE_COMPONENTS.md
joins the synced doc set so Claude Project Knowledge picks it up on
next upload of .claude-sync/.
Sync output:
- .claude-sync.conf: 34 → 35 entries (+1: PRIMEVUE_COMPONENTS.md)
- .claude-sync/*.md: 34 → 35 files (sync script output;
SYNC_MANIFEST.md auto-regenerated, not counted as net-new)
VUEXY_COMPONENTS.md kept in conf (deprecation stub still useful as a
forwarding marker during F4); removed in F6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors CLAUDE.md changes in B3:
- Stack line notes PrimeVue + Tailwind v4 as target, Vuetify as legacy
- New "UI framework strategy (migration-aware)" section forwards to
PRIMEVUE_COMPONENTS.md with surface-level guidance
- Vuexy reference-path section retained but scoped to legacy surfaces
- Vue 3 section split: Tailwind + pt on migrated, Vuetify-first on
legacy
- Top blockquote signals this file evolves as F4 progresses
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CLAUDE.md updated for the Vuetify→PrimeVue migration phase per
RFC-WS-FRONTEND-PRIMEVUE F2:
- Stack line: notes PrimeVue + Tailwind v4 as target, Vuetify still
present on un-migrated surfaces
- Replaced "Vuexy reference source" + "Vuexy-first strategy" sections
with a single "UI framework strategy (migration-aware)" section that
splits guidance into migrated / un-migrated / new surfaces and
forwards to PRIMEVUE_COMPONENTS.md
- Forms section now documents both target (@primevue/forms + Zod
resolver via FormField) and legacy (ref + VForm + :rules) patterns,
with the surface-level-consistency rule
- UI section reframed: PrimeVue + Tailwind on migrated surfaces,
Vuetify utilities on legacy surfaces, three-state pattern preserved
on both
- Order of work: framework note added for new pages during F4
Framework-agnostic sections (database, multi-tenancy, ULID,
controllers, models, security, testing) untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vuexy/Vuetify component reference is superseded by PRIMEVUE_COMPONENTS.md
per RFC-WS-FRONTEND-PRIMEVUE. Stub forwards readers to the new doc and
provides the explicit pre-F2 SHA (1c449ff620)
for retrieving the original 777-line content during F4a–F4c on
un-migrated surfaces.
File deleted entirely in F6 cleanup. Stub-not-delete decision per
2026-05-10 project chat (Bert): explicit forwarding marker beats
git-history archaeology while parallel-mode is in force.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation document for F2 of RFC-WS-FRONTEND-PRIMEVUE. Encodes
Crewli-specific conventions for the Vuetify→PrimeVue migration:
- Component mapping by category (form / layout / data display /
feedback / navigation / overlays), each with a paragraph on
migration spirit; cross-references PrimeVue docs rather than
duplicating reference material
- Aura theme + Crewli teal primary token plan (full token list in
RFC Appendix B; F3 implements)
- Canonical forms pattern: @primevue/forms + Zod resolver +
<FormField> wrapper (full API spec lives in RFC Appendix A —
cross-referenced, not duplicated)
- DataTable conventions: lazy / virtual / column-template, with a
slot translation cheat sheet from VDataTable
- pt API + Tailwind v4 + Aura tokens decision matrix
- Migration phase guidance (surface-level consistency rule, no
back-porting, F6 cliff)
- VIcon stays Iconify-Tabler per RFC AD-5; PrimeIcons not installed
Length: 385 lines. F4 sub-packages will extend §3 as surfaces migrate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default parallel execution of sync-check and git-lfs commands within
the pre-push hook deadlocks: both read from stdin (git pipes the push
refspec to pre-push hooks), and two parallel readers never reach EOF.
Add piped: true to force sequential execution. sync-check runs first
(only inspects push_files via lefthook templating, doesn't actually
consume stdin), then git-lfs runs second with clean stdin access.
Observed during chore/test-infra-001 sprint: LFS upload completed
100% but pre-push hook hung indefinitely. Workaround was --no-verify;
this commit removes the need for that.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Marks all three sprint backlog entries Resolved with sprint commit
references and documented deviations:
- TEST-INFRA-001 (b8d18e6, 82af117, f6509d9, 2dfb1e8) — Playwright
foundation operational locally. CI deferred.
- TEST-CONTRACT-001 (2dfb1e8) — 409 conflict shape verified against
real Laravel. Single-context replay instead of two-browser
concurrent edit; UI rollback assertion deferred to F4.
- TEST-VISUAL-001 (f6509d9) — 5 composite baselines from canonical
prototype. Composite-over-isolated rationale: prototype DOM lacks
data-* attributes; isolated artist-name locators would rot. F4
adds isolated baselines using stable data-test-id.
Opens TEST-INFRA-002 for the deferred CI work: Gitea/GitHub Actions
decision, runner image, caching, screenshot-diff artifacts, label-
gated nightly e2e. No deadline; surfaces when first review cycle
feels drift.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B5 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1).
- Add dev-docs/ARCH-TESTING.md (~13 KB):
§1 Five-tier pyramid (Unit / Component / Integration / Visual /
E2E) with environment, cost, and purpose per tier
§2 Decision tree — pick by what is being verified, not by speed
§3 Mock-vs-real-backend rules + the self-confirming-bias anti-
pattern that motivated TEST-CONTRACT-001
§4 Visual baseline workflow including the composite-over-isolated
strategy used in B3
§5 CI strategy stub — deferred to TEST-INFRA-002
§6 Conventions + 5 anti-patterns
§7 Vuetify-during-PrimeVue-migration: explicit doc that the
Vuetify plugin in playwright/index.ts is INTENTIONAL TEMPORARY
STATE replaced in F3 by PrimeVue. Forbids the "abstract the UI
framework provider" deferred-cost trap.
§8 Host setup — Node, pnpm, Chromium, Git LFS, MySQL 8, PHP, .env;
known risks (unpkg.com flakiness, shared crewli_test DB)
§9 Deferred work cross-references to BACKLOG entries
- Update CLAUDE.md ### Testing section to reference ARCH-TESTING.md
- Add ARCH-TESTING.md to .claude-sync.conf so the dev-docs sync
pipeline picks it up; sync script run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B4 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1).
- Add api/database/seeders/E2EBaselineSeeder.php — deterministic seed
for Playwright e2e: e2e@test.local user (org_admin) on a fresh org +
event + stage + StageDay + artist + engagement + performance
(version=0). Writes seeded IDs to api/storage/app/e2e-fixtures.json
so the Playwright fixture can construct API URLs without API
discovery calls.
- Add apps/app/tests/playwright-e2e/global-setup.ts — runs
`php artisan migrate:fresh --force --seed` against crewli_test (the
existing PHPUnit MySQL test DB) before the test suite starts.
Uses --env=testing to satisfy the dangerous-bash hook's migrate:fresh
guard.
- Add apps/app/tests/playwright-e2e/utils/fixtures.ts — typed reader
for e2e-fixtures.json. Cached after first read.
- Add apps/app/tests/playwright-e2e/utils/auth.ts — login helper that
POSTs /api/v1/auth/login and returns user/org IDs. Uses Bearer-via-
cookie flow (per api/.../SetAuthCookie.php), not stateful Sanctum.
- Add apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts —
the contract test: first move with version=0 returns 200, second
move with same stale version returns 409 with shape
`errors.conflict: 'version_mismatch'`. Catches the schema-drift
bug class that timetable-stabilization B5 surfaced.
- Update apps/app/playwright.config.ts — wire globalSetup, webServer
for `php artisan serve --port=8001`, baseURL `http://localhost:8001`
(NOT 127.0.0.1 — auth cookie's domain=localhost requires hostname
match).
- Update .gitignore — runtime e2e-fixtures.json never committed.
DoD-19 met locally: `pnpm test:e2e` passes against a real Laravel
test server. CI integration deferred to TEST-INFRA-002 (per A-1
amendment).
Constraint: e2e tests share the crewli_test DB with PHPUnit. Running
both concurrently would collide. Documented in ARCH-TESTING.md (B5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B3 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1).
- Add tests/playwright-ct/visual/static-server.mjs: 60-line Node http
server that serves the canonical prototype directory. No new
dependency added (vs. http-server / serve packages).
- Wire static server into playwright-ct.config.ts via webServer; tests
navigate to http://127.0.0.1:5179/crewli-timetable.html.
- Add tests/playwright-ct/visual/prototype-smoke.spec.ts to verify the
prototype loads in CT runner.
- Add tests/playwright-ct/visual/prototype.spec.ts with 5 @visual
composite baselines:
canvas-friday.png — all status colors, b2b indicators,
multi-lane stacking
canvas-saturday.png — conflict ring + capacity warnings
stage-row-multilane.png — first row in isolation
wachtrij-populated.png — sidebar list with parked + pending
popover.png — block-click popover layout
9 additional surfaces from RFC §A.3's enumerated list are documented
as test.skip() with reasons (cancelled status absent from prototype
data, isolated-block locators would lock to artist names, drag-mode
flaky under simulated pointer events, empty Wachtrij/empty day not
reachable from canonical seed). All deferred to F4 component-level
Vue baselines that will use stable data-test-id attributes.
- Baselines stored at tests/playwright-ct/__screenshots__/visual/
prototype.spec.ts/*.png; tracked via Git LFS (.gitattributes).
Composite-over-isolated rationale: the prototype's DOM exposes status
only via inline style.background, no data-* attributes. Isolated-block
baselines would require artist-name locators that silently rot if
prototype data changes. Composite captures yield the same visual
vocabulary in fewer, more stable images. dev-docs/ARCH-TESTING.md (B5)
documents this strategy and the F4 transition plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B2 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1).
- Add tests/playwright-ct/utils/mountWithProviders.ts: ergonomic
wrapper around Playwright CT's mount() exposing buildMountArgs()
and readNotificationState(). Documents the Vue Test Utils ↔
Playwright CT API divergence (provider plugins must be wired in
beforeMount, not at call time) and the Vuetify-temp lifecycle
(replaced by PrimeVue in F3).
- Add tests/playwright-ct/components/SanityButtonHarness.vue: a
v-btn harness with a click counter; lives in a .vue file so Vite
bundles its CSS-side-effect imports for the browser context
(Playwright CT runs the test orchestrator in Node and components
in a Vite-bundled browser, unlike Vitest's single jsdom graph).
- Add tests/playwright-ct/components/sanity-vuetify.spec.ts: two
tests proving (a) v-btn renders and propagates clicks, (b) the
--v-theme-primary CSS variable resolves to a parseable RGB triplet.
- Update playwright/index.ts: import 'vuetify/styles' so the v-btn
renders with its actual visual appearance (not unstyled). Required
for B3's visual baselines.
3 component tests pass. 402 Vitest tests still pass unchanged.
Lint + typecheck clean on new files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trigger: timetable-stabilization sprint (PR #18, #19) surfaced three
diagnostic incidents that the RFC v1.0 sequencing did not anticipate.
Adds TEST-INFRA-001 as prerequisite sprint before F2 (Playwright +
visual regression infrastructure, baselines against prototype HTML).
Extends F5 with dual-tier visual regression scope. Adds R-11 to risk
register, DoD-16 through DoD-20 to Definition of Done.
No changes to F2-F6 internal architecture, Aura preset, FormField API,
Tailwind v4, or bundle size targets.
Effort impact: +5-7 working days. Total now 15-19 days.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Three trigger upgrades + one new entry, in priority order:
TEST-INFRA-001 — trigger upgraded from "before opening Sessie 5" to
"eerstvolgende sprint na merge van fix/timetable-stabilization", with
explicit dependency: ART-S4-UX-PARITY and all Sessie 5+ work gate on
TEST-INFRA-001 merge. Reden quote captures the three sprint-blok
incidents that proved jsdom-tests do not protect against schema /
filter / UX drift.
TEST-VISUAL-001 — scope expanded to use the prototype HTML at
`./resources/Crewli - Artist Timetable Management/` as the visual
baseline source (not hand-curated screenshots). Added explicit state
matrix per surface: PerformanceBlock 8 states + B2B + cascade-pulse;
PerformancePopover full detail; AddPerformanceDialog drag-mode +
button-mode; Wachtrij filtered/grouped axes. Trigger remains "tweede
toevoeging na TEST-CONTRACT-001" inside the TEST-INFRA-001 sprint.
TEST-CONTRACT-001 — unchanged. Trigger ("eerste e2e na TEST-INFRA-001
lands") was already correct.
ART-S4-UX-PARITY (NEW) — captures Bert's screenshot-report findings as
a seed list grouped A/B/C/D (component-shape / interaction / logic /
AddPerformanceDialog two-mode). Explicit pointer at the bottom to the
Phase A finalization report for the full 20-item itemisation with
severity ratings. Trigger gates Sessie 5 + all subsequent Artist-domain
frontend work behind ART-S4-UX-PARITY merge.
Spelling consistency: VEE-001 entry "formalized" → "formalised" to
match British-English already used elsewhere in the doc and now
mandated by the new CLAUDE.md "Diagnostic discipline" section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New section in CLAUDE.md after "Order of work for each new module".
Three consecutive incidents in the timetable sprint led to formalising
this principle:
- B1 (controller assumed buggy, seeder was wrong) — Phase A's
schema-verify gate against SCHEMA.md:1285 + RFC §10.2 inverted the
fix direction.
- B5 (enum-shape assumed drifted, decimals were wrong) — Phase A's
field-by-field response audit caught the actual decimal-as-string
drift before any "fix" against the wrong hypothesis was written.
- Timetable UX (test-passing layer diverged from prototype) — the
mechanical-vs-UX split surfaced via browser test, not via the
389-test suite which all agreed with the buggy state.
Pattern across all three: the initial hypothesis was wrong. The fix
prompts ALL gated Phase A as STOP-and-report; the schema/contract/
prototype audit was reviewed before any code was written. Codifying
this as an explicit project principle so future fix prompts inherit
the gate by default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A finding A5 traced this race in the browser logs:
GET .../performances?day={festival_id} → 200, 0 results ← wrong day
GET .../children → 200, 3 sub_events
GET .../performances?day={subevent_id} → 200, 13 results ← correct
The pre-fix `isFlatEvent` was:
computed(() => !subEvents.value || subEvents.value.length === 0)
While `subEvents` was still loading (undefined), `!undefined` is `true`,
so isFlatEvent erroneously returned `true` for festivals during the
loading window. dayOptions then took the flat-event branch and seeded
validSubEventIds with the FESTIVAL id. useActiveDay's corrective watcher
rewrote the URL to `?day={festival_id}` and fired a wasted query that
returned zero results (correct semantics — performances live at sub-event
level — but waste + visible URL flicker).
Fix:
computed(() => eventDetail.value?.event_type === 'event')
EventResource always serialises event_type (verified at
api/app/Http/Resources/Api/V1/EventResource.php:26). EventTabsNav
already consumes event_type / is_festival from the same shape
(apps/app/src/components/events/EventTabsNav.vue:175,266) so this is
the canonical signal, not a one-off addition.
New behavior trace:
- Both queries pending → eventDetail=undefined → isFlatEvent=false
→ festival branch returns (subEvents ?? []).map(...)
→ validSubEventIds=[] → activeDayId=null
→ usePerformances.enabled=false → NO fetch
- subEvents resolves first → festival branch populates dayOptions
→ fetch fires with correct sub-event id
- eventDetail resolves first to flat event → flat branch fires
→ fetch with eventDetail.id (correct)
- eventDetail resolves first to festival → still false until subEvents
→ no false-positive flat-event fetch
402 tests still pass; typecheck + lint + production build all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apps/app/tests/unit/schemas/timetableContractShape.test.ts (NEW, 5 tests):
- base shape: one performance with stage assigned + full engagement
(Bert's browser-tested sample, field-for-field). Asserts decimal-as-
string contract on fee_amount/buma_percentage/vat_percentage AND
enum-label wrapper on booking_status AND nested computed object.
- parked shape: stage_id=null, stage=null (Wachtrij case)
- multi-perf shape: two performances sharing engagement_id
(RFC §D17 "Friday + Saturday under one combined deal")
- sanity: individual performanceSchema parses each fixture element
- regression guard: a payload with NUMBER fee_amount throws (locks
out the pre-B5 bug class)
Every fixture spells out explicit `null` for the schema's nullable-but-
required fields (timestamps, notes, deal_breakdown) so the
nullable() vs optional() distinction is exercised, not glossed over.
Schema surface change to support the test:
apps/app/src/schemas/timetable.ts now EXPORTS performanceArraySchema
(previously a private const inside useTimetable.ts).
apps/app/src/composables/api/useTimetable.ts imports the shared one
instead of redeclaring it locally — single source of truth for the
array shape consumers and tests share.
Test count: 397 → 402 (+5). Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A diagnosed the "Kon timetable niet laden" browser symptom as Zod
schema drift. The prompt's hypothesis (enum {value, label} mismatch) was
incorrect — the schema already uses the enumLabel() wrapper for every
enum field. The actual drift is decimal-cast columns: Laravel serialises
`decimal(N,M)` columns as strings to preserve precision, but the schema
expected numbers, so the very first response triggered a ZodError.
Affected fields, all on `artist_engagements`:
fee_amount decimal(10,2) → wire `"11503.58"`, schema was z.number()
buma_percentage decimal(5,2) → wire `"7.00"`, schema was z.number()
vat_percentage decimal(5,2) → wire `"21.00"`, schema was z.number()
deposit_percentage decimal(5,2) → wire `"…"`, schema was z.number()
Backend has no explicit `decimal:N` cast on these columns
(api/app/Models/ArtistEngagement.php:64-85 — the `casts()` method covers
the enums + booleans + dates + integers, but skips decimals).
Per the strategic decision (frontend adapts, backend stays):
- schemas/timetable.ts: four fields → z.string().nullable()
- types/timetable.ts: matching ArtistEngagement interface fields →
`string | null`
- PerformancePopover.vue:129: only consumer doing arithmetic on a
decimal field; coerce at the use site via Number(...).toFixed(2).
Single line.
- tests/component/PerformanceBlock.test.ts + tests/a11y/axe.test.ts:
spot-checked mocks; the two with hand-built engagement payloads
flipped fee_amount/buma_percentage/vat_percentage from numbers to
strings to match the new schema. No other mocks needed updating.
The {value, label} enum wrapper claim in the prompt was specifically
debunked in Phase A — every consumer (Wachtrij, PerformanceBlock,
WachtrijCard, PerformancePopover, AddPerformanceDialog, page entry)
already uses .value/.label access against an enumLabel-wrapped schema.
B6 will lock the wire-format contract with a real-API fixture
regression test.
All 397 tests still pass; typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B4 — jsdom-runnable assertions for the structural pieces of B2/B3.
apps/app/tests/unit/lib/timetable/row-height.test.ts (4 tests):
- laneCount=0 → 52px (Math.max(1, 0) fallback path)
- laneCount=1 → 52px (single-lane stage row)
- laneCount=3 → 148px
- laneCount=10 → 484px (10 × 48 + 4)
apps/app/tests/component/StageHeaderCell.test.ts (4 tests):
- row-height-px prop applies as inline blockSize on the root
- prop omitted → no inline blockSize set (legacy `block-size: 100%`
CSS path takes over for any caller still relying on parent-driven sizing)
- 484px for laneCount=10 round-trips through the prop without truncation
- conflict badge renders only when conflictCount > 0 (existing behavior;
locked in as part of touching this surface)
Visual scroll/alignment proof (sticky-left freeze pane, sticky-top axis,
horizontal scroll cohesion across 14 stages, diagonal trackpad scroll,
pixel-perfect header↔row alignment) is deferred to TEST-VISUAL-001
explicitly: jsdom does not compute position:sticky offsets, scrollbar
visibility, layout overflow chains, or scroll containment ancestry. This
is a known limitation of jsdom-based component testing — not a test gap
in this branch. The sticky behavior, z-index ladder, and DOM structure
are all in place per E1-E4; their validation requires a real browser,
which is exactly what the Playwright CT migration on TEST-INFRA-001 +
TEST-VISUAL-001 unlocks.
No existing tests asserted the old broken layout (no references to the
deprecated `tt-page__rows`, `tt-page__stages`, or `<GridBg>` in tests/).
The unused GridBg component file remains on disk; deleting it is a
stylistic cleanup outside this stabilization scope.
Test count: 389 → 397.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructures the canvas so the spreadsheet-feel works correctly with
the seeder's 14 stages: horizontal scroll moves the rows AND the
TimeAxis together; vertical scroll moves the rows but keeps TimeAxis
pinned; both panes intersect at a fixed corner cell. Diagonal trackpad
scroll behaves naturally because there's only one scroll container.
DOM restructure (E2 — sticky resolves to its nearest scroll ancestor;
fixed by giving sticky elements the right scroll-container parent
instead of patching with absolute positioning):
.tt-page__canvas position: relative; overflow: auto
└ .tt-page__layout display: grid; grid-template-columns: 200px auto;
inline-size: max-content
├ .tt-page__corner sticky top:0 left:0 z=3
├ .tt-page__axis sticky top:0 z=2 (full 1872px wide, no clip)
└ for each stage:
├ .tt-page__header-cell sticky left:0 z=2
│ └ <StageHeaderCell :row-height-px="row.rowHeightPx">
└ .tt-page__row-cell normal z=1 (height = same value)
└ <StageRow>
Z-index ladder (E1) is documented in the page CSS:
corner=3, axis row=2, header rail=2, row content=1, blocks=auto.
Popover + AddPerformanceDialog stay above via Teleport-to-body.
Drops the broken pre-stabilization layout:
- `grid-template: "corner axis" 28px "stages rows" 1fr / 200px 1fr`
that put ALL stage headers in ONE grid cell (cause of "lanes too tall"
via headers stretching to 100% of the 570px cell)
- nested `overflow: auto` on `.tt-page__rows` (cause of horizontal-scroll
desync — only the rows pane scrolled, axis stayed put)
- `overflow: hidden` on `.tt-page__axis` (E4 — clipped axis ticks beyond
the 1fr cell width)
- `<GridBg :total-height="0" />` which was a no-op anyway; gridlines now
render directly on each `.tt-page__row-cell` background
`inline-size: max-content` on the layout grid forces it wider than the
canvas viewport, so `overflow: auto` on the canvas actually fires a
horizontal scrollbar. Without this, the `auto` second column shrinks to
viewport and nothing overflows.
The page now passes `:row-height-px` to StageHeaderCell (B2 seam, now
load-bearing). Both header and row cell get the same explicit blockSize
inline so the freeze panes align pixel-for-pixel under whatever
laneCount each stage resolves to.
Visual scroll/alignment proof is deferred to TEST-VISUAL-001 — jsdom
cannot verify position:sticky behavior, scrollbar visibility, or pixel
alignment of the freeze panes. This is a known limitation, not a test
gap. B4 covers the structural assertions jsdom CAN verify.
All 389 existing tests still pass; production build smoke clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure structural seam — no layout changes yet (B3 wires the page through).
apps/app/src/lib/timetable/row-height.ts (NEW):
computeStageRowHeight(laneCount, laneHeightPx, lanePadPx) — one-line pure
function with the existing math: max(1, laneCount) * (laneHeight + lanePad) + lanePad.
Math.max(1, laneCount) keeps an empty stage row visible at single-lane
height instead of collapsing.
apps/app/src/components/timetable/StageRow.vue:
Switches its inline rowHeightPx computation to call the helper. Behavior
identical (the math was the helper's body).
apps/app/src/components/timetable/StageHeaderCell.vue:
New optional `rowHeightPx?: number` prop. When provided (B3 will pass it
from the page via the same helper), the header root applies blockSize
inline so the sticky-left column aligns pixel-for-pixel with the row.
When omitted, the legacy `block-size: 100%` CSS still applies — every
existing call-site keeps working.
apps/app/src/lib/timetable/index.ts: re-export the new helper.
Tests still green (389 across 54 files); typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A diagnosed an empty SPA timetable as a controller filter bug. B1.1's
schema-verify gate proved the opposite: the seeder violates Model A, the
controllers are correct.
Canonical model (Model A) per:
- dev-docs/SCHEMA.md:1285 artist_engagements.event_id → festival OR flat event
- dev-docs/SCHEMA.md:1329 performances.event_id → sub-event OR flat event ("show host")
- dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md:1247-1257 (§10.2 contract)
"performance.event_id must be flat event OR a sub-event of the
engagement.event_id festival"
- dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md:455-477 (§D17)
"Friday + Saturday under one combined deal = 1 engagement, 2 performances"
— only works if engagement is at festival level
Controller audit (B1.2): all five filters in
api/app/Http/Controllers/Api/V1/Artist/{PerformanceController,
ArtistEngagementController, StageController}.php already match Model A.
No controller changes needed.
Seeder change (B1.3) — single consistent fix:
ArtistTimetableDevSeeder::seedForFestival now creates one engagement per
(artist, festival) instead of per (artist, sub-event). When the same artist
recurs across iterations on different sub-events, the existing engagement
is reused and another performance is added (the D17 multi-perf path).
Performances continue to carry event_id = sub-event.
Same model fix in seedForSeries (engagement at parent series, performance
at week sub-event).
seedForFlatEvent already conformed (engagement.event_id = performance.event_id
= the flat event itself).
Existence-check semantics shift from `where event_id = $subEvent->id` to
`where event_id = $festival->id` (or $parent->id for series). Numerically
the test counts hold because the bucket-cycling makes scheduled artists
distinct within the festival window.
Tests (B1.4) — new TimetableSeederControllerIntegrationTest with 7 assertions:
- engagement.event_id is at festival level (DB invariant)
- performance.event_id is at sub-event level (DB invariant)
- GET /performances?day={subEvent} returns non-empty + correct event_ids
- GET /performances unfiltered returns all sub-event performances
- GET /performances?stage_id=null returns the seeded parked perf
- GET /engagements returns engagements with event_id = festival
- GET /stages returns 5 stages with event_id = festival
This locks the visible-symptom regression from Session 4: an empty SPA
timetable on a freshly-seeded festival cannot land again silently.
Existing ArtistTimetableDevSeederTest (4 tests) and the broader Artist
suite (121 tests) all stay green. composer analyse + Pint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The timetable canvas page at /events/{event}/timetable was added in
RFC-TIMETABLE Session 4 but had no UI entry point. EventTabsNav now
exposes it as the "Programma" tab between Artiesten and Briefings on
flat events, and between Artiesten and Briefings on festivals (in the
re-ordered tab list, post-Artiesten / pre-Briefings).
Changes:
- baseTabs gains the Programma entry at position 6 (after Artiesten).
- The festival re-order computed switches from positional indexing
(baseTabs[5], [6], [7]) to name-based lookup via a findTab helper —
insertions to baseTabs no longer break the festival branch.
- Icon: tabler-calendar-time. Conservative Dutch label "Programma" —
doesn't collide with "Programmaonderdelen" (the festival sub-events
page) since festivals see both tabs side-by-side.
vitest.config.ts: extend the component-project AutoImport to include
'vue-router' so tests of components that auto-import useRoute/useRouter
mount cleanly. (EventTabsNav was the first such test.)
tests/component/EventTabsNav.test.ts (NEW, 4 assertions):
- Programma tab is rendered with the correct label
- it carries the tabler-calendar-time icon
- the route binding resolves to the events-id-timetable name with the
/events/.../timetable URL pattern
- the tab is also visible on a festival (re-ordered tab list path)
Mocks the useEvents composables so the component skips its skeleton/
error branches and renders tabs immediately.
Test count: 385 → 389.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Lint cleanup spotted during Phase C — `router.replace` returns Promise<void>
which the no-void rule rejects. The dropped void had no behavioural effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new entries that codify the test-architecture roadmap surfaced
during the Session 4 follow-up:
TEST-INFRA-001 — Migrate timetable component+a11y tests to Playwright
Component Testing. **Trigger: before opening the Sessie 5 prompt.**
Sessie 5 builds Engagement Detail (6 tabs) + Portal pages (drag-to-
reorder, file uploads); adding more jsdom-based tests for those
surfaces compounds the migration cost.
TEST-CONTRACT-001 — End-to-end 409 contract test against running Laravel.
Trigger: first e2e flow added after TEST-INFRA-001 lands. Highest
contract-protection value per line of test code.
TEST-VISUAL-001 — Visual regression baselines for PerformanceBlock
states (RFC D21/D22/D25/D26). Trigger: second addition to the
TEST-INFRA-001 sprint.
ART-S4-TESTS marked ✅ Resolved with the audit trail of all 9 commits
that landed the test coverage closure (252 → 385 tests across both PRs).
.claude-sync/ regenerated by the post-commit hook (gitignored;
re-uploaded to Project Knowledge separately).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two integration tests that drive the entire RFC D17 lifecycle through
the mutation composable + TanStack cache:
1. happy-path lifecycle (5 stages):
- ADD → POST /performances + Idempotency-Key
- DRAG → POST /timetable/move (target_lane=1, version bumps),
server returns cascaded[] sibling — both surface in
the resolved Promise
- RESIZE → POST /timetable/move with new end_at + new version
- PARK → POST /timetable/move with target_stage_id=null
- DELETE → DELETE /performances/p1
final wire: 4 POSTs + 1 DELETE
2. drag rollback on 409:
- server returns version_mismatch
- mutation rejects with VersionMismatchError shape
- notification.show() invoked with the Dutch toast + 'error'
Why not the full page mount: events/[id]/timetable/index.vue requires
EventTabsNav, useEventDetail, useEventChildren, multiple VTabs/VBtn/
VDialog teleports — too brittle for jsdom CI. The end-to-end + visual
flavour of this flow lives on TEST-INFRA-001's Playwright migration
backlog (and TEST-CONTRACT-001 covers the 409 path against a real
backend).
Test count: 383 → 385.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three jsdom axe scans covering the user-facing surface of the canvas.
The scans surfaced two real a11y bugs which are fixed in this same
commit:
1. PerformancePopover — VProgressLinear (advancing aggregate) had no
accessible name. Added aria-label that announces "X van Y secties
afgerond (N%)".
2. AddPerformanceDialog — the icon-only close button (×) was missing
aria-label. Added 'Sluiten'.
Test scenarios:
- PerformanceBlock with focus
- PerformancePopover open
- AddPerformanceDialog open
Page-level axe rules (region, page-has-heading-one, landmark-one-main,
color-contrast) are disabled for fragment scans — they only make sense
on a full page, and color-contrast resolution is jsdom-blind. Both are
covered by Playwright CT in TEST-INFRA-001 / TEST-VISUAL-001.
Test count: 380 → 383.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>