# Autobot Loop State

> **LOOP STOPPED by user after iteration 24** (2026-06-27). Final teardown done: browser closed, sleep
> timer cancelled, all test data purged (vouchers/tournaments/comments/notifications/avatar_config/follows
> reverted; player gp_balance=5000), trees clean, touched suites green (50 passed). Dev server on :8765
> left running (pre-existing, the user's own instance — not killed). To resume: re-run /autobot; re-read
> SERVICES.md + this file; browser session was last ADMIN. Branch self-loop/hardening @ c3efd0a.

Branch: `self-loop/hardening` (commits go here, not master — this is the dedicated hardening branch).

## Status per repo
- **arcade (primary):** GREEN — 1720 passed / 7207 assertions (~58s). Pint clean on touched files.
  Frontend: `tsc --noEmit` clean, eslint clean, `vite build` OK (1835 modules). Playwright MCP now
  connected — UI layer exercised for the first time this run (iter 7).

## Iteration count
- 24 completed (iter 20/22/23 = verification passes, no commit; iter 21/24 = fixes).
- Browser session is now ADMIN (admin@arcade.local) — logged out player + in as admin in iter 24 for
  dashboard work. Future iters: switch via fetch POST /logout (with XSRF header) then login form.

## Process notes
- **Cleanup must include DOWNSTREAM side effects, not just the primary row.** Iter 20's voucher-redeem
  test was reverted (tx/redemption/voucher/balance) but the GP grant had ALSO created a UserNotification
  ('+20 GP / Voucher: AUTOBOT20') — missed, found lingering in iter 23. When a test action fires a service
  with side effects (GP/EXP grant -> notification + exp_events + audit_log; follow -> notification; etc.),
  clean up ALL of them. Safer: prefer reversible/transactional test paths, or note created IDs to purge.
- Commit messages via `git commit -m "..."` (double-quoted): NEVER use backticks — the shell runs the
  backtick content as a command (substitution), silently deleting it from the message. Use plain quotes
  or single-quote the word. (Bit iter 16: a backticked word vanished from the commit body — cosmetic only.)
- MCP `browser_type`/`.fill()` updates React controlled inputs; raw native-setter+input event does NOT.
- After an optimistic axios action, wait ~1.5s before asserting; don't rapid-fire two toggles in one
  evaluate (the 2nd reads before the 1st settles).
- Do NOT instruct the implementer to run `prettier --write` on files the repo hasn't kept prettier-clean.

## Theme
Concurrency + security hardening (check-then-act races, idempotency, validation, auth).

## History
- **Iter 1 (GREEN→hardening):** `fix(favorites)` 53f4630 — favorite() toggle counted+checked+inserted
  without a lock; concurrent requests could exceed plan favorite_limit / 500 on unique constraint.
  Wrapped in DB::transaction + User::lockForUpdate; moved EXP award outside the tx. Verified 12 tests pass.
- **Iter 2 (hardening):** `fix(scores)` 2d881c9 — submitScore read max(score)→computed isPb→cleared old
  PB flag→inserted, all unlocked; concurrent PB-qualifying submits could flag two rows
  is_personal_best=true and double-award EXP/leaderboard/tournament. Wrapped read→clear→insert in
  DB::transaction + User::lockForUpdate (mirrors favorite()); side effects stay outside. Added
  ascending-sequence regression test (one PB at highest score). Verified 65 tests pass across
  score/leaderboard/tournament/exp suites (2.23s).
- **Iter 3 (hardening):** `fix(follow)` 1670d07 — follow() did exists()-then-insert() unlocked;
  concurrent double-tap → 500 on unique(follower_id,following_id) + double EXP/notify. Replaced with
  atomic insertOrIgnore, gated EXP award + new_follower notification on affected-row count (fires once).
  Regression test asserts double follow → 1 row / 1 follow_user exp_event / 1 new_follower notification.
  Verified 34 tests pass across follow/notification/exp suites (1.16s).
- **Iter 4 (hardening):** `fix(ratings)` f3b9a49 — rate() did first()-then-insert/update unlocked;
  concurrent first-rate → 500 on unique(game_id,user_id) + double rate_game EXP. Replaced with atomic
  insertOrIgnore + conditional update; $existed derived from affected-row count so EXP fires only on a
  genuine first rate. Regression test asserts rate + re-rate → 1 row (rating 5) / 1 rate_game exp_event.
  Verified 39 tests pass across rating/aggregate/community-gate/repo suites (1.53s).
- **Iter 5 (scout→fix):** `fix(exp)` 0f88a8a — ExpService::award() read users.total_exp_earned/level
  outside the lock, wrote inside the tx; concurrent EXP awards for same user (play+rate+score across
  tabs) lost updates → corrupted total_exp_earned/level/exp_points (core economy). Moved cooldown check
  + totals read + exp_events insert + users update into one DB::transaction behind User::lockForUpdate
  (GpService pattern). Added ledger-invariant test (SUM(exp_events)==total_exp_earned). FULL SUITE
  re-run: 1720 passed / 7207 assertions (58s), no regressions (core service touched).
  Scout confirmed SAFE: TournamentService::register (locks tournament+user in tx);
  submitScoreForGame (atomic where('score','<',$score)->lockForUpdate()->update);
  Shop/Voucher/Gp services already lock per CLAUDE.md invariants.

- **Iter 6 (hardening):** `fix(play)` 5fe2cdc — PlayController did isFirstEver()→touch() check-then-act;
  first_play has cooldown=0 (config/exp.php) so ExpService can't dedup → concurrent first plays both
  awarded the 10-EXP one-time bonus. Made LastPlayedRepository::touch() atomic via insertOrIgnore on
  unique(user_id,game_id), returning whether THIS call inserted the first-ever row; gated first_play on
  that; removed now-unused isFirstEver(). Regression test: first_play awards once across repeated views.
  Verified 27 tests pass across play/exp/profile suites (0.84s).

- **Iter 7 (UI layer — GREEN→hardening):** `autobot: iteration 7` eeddf07 — Playwright MCP now connected.
  Drove the play page (`/play/halloween-chain`): home + play loaded with 0 console errors; play page
  emitted 1 console WARNING per load — "Unrecognized feature: 'pointer-lock'". Root cause: GamePlayer.tsx
  iframe `allow="autoplay; fullscreen; gamepad; pointer-lock"` — `pointer-lock` is NOT a valid
  Permissions-Policy feature token, so Chromium ignores it AND warns. Pointer lock was already correctly
  granted by the `sandbox` `allow-pointer-lock` token on the next line (untouched), and
  GameController.php:304 already omits it from `allow` (was already correct). Removed the bogus token from
  the `allow` attribute only. Rebuilt (vite, 1835 modules), re-navigated in-browser: play page now loads
  with 0 warnings / 0 errors. Mobile 375px responsive snapshot of play page: clean (search→icon, iframe
  16:9, content stacks). Delegated edit to autobot-implement (Sonnet); eslint + tsc clean.

- **Iter 8 (data/validation — GREEN→hardening):** `autobot: iteration 8` 80bb7bc — UI pass #2 started on
  the public SEARCH flow. Empty-state (`?q=<no-match>`) renders cleanly (clear msg + hint, filters kept),
  0 console errors/warnings; 375px responsive fine. Then probed input bounds: SearchController derived
  `$q = trim(query('q'))` with NO length cap, feeding the verbatim string to SearchQueryLogger (writes to
  `search_queries.query` TEXT) and GameRepository LIKE. On prod MySQL a >64KB `q` overflows TEXT (65535B
  max) → "Data too long" → HTTP 500 on the PUBLIC unauthenticated endpoint; long queries also write
  unbounded rows polluting Top Searches. Reproduced via tinker (600-char query stored verbatim). Fix:
  capped `$q` at controller boundary to 100 chars (self::MAX_QUERY_LENGTH) in index() + suggest(). Added
  regression test (250→100). Verified: 28 tests green (Search+SEO), pint clean, live `/search?q=<250>` →
  200 with 100-char stored row. Delegated to autobot-implement (Sonnet). Dev DB test rows cleaned.

- **Iter 9 (UI auth flow — GREEN→hardening):** `autobot: iteration 9` a151377 — UI pass #3. Logged in via
  browser as seeded `player@arcade.local` / `password` (gp=5000; other seeded: admin@arcade.local,
  author@arcade.local, all pw `password`). Drove WALLET (balance, voucher redeem, GP packs, tx history),
  INVENTORY (empty-state OK), SHOP (items + PageNav; image-less utility items show a "?" placeholder —
  cosmetic, not a bug). All 0 console errors, responsive OK. Scrutinized the wallet voucher error-state:
  initially looked like "red border, no error msg", but that was a FALSE ALARM — the red ring is just the
  focus ring (active color-theme=red), and my MCP browser_click wasn't registering on the button (harness
  quirk). Once the form actually submitted (via requestSubmit), it returns 302 and renders "That voucher
  code is not valid." correctly. LESSON: confirm the POST actually fires (check network) before calling a
  missing-feedback a bug. No UI bug found. Productive GREEN change instead: the public /search/suggest
  typeahead (capped in iter 8) had ZERO tests — added 3 covering its contract (2+char minimal JSON rows;
  <2char short-circuit to {"data":[]}; 8-result cap). 8 SearchTest tests green, pint clean. Test-only.

- **Iter 10 (UI pagination — GREEN→hardening):** `autobot: iteration 10` bc9763c — UI pass #4, global
  leaderboard (`/leaderboard`; note the URL is `/leaderboard`, NOT `/leaderboard/global` — the latter is
  the route NAME). Verified in-browser: renders podium (top 3) + 50/page table (103 users → 3 pages);
  PageNav boundaries correct (Prev disabled p1, Next disabled p3 with rows 101-103). Probed user-controlled
  `?page=` param: 999/0/-1/abc/1e9/"1 OR 1" all return 200 (paginator clamps/empties — no 500, no
  injection). Minor nit: `?page=999` renders an empty table w/ no "no results" message, but NOT
  user-reachable (Next disables at last page; windowed PageNav never links past it) → left as-is.
  GlobalLeaderboardTest had no malformed-param regression test → added one (999/0/-1/abc → 200 + valid
  Inertia shape). 6 tests green (104 assertions), pint clean. Test-only.

- **Iter 11 (UI a11y):** `autobot: iteration 11` fe5d284 — a11y audit of HOME via Playwright MCP DOM
  inspection. Found: home page had NO h1 (outline started at h2 "Featured Games"; 32 headings, zero h1,
  no sr-only h1) — broken heading hierarchy for screen readers + missing h1 on the top page for SEO.
  (Other findings: no skip-to-content link [minor]; no nav landmark [moderate] — see backlog.
  Images/buttons/inputs all had accessible names; main/banner/contentinfo present.) Fixed: added
  visually-hidden `<h1 className="sr-only">{t('Free Online Games')}</h1>` as first child of the main
  container in BOTH theme Home pages (default + lagged) — reuses the SeoHead title key (no new i18n
  string). Rebuilt; verified in-browser: exactly 1 h1, sr-only (1x1 clipped, NOT display:none -> stays
  in a11y tree), outline now starts at H1, zero visual change. eslint+tsc clean.

- **Iter 12 (UI a11y):** `autobot: iteration 12` fe96124 — default theme PublicLayout had ZERO <nav>
  landmarks (lagged already has them). Wrapped the footer's 3 link-group columns (Browse/Account/Info)
  in `<nav className="contents">` (display:contents preserves the 4-col grid; bare nav, no aria-label /
  new i18n key — matches lagged convention). Verified in-browser: nav count 0->1, a11y tree exposes a
  `navigation` landmark inside `contentinfo` with the footer links, layout unchanged. eslint+tsc clean.
  PROCESS NOTE: the implementer's `prettier --write` reflowed ~150 unrelated lines — I REVERTED and
  re-applied a minimal +2-line wrap by hand. LESSON for future delegations: do NOT instruct the
  implementer to run `prettier --write` on a file the repo hasn't kept prettier-clean; it churns
  unrelated lines. Use eslint+tsc as the gate; format only the touched lines.

- **Iter 13 (data/validation):** `autobot: iteration 13` 0582a38 — submitScore() (GameInteractionController,
  POST /api/games/{id}/scores) validated user-posted `score_data` as only ['nullable','array'] — no bound
  on count/nesting/size — then json_encoded into the `game_scores.score_data` JSON column. Public authed
  endpoint fed by the game iframe postMessage bridge → tampered msg could persist arbitrary large blobs
  (DB bloat / storage abuse / memory). Same class as iter-8. Capped to <=50 top-level entries (max:50) +
  4096-byte encoded ceiling (self::MAX_SCORE_DATA_BYTES, closure rule), both → 422. Added 3 regression
  tests. Verified 48 tests green across 5 scoring-related suites (no regression), pint clean.

- **Iter 14 (UI profile audit + validation coverage):** `autobot: iteration 14` f050a8e — audited the
  PROFILE surface (Playwright MCP, logged in as player). Real profiles (/profile/molly78, /profile/player)
  render clean: 0 console errors, single h1 (display name — iter-11 didn't regress), footer nav landmark
  (iter-12 holds), graceful empty-activity ("No recent games yet"). Missing user (/profile/<bogus>) → 404
  HTTP + a friendly 404 page (h1 "404 — Not Found", back-to-home link, full layout + nav landmark; the
  lone console error is just the inherent 404-status doc log, not a JS error). Public profile-update
  validation (Public/SettingsUpdateRequest) already bounded (bio max:2000, names max:255, country 64,
  locale 10). No bug. Productive GREEN change: UserSettingsTest covered only the happy path — added 3
  field-length regression tests (bio 2001 -> 422; first_name 256 -> 422; bio at 2000 limit -> accepted).
  11 tests green (51 assertions), pint clean. Test-only. NOTE: implementer correctly HALTED when my
  initial assertSuccessful() was wrong (endpoint returns 302); corrected to assertRedirect.

- **Iter 15 (UI BUG FIX — first real user-facing bug this run):** `autobot: iteration 15` 4a78480 —
  play-page comment post flow (logged in as player). REPRODUCED: posting a comment prepends it to the
  list + clears the textarea, but the "Comments (N)" heading stayed STALE (stuck at old count) until a
  full reload. Root cause: heading rendered the STATIC prop `initialComments.total` while the list is
  React state mutated on post/delete → count drifts. Fix (both theme Play pages): `commentTotal` state
  seeded from initialComments.total, reset in the store-previous-snapshot guard on prop change, +1 on
  submitComment, -1 (floor 0) on deleteComment, heading renders from it. Live-channel handlers + the
  `total > 0` sort-gate left untouched (pre-existing/out-of-scope). Verified in-browser: count now tracks
  the true DB count live on post (+1 per post, confirmed not double-increment via DB row count). eslint+
  tsc clean, build OK. Dev DB test comments cleaned. Comment validation already bounded (max:2000, and
  the textarea has maxlength=2000 client-side so over-long isn't typeable).

- **Iter 16 (UI BUG FIX — same class as 15):** `autobot: iteration 16` 0f54e0b — hunted the stale-derived
  -count pattern. Checked: play-page RATING aggregate is FINE (rate() re-sets aggregate from the POST
  response, line 316); FAVORITE is a boolean (no count on play page). Found it on the PROFILE page: clicking
  Follow flips the button to "Following" (persists) but the "Followers" stat renders the static prop
  stats.followers and stays stale until reload (reproduced: 0 stays 0; reload shows 1). Fix (both theme
  Profile pages): followerCount state seeded from stats.followers, +1 on follow / -1 (floor 0) on unfollow
  in toggleFollow(), Followers tile renders from it; Following tile (stats.following) untouched. No
  prop-sync guard (page remounts per-URL via key={key}, like the existing `following` state). Verified
  in-browser BOTH directions (follow ->1, unfollow ->0) live, 0 console errors. eslint+tsc clean, build OK.
  Dev DB follow cleaned (molly78 back to 0 followers).

- **Iter 17 (USER-REPORTED UI BUG):** `autobot: iteration 17` 5e14c3e — user noticed the /tournaments
  index page was narrower than other pages. Root cause: its container was `container mx-auto max-w-6xl`
  (cap 1152px) while the HEADER and the main browse pages (Home/Games/Leaderboard/Search) use full
  `container` (no max-w, up to 1536px@2xl). User confirmed the header width is correct. Removed `max-w-6xl`
  on BOTH theme Tournaments/Index pages. Verified in-browser at 1200px AND 1680px: content edges now align
  EXACTLY with the header inner container (was ~192px narrower per side at 1680). eslint clean, build OK.
  NOTE the wider width-grouping for future: full-width = Home/Games/Leaderboard/Search/Tournaments(now);
  constrained = Shop(max-w-6xl), Wallet/Inventory/Profile(max-w-5xl). Shop is also a card grid at
  max-w-6xl — possible same inconsistency, but user only flagged tournaments; left Shop as-is.

- **Iter 18 (APP-WIDE BUG FIX — highest-impact this run):** `autobot: iteration 18` f09c4e2 — while
  exploring tournaments, found a rejected registration (champions-cup is status=active but ended
  2026-06-13, so registrationOpen()=false -> 'Registration is closed.') produced NO user feedback. Traced
  it app-wide: server flash messages (back()->with success/error/warning/info) were NEVER toasting. Only
  pages that ALSO call toast.*() directly (settings: direct 'Profile updated') showed anything; the flash
  'Settings updated' was dropped. ROOT CAUSE: useFlashToast used useEffect deps [props.flash] (object
  identity), but Inertia keeps a STABLE props.flash reference across visits -> effect ran once at mount
  (empty) and never again. FIX: depend on the flash VALUES. Verified in-browser: failed registration now
  toasts 'Registration is closed.'; settings now also surfaces flash 'Settings updated'; direct toasts
  unaffected. Affects ALL flash feedback (public + dashboard, shared hook). eslint clean, build OK. The
  detail-page redirect flagged in iter 17 was a Playwright timing artifact (detail returns 200) — NOT a bug.

- **Iter 19 (UI polish — iter-18 follow-up):** `autobot: iteration 19` ffa9135 — iter 18 exposed a
  double-toast: the Settings page already toasts specific per-action messages (Profile/Password/Avatar/
  Cover updated) in each form's onSuccess, and UserSettingsController ALSO flashed back()->with('success').
  Pre-18 the flash no-op'd; post-18 it toasts too -> TWO success toasts per save. Removed the redundant
  SERVER flash from update/updatePassword/updateAvatar/updateCover (kept the more-specific client toasts;
  no test asserted the flash). Verified in-browser: settings save now shows ONE toast ('Profile updated').
  11 UserSettingsTest green, pint clean. NOTE: other pages with BOTH an onSuccess direct toast AND a
  server success flash may also double now — a future sweep could grep `onSuccess: () => toast.success`
  across pages and dedupe each against its controller's flash. Decision rule used: keep the more specific
  message, drop the duplicate (here: drop server flash since client msgs are more specific).

- **Iter 20 (VERIFICATION pass — no commit, all GREEN):** swept the double-toast pattern + validated the
  iter-18 flash fix across real flows. Findings, all HEALTHY:
  - Double-toast sweep: Settings was the ONLY double (fixed iter 19). Other direct toast.success calls are
    client-only (copy-link, score/EXP feedback via axios) or flash-only (no direct) — no other doubles.
  - Wallet VOUCHER REDEEM: created test voucher AUTOBOT20, redeemed in-browser → balance updated LIVE
    5000->5020 (useLiveGpBalance reflects the refreshed Inertia prop — no stale-count bug), single flash
    toast '+20 GP added to your wallet.' (iter-18 working). Reverted all (tx/redemption/voucher/balance).
  - Tournament REGISTRATION success path: created an open test tournament, registered → entry count
    incremented LIVE 0/50->1/50 (prevEntries sync guard works), button -> 'Registered' (disabled), single
    success toast 'Registered for tournament.' Reverted (entry + tournament deleted).
  No bug found → no code change (loop allows no-commit iterations). iter-18 fix validated on economy +
  tournament flows.

- **Iter 21 (BLOG surface + a11y fix):** `autobot: iteration 21` 19cd9da — explored the blog (fresh
  surface). Listing + article pages healthy: single h1, footer nav landmark, 0 console errors;
  ArticleComments correctly tracks its count in STATE (setTotal +1 post / -1 delete — NO stale-count bug,
  unlike the play page; verified in-browser: 'Comments (0)'->'(1)' live on post). Blog comment endpoint
  already tested (ArticleCommentTest). FOUND + FIXED an a11y issue: the article page has TWO nav landmarks
  (in-content breadcrumb Home/Blog/article + footer nav), both UNLABELED -> ambiguous for screen readers.
  Added aria-label={t('Breadcrumb')} to the breadcrumb <nav> in both theme Blog/Show pages (the 'Breadcrumb'
  key already exists in navigation.json; lagged Play.tsx breadcrumb was already labeled this way -> these
  were just missed). Verified in-browser: breadcrumb now labeled, the 2 navs are distinct. (Note: Search's
  'filters' row matched the className but is a <div>, not a nav -> fine.)

- **Iter 22 (AVATAR BUILDER verification — no commit, HEALTHY):** thoroughly exercised the avatar builder
  (Settings > Media > Build avatar). Findings, all confirming it's solid:
  - Plan gate: free plan grants allow_avatar_builder=YES (demo seed), so player can save — correct. Gate
    is TESTED (AvatarControllerTest:76 '403s for users without the plan flag'; :85 admin bypass).
  - AvatarConfigValidator (app/Services): validates VALUE against owned+free part shop_item_ids (real
    security control — can't use unowned parts; TESTED). Probed via crafted POSTs: out-of-range/negative/
    non-int/200-key junk -> only valid owned/free part ids persist; invalid dropped.
  - Slot KEYS are intentionally OPAQUE (not validated vs subtypes) — confirmed by existing tests using
    arbitrary slot 'hat' (AvatarConfigValidatorTest:26,52). Adding slot-name validation would BREAK those
    tests + change intended design. The renderer (AvatarBuilderRenderer RENDER_ORDER) iterates only known
    subtype slots and looks up part by slot+id, so junk slots are ignored + wrong-subtype values fail-safe
    (render nothing). So junk-data has ZERO functional/security impact -> NOT a bug.
  No bug, no coverage gap. Cleaned up player's avatar_config (reverted to null/auto after crafted POSTs).

- **Iter 23 (NOTIFICATIONS verification — no commit, HEALTHY):** exercised the notification bell. The
  useUserNotifications hook tracks unreadCount in STATE correctly (+1 on new/broadcast, -1 on markAsRead
  [line 62], 0 on markAllAsRead [line 74]) — no stale-count bug (built right, like blog comments). The
  /api/notifications endpoints (index, mark-all-read, {id}/read) are TESTED (NotificationApiTest). Verified
  in-browser: bell aria-label 'Notifications, 1 unread', badge '1', panel renders; clicked Mark All Read ->
  badge cleared live (1->0) + aria-label -> 'Notifications'. App uses a CUSTOM UserNotification model/table
  (NOT Laravel's notifications table — that table doesn't exist; \$user->notifications() throws). No bug.
  ALSO cleaned an iter-20 leftover (the voucher GP-grant had created a UserNotification not purged in iter
  20 cleanup — see Process notes).

- **Iter 24 (DASHBOARD double-toast — broad fix):** `autobot: iteration 24` c3efd0a — logged in as ADMIN,
  explored /dashboard/vouchers. Empty-state + create-modal validation ('The Code field is required.')
  healthy. FOUND: creating a voucher showed TWO toasts ('Voucher created' [page onSuccess] + 'Voucher
  created.' [controller flash, surfaced by iter-18]). WIDESPREAD: 37 dashboard controllers flash success +
  37 dashboard pages toast in onSuccess + DashboardLayout mounts useFlashToast -> every dashboard CRUD save
  doubled post-18. (iter-19/20 sweep only covered resources/js/themes, NOT pages/Dashboard.) FIX: added a
  { success } option to useFlashToast (default true); DashboardLayout now calls useFlashToast({ success:
  false }) -> dashboard skips flash-SUCCESS (pages toast it themselves) but KEEPS flash error/warning/info.
  Keeping errors is required: demo-mode denials (ChecksDemoMode::denyInDemoMode) flash an error + force a
  full redirect so only that shows. Public layouts unchanged (call useFlashToast() with defaults). Verified
  in-browser: voucher create now ONE toast + appears in list. eslint clean, build OK.

## Candidate backlog (verify before fixing — ranked)
- Public-engagement races swept. Search index+suggest bounded&tested (8/9). Auth wallet/shop/inventory
  healthy (9). Global leaderboard pagination verified + param-hardened (10). Next passes:
  1. **UI layer (Playwright MCP) — CONTINUE.** (a) a11y pass: drive home/play with KEYBOARD only (Tab
     order, focus ring visibility, skip-to-content, roles/labels/alt); axe scan if available. (b) profile
     page states (own vs other user, empty activity, 404 for missing user). (c) settings page plan-gated
     upload hints (logged in as player — non-admin, so gates should show). (d) play-page COMMENTS post flow
     logged-in (was logged-out in iter 7). (e) per-game leaderboard `/leaderboard/game/{slug}` PageNav +
     the api_enabled=false 404 gate. Pick ONE.
  2. **Data/validation edge cases (CONTINUE)** — score_data array depth/size (submitScore is the big one:
     user-posted JSON via postMessage → verify the scoring request caps array size/depth); profile
     bio/username/social field bounds. `searchActiveForPicker` term LIKE uncapped (LOW sev, admin-only).
  3. **GP-style ledger-invariant tests** for already-locked economies (GpService/inventory) — SUM-equals
     -balance. GREEN hardening.

## Next focus
Pass #19 — CONTINUE the ADMIN DASHBOARD (iter 24 found+fixed the broad double-toast; now hunt other
issues). Already admin-logged-in. Pick a DIFFERENT dashboard surface than Vouchers: candidates = Shop Items
(/dashboard/shop-items — image upload, avatar_asset field, subtype), GP Packs (reorder, toggle), Users
(/dashboard/users — GP adjust, role, ban), Articles (AI draft, status transitions), Games, Tournaments
(/dashboard/tournaments — modal CRUD), Settings (AI tab — testAi). Exercise create/edit/validation/
empty-states/list-refresh-after-delete/console errors. The double-toast is now fixed globally, so focus on
OTHER issues (stale lists after delete, missing field bounds, broken states, console errors, reorder/toggle
flows). Reproduce one concrete issue in-browser before fixing; clean up created rows. One issue only.

## Earlier next-focus (superseded)
Pass #18 — ADMIN DASHBOARD (the big remaining frontier: 30+ controllers, admin-only, mostly untested by
real users -> highest remaining bug potential). All public/feature surfaces are now swept healthy
(home/search/leaderboard/play/profile/blog/tournaments/wallet/shop/inventory/settings/avatar/notifications;
toast+stale-count classes fixed). MUST log in as ADMIN first (admin@arcade.local / password; player
session is currently active -> log out via user menu, or open a fresh login). Then pick ONE dashboard CRUD
surface and exercise create/edit/validation/empty-states/console: candidates = Shop Items (/dashboard/shop),
Vouchers (/dashboard/vouchers), Users, GP Economy, Articles, Games, Cron page, Settings (AI tab). Watch for:
modal form validation feedback (now that flash works), missing field bounds, console errors, broken
empty/error states, stale lists after create/delete (the stale-count class but for lists). Reproduce one
concrete issue in-browser before fixing; clean up any created rows. One issue only.

## Earlier next-focus (superseded)
Pass #17 — NOTIFICATIONS (bell dropdown, contained, not yet exercised). Via Playwright MCP logged in:
open the notification bell, check the unread count/badge, mark-one/mark-all-read, and whether the count
updates LIVE (stale-count candidate — same class as iters 15/16; check UserNotificationBell.tsx). Also
the empty state (no notifications) + the /api/notifications endpoint shape. Player may have few/no
notifications — can seed one via tinker (NotificationService) and clean up. Reproduce concretely before
fixing. If clean, the big remaining frontier is the ADMIN DASHBOARD (30+ controllers, admin-only, mostly
unexplored) — pick one dashboard CRUD surface (e.g. Shop Items, Vouchers, Users) and exercise create/edit/
validation/states. One issue only; clean up test rows.

## Earlier next-focus (superseded)
Pass #16 — AVATAR BUILDER (the richest unexplored client-side feature: plan-gated layered-SVG composer).
Via Playwright MCP: as admin (always passes the allow_avatar_builder gate; player may be plan-blocked),
go to /settings, open the avatar builder, select parts across subtypes, save, and verify: the config
persists, renders in <UserAvatar> across the app (header, profile), no console errors, the part picker
states (selected/empty/locked), and the AvatarConfigValidator rejects bad input. Also confirm the
plan-gate: a non-allowed user can't WRITE config (AvatarConfigUpdateRequest::authorize) but CAN still see
existing config (reads not gated). Reproduce one concrete issue before fixing; clean up any avatar_config
changes to the test user. If clean, fall back to NOTIFICATIONS (bell dropdown, mark-read, live count) or a
GREEN hardening. One issue only.

## Earlier next-focus (superseded)
Pass #15 — the toast/flash + stale-count classes are now well-swept (comment count, follower count,
voucher balance, tournament entry count all verified; flash feedback fixed app-wide). SHIFT to a FRESH
surface not yet exercised this run. Candidates (pick ONE, logged in as player/admin via Playwright MCP):
(a) AVATAR BUILDER (plan-gated layered SVG composer at /settings avatar — exercise part selection, save,
render; check console + that the saved config renders in <UserAvatar> across the app). (b) NOTIFICATIONS
(bell dropdown — mark-read, counts updating live = stale-count candidate). (c) BLOG public pages
(/blog, article show — comments, related posts, SEO). (d) the AVATAR/COVER upload flow in settings (plan
-gated; file validation). (e) a GREEN hardening: backend test locking the tournament register rejection
contract (closed -> back with error flash) if untested. Reproduce concretely before fixing; clean up test
rows. One issue only.

## Earlier next-focus (superseded)
Pass #14 — sweep the double-toast pattern across OTHER pages (iter 19 fixed only Settings). Grep
`onSuccess: () => toast.success` / `.then(... toast.success` in resources/js/themes pages; for each, check
whether the corresponding controller ALSO `back()->with('success', ...)` (which now double-toasts post
iter-18). Candidates: favorite/follow/comment flows (Play.tsx uses direct toasts + may flash), wallet
voucher redeem (flashes success on redeem — does the page also toast?), shop checkout. Pick ONE page,
verify the double in-browser, dedupe (keep the more specific message), confirm single toast. If no other
doubles exist, shift to: SUCCESSFUL tournament registration happy-path (needs an OPEN tournament — may
require fixing the stale demo dates first) OR a fresh surface (blog / notifications / avatar builder).
One issue only; clean up test rows.

## Earlier next-focus (superseded)
Pass #13 — NEW high-value angle opened by iter 18: now that flash toasts work, re-examine flows that
relied on them. (a) The settings page calls a DIRECT toast.success('Profile updated') AND now also gets
the flash 'Settings updated' -> DOUBLE toast (the direct one was likely a workaround for the iter-18 bug);
consider removing the redundant direct toast in Settings.tsx (both themes) so users see one clean message.
(b) Grep for other pages with direct toast workarounds that are now redundant with flash. (c) OR verify a
SUCCESSFUL tournament registration on an OPEN tournament shows the success toast + entry-count increments
live (Show.tsx uses the prevEntries sync guard — likely fine; confirm). Pick ONE; reproduce in-browser;
clean up test rows. One issue only.
(prior pass-12 tournament exploration DONE in iter 18 — found+fixed the flash-toast bug; tournament
width/detail/register-count all verified OK.)

## Earlier next-focus (superseded)
Pass #12 — continue exploring the TOURNAMENTS surface (was mid-exploration when the user interjected
about width). Via Playwright MCP as logged-in player: open a tournament DETAIL page (/tournaments/{slug};
note champions-cup detail redirected to the index earlier — investigate WHY: is that a routing/guard bug
or expected?), check the register/join flow + whether the entry/participant count updates live on register
(stale-count candidate), console+network for errors. Also: is the /tournaments DETAIL page (Show.tsx) also
max-w-constrained vs the header (same class as iter 17)? Reproduce concretely before fixing; clean up any
test registration row. If tournaments are clean, shift to a GREEN hardening test. One issue only. Quick checks
(logged-in player, Playwright MCP): (a) the OTHER side of follow — does the LOGGED-IN user's own
"Following" count (on their own /profile/player) update after they follow someone? (likely same static
prop, but lower-traffic — verify). (b) wallet GP balance after a voucher redeem / the favorites count on
own profile after favoriting a game from elsewhere. If these are consistent OR low-value, SHIFT layers:
do a GP-style ledger-invariant test (GpService or inventory: assert SUM(delta)==balance) as GREEN
hardening, OR the low-sev searchActiveForPicker term-length cap (validation). Reproduce concretely before
fixing; clean up any test rows. One issue only.
(prior next-focus DONE in iter 16: rating-aggregate was already correct; profile follower count fixed.)
bug, hunt the SAME CLASS elsewhere (high-value pattern). Candidates to check in-browser as logged-in
player: (a) the play-page RATING aggregate — does submitting a rating update the displayed avg/count live,
or only on reload? (Play.tsx has setAggregate — verify it actually refreshes.) (b) FAVORITE toggle — does
the favorites count on the profile/anywhere update? (c) follow/unfollow follower counts on profile. Pick
ONE, reproduce in-browser, fix minimally + verify. If all are consistent, fall back to: GP-style
ledger-invariant test (GpService/inventory SUM==balance) or the low-sev searchActiveForPicker term cap.
REMEMBER: MCP browser_type (.fill) DOES update React controlled inputs (raw native-setter+input event
does NOT reliably) — and async posts need a settle wait before asserting. One issue only; clean up any
test rows.
Via Playwright MCP as seeded player: open /play/halloween-chain, post a comment, verify it appears +
the count increments + 0 console errors; try empty + over-long (>2000) comment to confirm the validation
error-state surfaces in the UI (comment rule is max:2000 per CLAUDE.md — verify the UI shows the error,
not just a silent reject). REMEMBER the iter-9 harness quirk: MCP browser_click may not submit a shadcn
form — check the POST fires via network, fall back to evaluate requestSubmit(). Reproduce one concrete
issue in-browser before fixing; clean up any test comment row in CLEANUP. Fallback if clean: GP-style
ledger-invariant test for GpService or inventory (SUM(delta)==balance), or the low-sev
searchActiveForPicker term-length cap. One issue only. Verify in PublicLayout (themes/{default,
lagged}/components/PublicLayout.tsx) that the footer Browse/Account/Info link groups and/or primary header
nav aren't wrapped in a <nav> element; if confirmed, wrap the navigation region(s) in <nav> (with
aria-label to distinguish header vs footer) so screen readers get landmark navigation. Re-verify
in-browser that nav-landmark count increases with zero visual change. Check the same gap on
play/leaderboard (shared layout); confirm the iter-11 home h1 didn't regress those pages' single-h1
invariant. One issue only. Fallback: submitScore score_data input bounds (backlog #3) — highest-value
remaining validation gap (user-posted JSON via postMessage; verify the scoring request caps array
size/depth, add a cap + test).

## Deferred
- **TournamentService::submitScore lost-update (LATENT)** — submitScore() (TournamentService.php ~95-115):
  read-entry → if($score>entry->score) → update, unlocked → concurrent submits, lower score can overwrite
  higher. NO production caller (only TournamentTest:87; all HTTP scoring uses the safe submitScoreForGame).
  Latent only — harden in a dedicated pass by matching submitScoreForGame's atomic guarded update, or fix
  when a caller is added.
- **Email subject frozen at construct-time locale** — see SERVICES.md for full fix recipe (~17 files).
  Non-English cron/admin mail gets English subject + translated body. Lower severity, dedicated pass.
- **No JS/e2e test harness** — repo has no Playwright config, no Vitest/Jest, no `*.spec.tsx` (package.json
  `test` = `php artisan test` only). UI regressions found via Playwright MCP can't be codified as durable
  specs; each is verified by live-browser re-check only. Standing up a JS e2e harness is a deliberate infra
  decision (out of scope for one loop iteration) — flag for the user. Until then, UI fixes rely on the
  MCP re-verify + (where applicable) the TS typecheck/eslint/build as the regression net.
- **Stale demo tournament data (dev-only) — NOT a bug (iter 20 re-checked).** DemoSeeder ALREADY uses
  relative dates (`now()->modify($startsAt)`, line ~1415). The champions-cup "ended 2026-06-13" staleness
  is just because THIS long-running dev DB was seeded ~2 weeks ago and time passed + the
  `app:tournament-automation` cron isn't running in dev to transition ended->completed. A fresh
  `migrate:fresh --seed` produces current dates; prod runs the cron. No seeder fix needed. (To test
  tournament flows in this aged dev DB, create an open tournament via tinker — see iter 20.)
- **Redundant settings success toast** — Settings.tsx (both themes) calls a direct
  toast.success('Profile updated') in onSuccess; with iter 18 the server flash 'Settings updated' now also
  toasts -> two toasts on save. Likely a workaround predating the flash fix. Candidate cleanup (see next
  focus): drop the direct toast so one clean message shows.

## Blocked
- _(none)_
