Skip to content

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

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

Nuxt 4 SPA migration: Laravel becomes API-only#898
edwh wants to merge 349 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 and others added 30 commits July 16, 2026 02:23
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.
SKIP_NPM_INSTALL leaves no Vite manifest, so the Blade homepage the shared
readiness check curled 500s (ViteManifestNotFoundException) — the same trap
class the Phase F audit flagged for the cutover. The wait-for-core command
gains a probe parameter; the build job points it at /api/v2/session.
The npm-less build job exposed two couplings: 13 legacy page-render tests
throw ViteManifestNotFoundException without a built manifest (they assert
content, not asset tags — withoutVite() in TestCase::setUp removes the
dependency; these tests die at Phase F anyway), and phpunit starting ~12
minutes earlier hit a cold Discourse whose 5s-timeout retries made every
integration-adjacent test crawl (82 tests in 45 min). The build job now
waits for the Discourse web app non-fatally before phpunit.

Local phpunit verification deferred until the e2e agent releases the shared
DB; CI verifies in isolation meanwhile.
notifications.js moves to global/js/misc (canonical, survives cutover) with
a temporary annotated copy at the old path — vite's define global->window
shim (for select2) mangles any import path containing the token 'global'
from resources/js, and that tree dies at F anyway. box-shadows moves into
global/css/helpers; _cards.scss gets a local copy of the legacy fixometer
variables instead of reaching into resources/sass. Legacy vite build
verified green (also surfaced + fixed stale container deps from the #887
merge: leaflet-control-geocoder).
StatsShare rewritten as a vanilla self-initializing module under
resources/global/js/widgets (own vite entry — the partner iframe shouldn't
load the general global bundle). Translations baked server-side into
data-t-* attributes by the Blade partial. Bug-for-bug compatible with the
Vue original by design (fontSize switch fallthrough; emHeightAscent no-op
highlight box) — both documented in-code as candidates for a deliberate
follow-up, not silent changes to a live partner embed. leaf.blade.php no
longer references resources/js/app.js.

Ported by the statsshare-port agent; build re-verified (9.15kB chunk).
…ockfile repair

Closes the gap the redirector work exposed: unconsented users (admin-created,
pre-2018 accounts) had no path to record consents once the Blade form became
a redirector. pages/user/consent.vue posts to /api/v2/auth/consent (reusing
the registration lang strings) and auth.global routes consent.given===false
users there — strict false check so a still-loading session never redirects.
812 vitest green.

Also: vite.server.allowedHosts fixes the container-hostname rejection that
silently stalled e2e iteration for hours, and the client lockfile is
regenerated with STANDARD npm resolution — --legacy-peer-deps (a root-repo
necessity) drops peer deps like @popperjs/core and produces npm-ci-
incompatible lockfiles for the clean-npm client (third drift incident,
root cause closed).
Ported event/device/admin/grouptags flows (~46 tests; grouptags keeps the
legacy 34-test permission matrix incl. the 4 pure-API checks). Specs
authored by the phase-cde-e2e agent; verification and fixes completed
after takeover.

Client bugs found by these flows, all fixed with unit coverage:
- No cold-boot session bootstrap: full page reloads rendered logged-out
  (plugins/session.ts, awaited pre-paint).
- Event-page permissions derived from the dashboard's capped your_groups:
  stale for new groups; now GET /api/v2/users/me/groups via the groups
  store.
- hide-footer is not a bootstrap-vue-next prop: every modal (10 across 8
  components) rendered a duplicate default footer; now no-footer.
- DeviceForm sent null for empty optionals; the endpoint's validation
  (2025, pre-branch) requires omission — legacy client provably omitted
  falsy keys (store/devices.js FormData truthiness guard).
- Forced device-list refreshes swapped the table for a spinner, unmounting
  the row being edited (photo upload closed the edit form); panel now
  stale-while-revalidates.
- Category suggestion only reacted to typing, not to datasets arriving
  after; split watchers (user edits may clear, data arrival only fills).
- Suggestion e2e is self-seeding (test DBs have no production item-type
  mappings; the legacy spec was CI-excluded for this).
- e2e URL matchers: endsWith broke on BaseAPI's locale param.
… green)

user/reset + user/recover (GET only now; the API owns submission),
user/register/{hash?} (invite_hash carried through), group/party
accept-invite (DB semantics unchanged, redirect + joined/invalid query
flags replace session flash), group/party invite/{code} (session-array flow
gone entirely — the SPA claim endpoint owns it). Party::shareable_link now
points at the frontend like Group's. AcceptUserInvites middleware is now
provably unreachable (removed at F proper).

Authored by the f-redirectors agent; verified after e2e completion (two
route-removed tests needed withExceptionHandling for their 405 assertions).
The accept-invite redirectors replaced Laravel session flashes with query
flags, which left groups.invite_confirmed unused in Laravel code and broke
testCheckTranslations. The checker now scans client/app and client/e2e so
SPA t() usage counts. The group and party view pages now toast on
?joined=1 / ?invite=invalid and strip the params, restoring the legacy
flash messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzRkdRa6w1P3ZXKH5pGDh5
The two delete tests inline the flow to assert on tag-delete-warning
mid-way, so the helper was never called.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzRkdRa6w1P3ZXKH5pGDh5
fetchForEvent and addDevice created a plain entry object, assigned it
into byEvent, then kept mutating the plain reference - those writes
bypass Vue's proxy, so subscribers were never notified. Whether the
event page's devices panel left its loading skeleton depended on the
fetch beating the panel's first render: the recurring spare-parts /
add-device e2e flake (deterministic 6-minute timeouts on slow CI).
Assign the fallback into state first and read the proxy back. New spec
pins the notification, not just the post-await value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzRkdRa6w1P3ZXKH5pGDh5
/group/map as its own route/tab: markers for every non-archived group
from the names index (clustered, leaflet.markercluster), visible rows
hydrated via GET /api/v2/groups/summary?ids= chunked at the API's
200-id cap - the split-fetch architecture from PR #887. Photon place
search (leaflet-control-geocoder), CARTO tiles, row<->marker hover
linking in GroupsTable, and pure geometry helpers split into a
composable for unit testing. Deviations from legacy documented in
GroupMap.vue (no initial user-area bounds - no v2 source; marker popup
links to /group/view instead of the modal). 53 new vitest cases plus a
groupmap e2e spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzRkdRa6w1P3ZXKH5pGDh5
edwh and others added 29 commits July 21, 2026 15:31
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
First step on the 105-item parity audit. RC1 - the largest coordinated cause -
is that vue-multiselect is Vue 2 only, so one form substituted a native
<select multiple>, and every later form cited that one as precedent. Seven
controls diverged from a decision nobody ever took deliberately.

Freegle's Vue 3 client hit the same wall and answered it by building its own
control on bootstrap-vue-next primitives (components/AutoComplete.vue) rather
than taking another dependency, so this follows that pattern rather than
inventing a third approach.

TagMultiselect reuses vue-multiselect's class names on purpose:
develop styles .multiselect__tags/.multiselect__option/--highlight directly in
resources/sass/_global.scss:515+ ("we want to make them look the same" as
select2), so reusing the names makes that porting meaningful instead of
inventing a parallel look. _multiselect.scss ports those rules - square
corners, 2px #222 border, 3px when active, $brand-muted highlight - together
with the geometry the library used to supply. $brand-muted (#6c757d) added to
_variables.scss from develop's.

The invite modal now uses it for both pickers, as develop does: group members
by name, and a taggable free-text box for manual addresses. Malformed
addresses show as danger-filled chips before submit rather than only as an
error line after it - develop flags them the same way via has-invalid-email.

Behaviours a native <select multiple> could not offer at all, now tested:
individually removable chips, type-to-filter, enter-to-select, free text,
backspace-to-remove-last.

Remaining substitutions (EventForm, EventFilters, DeviceForm, GroupsTable
filters, DashboardAddData) still to convert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
The highest-severity item in the parity audit, and a consent gap rather than a
styling one: "Cookie settings" was a NuxtLink to /about/cookie-policy, so a
visitor could accept everything or read an article - there was no way to
decline analytics. Consent was a single boolean in localStorage.

develop's gdpr-cookie-notice opens a modal listing each category with its own
switch and a Save button. /about/cookie-policy is a SEPARATE "statement" link
inside that modal, not the destination of the settings control - we had wired
the secondary link in place of the primary one.

Now: a settings dialog with Essential (listed, "Always on", no switch, because
the site cannot work without it), Performance, Analytics and Marketing
toggles, and a Save that persists the choice. The OK button keeps develop's
accept-all defaults - marketing off, everything else on.

Stored as develop's cookie, not localStorage, keeping its exact name
(gdprcookienotice), shape and key spellings - including "performace", which is
misspelled in develop. Renaming it would silently discard the stored
preference of every visitor who set one on the legacy site, and the retained
Blade widgets read that cookie: header.blade.php derives
window.restarters.analyticsCookieEnabled from it to gate Matomo and Sentry.
useCookieConsent now exposes analyticsEnabled for the same purpose here.

Strings taken verbatim from develop's gdpr-cookie-notice locale, translated
for fr/fr-BE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
Controls that looked right and did the wrong thing - the class a screenshot
diff cannot catch, because the pixels match and the behaviour does not.

1. EventCard offered a live RSVP/cancel button on events that had already
   happened. GroupEventsScrollTableActions.vue gates the whole actions cell on
   `v-else-if="upcoming"`, and for an attendee shows static bold "You're
   going!" text - there is no un-RSVP from this table at all. Two tests
   asserted our version, including one I added earlier, so they pinned the
   divergence rather than develop's behaviour; both corrected.

2. The dashboard's nearby-groups "Join" button navigated to the group page
   instead of joining. Its comment said the join endpoint "lands with
   B2/B4-B5"; POST /api/v2/groups/{id}/members/me has existed since then and
   groupsStore.join() already wraps it. A button labelled Join that does not
   join.

3. Password reset ended on an invented in-page success alert with no route
   back to the sign-in form. UserController::reset() redirects to /login with
   the passwords.updated flash; login now shows that flash on arrival.

4. Nearby-group join and reset both needed their specs updating rather than
   worked around - the join spec asserted an href, which was the bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
1. Shareable-link invites sent logged-out visitors to /login.
   confirmCodeInvite redirects to /user/register with the
   auth.login_before_using_shareable_link banner - a shareable link normally
   goes to someone with no account yet, so the sign-in form was the wrong step
   first. The banner still offers the login route.

   The success message also reused groups.invite_confirmed, develop's text for
   the unrelated email-hash accept-invite flow, and named nothing. develop
   flashes groups/events.you_have_joined - "You have joined <linked name>".
   The claim endpoint now returns the group/event name so that message can be
   rendered. already_member stays as our own string: develop has none here,
   and inventing linked copy to match would repeat the mistake this audit was
   about.

2. The navbar showed the full wordmark at every width. navbar.blade.php swaps
   to the compact power mark below md (includes/logo vs includes/logo-plain),
   which is part of why the header clipped on a phone.

3. brands.vue offered delete. develop has no brand delete anywhere - no route,
   no control, no controller method - so the workflow was invented outright.
   allow-delete is now false for that page; AdminCrudTable keeps the
   capability for the pages that legitimately have it.

Recorded as accepted deviations rather than reverted:
- Uppy for profile photo and network logo, where develop has a plain file
  input. Standing instruction from the product owner: all photo uploads go
  through Uppy.
- AdminCrudTable's delete confirmation. develop deletes on an unconfirmed GET
  link; removing a confirmation step from a destructive action to match that
  is not a sensible reading of parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
GroupEventScrollTable.vue is a b-table with every column sortable. Only the
date header sorted here, so the stats columns restored in b1877c9 were
decorative - a host could see participants or CO2 per event but not order by
them, which is most of why you would look at the table.

All columns now sort, first click ascending and the active one flipping,
matching b-table. Missing stats sort as -1 rather than 0: "not recorded" and
"a recorded zero" are different, and interleaving them would misreport which
events had nothing logged.

Caught while wiring it: the existing header's `@click="toggleSort"` passed the
MouseEvent as the new sort key once toggleSort took an argument, which
silently broke sorting entirely. Its test failed immediately, which is the
only reason it did not ship.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
The bulk of the 105-item parity audit, fixed in twelve concurrent batches with
disjoint file ownership, then a single strings pass (sole owner of lang/**) and
a verification pass. Every fix was made by reading develop's actual markup/CSS
for the element and matching it - the discipline whose absence produced these
deviations in the first place.

Highlights by root cause:

RC1 control-substitution: the native controls that stood in for develop's
vue-multiselect are now TagMultiselect (DashboardAddData, EventForm,
EventFilters, GroupsTableFilters, DeviceForm). Date filters use
vue-datepicker-next, the same library Freegle uses and which we already depend
on. Every string these needed was taken verbatim from develop's lang files, not
invented.

RC2 orphaned CSS / rewritten markup: doodle icons restored to their native SVG
dimensions (develop sizes most of them not at all); Talk-topic comic-card
spacing and hover box-shadow restored from DiscourseTopic.vue; row dividers
given develop's solid-black hr instead of Bootstrap grey (with the !important
BS5's border utilities require); onboarding modal restyled from _onboarding.scss
against its own class names.

RC3 invented strings replaced with develop's verbatim: forbidden.title,
moderate_approve_help (both traced to develop source - forbidden.blade.php and
EventAddEdit.vue:84), and 17 keys added rather than made up.

RC4 dropped behaviours restored: whole-row click targets, the onboarding close X
gated to the last slide, Previous hidden not disabled on the first.

Two dead keys removed, both now genuinely unreferenced:
- dashboard.getting_the_most_intro - this reverts my own earlier commit
  3b9d4ce, which added it as "visible at all widths". It is not: develop wraps
  it in a d-none d-md-block <p> whose only child is d-inline d-md-none - mobile
  content in a desktop-only container, invisible at every width. The identical
  dead-markup pattern I correctly caught for sidebar_help in the same file and
  then missed here.
- admin.role_permissions_help - develop's role-edit modal uses a literal
  untranslated paragraph; no lang key is referenced there.

Tests: several specs asserted our old (wrong) behaviour and were corrected to
develop's, not weakened - the onboarding close-on-last-slide test, the
whole-row-link test, and the native-select-to-TagMultiselect driver changes.
Full client suite 1568 passing; the 2 failures are the known useStatsShareImage
container-mount artifact that passes in CI. translations:check exits 0, eslint
0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
…in the 88 fixes

An exhaustive verification pass re-read all 88 fixes from ccc4b84 against
develop. 77 matched; 11 diverged - the fix had invented or mis-copied a value
and shipped it under a plausible note, which is exactly the failure mode the
whole parity effort was cleaning up, recurring in the cleanup. Sampling would
have missed all 11.

Corrected against develop's actual source:
- DashboardOnboardingModal: nav buttons bottom:25px/left:40px/right:40px and
  finish bottom:32px (were 0/0); slide dots 15x15px #81CAD3 active #000 (were
  8px #d8d8d8/#222); modal max-width 615px at lg (was ~500). From
  _onboarding.scss and _slideshow.scss.
- RichTextEditor: pasted <img>/<table>/<iframe> were embedded; develop's
  quill-paste-smart strips every tag outside [a,b,strong,u,s,i,p,br,ul,ol,li,
  span,h4,h5,h6]. Added the tag allowlist matcher (text kept, embed dropped).
  Security-relevant - this was a real sanitisation hole, not just styling.
- DevicesSearchTable brand filter: native <datalist> replaced with the custom
  suggestion panel DeviceForm already built and the codebase deliberately
  chose over datalist for parity with vue-typeahead-bootstrap.
- LocaleSwitcher: restored _languages.scss's position:fixed;bottom:0 on the
  login/guest layout with 65px page padding.
- AppNotifications: coloured pill badges (Patua One, badge-left/right padding)
  from _badge.scss, were transparent.
- CalendarsTab: the .span-vertically-align-middle class was referenced in
  markup but never ported - added it (and the cog before "All events").
- networks/[id] coordinator card border #000 -> #222 and hover colour.
- AdminCrudTable .edit-panel__device padding rule from _edit.scss.
- Two *-icon-brand.svg assets were missing from client/public entirely, so
  correct markup rendered a broken image - copied from the Laravel public dir.

NOT applied - recorded accepted non-match: category.vue weight/footprint/name
column labels. develop's CategoriesTable.vue hardcodes English headers
bypassing its own lang file; matching literally would delete our fr/fr-BE.
Reverted the batch's name->'Name' change and its test to keep all three
translated, consistent with 1eb9863.

Full client suite 1569 passing (2 known useStatsShareImage container-mount
failures), translations:check 0, eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
e2e-client was timing out - not slowly, but hanging. grouptags.test.js's
network-tag helpers waited 360s for getByTestId('tag-add-button'), never found
it, retried, and blew the whole job's wall clock, taking all e2e signal down
with it.

Root cause, and it is NOT this session's parity work: the network-tag section
was written for a modal CRUD UI (tag-add-button -> tag-create-modal ->
tag-create-submit, tag-edit-modal, tag-delete-modal) with testid-prefix "tag".
The actual component, NetworkTagsManager.vue, has always been a bespoke
inline create form (network-tags-create-*) plus edit/delete modals
(network-tags-edit-*/network-tags-delete-*). git log --all -S "tag-add-button"
finds it nowhere in client/app - it never existed. My one touch of this file
(ccc4b84) changed two icons and a background colour, no testids; the mismatch
predates the whole migration branch, which is why e2e-client was already
timing out on pipeline #4894.

Rewrote the network-tag helpers and test bodies to drive the component that
actually renders - the same fix applied to party/index.spec.js earlier this
session: address the real control, not one that was never built. Create is an
always-visible inline form (no add-button, no create-modal - the redundant
"wait for create modal to close" assertions after each create are removed).
Edit and delete are modals, remapped to their real testids. Added the one
missing testid the flow needed: network-tags-delete-cancel on the delete
modal's cancel button.

Every remapped testid verified present in NetworkTagsManager.vue /
networks/[id].vue; the create/update/delete API URLs and methods the helpers
wait on already matched the component's store actions. eslint clean.

NOT run locally: a background job holds the shared test DB, and running
Playwright into it would corrupt both. CI validates the e2e; global-tag and
group-tag helpers in the same file were already correct and untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
Four issues from the preview, all confirmed against develop.

1. Dead session shows a dashboard error instead of redirecting to login.
   The console evidence (GET /api/v2/users/me/events 401, "/" redirecting to
   /dashboard) is a token present in localStorage - loggedIn (=!!token) true,
   so the guard admits it - while the session behind it is invalid, common on
   a preview whose DB was reset so the token maps to a deleted user. BaseAPI
   only clears auth on a /session *401*; a 500, or a /session that returns a
   null user for a bad token, left the token in place. The cold-boot session
   plugin now clears the token on ANY failed resolve OR a null-user response,
   so the whole app - guard, navbar, the "/" redirect - consistently treats it
   as a guest and routes to /login. Two tests pin this.

2. Logged-out Sign in / Join buttons. develop writes plain anchors with inline
   styles the .btn classes were fighting: square corners (we rendered a pill
   radius), a 10px gap between them (they were touching), Sign in white with a
   2px black border, Join solid black. All re-asserted to match, sentence case
   per develop's text-transform: initial.

3. Language bar was not full width - AppFooter wrapped it in Bootstrap's
   .container, capping it to the content max-width. Removed; the bar is now
   width:100% with develop's top border, matching _languages.scss.

4. Language bar sat mid-page on short pages instead of pinned to the bottom.
   develop sets <body class="fixed-layout"> in BOTH header.blade.php (app
   pages) and header_plain.blade.php (guest), and .fixed-layout .language-bar
   is position:fixed;bottom:0;z-index:10 - i.e. pinned on every page, with the
   body padded 65px so it never covers content. An earlier fix scoped this to
   guest pages only; generalised it to every page as develop does.

The dashboard error box (#2 of the reported set) is left as-is: once the dead
session redirects to /login it no longer appears in that flow, and develop
server-renders the dashboard so there is no SPA error box to match against.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
When no groups are in the map view, the count line read "There are 0 groups.
Zoom out to see more." - technically true but a dead end. It now reads "If you
can't see any here yet, why not find a group near you?", with "find a group"
linking to /group/nearby.

Not a develop parity change - develop has no group_count_map string at all,
this whole count line is ours - so this is a UX improvement, and the friendly
copy plus link is the requested behaviour.

The link uses <i18n-t> with a #link slot rather than a raw <a> in v-html: it
keeps the link's position translatable (it moves in the French sentence) and
gives a real client-side NuxtLink instead of a full page reload. Same pattern
as party/edit's "Editing <name>" heading. Non-zero counts keep the existing
pluralised count string. fr/fr-BE added, using the map's own "Repair Cafe"
wording for the link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
Two correctness fixes from the RES-1995 map-of-groups work (PR 887), still
unmerged to develop, that the nuxt branch's own rewrites had dropped.

1. GroupSummary next_event dropped the approved filter. The base intent -
   Group::getNextUpcomingEvent - selects the next event WHERE approved=true.
   The bulk-cached summary rewrite (for the map, which fetches many groups at
   once) cached Party::future()->get() with no approval filter, so a
   pending-moderation event could surface as a group's public next event. Now
   filters approved=true, and the cache key is future_approved_events to match
   (the old key would otherwise serve stale all-events data). The comment
   already said "next approved event" - the code just didn't.

2. Party::scopeFuture now reorder()s before its orderBy. orderBy appends, so a
   parent scope's existing ORDER BY (e.g. DESC) stacked into
   "... DESC, ... ASC" with DESC winning, making future() return the LAST
   future event first - so a next-event lookup picked the wrong one.

Test asserts an unapproved future event does NOT become a group's next_event;
the existing tests only checked approved ones appear. The stale
future_events cache key in the test is updated to the new name.

Not runnable locally right now (a background job holds the shared test DB);
CI validates. develop is exactly at the 887/nuxt merge-base, so 887 has not
landed there yet - this brings its fix into 898 early rather than regressing
the map when it does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
PR 887 (RES-1995 map of groups) is unmerged and develop sits exactly at its
merge-base, so its wording never reached the nuxt branch - and my ac4f5a5
"drift" sync had actually reverted some of it toward the stale develop values.
This brings 887's group/map copy into 898.

Filter bar (used by list and map): search placeholders -> "Search by name",
"Search by location", "Filter by country", "Filter by tags"; "Show filters" /
"Hide filters" (sentence case). This reverses the direction of ac4f5a5 for
these keys - 887, not the current develop, is where the project moved.

Nearby/find tab: groups_title2 "Other groups nearby" -> "Find a group".

Map count line: adopts 887's wording - "There is N group in this area. Search
and zoom to find more." - and its zero-state group_count_none ("...why not
find a group near you?", linking to /group/nearby). My last two commits had
built ad-hoc versions of exactly these (group_count_map_empty, find_a_group,
the i18n-t/NuxtLink zero-state); those keys are removed and the map page now
uses 887's group_count_none verbatim, rendered via v-html since 887 carries
the link inline. The rendered text is what those two commits already showed -
this just converges on 887's actual keys so the two branches don't collide in
lang/groups.php when both reach develop.

Deliberately NOT changed: /group/all's own group_count stays a plain "There
are N groups." - 887's "in this area, search and zoom" belongs to the map,
and 898 split develop's single /group page into separate list/map/nearby
routes where that wording would be wrong on the list. groups_title2_mobile is
not added: 898 references it nowhere and translations:check rejects unused
keys.

fr/fr-BE taken from 887 verbatim (it uses "Repair Cafe" for groups on these
pages). Specs asserting the old labels updated to 887's; full client suite
1572 passing (2 known useStatsShareImage container failures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
The one 887 feature 898 had deliberately dropped: clicking a map marker opens
a modal with the group's next event and a "Go to group" action, rather than a
bare popup link. 898's comment said a Vue modal "isn't reachable from inside a
marker's click handler without extra plumbing" - this is that plumbing.

The seam: markers are imperative Leaflet layers (for clustering), so
GroupMap's marker click emits `select` with the group id; the page turns that
into a GroupInfoModal for the selected group's summary, and clears it on
close. GroupInfoModal ports develop's component to Vue 3 / bootstrap-vue-next:
header (image + name + location) linked to the group, "Next event: <date>
<title>" or "None planned", and Go to group / Close. Date formatted exactly
like GroupsTable's dateLabel so the map and list agree. The redundant marker
popup is removed (887 has the modal, not both), which also drops the now-unused
escapeHtml helper.

Built test-first: GroupInfoModal.spec (6 - render/next-event/none-planned/goto/
close), GroupMap.spec (marker click emits select with the id), map.spec (select
opens the modal for that group; close clears it). Each failed before the
implementation. goto_group/next_event lang keys added from 887 (fr/fr-BE too).

Full client suite 154 files, only the 2 known useStatsShareImage container
failures; eslint clean; translations:check 0. The backend approved-next-event
fix (c51f096) is confirmed live: GET /groups/summary?includeNextEvent returns
the seeded approved future event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
The marker-info modal showed the location as {{ group.location }}, but the
summary API returns location as a GroupLocation object ({location, area,
postcode, country, country_code, lat, lng}) - so the modal leaked the whole
JSON blob into the header. develop's GroupInfoModal renders
group.location.location; match it. The unit fixture used a plain string so
the bug never surfaced in tests; browser verification (WSL chromium, GPU
disabled) caught it. Fixture now uses the real object shape and asserts no
raw object leaks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@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