Nuxt 4 SPA migration: Laravel becomes API-only#898
Open
edwh wants to merge 335 commits into
Open
Conversation
…ions; logout lands on plain-layout login tm() returns compiled message ASTs for precompiled JSON locales, so the register page's country list crashed with localeCompare-is-not-a-function — resolve names through t() per code. All 8 client e2e flows now pass.
… green) Built by the phase-b-client workflow against the committed contracts doc: /dashboard (your/nearby/new groups, upcoming events, onboarding modal), /group + /group/all + /group/nearby lists, /group/view/[id] (stats, volunteers, events, permission-gated actions, invite modal), /group/create + /group/edit/[id] (Quill wrapper, location picker, tus image upload). All missing server endpoints recorded honestly in docs/nuxt-migration/api-gaps.md — no invented API paths (verified by the workflow's audit stage).
The SPA authenticates with Bearer tokens only. The default guard=>['web'] fallback made Sanctum (first in the dual-guard chain) hijack auth from the legacy ?api_token= TokenGuard in mixed session+token requests, breaking APIv2GroupTest::testNetworkDataUpdatedAt and GroupHostTest:: testNetworkCoordinatorDemoteHost. Full suite: 723 tests, these were the only two failures.
…s proxy v2
GET /api/v2/dashboard (+has_location), POST users/me/onboarding-complete,
GET users/me/groups, POST/DELETE groups/{id}/members/me, GET groups/nearby,
POST groups/{id}/invites, DELETE groups/{id} (archive), GET groups/{id}/stats
(composite ported from the group-view Blade), POST/DELETE group images via
tus, GET /api/v2/maps/* (auth, same controller as the legacy web proxy).
Group resource gains shareable_link (now pointing at the frontend origin)
and is_member. 68 new tests green; group regression slice (48 tests) green.
Client locale JSONs regenerated for the new groups.image_upload_error key.
Implemented by the phase-b-server agent; two test-code bugs fixed in review
(unguarded category seed collision; Auth::logout on a TokenGuard).
Ported the legacy group.test.js flows: create group (redirects to
/group/edit/{id} per the legacy contract), join/unfollow, image upload
persisting on the view page (real PNG fixture via Uppy/tus). Dashboard
smoke test. Three client bugs found by the flows and fixed: package-lock
missing @popperjs/core (crash-looped npm ci); blank postcode sent as null
500'd group creation (NOT NULL column + ConvertEmptyStringsToNull — key now
omitted when empty); uploaded image src built relative to the SPA origin
instead of the API origin (new useUploadedImageUrl composable).
Implemented by the phase-b-e2e agent; independently re-verified 12/12 e2e
and 266/266 vitest before commit.
Drafted by the phase-c-contracts agent. Notable findings preserved in the doc: /party/all, /party/all-past and /device/search are dead legacy routes (wired to non-existent controller methods); moderation approve needs no new endpoint (existing PATCH param); category suggestion is exact-match today (plan note corrected — no fuzzy library to port).
49 lang/ files were CRLF in the working tree while the repo stores LF (eol=lf), so multi-line translation strings embedded \r\n in locally generated locale JSONs but plain \n in CI's checkout — failing the translations sync check on fr.json. Working tree renormalized via checkout smudge; only the two affected generated files change (CR removal inside two multi-line strings).
…green) Built by the phase-c-client workflow against the Phase C contracts doc: /party lists (incl. minimal rebuilds of the dead /party/all(-past) routes), /party/view/[id] (RSVP, attendees, calendar links, invite modal), create/edit/duplicate forms (vue-datepicker-next, moderation select), device recording (exact-match category suggestion port, tus photos, optimistic CRUD), /fixometer home + /device/search. Gates verified directly (the workflow's verify stage crashed on structured output): 553/553 vitest, clean nuxi build, 0-error lint, locale files parallel (114 client keys per locale).
Both phpunit timeouts on this branch hung at different late-position tests whose code paths are provably IO-free — a memory-pressure stall, not a test bug. The delta vs previously-green CI: docker:up-all now also starts restarters_client (nuxi dev, ~1GB+), which this job never uses. Stop it after startup; e2e-client keeps it.
…th Coveralls token The CIRCLECI skip relied on the container-startup migrate:fresh, which runs with no set -e — a silent startup migration failure left CI's test DB missing only the newest table (sso_tickets), failing all 7 SsoBridgeTest setUps while everything older existed. Explicit migrate:fresh on the test connection right before phpunit removes that divergence class. Coverage instrumentation now only runs when COVERALLS_REPO_TOKEN is set: XDEBUG coverage over the grown suite is the prime suspect for CI stalling silently at test ~683-702 (three runs, different provably-IO-free tests) — and without the token the clover output was discarded anyway.
The explicit migrate:fresh --database=mysql_testing runs via plain artisan, which reads .env — phpunit.xml's DB_TEST_* env only applies inside the phpunit process. Without these, DB_TEST_HOST fell back to 127.0.0.1 and the reset died with connection refused.
All three phpunit 'hangs' died at exactly 60.0 minutes: CircleCI's plan-level max job runtime, not a test fault. develop's build already ran ~54 min; this branch's ~120 added tests pushed the coverage-instrumented run over the cap at whatever test happened to be executing. Feature branches now run phpunit without XDEBUG coverage (Coveralls only consumes develop anyway). Pre-merge cliff recorded in plan G1: develop needs a job split or plan bump once this branch lands.
The full suite went green in CI (791 tests, 39:59, under the 60-min cap) but the step failed on 'clover.xml not found' — the upload block still ran on a branch where generation is skipped. Same branch gate on both now.
Six headline findings that would have broken cutover: kept partner widgets @Vite()-reference deleted entries; error pages include deleted layouts; StatsShare.vue lives inside a kept widget; /group-tag/stats KEEP route is already broken; /user/register/{hash} deep link missing from the redirector list; the invite/reset 'redirectors' are full legacy controllers needing rewrites. Plus route-classification corrections, the exact Blade retention set, vite trim plan, and middleware death list with a same-commit constraint on Authenticate::redirectTo.
Passes in ~2s warm locally but exceeded vitest's 5s default on a cold CI node executor (build-client's only failure once the Laravel build job went green).
attending field on the event resource; attendees list; RSVP family (POST claims pending hash-invites); volunteer PATCH/DELETE + invites; headcounts folded into event PATCH (tightens legacy gate — verified subset); event devices list; event+device image galleries via tus (FixometerFile gains a clear param so uploads append, not replace); DELETE event (legacy parity — no canDelete gate); devices options/paginated list; latest-repaired-event; users/me/events (approved-groups + location rules). 11 test files; regression slice green; independently re-verified 112/112. Implemented by the phase-c-server agent. Notable agent finds: nested simulated-HTTP fixtures pollute guard identity across actingAs switches (fixtures now built via Eloquent directly); Party::factory doesn't geocode (explicit lat/lng needed for nearby tests).
… nuxt-client # Conflicts: # resources/js/app.js # routes/api.php
Built by the phase-de-client workflow: /profile/edit/[id] five+ tabs against the folded PR-868 API (Uppy/tus photo, delete-account flow), public /profile/[id] (graceful until D2 endpoint), /user/all admin list, generic AdminCrudTable + five reference-data pages (PR-863 API), /networks pages with the group-tags permission matrix, cookie policy, onboarding modal, notifications dropdown, locale switcher, and the guest landing page (preserving the legacy h2 contract string). Plural parity spec made self-adapting after #887's networks.php rework removed the corpus's only three-form string.
Phase C's tests pushed the combined job over CircleCI's plan cap even without coverage (phpunit passed; the kill landed mid-legacy-Playwright). build is now phpunit-only with an npm-less container startup (SKIP_NPM_INSTALL honored by docker_run.sh — pure-API jobs never needed the ~10-12 min npm/Vite work); build-legacy-frontend runs jest + the legacy Playwright suite and is deleted wholesale at Phase F cutover.
…n RSVP block
Three gaps, all found by diffing develop's lang keys against ours:
1. Both invite modals (group and event) have a chain-link toggle in their
header that swaps the email form for a box holding a join link anyone can
follow - resources/views/includes/modals/{group,event}-invite-to.blade.php.
We shipped only the email half. GroupInviteModal.vue's comment said the
link half was dropped because "the v2 Group resource has no equivalent
field"; that has not been true for some time - Group.php:395 exposes
shareable_link already. The event resource genuinely lacked it, so this
adds it.
Event's copy is gated on Fixometer::userHasEditPartyPermission - the same
permission that guards sending invites, because the panel is only ever
rendered to hosts. Group's stays unconditional, which is correct rather
than inconsistent: claiming a group link grants the same status 1 /
role RESTARTER that the public join button already does
(GroupMembershipController::joinv2), so there is nothing to protect.
Verified by the three new tests in APIv2EventResourceFieldsTest - host
sees the link, restarter and anonymous get the key absent entirely.
2. events.tooltip_type aside, EventCard let you RSVP to an event starting
later today. GroupEventsScrollTableActions.vue disables the button once
Party::isStartingSoon() is true. An existing attendee stays able to
withdraw - that is the only way off the list.
3. devices.tooltip_type is still deliberately unported (previous commit):
develop defines it and references it nowhere.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
…exports
Two problems in ExportController, found by auditing the retained web-route
surface for authorization rather than just for method (my earlier pass
checked every route was GET and verified the invite hashes, but did not ask
who is allowed to call each one).
1. groupEvents()/networkEvents() applied no visibility filtering whatsoever -
just $group->parties()->undeleted()->get(). undeleted() excludes deleted
events, not unapproved ones. So an anonymous GET of
/export/groups/{id}/events returned events belonging to groups still
awaiting moderation: dates, event names, venue, volunteer and participant
counts - exactly what /api/v2 withholds from the same caller
(User::userCanSeeEvent, asserted by APIv2EventVisibilityTest).
devices() has always filtered per row with userCanSeeEvent. The event
export never did, which reads as an oversight rather than a decision.
2. devices() wrote its CSV to public/repair-data-{name}.csv and returned
Response::download without deleteFileAfterSend, and nginx serves
root /var/www/public. The rows are filtered for the requesting caller, so
the finished file holds whatever THAT caller could see - and it was then
left under the docroot, at a name derived from the group or event name,
for anyone to fetch. That silently undid the per-row filter. .gitignore
already carried /public/repair-data*.csv, which is what you add after
noticing the files piling up.
exportEvents() had a related but milder bug: a fixed relative filename
(events.csv) in the process working directory, shared by every caller, so
concurrent exports for different groups raced and one caller could be
handed another's rows.
Both now build into per-request temp files removed after send.
ExportTest was reading the CSVs off those side-effect paths -
public_path().'/'.$filename and the bare events.csv - including an
assertFileExists that asserted the leak. It now reads the response's own
file, so it tests what the caller receives rather than what was left behind.
NOT VERIFIED LOCALLY: this worktree has no PHP runtime available (no host
php, and the restarters containers are not currently up), so none of this
has been linted or run. CI is the gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
3755f34 was pushed without a PHP runtime to verify it. CI found three problems; the tree is back on this branch so all three are now fixed and run green locally. 1. ExportTest::testExport, Host dataset: the network export asserted that every role sees group3's event - and group3 is the deliberately unapproved group the fixture creates ("two approved and one not"). A host of the other two groups may not see it, so fgetcsv returned false and indexing it errored. The expectation is now role-dependent, which is the security property rather than an incidental: Administrator and NetworkCoordinator see it, Host does not. Like the assertFileExists this commit also removes, it was a test encoding the bug. 2. testEmdashInGroupNameExport still read the shared events.csv, and Fixometer/BasicTest still read public/repair-data.csv. I missed both: my survey for readers of those paths was piped through `head -10` and truncated, and I treated the truncated output as the full list. Grepping without the pipe returns exactly these two files. 3. assertFileDoesNotExist failed against a file a previous build had left behind, not against anything this request wrote. The test now removes any stale copy first, so it reflects the request under test. The accumulation is not hypothetical: this dev checkout held 270 leaked repair-data-*.csv files totalling 1.1M, oldest dating from May. Removed, and after a full run of both suites public/ has none - the export no longer writes there. Verified locally: ExportTest 6/6 (179 assertions), BasicTest 1/1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
GroupEventScrollTable.vue shows seven per-event stats on past events -
participants, volunteers, waste, CO2, then fixed/repairable/end-of-life
counts - as icon-only columns with the label as a tooltip. Ours showed
date, title and location only.
The component documented this as unfixable: "EventSummary only carries
{id, start, end, title, location, ...}... this v2 endpoint has no
equivalent". That stopped being true - PartySummary::toArray already
returns `stats` from getEventStats(), and getEventsForGroupv2 already
eager-loads the relations it needs specifically so this list doesn't N+1.
The API side was done; only the table was never updated. Comment corrected.
Also ports develop's danger highlighting, which is the reason a host scans
this table at all: an event nobody attended (dangerIfZero), one with a lone
volunteer (dangerIfOne), and a finished event with no devices logged
(noDevicesError) all get .cell-danger.
An event without stats renders an empty cell, not a zero - a missing figure
and a genuine zero mean different things to a host, and only one of them
means "nothing was logged".
Upcoming events get no stat columns, matching develop's separate field set
for that tab.
Verified: 15 tests in GroupEventsList.spec.js (5 new), full client suite
1474 passing - the 2 failures are the known useStatsShareImage container
mount artifact that passes in CI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
GroupPage.vue renders a green banner naming the group ("You have now
unfollowed X", with a link back) after a successful leave. Ours updated the
button optimistically and said nothing, so the only feedback for a
deliberate, slightly consequential action was a control changing label.
Set on success only: a failed leave already toasts and reverts the
optimistic update, so a banner there would contradict it. Both cases covered
by tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
GroupActions.vue offers two distinct destructive actions: Archive (sets
archived_at, reversible, open to Administrators and the group's
NetworkCoordinator) and Delete group (removes the group and its events for
good, Administrator only). We shipped archive only, and the group view page
asserted in a comment that the other one was impossible: "There genuinely is
no hard-delete route wired up server-side... there's nothing for a 'Delete
group' menu item to call."
That was true of the routes and false of everything else. The
can_see_delete/can_perform_delete flags were already computed and already on
the wire; only the endpoint was missing. Adds DELETE
/api/v2/groups/{id}/permanent, porting GroupController::delete: Administrator
only, blocked by Group::canDelete() so nothing with recorded repair data can
be destroyed, and force-deleting events_users rows first because they are not
cascaded in the DB and the event delete would otherwise hit a constraint
violation.
The menu item follows develop in showing greyed-out rather than hidden when
the group can't be deleted, so an admin learns the option exists. It renders
as a class rather than a disabled attribute, because the item is an <a> -
the click guard is what actually withholds the action, and is tested
separately from the styling.
Verified: APIv2GroupMembershipTest 21/21 (5 new, covering anonymous, host,
coordinator, admin, and the has-a-device block), group view spec 31/31 (6
new), full client suite green apart from the known useStatsShareImage
container-mount pair.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
EventInviteModal.vue lets a host pick group members instead of retyping
their addresses, with a tickbox that selects everyone at once. Ours offered
manual email entry only, and said so: "the legacy component's 'select group
members' multiselect ... is dropped here - manual email entry covers the
same endpoint". It does cover the same endpoint, but it makes the host go
and find the addresses first.
GET /api/v2/groups/{id}/volunteers already supports exclude_event, so the
list only offers people not already confirmed at this event - the same call
develop makes. Fetched through a store action that RETURNS the list rather
than storing it: the `volunteers` slice backs the group view page's own
list, and a modal opened elsewhere must not overwrite it.
Only members with an address are offered. Volunteer.email is returned solely
to hosts, coordinators and admins, so for anyone else the list arrives
without addresses and the picker doesn't render - which lines up with the
permission guarding this modal anyway.
Picked addresses are merged with typed ones and deduped, since a member's
address can also be typed by hand. Only the typed half is format-validated:
member addresses come from the API, and rejecting one would blame the user
for it. Tested.
A native multiple <select> is used rather than vue-multiselect, matching the
plain-control idiom used elsewhere in this client. events.select_members_
placeholder is therefore deliberately not restored - it exists only as
vue-multiselect's placeholder, and translations:check correctly rejected it
as unused. develop's mention of a "⚠" marker in the tickbox copy is stale
there too: nothing in its resources/js renders one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
group/view.blade.php shows a warning banner when the caller has an
unaccepted invitation to the group they are looking at, so someone who
navigates there directly - rather than through the emailed link - can still
accept. We had the emailed flow (useInviteClaim + /group/invite/[code]) but
nothing for the case where the user simply browses to the group.
getGroupv2 now returns has_pending_invite: the users_groups.status value,
which doubles as the invite hash in /group/accept-invite/{group}/{hash}. A
joined member has status '1', which is not pending.
It is an invite hash, so the query is scoped to the caller's own row and
returns null for anonymous callers. Tested three ways: the invitee sees
their hash, a different authenticated user gets null for the same group, and
a joined member gets null.
The accept link stays a plain href rather than a NuxtLink - it is a retained
Laravel web route that establishes a session and redirects back, not an SPA
route.
Verified: 3 new API tests, 33/33 in the group view spec, full client suite
green apart from the known useStatsShareImage container-mount pair.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
… comments
Four of the five parity gaps just fixed were hidden behind comments claiming
the API couldn't support them - claims that had been true once and silently
stopped being true. So I swept the client for the rest of that phrasing.
Six hits, one of them a real gap:
EVENT PHOTO DELETION. events/edit.blade.php gives every photo a "Remove
file" link. Our edit page could upload but not remove, and the store said
why: "Write-only from the client's point of view: no endpoint lists an
event's existing photos". Both halves were stale - the Event resource
carries `images` (the view page already renders them via
EventImagesGallery), and DELETE /api/v2/events/{id}/images/{idimages} has
existed all along. EventAPI.deleteImage was even written; nothing called it.
The edit page now lists the photos with a remove control, same shape as
DevicePhotos.vue.
uploadEventImage now refetches the event, or an upload wouldn't appear in
that grid. Its store test asserted only the return value, so it passed
against a store that never updated any state - it now asserts the refetch
too.
The other five were mis-documented but working, which is worse than a bug:
they read as settled decisions and stopped anyone looking. All corrected -
POST /api/v2/networks/{id}/groups (associateGroupsv2) and GET
/api/v2/users/{id} (getPublicProfilev2) both exist and both were already
being called successfully by the code the comments sat above.
/party/all's comment is left alone: it describes a route that is genuinely
dead in develop too, which is a real finding rather than a stale one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
/profile/edit rendered 1657px wide against a 1440 viewport, so the whole page scrolled sideways. Uppy's Dashboard defaults to a fixed 750px width and we only ever set `height`, so in the narrow "Change my photo" column the panel ran past the right edge of the window and dragged the document with it. Now tracks its container, with a max-width guard in the component's own styles so no future Uppy default can do this again. Found by the visual parity harness, which had itself been failing to capture anything for the "new" side - see the environment notes below. Adds client/e2e/layout.test.js asserting document.scrollWidth never exceeds the viewport on four pages. Deliberately e2e rather than unit: this is computed layout, which jsdom cannot see. All 166 component tests over TusImageUpload and its consumers passed for the entire time the bug was live, and would have gone on passing. The assertion names the offending element in its failure message. Verified by reverting the fix: "overflow past 1440px from <div class="uppy-Dashboard-inner"> ending at 1657px". A bare "1657 !== 1440" means hunting the DOM by hand, which is what this cost the first time. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
…heading Both found by diffing the rendered text of every page against develop's, on the same database - the parity harness's whole point, and the first time it has been usable this session. 1. /group/all offered "FOLLOW GROUP" for a group the user is already in; develop correctly said "UNFOLLOW GROUP". The store explained why: "there's no per-group 'am I a member' field on the names index or the single-group endpoint". Half true - the names index has none, but the single-group endpoint does (Group.php sets is_member from isVolunteer), and the page already fetches full details for every row. It was falling back to memberIds, which only knows groups seen via `mine` or joined this session. Both /group/all and /group/view now prefer the server's answer. Same class of stale comment as the five gaps before it, and the same consequence: a claim that was true once, quietly wrong later, and taken as settled. 2. /group/all rendered a bare "None" above the page title. It reuses the networks moderation table, which owned its own empty placeholder. develop splits it the other way: GroupsRequiringModeration is `v-if="loaded && groups.length"` and renders nothing, while NetworkPage supplies its own "None" under that section's h2 - where the word has a heading to belong to. The placeholder is now a slot, so the networks page keeps its "None" and the groups page shows nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
New check: compare the VALUE of every lang key we share with develop, not
just which keys exist. Every earlier sweep compared key sets, so a key
present in both but rewritten was invisible - and rewritten is exactly what
these were. Ten differ out of thousands; nine are fixed here.
The group filter bar was the visible one, and how this surfaced: develop
renders "Search name...", "Tag", "Search location...", "Country...", ours
"Search by name", "Filter by tags", "Search by location", "Filter by
country". Also "Show Filters"/"Hide Filters" capitalisation,
calendars.see_all_calendars ("my" vs "your"), and
devices.add_data_description, which had been rewritten into a different
sentence entirely.
groups.read_less is a minus icon followed by "READ LESS" in develop, and we
had replaced it with plain "Read less". Restored - and the group page now
renders it with v-html, as the event page already did for the identical
events.read_less, otherwise the markup shows as source.
admin.co2_footprint is deliberately NOT synced. develop has
"CO<sub>2</sub> Footprint (kg)"; we have "CO₂ Footprint (kg)". It is used as
a table column label and a form field label - both plain text - so develop's
markup would render as literal tag source there. Ours produces the intended
output, which is the parity that matters.
Four specs hardcoded the old copy. They now read the placeholder from the
locale file, so the next copy change doesn't need a matching spec edit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
develop's rendered output contains "(Click to sort ascending)" on every sortable column and ours contains nothing - the last outstanding difference from the page-text diff. b-table gives develop both that hint and aria-sort for free; our hand-rolled headers had neither. Worse, the one place that looked instrumented was not. FixometerSortHeader set aria-sort on its sort <button>. ARIA only honours aria-sort on an element with the columnheader/rowheader role - the <th> - and a button's role does not support it, so assistive tech ignored the attribute entirely. Its spec asserted the attribute was present and passed throughout, which is why this looked done. The attribute now sits on the <th>, and the spec asserts it is NOT on the button, so the mistake cannot come back quietly. aria-sort carries the current state; the visually-hidden hint on the control says what a click will do next. Those are different sentences and it is worth keeping them apart: a column already sorted ascending offers "sort descending". Covers GroupsTable and the device search table. role.vue, user/all.vue, AdminCrudTable and GroupEventsList sort the same way and still need it - shared helpers (composables/useSortAria.js) are in place for them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
c84398e covered GroupsTable and the device search table and left four behind: AdminCrudTable (which backs brands/skills/tags/category), role.vue, user/all.vue and GroupEventsList. All now put aria-sort on the <th> and a visually-hidden hint on the control, through the same helpers. AdminCrudTable needed useI18n, which its spec had never provided - the five pages built on it all mount through that spec's harness, so the plugin goes in there rather than each page working around it. GroupEventsList's sort arrow was announced as content; it is decoration beside the real state now carried by aria-sort, so it is aria-hidden. Adds unit tests for the two helpers, in particular that the hint is the OPPOSITE of the current direction. Getting those the same way round would announce the column's state twice and never tell anyone what clicking does, which reads as correct in code and is useless in a screen reader. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
The roles table showed "Admin", "Repairer" and "Network Coordinator" where
develop shows "Administrator", "Restarter" and "NetworkCoordinator" - same
database, different text.
Role names are the raw roles.role column, and those literals are themselves
i18n keys, so t() renders them as user-facing labels. That is correct on a
profile. It is wrong here: this screen manages the very values that
hasRole('Restarter') is checked against, and an admin who reads "Repairer"
has no way to learn the stored name is "Restarter". RolesTable.vue renders
`{{ data.item.role }}` untranslated for the same reason. The edit modal's
heading had the same problem and is fixed with it.
PublicProfileView keeps translating - that IS the user-facing case.
Also parenthesises the sort hint added in c84398e: b-table renders
"(Click to sort ascending)" and ours omitted the brackets, which is what made
the difference show up in the page-text diff again after it was supposedly
fixed.
The new test was checked by reverting the change - it fails with
"expected 'Repairer' to be 'Restarter'" rather than passing regardless.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
The modal rendered alt="" on all three slides. app.blade.php describes each
one - "Two Restarters attempting a fix", "A volunteer helping a member of the
public with their phone", "A Restarter fixing a device at one of our events".
An empty alt is a positive claim that an image carries no information, so a
screen reader user was told nothing about the pictures a new user is shown
first. develop hardcodes the English; translated here.
Found by rendering-text diff after teaching it about three artifact classes
that had been producing confident false positives:
- innerText joins inline siblings, so develop's "helper! BUTTON" never
matched our two separate lines;
- innerText applies text-transform, so a CSS-uppercased button reads
"USE GROUP LOCATION" one side and "Use group location" the other;
- innerText excludes placeholder/title/aria-label/alt entirely, which is
why alt text could not be compared at all until now.
Every /party/create "finding" from the previous run turned out to be one of
those three, not a defect. The alt text only became visible once attributes
were harvested into the comparison.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
user/all.blade.php labels every filter with a trailing colon - "Name:", "Email:", "Town/City:", "Country:", "Role:", "Permission:" - and ours had none. The colon is hardcoded in the blade, not part of the lang string, which is why comparing lang values never surfaced it; it took diffing what the two pages actually render. This is the whole of the difference on that page. The Role and Permission filters flagged alongside it in the same diff are already implemented, wired through buildParams() to the role/permissions[] query params the API has supported all along - a false positive from develop's labels differing, not a missing feature. Checked before changing anything. Deliberately NOT matched, on the categories admin table: develop's CategoriesTable.vue hardcodes 'Name', 'Weight [kg]' and 'CO₂ Footprint [kg]' in the component, bypassing its own lang file (which says "(kg)", with parentheses). Copying develop's rendered output there would mean hardcoding English column headers and losing fr/fr-BE, so ours stays translated. Same call as admin.co2_footprint's <sub> markup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
CollapsibleSection's expand/collapse affordance was a <span> inside a <div> that carried the click handler. Neither is focusable, so the control could not be reached or activated from a keyboard at all, and aria-expanded on a span is ignored because a span has no role that supports it. Its only content is a "+"/"-" glyph, so even reached it announced as a character. It is now a real <button> with aria-expanded, aria-controls pointing at the body region, and a name that flips between "Expand" and "Collapse". The header row still toggles on tap, so the button stops propagation - otherwise its own click bubbles to the header handler and the section toggles twice, which looks like the control doing nothing. The body id comes from Vue's useId(). My first attempt used a counter declared in <script setup>, which is per-instance and hands every section on the page the same id - aria-controls would then point at whichever one the browser found first. Dashboard pages render several of these. Found at a 390px viewport. Every comparison until now ran at 1440, where this control is display:none - the desktop layout never shows it, so no amount of desktop diffing could have surfaced it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
DeviceBrand.vue and DeviceModel.vue prompt with "Brand (if known)" and "Model (if known)". We dropped both strings. That parenthetical is the only thing telling anyone those fields are optional, so losing it loses information, not just styling - which is why it is worth restoring even though an earlier pass filed it as cosmetic. Kept as placeholders ALONGSIDE the existing <label>, not instead of one: develop is placeholder-only, which leaves the field with no accessible name at all once the user types into it. Found at a 390px viewport, where the fixometer form is one column and the hints are the widest thing in it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
The test added in 950315c used documentElement.scrollWidth, which counts a wide element even when an ancestor with overflow-x:auto is scrolling it. A legitimate .table-responsive therefore reads as page overflow - /user/all measures 687 in a 390 viewport while behaving correctly. It only passed because it ran at desktop width on pages without such a table; adding mobile coverage would have failed it on correct code. The obvious alternative is also wrong: window.scrollX reports 0 even with the Uppy bug present, because the page is clipped horizontally rather than scrollable. That is what I got wrong in 950315c's message - the unfixed panel did not add a scrollbar, it put the widget's right-hand 217px off the edge unreachable. body.scrollWidth separates the cases (390 contained table, 1657 Uppy panel). Verified both directions: green on the fixed tree, and reverting the fix gives "content runs to 1657px in a 1440px viewport; widest unclipped element is <div class="uppy-Dashboard-inner">". Also extends to 390px and four more pages, including the two whose tables caused the false positive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
…tacking Three defects from mobile screenshots of the preview app. 1. JOIN was invisible: dark text on a dark fill. The navbar's %nav-items-shared "> a" block styles the icon-and-caption nav items (TALK/FIXOMETER/...) - stacked flex column, fixed 60px height, colour forced to $black - and the logged-out Sign in / Join controls are also <a> inside <li>, so they inherited all of it. #222 landed on .btn-dark's #212529 fill: about 1.02:1 contrast. The fixed height and column layout also broke "SIGN IN" across two lines. That block now excludes .btn, and the two buttons get uppercase and nowrap of their own. Confirmed by computed styles, before rgb(34,34,34) on rgb(33,37,41), after rgb(255,255,255). 2. "is 23 23 items to be repaired". events.to_be_repaired and its siblings already begin with ":value", and the event page prefixed the count again. GroupStats.vue builds the same sentence from the same strings without a prefix, which settles which of our two implementations was wrong. The test asserted toContain('2 items to be recycled') - and "2 2 items to be recycled" contains that too, so it passed throughout. Now an exact match on the whole sentence. 3. Stat cards were cramped on mobile: three across at 109px each, so the icon/count/label stacked into thin columns that no longer lined up. The live site puts the fixed count on its own full-width row with powered/unpowered beneath, and gives each environmental-impact card the full width. Measured both sides at 390px and matched it. NOT changed: the logged-out event page overflows its viewport horizontally (437px against 390). develop does the same, slightly worse at 445, so this is pre-existing on both rather than something the migration introduced. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
d5bd3f5 fixed the invisible JOIN label but styled the buttons uppercase. navbar.blade.php sets text-transform: initial on them, deliberately, along with width 120px, height 40px and explicit colours - all inline, precisely to defeat the nav-link styling those anchors would otherwise inherit. The labels are "Sign in" and "Join", in sentence case. That inline styling is also the answer to why this broke at all. The SASS was reused; the markup was not. develop writes these as plain anchors whose inline styles override %nav-items-shared's colour and fixed height. We replaced them with Bootstrap .btn classes - a class selector, which loses to .nav-wrapper .nav-right > li > a - so a rule develop had silently overridden for years suddenly applied. Buttons now measure 120x40 on both. Still not matched: the logged-out navbar overflows 390px on both systems, and ours is now wider than develop's (474 vs 445) because these buttons are no longer shrinking below their intended size. The cause is separate - our nav-left occupies the full viewport width alongside the brand and nav-right - and is not something these buttons should paper over by staying too small. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Summary
client/) that talks to Laravel exclusively via/api/v2./api/v2plus a small pinned web surface (SSO bridge, emailed/shared deep-link redirectors, anonymous exports + calendar feeds, embeddable stats widgets, admin preview-deploy) and a catch-all that 302-redirects any other browser navigation to the SPA.tests/Feature/ApiOnlyRouteSurfaceTestpins that surface.laravel/ui, the 5 auth controllers, ~200 Blade views,resources/js/resources/sass, Jest, thebuild-legacy-frontendCI job, and the legacy global jQuery (resources/global/js/app.js).What's included
Nuxt 4 SPA: Vue 3, Pinia, bootstrap-vue-next (Bootstrap 5),
@nuxtjs/i18n, Vitest, Playwright e2e. All pages ported: dashboard, groups (+ map/nearby/all/create/edit), events (+ create/view/devices/attendees), fixometer, profile (view + edit tabs), admin reference-data, user/all, networks, notifications, auth.Auth: Sanctum bearer + dual guard
auth:sanctum,api; an SSO bridge (/auth/bridge) minting a web session for Discourse SSO + MediaWiki silent login.Infra: docker-compose split into Nuxt + API containers; CI four-job topology (
build,build-client,e2e-client); client translations exported fromlang/*.php.Design parity with the live site (G6) — reviewed page-by-page against production and matched:
.panel/.panel__orangecards with the offset drop-shadow (ported the legacy_panels.scssinto the global layer — no component overrides)..content-divider; brand cards global, only page-specific layout scoped./images/*set populated.API security review (verified against the pre-migration controllers) — fixed a critical device IDOR, an open redirect in the SSO bridge, an event-visibility regression (unmoderated-group events were public), and a password-reset token replay; each with a regression test. See
docs/nuxt-migration/findings/security-review-api.md.Code Quality Review
ApiOnlyRouteSurfaceTest) and per-endpoint OpenAPI response validation guard against surface/shape drift.role/api_tokenexcluded from$fillable; Eloquent bindings — no raw SQL).wiki.js) and the embeddable widget pages' CDN libs — both outside the Vue/Nuxt app.Test Plan
buildphpunit,build-clientvitest 882 + lint + build,e2e-clientPlaywright) green on the branch head.APIv2PublicProfileTest,APIv2DeviceUpdateAuthTest,APIv2EventVisibilityTest,SsoBridgeTest,AuthEndpointsTest,DashboardAddData.spec, e2e for dashboard/device/event flows).Future Improvements
docs/nuxt-migration/findings/): the SSO bridge is irreducibly a web route; the redirectors + catch-all could move to nginx; admin preview-deploy should move into the SPA + an/api/v2endpoint (it currently can't authenticate the SPA user, which is a session-vs-token mismatch). The anonymous export/calendar feeds and embeddable stats widgets are kept for external consumers/partners.security-review-api.md(repair-directory read gate, dead v1/api/groups/dump, dead draft-image copy path, tus filename-collision + disk-fill sweep, throttle gaps). The public group directory + shareable-link exposure were reviewed and deliberately left as-is.