Skip to content

Nuxt 4 SPA migration: Laravel becomes API-only#898

Open
edwh wants to merge 335 commits into
developfrom
nuxt-client
Open

Nuxt 4 SPA migration: Laravel becomes API-only#898
edwh wants to merge 335 commits into
developfrom
nuxt-client

Conversation

@edwh

@edwh edwh commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

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 from lang/*.php.

  • Design parity with the live site (G6) — reviewed page-by-page against production and matched:

    • Login: brand .panel/.panel__orange cards with the offset drop-shadow (ported the legacy _panels.scss into the global layer — no component overrides).
    • Dashboard: "Welcome to Restarters" hero + doodles, two-column Your Groups / Upcoming events panel, repair photo, blue info panel, amber "Getting started" call-out, and the restored Add Data group→event picker.
    • Fixometer: the teal stat-card grid + the inline searchable POWERED/UNPOWERED repair-records table with filter collapsibles.
    • CSS consolidation: the dashed content divider promoted to a global .content-divider; brand cards global, only page-specific layout scoped.
    • Assets rationalised to WebP (icons stay SVG); the missing /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

  • The pinned web-route surface (ApiOnlyRouteSurfaceTest) and per-endpoint OpenAPI response validation guard against surface/shape drift.
  • New endpoints follow v2 conventions (explicit validated field lists — no mass assignment; role/api_token excluded from $fillable; Eloquent bindings — no raw SQL).
  • The Nuxt client is jQuery-free; jQuery survives only in the MediaWiki skin (wiki.js) and the embeddable widget pages' CDN libs — both outside the Vue/Nuxt app.

Test Plan

  • CI (build phpunit, build-client vitest 882 + lint + build, e2e-client Playwright) green on the branch head.
  • New coverage for every restored/added endpoint and flow (APIv2PublicProfileTest, APIv2DeviceUpdateAuthTest, APIv2EventVisibilityTest, SsoBridgeTest, AuthEndpointsTest, DashboardAddData.spec, e2e for dashboard/device/event flows).

Future Improvements

  • Remaining non-API web surface (see 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/v2 endpoint (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 hardening backlog documented in 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.

edwh added 30 commits July 15, 2026 20:46
…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
edwh and others added 16 commits July 21, 2026 15:28
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
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
E Security Rating on New Code (required ≥ A)
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

💡 Need a hand with PR review? Try Gitar by Sonar!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

preview Deploy a Fly.io preview for this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants