Moves the `v2-` de-dup (needed because getPascalCaseRouteName folds the
v2/ URL segment into the base) into the unit-tested v2RouteName helper
and simplifies the vite.config.ts call site to v2RouteName(raw, nodePath).
Removes the duplicated isV2 detection. No behavioural change: /v2/dashboard
still resolves to route name v2-dashboard; v1 names unchanged. Addresses
the Task 3 code-review Important finding.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a second routesFolder (src/pages-v2 -> /v2/) and extends
getRouteName so v2 routes get a v2- NAME prefix, preventing collisions
with same-named v1 pages. getPascalCaseRouteName already folds the v2/
URL segment into the base name, so the leading v2- is stripped before
v2RouteName re-adds the canonical prefix (avoids v2-v2-dashboard).
Includes the regenerated typed-router.d.ts and a boot-proof
pages-v2/dashboard.vue placeholder.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tailwind's lg:hidden loses to PrimeVue's .p-button { display: inline-flex }
due to equal specificity but later cascade order. Resulted in the mobile
hamburger remaining visible on desktop, allowing the Drawer to open over
the already-visible permanent sidebar.
Fix: wrap mobile-only cluster (hamburger + title) in a plain <div lg:hidden>
so the wrapper owns the visibility toggle. The wrapper is not a PrimeVue
component, so no specificity competition.
The Drawer itself had the same anti-pattern (class="lg:hidden") and is
worse, because PrimeVue Drawer teleports to body — a wrapping div on the
parent does not isolate the teleported overlay, and a class on the Drawer
root loses to .p-drawer { display: flex } when visible. Converted to
v-if="!isLg" driven by useMediaQuery('(min-width: 1024px)'). Vue simply
does not render the component on lg+, so no display rule competes.
Audited all 5 layouts for the same anti-pattern:
- AppShell.vue — fixed (Button + Drawer described above)
- default.vue / OrganizerLayout.vue / PortalLayout.vue — delegate to
AppShell; no PrimeVue elements with responsive classes
- blank.vue — plain <div>, no PrimeVue
- PublicLayout.vue — plain <main>, no PrimeVue
useMediaQuery is auto-imported via unplugin-auto-import's @vueuse/core
entry in vite.config.ts; explicit imports get stripped by the post-edit
ESLint --fix hook as redundant.
F3-introduced bug (commit 43915501); surfaced during F3.5 testing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
main.ts explicitly calls installPrimeVue(app) AFTER registerPlugins(app)
per the comment in main.ts ("so PrimeVue lives outside the Vuexy @core
machine"). The intent was a single registration site outside the
auto-discovery loop.
Bug: registerPlugins (src/@core/utils/plugins.ts) globs
plugins/*/index.{ts,js} eagerly and invokes the `default` export of
each match. plugins/primevue/index.ts was exporting installPrimeVue
as the default, so registerPlugins also picked it up and called it.
End result: PrimeVue and its three services (Toast, Confirmation,
Dialog) were each registered twice on every app boot. Visible
symptoms: duplicate Toast emissions on a single Toast.add() call,
and ConfirmationService callbacks firing twice for one user
confirmation.
Fix: convert `export default function installPrimeVue` to a NAMED
export, and update main.ts's import to `{ installPrimeVue }`. The
registerPlugins glob still picks up the module path but the
`pluginImportModule.default?.(app)` invocation becomes a no-op via
optional chaining (no default export to call). main.ts remains the
single registration site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The PrimeVue side of the parallel-mode app renders icons via
@iconify/vue's <Icon> component (src/components/Icon.vue). At runtime
@iconify/vue resolves an icon name like "tabler-eye" by looking up
its data in the in-memory icon registry; on a miss it falls back to
fetching https://api.iconify.design/tabler/eye.json. The CSP blocks
that origin, so every Tabler icon used in AppShell, SidebarHeader,
SidebarUserCard, and the migrated login form rendered as an empty
<svg viewBox="0 0 16 16"></svg>.
New plugins/iconify.ts loads the full Tabler set
(@iconify-json/tabler/icons.json, already in package.json as 1.2.23)
and registers it via addCollection() at module-load time. main.ts
side-effect-imports it before any other import so the registry is
warm before the first Icon mounts.
This is a NEW concern, separate from the existing plugins/iconify/
(index.ts + icons.css) which generates Vuexy-style i-tabler-* CSS
classes for Vuetify's VIcon adapter. The two systems must coexist
during F3–F6 parallel mode; the legacy directory can be deleted
alongside Vuetify when F6 lands.
Bundle cost: ~1.9 MB uncompressed JSON, ~400 KB gzipped in the main
chunk. Per-icon imports are a future optimisation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
unplugin-vue-components' Components({ dirs }) in vite.config.ts only
scans src/components, src/@core/components, and src/views/demos. The
sub-components introduced in B1/B3 live under src/layouts/components/,
which is NOT in the auto-import scan path. Without an explicit script
import, Vue renders <SidebarHeader> and <SidebarUserCard> as unknown
HTML elements (no DOM output, no errors), which is why the topbar and
sidebar-bottom cards looked empty in browser inspection.
Adding the two imports inline with the existing Icon import keeps the
component graph explicit. The post-edit eslint --fix hook preserves
the imports because the template usages (already present from B1 and
B3) make vue-eslint-parser see them as used.
The original B1/B3 commits had the imports stripped by the hook
because the imports were added in a separate Edit *before* the
template usages — eslint --fix correctly removed them as unused at
that moment, and the next Edit added the template usage but not the
import.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Left side gains a desktop-only breadcrumb "Organisation / Page title"
using the current organisation from useAuthStore and a page title
resolved by:
1. route.meta.title (if a page sets it explicitly), then
2. matching the active route name against the navItems prop, then
3. humanizing the route name as a last-resort fallback.
The chevron separator is suppressed when either side is empty, so
portal and pre-org users see just the page title. Mobile preserves
the existing hamburger + title text (the breadcrumb is hidden on
<lg to keep the topbar single-row).
Right side gains a notification bell and a help icon. The bell is a
visual placeholder (no badge) — clicking shows a PrimeVue Toast
"Notificaties komen binnenkort beschikbaar" until the notification
framework lands as a separate sprint.
The help icon would normally open https://docs.crewli.app in a new
tab, but the host currently serves with a TLS cert that does not
cover the name (ERR_TLS_CERT_ALTNAME_INVALID), so the click handler
falls back to a Toast. A TODO comment in the source records the
target URL and the one-line switch to make once the cert is fixed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Consolidates the user menu into a single sidebar-bottom location.
SidebarUserCard.vue shows avatar (initial), full name, role (Dutch
label mapped from org pivot role or 'Super Admin' fallback) and a
chevron-up that opens a PrimeVue Menu with "Mijn Profiel" and
"Uitloggen". The Menu uses popup mode; PrimeVue v4's absolutePosition
logic auto-flips above the trigger when the panel would overflow the
viewport bottom — verify in Phase C.
AppShell loses the topbar avatar Button + Menu and the associated
state (userMenuRef, userInitial, userMenuItems, toggleUserMenu) plus
its imports (Avatar, Menu, useAuthStore, computed). The component is
now a pure layout shell with no auth-store coupling. The topbar's
right side is intentionally empty in this commit; B4 fills it with
breadcrumb / notification bell / help icon.
Layout: nav uses min-h-0 flex-1 overflow-y-auto so it shrinks under
viewport pressure and lets the user card stay pinned at the bottom
of the sidebar. Mobile Drawer's content pt-override sets the same
flex-column behaviour so the user card sits flush at the bottom of
the drawer overlay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Section headings ("Beheer" / organisation name, "Platform") were
already uppercase + muted but read as bold paragraph dividers more
than as quiet group markers. Tighten letter-spacing, drop weight
from semibold to medium, lighten the color one step (surface-500 →
surface-400), and shrink text to 11px so the headings recede and
let the nav items themselves carry the visual weight.
Spacing nudged from mt-4/mb-2/px-2 → mt-6/mb-1/px-3: more breathing
room above each group, less below (the items already have py-2 on
top), and the heading left-edge now lines up with the icons of the
nav items beneath it (both at px-3).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces SidebarHeader.vue — a PrimeVue-only org-switcher that
replaces the centered Crewli wordmark at the top of the sidebar. The
component mirrors the legacy Vuetify OrganisationSwitcher (avatar with
org initials, organisation name, plan-tier placeholder, dropdown
chevron, PrimeVue Menu of available orgs) but cannot reuse it
directly per the R-10 layout-shell-isolation invariant.
Plan-tier shows a hardcoded "Pro" placeholder until the backend
Organisation resource exposes a plan field — tracked separately, not
in F3.5 scope. When the user has no active organisation (portal
users, fresh super_admin), the component degrades to the original
title block so PortalLayout continues to read "Crewli Portal".
Desktop sidebar width bumped w-64 → w-72 (256 → 288 px) to give the
org-switcher card breathing room and accommodate the user-info card
arriving in B3. Mobile Drawer width bumped 16rem → 18rem to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Installs Storybook 10.4 in apps/app/ as a component-development and
autodoc tool. Configures viteFinal with all seven SPA aliases so
stories resolve imports identically to the dev/build pipeline.
preview.ts reuses @/plugins/primevue's installPrimeVue() so Storybook
stays in lock-step with main.ts whenever the PrimeVue config changes.
Only the addons we need are wired: addon-docs (autodocs) and
addon-a11y (axe-core checks). addon-interactions is intentionally
omitted — interaction testing stays in Playwright CT per the testing
architecture.
Seed stories: PrimeVue Button (Primary/Secondary/Danger), Tailwind
utility box, and FormField (Default/WithError/Disabled) wrapped in
@primevue/forms Form + Zod resolver.
Adds make storybook target alongside make app / make docs.
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>
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>
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>
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>
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>
11 tests for useTimetableKeyboard (RFC v0.2 D20):
- Arrow Left → nudge(-SNAP_MIN, 0, 0)
- Arrow Right → nudge(+SNAP_MIN, 0, 0)
- Shift+Arrow → nudge(±60min)
- Arrow Up/Down → ±lane
- Shift+Arrow Up/Down → ±stage
- ] / [ → cycle stages preserving time + lane
- Enter → openPopover with the selected performance
- Delete → remove with the selected performance
- Space → drag mode + aria-live announce; Arrow keys accumulate; Enter
commits with the cumulative offset; aria-live announces 'bevestigd'
- Esc cancels keyboard drag, no mutation, aria-live announces 'geannuleerd'
- all keys are no-ops when no performance is selected
Tests the composable directly with a host component that owns a focusable
canvas root and exposes the spies + announce ref — much more reliable
than mounting the whole timetable page (heavy + asynchronous).
Test count: 369 → 380.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Minimal seam in src/composables/api/useTimetableMutations.ts: the move()
mutation's onError now calls useNotificationStore().show(...) on a 409
status. Generic axios errors stay quiet here — the global response
handler in lib/axios/factory.ts already toasts those. RFC D14 wanted
the version-mismatch toast specifically.
apps/app/tests/component/useTimetableMutations.test.ts (NEW, 5 tests):
- on success: returns server payload with bumped version + sends the
Idempotency-Key supplied by the caller
- 409: rejects with VersionMismatchError + notification.show()
invoked once with the Dutch translation + 'error' level
- cascade: success with cascaded[] populated puts those peers into
the result.cascaded array
- Idempotency-Key uniqueness: two distinct logical move() calls send
distinct keys
- Idempotency-Key reuse: caller-controlled retry within the same
logical action sends the SAME key on the wire (so the backend's
60s idempotency middleware dedupes)
The two existing unit-project tests now register a Pinia instance
(createPinia + setActivePinia) so useNotificationStore() resolves.
Existing assertions unchanged.
Test count: 364 → 369.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 component tests via mountWithVuexy:
- happy path: valid form values → POST /performances called with the
correct body shape (engagement_id, event_id mapped from dayId,
stage_id, start_at, end_at)
- end_at < start_at → submit blocked, schema-level error visible on
the end_at field
- empty engagement_id → submit blocked, error visible on the engagement_id
field
- cancel button → emits update:modelValue=false
Test seam: AddPerformanceDialog.vue gains `defineExpose({ form, errors,
submit })` so jsdom tests can drive validation deterministically without
piping through Flatpickr / VAutocomplete plumbing. Three lines, exposes
internal refs only — no behavioural change.
VDialog stubbed in the test (it teleports to body, which puts content
outside the wrapper); App* wrappers stubbed (we test the schema +
submit pipeline, not Flatpickr ergonomics).
Test count: 360 → 364.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 component-level tests via mountWithVuexy:
Visual states (10):
- status palette × 3 (option, confirmed, cancelled) — asserts both the
CSS class AND that the matching --tt-status-{X}-bg custom property
resolves on :root (proves the token sheet really loaded)
- capacity icon present when crew + guests > stage.capacity
- capacity icon absent when sum ≤ capacity
- capacity icon absent when stage.capacity is null (no warning possible)
- B2B left dot present when b2bLeft prop true
- B2B right dot present when b2bRight prop true
- no dots when neither prop true
- conflict ring class when warnings includes 'overlap'
- cascade-pulse class when pulse=true
- aria-label includes artist + stage + status + HH:mm time window
- tabindex="0" for keyboard focus
Interactions (5, in second describe):
- click → emits select with performance + DOMRect
- pointerdown → emits pointerdown with (event, performance)
- Delete keypress → emits delete
- Enter keypress → emits select
Test count: 333 → 350.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Phase A finding A6 — the previous three-watcher Pinia-store design had
no validation. Landing on /events/{e}/timetable?day=DOES_NOT_EXIST quietly
set store.activeDayId to that bogus value and showed an empty page.
Cross-org sub-event IDs were silently accepted (backend OrganisationScope
returned an empty perf list, so the UI looked broken without telling the
user).
New design (Session 4 follow-up Step 5):
- src/composables/timetable/useActiveDay.ts (NEW)
- The URL `?day` is the source of truth; Pinia does NOT hold this value.
- `activeDayId` is a computed: queryDay if it appears in `validIds`,
else the first valid id, else null when the list is empty.
- One corrective watcher (immediate:true, flush:'post') quietly rewrites
the URL when `?day` is missing or invalid; runs after Vue settles and
after validIds has been recomputed from a fresh fetch.
- `setActiveDay(id)` is the user-driven entry point — calls replace().
- Cross-org IDs are blocked transparently: OrganisationScope keeps them
out of validIds, so they fail the .includes() check and fall back.
- src/stores/useTimetableStore.ts
- Removed `activeDayId` state and `setActiveDay()` action; the store
docstring now documents that day-state lives at the URL.
- src/pages/events/[id]/timetable/index.vue
- Replaced the three watchers + onMounted bootstrap with one
`useActiveDay({ queryDay, validIds, replace })` call. The day-change
side-effect watcher (clear drag, deselect performance) stays.
- VTabs binds dayIdRef + setActiveDay directly.
- tests/unit/pages/timetableDaySync.test.ts (NEW, 9 tests)
- Valid ?day=X → activeDayId=X, no URL rewrite.
- Missing / invalid / cross-org ?day → fallback + URL replaced once.
- Empty validIds → activeDayId=null, URL untouched.
- setActiveDay(id) → calls replace.
- setActiveDay(null) → no-op.
- External URL change (browser back) → activeDayId follows.
- validIds populated AFTER mount → fallback fires correctly.
- tests/unit/stores/useTimetableStore.test.ts: assert that activeDayId
and setActiveDay are GONE from the store surface.
Test count: 324 → 333.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Phase A finding A5 — Zod schemas in @/schemas/timetable.ts were
types-only; nothing parsed actual server responses. Backend → frontend
contract drift would only surface as TypeError deep in components.
useTimetable.ts queries now parse:
- useStages → stageArraySchema.parse()
- usePerformances → performanceArraySchema.parse()
- useWachtrij → performanceArraySchema.parse()
- useEngagement → artistEngagementSchema.parse()
useTimetableMutations.ts mutations now parse:
- move success → moveTimetableSuccessSchema.parse()
- move 409 errors → moveTimetableConflictSchema.parse() (the .errors
sub-object — see backend canon at TimetableMoveController:64)
- create / updateNotes → performanceSchema.parse()
- createStage / updateStage → stageSchema.parse()
The move() success parse runs OUTSIDE the try/catch so a Zod failure on
a 200 response surfaces as a true error rather than being misclassified
as a 409. Per Phase A finding A8 the conflict shape already matches
backend field-for-field; no schema correction needed, but the parse()
locks future drift in.
Regression test (tests/unit/composables/api/zodParseFailure.test.ts):
- move() success with missing fields → rejects with ZodError
- move() 409 with malformed errors payload → rejects with ZodError
- createStage() with missing fields → rejects with ZodError
Existing test fixture for createStage was missing created_at/updated_at;
fixed in same commit (real backend responses always include them).
Test count: 321 → 324.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation for the upcoming component / integration / a11y tests.
vitest.config.ts now declares two projects:
- "unit" — pure-logic tests under tests/unit/, src/**/__tests__/,
and tests/*.spec.ts (the legacy sanity test).
happy-dom, no Vuetify, fast path.
- "component" — tests under tests/component/, tests/integration/,
tests/a11y/. jsdom, Vuetify inlined via SSR noExternal,
CSS imports processed (so :root token sheet loads), and
no global vue-router mock so the real router can run.
Both share the same alias map and AutoImport bag.
tests/utils/mountWithVuexy.ts (new):
- Real Vuetify with the Crewli theme tokens
- createTestingPinia (actions execute by default; stubActions opt-in)
- vue-router with memory history at the configured initialPath + ?query
- Fresh QueryClient per call (zero cross-test cache leak)
- Notification mock injected via Pinia plugin so any useNotificationStore()
resolves to { show: vi.fn(), hide: vi.fn() } — matches the actual
NotificationStore API surface (per Phase A finding A4)
- Imports `@/styles/tokens/_timetable.css` at module load so JSDOM resolves
var(--tt-…) when components call getComputedStyle()
tests/setup.component.ts (new):
- vitest-axe matcher registration
- JSDOM polyfills: scrollIntoView, ResizeObserver, visualViewport, body
bounding rect — Vuetify menus / overlays would crash without them
- Deterministic crypto polyfill (mirrors tests/setup.ts so
generateIdempotencyKey() is stable, but without the router mock)
tests/component/_smoke.test.ts (new):
- Mounts a trivial component → asserts wrapper, queryClient, pinia,
router, notificationMock all populated
- Calls getComputedStyle(documentElement).getPropertyValue('--tt-status-confirmed-bg')
→ asserts '#e8f8f0' (proves the CSS token sheet really loaded)
devDependencies added: jsdom, axe-core, vitest-axe, @pinia/testing.
Total: 319 → 321 tests; 42 → 43 files. Both projects green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Phase A finding A2 — `_timetable.scss` was functionally pure CSS:
only :root custom properties + @keyframes + one .tt-cascade-pulse class.
The only SCSS-specific syntax was `// line comments`. Zero $vars, @use,
@mixin, @function, nesting, or color functions.
Why move to .css: Vitest+jsdom can `import '@/styles/tokens/_timetable.css'`
directly so getComputedStyle() resolves var(--tt-…) in component tests
(needed for the upcoming PerformanceBlock visual-state assertions). SCSS
imports require Vite's SCSS plugin, which the vitest.config.ts intentionally
skips for unit-test speed.
Changes:
- `_timetable.scss` → `_timetable.css` (line comments converted to /* */
block comments; everything else byte-identical)
- `assets/styles/styles.scss`: switch from `@use "@/styles/tokens/timetable"`
to `@import "@/styles/tokens/_timetable.css"`
- Production `npm run build` passes (16s, no asset warnings)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strict-regex sweep of apps/app/src/ confirms zero VeeValidate usage:
no `from 'vee-validate'` imports, no <Field|Form|ErrorMessage>,
no defineRule(), no useForm(). The 15 prior fuzzy matches were
false positives where /useForm/ matched useFormDraft/useFormSteps/
useFormSchemas/useFormFailures.
Changes:
- Remove `vee-validate` and `@vee-validate/zod` from apps/app/package.json
- Regenerate pnpm-lock.yaml (no other deps shifted)
- CLAUDE.md "Forms": replace VeeValidate prescription with the actual
ref + @core/utils/validators + Zod-payload-schema pattern that the
codebase already uses everywhere
- VUEXY_COMPONENTS.md: correct the stale "Registration uses VeeValidate"
claim (the page actually uses useFormDraft + validators); update the
"Form validation" reference row
- BACKLOG.md: close VEE-001 with the audit trail
All 319 existing tests still pass; vue-tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drift from Session 4 step 11 — unplugin-vue-components and unplugin-vue-router
regenerated their .d.ts files for the new timetable surface. Was missed in the
original commit because the test runner doesn't trigger regen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- PerformancePopover.vue — teleported floating panel; closes on Esc; shows
status chip, advancing %, computed Buma/VAT/total cost; deal-summary +
delete + open-detail buttons. Position math (340px wide, 12px margin,
flip side if no room) ports prototype's pickPos verbatim.
- AddPerformanceDialog.vue — Vuetify VDialog + raw ref form pattern (matches
CreateShiftDialog and the rest of the codebase). Uses createPerformancePayloadSchema
for client-side validation; falls back to surface-level errors map per field.
- StageEditor.vue — single-stage CRUD modal with name + capacity + 10-swatch
palette picker. Window.confirm cascade-park warning on delete.
- LineupMatrix.vue — stages × sub-events checkbox matrix; only dirty stages
fire replaceStageDays (atomic per stage).
- Wachtrij.vue — sidebar with search + 9 toggleable status chips with counts;
reads/writes useTimetableStore.statusFilter and searchQuery.
- WachtrijCard.vue — initials avatar + status dot + dot label + cancelled
strike-through. role=button, tabindex=0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
usePointerDrag — PointerEvents primitive with capture, escape-cancel,
keyboard-cancel, and onBeforeUnmount cleanup. Replaces the legacy
mousedown stack the prototype used.
useDragOrClick — threshold-based drag/click disambiguation (4px Manhattan,
matches prototype audit §4.1). Emits onClick when the pointer never crossed
the threshold; otherwise enters drag mode and emits onDragStart / onDragMove /
onDragEnd. Installs the one-shot capture-phase click suppressor on drag-end
so the synthetic click never opens the popover.
RFC v0.2 D7 — implemented once instead of three times like the prototype.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>