v1.3.0
Theme: a cart and review workflow, more cover-art sources, Help pages, and a safer local server.
2026-09-28 Fixed — Library cards no longer lose their borders
The selected Featured tile's highlight was clipped at the top, and the tiles sat at uneven heights. In the game grid, box art spilled past each card and the next row covered its bottom border and title. Cards now fit their rows, with a gap between rows, and the art keeps its size. List view shows a thumbnail beside the title instead of squeezing the picture into a sliver.
2026-09-28 Security — Other websites can no longer change the app's settings (mame-curator-1083)
The local server now refuses browser requests that come from another site, and requests addressed to a web name that is not this machine (which blocks "DNS rebinding", where an attacker's domain is pointed at 127.0.0.1). Your own browser tab, the dev server, the command line and scripts work as before. A blocked request shows "Blocked a request that came from another website".
2026-09-28 Changed — Frontend on its dependencies' new major versions (mame-curator-1125)
jest-dom 7, jsdom 30, vitest and @vitest/coverage-v8 5, framer-motion 13 and react-router 8, none needing code changes. TypeScript stays on 6: typescript-eslint does not support 7 yet, which the Version-break registry records with its re-test trigger. @types/node stays on 24 while engines pins Node 24.
2026-09-28 Added — Settings shows "Settings saved" after each change (mame-curator-1038)
The Settings page has always saved as you edit, but it gave no sign of it. Each successful save now shows a brief "Settings saved" message; quick edits in a row update the same message instead of piling up.
2026-09-28 Security — Frontend dependencies updated; npm audit reports no advisories (mame-curator-1124)
Every web-app dependency is now at its newest release inside its current major version, which clears the 11 known advisories npm reported (including one in react-router). No app behaviour changes.
2026-09-28 Added — The build fails if the web app's JavaScript grows past its 350 kB budget (mame-curator-1120)
npm run size (size-limit) measures every JavaScript file the frontend
build emits, gzipped, against the 350 kB budget the P06 spec set. CI,
the release build and ./local-CI.sh run it after the build. Today's
total is about 229 kB.
2026-09-28 Fixed — Fixes from the 2026-09-28 code review of the last release batch
run.sh/run.batno longer install the developer tools when they start the app (uv runnow gets--no-devtoo).- Settings → Updates no longer says "You're on the latest version" when it has not checked; it says checking is not available yet.
- The Alternatives drawer keeps a loaded list usable when a background refresh fails, tells screen-reader users what failed, and highlights the first row after a successful retry.
mame-curator filterreads the DAT before the slow listxml again, so a bad DAT fails fast with its own error.
2026-09-28 Security — Build workflows pin every action to a commit and run with read-only rights (mame-curator-1122)
GitHub Actions now name each third-party step by an exact commit, so a
moved tag cannot change what runs; Dependabot proposes updates weekly.
Workflows get read-only access unless a job must publish, checkouts no
longer keep the access token on disk, release builds restore no caches,
and the release is published with GitHub's own gh tool. The release
build also runs the Prettier check CI already ran.
2026-09-28 Fixed — local-CI.sh stops if it cannot reach the repo root; typos runs clean (mame-curator-1123)
local-CI.sh now exits when its cd to the repo root fails, instead of
running the checks from the wrong directory. A new _typos.toml
records the project's real words (MAME's fullset, PyInstaller's
datas, commit ids, acronym plurals) and skips the built frontend,
so the spelling checker reports nothing on the whole tree.
2026-09-28 Added — What counts as a breaking change is now written down (mame-curator-1119)
docs/standards/versioning-overrides.md names what users rely on: config.yaml and where it is found, the files the app saves and reads back (including Backup exports and the cart), the command line, what a copy produces, how the app starts, and page addresses and shortcuts. Breaking any of these means a major version unless the release migrates the old form. The web API between the page and the server is internal.
2026-09-28 Changed — Library loads faster: the MAME -listxml file is read once, not four times (mame-curator-1118)
Startup read the ~300 MB -listxml four times, once per fact it needed.
A new parse_listxml collects parent/clone links, BIOS chains, CHD
requirements and driver status in one pass: 5.7 s instead of about 22 s
on listxml-0.287.xml. The filter and copy commands use it too.
2026-09-28 Fixed — Settings → Updates says how to update instead of naming an internal phase (mame-curator-1114)
The update-available banner said the apply flow "ships in Phase 7". It
now tells the user to close MAME Curator, run git pull in its folder
and start it again with run.sh or run.bat.
2026-09-28 Changed — Developer tools move to a dev dependency group (mame-curator-1106)
Contributors now set up with a plain uv sync; the old
uv sync --extra dev fails because the extra no longer exists. A plain
sync used to uninstall mypy, ruff and pytest; it now keeps them. The
run.sh / run.bat launchers pass --no-dev, so end users do not
download the test and lint tools.
2026-09-28 Changed — Frontend build no longer warns about the entry chunk's size (mame-curator-1108)
Vite's raw-size warning now fires at 700 kB instead of 500 kB. The entry chunk is well inside the 350 kB gzipped budget the frontend spec sets, so the warning was noise.
2026-09-28 Fixed — Alternatives panel reports a failed load instead of an empty family (mame-curator-1107)
When the request for a game's other versions failed, the side panel said "0 versions in this family". It now says the versions could not be loaded and offers a Try again button, which stays up while the retry runs.
2026-09-27 Fixed — Emulation quality is known again (mame-curator-1099)
Pleasuredome DATs carry no emulation-quality ("driver status") data,
so every game read as "unknown". It now comes from MAME's -listxml,
as parent/clone links already did. The Stats page shows real figures,
the "drop preliminary emulation" setting now drops barely-working
games, and the picker prefers the better-emulated version of a game.
Expect your winners list to change if that setting is on. Loading the
library takes a few seconds longer (one more -listxml read).
2026-09-27 Fixed — Playlist goes where Settings says (mame-curator-1104)
Copying now writes the RetroArch playlist to the path set in
Settings → Paths. It used to go into the ROM destination folder
whatever the setting said, where RetroArch usually does not look.
The "playlist already exists" check now looks at the configured
path too. The command-line copy still writes <dest>/mame.lpl.
2026-09-26 Added — The Help page has content (mame-curator-1063)
Help used to open on "No help topics available". It now has four pages: getting started, choosing your games, copying games to RetroArch, and command-line use. The empty-page message no longer refers to an internal phase name.
2026-09-26 Fixed — The Windows launcher starts again and reads PORT like Linux does (mame-curator-1089)
run.bat stopped before starting the app on every Windows machine: its
Python check always said "too old", and a stray bracket made Windows
reject the script. Both are fixed. A bad PORT now gets the same clear
error as on Linux and macOS, and an unset one uses the port in
config.yaml.
2026-09-26 Fixed — A very fast copy no longer leaves the progress window stuck (mame-curator-1111)
If a copy finished before the window connected, the window never heard that it started or finished. The server now replays the finished copy.
2026-09-26 Fixed — Copies take only the files a game needs, and app data stays beside the config (mame-curator-1109, mame-curator-1105)
Copying no longer plans dozens of files that don't exist (mame-curator-1109) BIOS option names (euro, japan, …) were treated as files; now only real BIOS machines are copied, and a clone's parent is no longer copied as a "BIOS". The "BIOS missing" badge and filter now agree with each other.
The activity log, recycle bin and MobyGames key stay in the data folder beside config.yaml (mame-curator-1105) Starting the app from another folder used to write them there, so copies never appeared on the Activity page.
2026-09-26 Fixed — BIOS warnings say which game and why (mame-curator-1110)
The copy window listed each BIOS problem as a bare "BIOS warning". It now names the game and the reason.
2026-09-26 Fixed — Copy progress now counts up (mame-curator-1103)
The copy window's counter used to stay at zero for the whole copy.
The done count rises as each file finishes (mame-curator-1103) Skipped and failed files count too, so the counter reaches its total.
The window reads "Preparing copy…" until the job's size is known (mame-curator-1103) It used to show "0 / 0" first.
2026-09-26 Fixed — Loading no longer reads as "nothing here" (mame-curator-1100, mame-curator-1102)
The library and the versions panel now say they are loading while their data is on its way.
Library grid shows "Loading games…" while a filter's results load (mame-curator-1102) It used to flash "No games match your filters" first.
Versions panel reads "Loading versions…" until its list arrives (mame-curator-1100) It used to claim "0 versions in this family" first.
2026-09-26 Fixed — Beat 'em Ups, Run & Gun and SHMUPS tiles show games again
These featured tiles searched for genre names that the current catver.ini no longer uses, so each showed "0 games". They now use the current names: "Platform / Fighter Scrolling", "Platform / Shooter Scrolling" and "Shooter / Flying Vertical".
2026-09-26 Fixed — Genres are real genres again, not version numbers (mame-curator-1098)
catver.ini also lists the MAME version that added each game, under the same game names. That list overwrote every category, so genres read like "0.162" in the genre filter and on the Stats page. The parser now skips it, and server start no longer logs a duplicate-key warning for every game.
2026-09-26 Fixed — Copy in the dry-run preview now copies (mame-curator-1101)
The preview says "Review the diff and confirm to copy", but its Copy button only closed the window. It now closes the preview and starts the same copy as the cart bar's Copy button.
2026-09-26 Fixed — Launching the app no longer uninstalls developer tools
run.sh and run.bat now sync with uv sync --inexact. A plain sync uninstalled mypy, ruff and the test tools from a developer's environment, which made the next check run fail as if the code were broken.
2026-09-26 Changed — App icon
The browser tab now shows MAME Curator's own icon — a pixel-art arcade cabinet with a tick on its screen — instead of Vite's default logo.
mame-curator-1090 — the server settings in your config now do something (2026-08-04)
Added
- The Server settings you can already edit — address, port, and whether
to open a browser on start — are now actually read when the app starts.
Until now the app saved them, told you a restart was needed, and then
ignored them. Setting a port in
config.yamland running./run.shuses that port. APORTyou set beforehand still beats the file, and an explicit--portbeats both. (mame-curator-1090) - The browser now opens when the app is genuinely ready, rather than two seconds after launch and hoping. On a first run, where the app spends a while reading a ~48 MB game list before it can answer anything, that guess usually lost — you got an "Unable to connect" page and had to reload. (mame-curator-1090)
Changed
./run.shno longer pins the port to 8080 on every launch, which is what made the config setting unreachable. With noPORTset it starts the server and lets it pick, so your config wins. (mame-curator-1090)- The Windows bootstrap (
run.bat) no longer opens a browser itself; the app does it. Keeping both would have opened two tabs every time. (mame-curator-1090)
Fixed
--no-open-browsernow works. The option was accepted and then ignored, so the browser opened anyway. (mame-curator-1091)- An impossible port such as
--port 99999now stops with one readable line instead of a page of Python error text. (mame-curator-1091) - Error messages no longer delete anything you wrote in square brackets. A
path like
/games/[roms]/config.yamlwas reported back as/games//config.yaml— dropping the one detail the message existed to give you. (mame-curator-1091)
Dev docs — one home per document kind (2026-08-03)
Changed
- The design spec and the long-form phase plan moved out of
docs/superpowers/to the places the project's own conventions name:docs/design.md,docs/plans/<ID>-<topic>.md,docs/specs/<ID>-<topic>.md. All 50 references across the roadmap, changelog, journals, ADRs and specs were updated with them, and the format contract they follow now lives in-repo atdocs/standards/spec-format.md. No behaviour change — this is where contributors look for things. (mame-curator-1092)
mame-curator-1088 — choose the port with PORT (2026-08-03)
Added
- You can now tell MAME Curator which port to serve on by setting
PORTbefore starting it —PORT=5999 ./run.sh— so another tool can place the app wherever it needs it. Runningmame-curator servedirectly honours the same setting; an explicit--portstill wins over it. LeavingPORTunset keeps the usual port 8080, exactly as before. (mame-curator-1088)
Changed
- A nonsense or unusable
PORT(a word, or a number outside 1024-65535) now stops the app immediately with one line naming what you typed and what's allowed, instead of a confusing error from deeper in the stack — or, for a privileged port like 80, a permission failure much later. The message is identical whichever way you start the app. (mame-curator-1088)
mame-curator-1084 — turn art sources on or off (2026-07-04)
Added
- Each art source in Settings → Media now has an on/off switch, so you can drop one from the lineup entirely instead of only reordering it — for example, skip MobyGames if you'll never add a key. Sources you've switched off collect in an "Available sources (off)" list underneath; flip one back on to return it to the fallback chain. (libretro stays on — it's the built-in baseline the app always falls back to.) (mame-curator-1084)
mame-curator-1081 — snapshot pack folder is now a setting (2026-07-03)
Added
- A new Snapshot pack folder setting (Settings → Media) tells the app where the progettoSnaps snapshot art lives. Point it anywhere; the downloader and the viewer both follow it, so a relocated pack can't go unseen. (mame-curator-1081)
Fixed
- The snapshot art source used to look only in the fixed
./data/snapsfolder, even if you downloaded the pack elsewhere withrefresh-snaps --dest …— so those images silently never showed. The viewer now reads the folder from your config (media.snaps_dir), andrefresh-snapsreads the same setting when you don't pass--dest, so the two can't drift apart. (mame-curator-1081)
Dev tooling — silence the Starlette test-client deprecation warning (2026-07-03)
Changed
- Every
pytestrun printed a harmless "this will change in a future version" notice from the web test-client, which risked burying real warnings in the noise. Added thehttpx2package to the developer test tools so the notice no longer appears. The app itself is unchanged — this only affects how tests run. (mame-curator-1082)
FP34 — P10 third closing-review fold-in (2026-07-02)
The final review round confirmed FP33's fixes and found three small leftovers.
Fixed
- A corrupt or non-text MobyGames API-key file no longer takes the whole artwork feature down — it just disables that one source, like every other bad-key case. (mame-curator-1087)
- Housekeeping: corrected a "modifies data" marker on the API-key endpoint in the API spec (it only writes a key file, holds no lock), and added the friendly message for the "unknown media source" error. (mame-curator-1087)
FP33 — P10 second closing-review fold-in (2026-07-02)
A deeper re-review after FP32 shipped found two real bugs FP32's first pass missed — same class, one level deeper. Fixed TDD, a failing regression test first.
Fixed
- Security: an artwork source could no longer be tricked into serving a
private file off the server. The "serve a local file directly" fast-path is
now restricted to the local snapshot pack; a network source (fetched over
plain HTTP) handing back a
file://…path is dropped instead of read off disk. (mame-curator-1086) - FP32's "unexpected response shape" guard only checked the top level; a few deeper spots (a list element / URL field / thumbnail / link block that isn't the expected type) could still crash an image or "About" request. All are now type-guarded and fall through gracefully. (mame-curator-1086)
- A snapshot-pack folder the server can't read no longer takes the media feature down — it just disables that source. (mame-curator-1086)
- The "copy command" button no longer reads "Copied!" when you reopen the dialog without having copied again. (mame-curator-1086)
- Housekeeping: the API-key box trims pasted whitespace (and rejects a spaces-only key); three shipped media endpoints + one error code are now documented in the API spec; and several stale/dead doc references were corrected. (mame-curator-1086)
FP32 — P10 closing-review fold-in (2026-07-02)
The closing indie-review of the media feature (run once all 11 P10 chunks had shipped) found real defects the spec cold-eyes couldn't — they reviewed contracts, not code paths. Fixed TDD, a failing regression test first.
Fixed
- An art source returning a valid-but-unexpected response (a JSON array or
nullinstead of an object) could make an image request error out — and stay broken via a poisoned cache slot — instead of quietly trying the next source. Now it falls through the fallback chain as intended. (mame-curator-1085) - The "copy
refresh-snapscommand" button no longer claims "Copied!" when the browser has no clipboard access (plain-HTTP LAN) or the copy is denied, and a stray file where the snapshot-pack folder is expected no longer takes the whole media feature down. (mame-curator-1085) - The Wikipedia "Read more" link only renders for a genuine
https://URL — defence against a poisoned/MITM link. (mame-curator-1085) - Housekeeping: retired the dead 502 media-upstream error surface, made the rate-limiter safe under a non-monotonic clock, and the API-key dialog now shows the server's actual reason on failure instead of a generic message. (mame-curator-1085)
P10 chunk 11 — Wikipedia "About" paragraph in the Alternatives drawer (2026-07-01)
Added
- The Alternatives drawer now shows a short Wikipedia "About" paragraph for the
selected game, with a "Read more on Wikipedia" link and a CC-BY-SA
attribution line. It appears only when a matching Wikipedia page exists —
otherwise (or while loading) it's silently absent, since it's non-essential
flavor text. Consumes the chunk-8
GET /media/{name}/wikiendpoint via the newuseWikipediaExtracthook. (mame-curator-1005)
P10 chunk 10 — Settings → Media source list (2026-07-01)
The Media tab in Settings gains a live list of your art sources.
Added
- A reorderable art-source list on Settings → Media: each source shows a status dot (green Active / grey Disabled), the image kinds it covers, and — when it's off — the reason why. Drag-free reorder (arrow buttons) sets the fallback priority, saved to your config. (mame-curator-1005)
- A "Configure…" button on MobyGames (when it has no key) opens a modal to paste your API key; on success the dot flips to green without a restart. (mame-curator-1005)
- A "Download pack…" button on progettoSnaps (when the pack isn't downloaded)
opens a modal with the
mame-curator refresh-snapscommand and a copy button — run it in a terminal, reopen the tab, and it flips to green. (mame-curator-1005)
Deferred
- Turning a source fully off (a per-row enable/disable checkbox) is a follow-up (mame-curator-1084); chunk 10 ships reorder + status + the key / pack helpers. (mame-curator-1005)
P10 chunk 9 — media source readiness + key-paste API (2026-07-01)
Backend for the upcoming Settings → Media tab: a way to see which art sources are working and to paste a MobyGames API key.
Added
GET /api/media/sources— per-source readiness: for each of the five art sources, whether it's active, whether it's in your fallback order, which image kinds it covers, and (if disabled) a human-readable reason (e.g. "no key configured" for MobyGames, "run refresh-snaps" for progettoSnaps). Surface-only — no network calls. (mame-curator-1005)PUT /api/media/sources/{name}/secret— paste a MobyGames API key; it's written todata/secrets/mobygames.keyat mode 0600 (owner-only) via an atomic write, and the very next request picks it up (no restart). The key value is never logged and never appears in a config export. Unknown source name or empty key → 422. (mame-curator-1005)SourceReadinessRow/SourceReadiness/SourceSecretAPI schemas, mirrored in the frontend typed config. (mame-curator-1005)
Security
- The key-paste route uses loopback-trust (no auth gate) — the server binds
127.0.0.1by default, consistent with every other write endpoint in the app. App-wide cross-site-request hardening is tracked as a future consideration (mame-curator-1083). (mame-curator-1005)
P10 chunk 8 — Wikipedia "About" flavor-text endpoint (2026-07-01)
A new endpoint serves a one-paragraph Wikipedia summary for a game, for the "About" section of the Alternatives drawer (the drawer UI itself lands later).
Added
GET /media/{name}/wiki— returns aWikipediaExtract(title/extract/url/license) or JSONnullwhen there's no matching Wikipedia page. It reuses the same Wikipedia REST summary the image source already fetches, so one lookup warms both caches. (mame-curator-1005)src/mame_curator/media/wikipedia.py—WikipediaExtract(frozen model) andresolve_wikipedia_extract, which shares thewikipedia_limiterrate-limit budget + the on-disk text cache with the chunk-5 image source, and applies the same parse-before-trust cache-poisoning guard.WikipediaExtractmirrored in the frontend typed config (types.ts+ Zodschemas.ts) and added to the API type-sync gate. (mame-curator-1005)
Behaviour note
- The "About" paragraph is non-essential: if Wikipedia is rate-limited or
unreachable, the endpoint returns
null(the section just hides) rather than surfacing a 5xx error. (mame-curator-1005)
P10 chunk 7 — media source registry + fallback orchestrator (2026-07-01)
The five P10 art sources become a real fallback chain. A media request now walks the configured sources in order and returns the first hit, instead of always going straight to libretro-thumbnails.
Added
src/mame_curator/media/resolve.py—resolve_image, the fallback orchestrator: it walks the per-kind source chain, serving the first cached image and falling through past any source that rate-limits, errors, or has no candidate. A local snap-pack (file://) hit is served directly. Plusbuild_registry, the composition root that constructs the configured sources with the app-state rate limiters + MobyGames disabled-flag injected. (mame-curator-1005)MediaSourceRegistry(media/sources.py) — orders + filters the configured sources into a per-kind chain: unknown names dropped (one-time warning),libretroappended as the baseline, kind-mismatched and disabled sources skipped. (mame-curator-1005)media.sourcesconfig field (default order: libretro, progettoSnaps, arcadeDB, wikipediaImage, mobyGames) — the fallback priority; reorder to change which source is tried first. Mirrored in the frontend typed config. (mame-curator-1005)
Changed
GET /media/{name}/{kind}now resolves through the fallback chain. The 502media_upstream_errorresponse is retired for media: a single source's upstream 5xx / transport error no longer fails the request — the chain advances, and only a chain that misses everywhere returns 404media_upstream_not_found. This trades the 5xx-vs-404 distinction for resilience (one flaky mirror can't break a thumbnail). (mame-curator-1005)- The keyless-MobyGames startup warning is now deduped process-wide, so per-request source reconstruction logs it once per process rather than on every thumbnail request. (mame-curator-1005)
P10 chunk 6 — MobyGames key-handling (2026-07-01)
The fifth and final P10 fallback art source lands its key-handling half. MobyGames needs a personal API key; the cover-image fetch that depends on a real key to verify the response shape is deferred (mame-curator-1079).
Added
src/mame_curator/media/mobygames.py—MobyGamesSource(boxart-only,license_compatible=False). Resolves an API key fromMOBYGAMES_API_KEYor a mode-0600data/secrets/mobygames.keydotfile (group/other-readable files are rejected with a warning), self-disables with a user-readable reason when no key resolves, and flips a process-wideSourceDisabledFlagon a 401/403 from a configured key. The lookup request carries the key in its query string, so every error/log message redacts it. Split into its own module sosources.pystays under the 500-line cap. (mame-curator-1005)media.mobygames_rate_limit_per_minconfig field (default 5 req/min) plus themobygames_limitertoken bucket andmobygames_disabledflag wired onapp.stateat lifespan startup. (mame-curator-1005)
Deferred
- MobyGames cover-URL parse + JSON-body caching — gated on a real API key to capture a fixture and verify the response field path. Until it lands, MobyGames participates in the chain (when keyed) but yields no covers; it is last in the default fallback order, so no user-visible regression. (mame-curator-1079)
Cleanup / debt — frontend file-size + Snapshots caveat (2026-06-30)
Two frontend cleanup-debt items surfaced during the P14 docs work,
bundled by shared lane.
Fixed
- Settings → Snapshots now shows a one-line caveat that per-game review
state is excluded from config snapshots, so review marks can't be
rolled back there — recovery is via Activity replay. The caption
(
settings.snapshotsStateExclusionNote) was specified in the P14 spec but never shipped. (mame-curator-1078)
Changed
frontend/src/pages/LibraryPage.tsx(579 lines) split under the §2 frontend component hard cap of 350 lines — non-render logic extracted to a newuseLibraryControllerhook plus pure helpers inlibraryPageHelpers.ts; the JSX stays in the page so the source-text structural tests keep asserting the rendered shape. Behaviour- preserving (all 323 frontend tests green). (mame-curator-1077)
P14 — Per-game review state (closed 2026-05-17)
Per-game pending / reviewed / skipped / needs-decision state
persists across sessions in data/state.yaml (sparse store —
absence = pending). Surfaces in the library grid via R / S / ?
keyboard shortcuts, a segmented filter in the sidebar, a frontend-
only badge on each card, and a header progress chip with a
walkthrough auto-advance toggle.
Added
src/mame_curator/filter/review_state.py— frozenReviewStatePydantic + two enums (ReviewStateValuestorage 3-value,ReviewStateFilterquery 5-value) +load_review_stateloader mirroringload_overrides.ReviewStateErrorjoins the typed- error hierarchy infilter/errors.py.- Three routes on
api/routes/curate.py:GET /api/state,POST /api/state,DELETE /api/state/{short}. Sparse-store enforced at the wire layer (StatePostRequest.state: ReviewStateValuerejectspendingwith 422 before the handler runs). No-op-write skip on both POST and DELETE. - Per-request
?review_state=filter onGET /api/games, applied in the route handler after the existingkeep()slice (NOT inrun_filter()— review state does not gate eligibility, soworld.filter_resultstays cached at world-build cost). ActivityEventtagged union extended with aREVIEW_STATEarm +ReviewStateDetailspayload.stateandpreviousare plainstrso the log records the sparse-store sentinel"pending";session_idis the empty string per spec.WorldState.review_statefield +replace_world(review_state=)kwarg as a passive field swap (INV-4 — review-state-only swaps leavefilter_resultis-identical to the base).- Optimistic-UI
useReviewStatehook (React QueryonMutate/onErrorrollback /onSettled; cache key['reviewState']). useGameGridFocushook extracted fromLibraryGrid's existing FP21-T roving-tabindex, addsfocusNextPending(startIndex)andsetActive(idx). The parameterised signature is load-bearing for the race the spec calls out (caller passesactiveIndex + 1so the just-marked card is skipped regardless of when its state propagates).- Frontend-only review-state badge on
GameCard(parallel maps keyed byReviewBadgeKind; lucide-react icons; tintstext-emerald-500/text-rose-500/text-amber-500). - Segmented review-state filter in
FiltersSidebar(shadcnRadioGroup;ToggleGroupisn't in the project per spec). - Grid R / S / ? handlers on
LibraryGrid— toggle-on-same-key returns to pending; walkthrough auto-advance (default on) callsfocusNextPending(activeIndex + 1)after a non-pending mutation; end-of-list surfaces awalkthroughCaughtUptoast. - Drawer R / S / ? + ArrowUp / ArrowDown on
AlternativesDrawerviauseKeyboard. INV-7 — drawer mutations do NOT auto-advance regardless of walkthrough setting. - LibraryPage header chip (
{handled}/{total} handled · {pct}%)- walkthrough toggle persisted to
localStorage['mame-curator:walkthrough-mode'](defaulttrue).
- walkthrough toggle persisted to
- 19 backend tests + 7 hook tests + 4 GameCard badge tests + 1
cross-side parity contract test (INV-12) covering all 13
invariants in
docs/specs/P14-review-state.md.
Notes
data/state.yamldoes NOT snapshot (high-frequency keypress writes would churn the 200-entry shared snapshot pool); recovery is viadata/activity.jsonlreplay only.frontend/src/pages/ActivityPage.tsxalready displays each event's human-readablesummary— the chunk-4 emitter's"marked sf2 as reviewed"/"cleared sf2"summaries surface without a dedicated switch-case. The planned chunk-8 dedicated renderer folded into the generic path.- US-keyboard-layout assumption for
?(Shift+/ →event.key === '?'); R / S work on any layout. Follow-up could matchevent.code === 'Slash' && event.shiftKey. - E2E Playwright spec deferred to a follow-up; per-test coverage in vitest + pytest pins behaviour against drift.
Documentation bundle — README hero shot + CONTRIBUTING.md (closed 2026-05-16)
Two queued documentation items from 1.4.0 closed together as a single docs bundle.
Added
- Hero shot at the top of
README.md(docs/screenshots/library.png) and a new## Screenshotssection showing the alternatives drawer (parent/clone picker), the filters tab in Settings, and the named- sessions panel. CONTRIBUTING.mdcovering local-dev quickstart, bug-report template, the local CI gate (backend five + frontend three), the TDD policy with per-module coverage floors, the per-featurespec.mdrequirement, Conventional Commits, a summary of the App-Build 9-step phase loop, and a "what this project deliberately does not do" section.frontend/screenshots/directory holding a dedicated Playwright config (playwright.config.ts) + capture spec (capture.spec.ts) that regeneratesdocs/screenshots/*.pngagainst the realconfig.yaml. Independent offrontend/e2e/so the regression suite continues to use the deterministic 6-machine fixture.
Changed
README.md's short "Contributing" section now points at the newCONTRIBUTING.mdinstead of restating commit conventions inline.
Notes
- The
settings — paths tabcapture was deliberately omitted from the spec — it renders the user's real/mnt/...mount paths, which isn't a good first-impression image for the README. Re-add behind a redaction step if it's wanted later.
DS03 — Dependency freshness sweep (closed 2026-05-16)
8 clusters folding every direct dep + GitHub Actions pin + pre-commit
hook rev forward to its current latest stable, plus a new frontend CI
lane that gates npm run lint / tsc --noEmit / npm test on every
push. Spec at docs/specs/DS03-dependency-freshness.md; closing
/audit returned clean (trivy/gitleaks/semgrep/ruff/bandit all zero
findings); closing /indie-review 4-lane sweep surfaced 7 actionable
findings folded as Cluster R1. Shipped across 10 commits
(1916cd7..f9be074).
Two new docs-tests at tests/docs/ (test_dep_pin_coupling.py,
test_no_pre_release_pins.py) make the cross-pin invariants
(uv.lock ↔ pre-commit hook revs; ci.yml/release.yml ↔ pre-commit
gitleaks rev) CI-enforceable so the DS02-R2-shape "CI catches what
local missed" gap cannot recur for dep pins.
Highlights (user-visible deliverables):
- Latest stable across the dependency tree. Five Python runtime
- dev floors bumped (
pydantic >=2.13,uvicorn[standard] >=0.47,sse-starlette >=3.4,hypothesis >=6.152,ruff >=0.15). Eighteen frontend floors bumped (React 19.2.6, Vite 8.0.13, Vitest 4.1.6, Tailwind 4.3.0, TypeScript 6.0.3, ESLint 10.4.0, Playwright 1.60.0, etc.). Five pre-commit hook revs aligned.
- dev floors bumped (
- Node 24 LTS.
engines.node: "20.x"→"24.x"(Node 20 reached End-of-Life April 2026; Node 24 is the current Active LTS). - New frontend CI lane.
frontend-lint-types-testjob inci.yml+release.yml(Linux-only matrix; readsnode-version-file: frontend/package.jsonfor the LTS floor; gatesnpm ci→eslint→tsc --noEmit→vitest). Closes the gap where every prior release shipped trusting local pre-commit for the frontend. - Opportunistic mypy 1 → 2. The
>=1.13constraint was unbounded souv lock --upgradepulled the major;uv run mypyreturned clean on all 175 source files understrict = trueunchanged, so the bump qualified under the spec's "non-breaking only" test. The mypy 2.0 strict-mode default-shifts (--strict-bytes,--local-partial-types) are documented inline inpyproject.tomlso future contributors aren't surprised. - Cross-pin lockstep enforced. New
tests/docs/test_dep_pin_ coupling.pyassertsuv.lock↔.pre-commit-config.yamlparity for ruff/mypy/bandit and triple parity (ci.yml↔release.yml↔.pre-commit-config.yaml) onGITLEAKS_VERSION. The HEAD-visible drift surfaced at Step 1 (pre-commit gitleaksv8.21.0vs CI8.24.3) is closed and the test prevents recurrence. - Spec-text drift caught. Cluster H corrected
pnpm→npmcommand samples indocs/specs/{DS02,DS05}.mdthat had diverged fromP06-frontend-mvp.md's source-of-truth ("npm not pnpm/yarn/bun").
Deferred to follow-up phases (per the spec's non-breaking-only
rule): pydantic v3, fastapi 1.0, react 20, vite 9, mypy 3.x,
actions/upload-artifact v5+, actions/download-artifact v5+,
softprops/action-gh-release v3, pre-commit-hooks v6,
@types/node v25.
DS05 — Test-file seam-split sweep (closed 2026-05-16)
Three test files breaching their size caps split along stable seams;
one permanent fix for the DS02 R2 lesson wired into pre-commit.
Spec at docs/specs/DS05-test-file-seam-split.md; two cold-eyes
review loops converged on the implementation; closing /audit
returned clean (10/10 gates pass); closing /indie-review 5/5
lanes PASS with 2 LOW spec-history nits folded as Cluster R1.
Shipped across 7 commits (738b418..d9c6817).
Patterns addressed: three test files over their layer's hard cap
split into siblings + helper modules so the entire test suite
respects coding-standards.md § 2; one CI-only gate
(tools/check_api_types_sync.py) wired into .pre-commit-config.yaml
so the DS02 R2 "CI catches what local missed" gap is closed
permanently. No production-code changes; same tests run before
and after, just organised across more files.
A — SettingsPage.test.tsx split (742 → 336 + 301 + 104) 2e1f754:
- New
_settingsPageFixtures.tsxhoists therenderwrapper +config: AppConfigResponseliteral so all three test files import one source. SettingsPage_render.test.tsx(was L72-L349 of the original) carries the 9-tab headers + RetroArch Setup-bannerit.each- Updates R36 banner + Filters/Picker chip-lists + Updates/Interface dropdown render-and-patch pairs.
SettingsPage_destructive_confirm.test.tsx(was L520-L602) carries the FP12 § H + FP13 § B2 destructive-DAT-confirm cluster (4itblocks).- Main
SettingsPage.test.tsxretains year-range, region-priority, snapshots, media-cache, paths, backup-export, restart-banner, cart_clear_on_copy + the DS02 D1?tab=URL-state nested describe.
B — test_runner.py split (526 → 240 + 277) 1ed6d3f:
- New
tests/copy/_runner_helpers.pyhoists_machine+_planfactory helpers so both test files import them. test_runner_lifecycle.py(was L274-L526) carries Pause / resume / cancel + DS01 + FP05 clusters.- Main
test_runner.pyretains Dry-run + Apply + Playlist conflict tests.
C — test_dat.py split (447 → 112 + 121 + 255) 7b565f2:
test_dat_basic.py(was L13-L105): happy-path parsing.test_dat_security.py(was L107-L211): XXE entity exfiltration, billion-laughs DoS, zip-bomb member-size cap.test_dat_validation.py(was L212-L447): structural well-formedness, value-range checks, file-typing, driver- status rate-limiting, FP04 OSError surfacing.mini_datfixture intests/parser/conftest.pyauto-inherited via pytest's conftest scoping; no fixture duplication.
D — Permanent fix for the DS02 R2 lesson aa2ded0:
- D1:
.pre-commit-config.yamlgains a localcheck-api-types-synchook (pass_filenames: false,always_run: true). The hook now fires on every commit so the Python ↔ TS drift gate runs locally, not just in CI. - D1-test:
tests/tools/test_check_api_types_sync.pypins two invariants: the script exits 0 at HEAD;PYTHON_SOURCEScovers everyapi/schemas*.pysibling at HEAD (exact DS02 R2 root-cause regression-lock — a future split that adds another sibling fires this test in the same commit). - D2:
docs/standards/coding-standards.md§ 2 extended with an explicit test-file caps row (Python tests 500/300, frontend test files 500/300). Closes the test-cap ambiguity DS04 left unresolved. - D3:
docs/journal/DS02.md"What was learned" updated to cross-reference DS05 Cluster D as the closing fix for the R2 post-mortem follow-up.
R1 — Closing-review fold-in (2 spec-history corrections) d9c6817:
- R1a:
docs/specs/DS05-test-file-seam-split.md§ "Tests to write first" said "three structural-assertion tests"; HEAD has 18 cases across two files. Spec updated. - R1b: spec named
_settingsPageFixtures.ts; HEAD is.tsx(JSX requirement). Spec note added.
Five backend gates green at close: 605 pytest pass / 87% coverage /
0 ruff / 0 ruff-format / 0 mypy / 0 bandit. Frontend gates green:
301 vitest pass / 0 eslint / 0 tsc. New pre-commit gate green:
check_api_types_sync.py exits 0 at HEAD (89 models scanned across
12 files; 61 TS interfaces match).
DS02 — Tier 3 structural debt sweep (closed 2026-05-15)
18 sub-bullets across 7 clusters sourced from the 2026-05-04 +
2026-05-14 indie-review Tier 3 partitions and the 2026-05-14
debt-sweep mechanical-drift batch, scoped down by Step 1
verification (6 of 17 original sub-items dropped as verified stale).
Spec at docs/specs/DS02-structural-debt-sweep.md; cold-eyes review
converged through 2 loops; closing /audit returned clean; closing
/indie-review surfaced 3 MED + 1 LOW on the DS02 surface itself,
folded as Cluster R1. Shipped across 4 commits (c0a6ad6..eb000e4).
Patterns addressed: five source files over the 500-line hard cap
split into smaller modules; six hardcoded "Loading…" JSX strings
moved into strings.loading.*; skip-to-main link + <main> label
aria-livepolite regions on route-level loading fallbacks + sibling-landmark labels on Help / Library / Cart asides; Settings-tab URL state viauseSearchParams; AlternativesDrawer + CopyModal wrapped inErrorBoundarywith named-string fallbacks; CHANGELOG versioning-policy paragraph refreshed to current truth +frontend/package.jsonlockstep withpyproject.tomlat v1.2.0 + new.claude/bump.jsonrecipe entry;revision_key_ofmemoization via@functools.lru_cache(8192);WorldState.bytes_by_machineprecomputed at construction soGET /api/gamesper-request bytes-sum drops from O(M × R) to O(|filtered|).
A — Oversized source file splits (A1–A5) 8bf3844:
- A1:
frontend/src/api/types.ts1032 → 493 lines; zod validators extracted to siblingfrontend/src/api/schemas.ts(591 lines — acknowledged "Deliberately not in scope" exception, see R1c). Public import surface preserved via re-export. - A2:
src/mame_curator/cli/__init__.py631 → 187 lines; one module per subcommand undercli/commands/{parse,filter,copy,setup, serve,refresh_inis}.py; dispatchingbuild_parser()+main()stay incli/__init__.py.argparse.set_defaults(func=...)dispatch shape unchanged. - A3:
frontend/src/strings.ts622 → 14 lines (re-export barrel); body extracted tofrontend/src/strings_internal.ts(646 lines — pre-approved as "Deliberately not in scope": per-domain folder split would force every call-site to update its import path). - A4:
src/mame_curator/copy/runner.py518 → 540 lines (file still over cap);_resolve_conflicts(~80 lines) extracted as module-level helper. Full per-phase decomposition ofrun_copyremains deferred per spec § Deliberately not in scope (warrants its own focused spec). - A5:
src/mame_curator/api/schemas.py515 → 329 lines (re-export facade); 40 Pydantic models split into 5 sibling modules (schemas_copy.py,schemas_fs.py,schemas_games.py,schemas_overrides.py,schemas_setup.py). Sibling-file layout preferred over the originally-namedschemas/package directory (functionally equivalent, one fewer directory).
B — Hardcoded UI strings (B1/B2) eeaff05:
- B1: added six
strings.loading.*entries (sessions,activity,stats,help,settings,generic) matching the existingerror.*/confirm.*namespacing. - B2: replaced six literal "Loading…" JSX strings in
frontend/src/App.tsxwithstrings.loading.*references.
C — Accessibility polish (C1–C4) eeaff05:
- C1: "Skip to main content" link added at the top of
AppShell.tsx(standardsr-only focus:not-sr-onlypattern);<main>gainsid="main"+tabIndex={-1}. - C2:
<main>landmark labelled viastrings.a11y.mainLandmarkso screen readers announce "Main content, region" not just "main, region". - C3: five route-level loading fallbacks in
App.tsxwrapped in<div role="status" aria-live="polite">so screen readers announce route transitions. - C4: four sibling-landmark
aria-labels on<aside>/<article>inHelpPage.tsx(topic list + rendered topic),LibraryPage.tsx(FiltersSidebar), andCartPanel.tsx.
D — Settings-tab URL state (D1) eeaff05:
- D1:
SettingsPage.tsxnow persists the active tab viauseSearchParams(?tab=…). Deep-linking a tab works; back-button moves between tabs; default-tab behaviour preserved when the param is absent.
E — ErrorBoundary nesting (E1/E2) eeaff05:
- E1:
AlternativesDrawerwrapped in<ErrorBoundary>so a render error stays scoped to the drawer subtree instead of crashing the library page. - E2:
CopyModalwrapped in<ErrorBoundary>for the same reason. Named fallback strings wired in R1b.
F — Mechanical drift (F1/F2) eeaff05:
- F1:
CHANGELOG.mdversioning-policy paragraph rewritten to current truth ("v1.0.0 shipped at P09 (2026-05-04). Subsequent minor releases accumulate phase-closing tags between them…"). - F2:
frontend/package.jsonbumped 0.0.1 → 1.2.0 to matchpyproject.toml;.claude/bump.jsonrecipe entry added so future/bumpruns roll both versions in lockstep.
G — Perf micro-fixes (G1/G2) eeaff05:
- G1:
revision_key_ofinfilter/heuristics.pydecorated with@functools.lru_cache(maxsize=8192). Each call inside_cmp_revision(twice per pair × N log N comparisons per candidate-group sort) now hits the cache instead of redoing the regex-and-tuple work. Tests intests/filter/test_picker_revision_memoization.pycallcache_clear()before each stateful assertion so process-wide state doesn't leak between tests. - G2:
bytes_by_machine: Mapping[str, int]precomputed onWorldStateat construction (api/state.py); call sites inapi/routes/games.pynow doO(|filtered|)instead ofO(M × R)per request.
R1 — Closing-review fold-in (3 corrections) eb000e4:
- R1a:
frontend/src/api/types.ts:6comment mis-cited the DS02 cluster id for the zod extraction ("A3" — the strings split; the types/schemas split is A1). One-line correction. - R1b: DS02 spec § E1/E2 promised named fallbacks on the two
modal
ErrorBoundarywraps; HEAD wrapped both modals structurally but passed nofallbackprop. Addedstrings.errors.alternativesFailed+strings.errors.copyModalFailedand wiredfallbackprops on both boundaries using the project's existing alert-panel pattern. Test extended with two source-text grep assertions (RED pre-fix, GREEN post-fix). - R1c: DS02 spec amendments — § A5 acknowledges the sibling-file
layout that actually shipped (vs originally-named
schemas/package directory); § "Deliberately not in scope" addsfrontend/src/api/schemas.ts(591 lines) entry with the per-domain split rationale.
Five backend gates green at close: 583 pytest pass / 87.27% coverage / 0 ruff / 0 ruff-format / 0 mypy / 0 bandit. Frontend gates green: 301 vitest pass / 0 eslint / 0 tsc.
FP28 — Tier 2 review fold-in: hardening + correctness (closed 2026-05-15)
14 sub-fixes sourced from the 2026-05-14 11-lane /indie-review (Tier 2
partition). Spec at docs/specs/FP28-hardening-correctness.md; cold-eyes
review converged through 3 loops (33 verified findings folded inline);
closing /indie-review surfaced 3 findings (1 HIGH + 2 MEDIUM) folded
as Cluster R1. Shipped across 6 commits (cb35f26..72505d8).
Patterns addressed: concurrency invariants under non-loop-thread or
parallel-session entry (JobManager._emit, recycle_file), regex
mis-capture on nested parens (_LICENSE_RE) and false-positive on
parenthetical-title region words (REGION_RE), mixed-content text
truncation in Machine.description, half-wired dual-channel
warnings in filter.runner, raw KeyError leaking past the typed-
error boundary in _apply_session, missing boundary validation for
paths.retroarch / paths.retroarch_core (PATCH → launch chain),
missing browser-cache headers in /media/* proxy, wrong POSIX exit
code from serve on Ctrl-C, bare except Exception around
create_app, raw ImportError traceback from refresh-inis, and
the wizard-vs-runtime trust-model split for INI refresh.
A — Concurrency hardening (A1/A2/A3) cb35f26:
- A1: hoisted
JobManager._loopassignment fromstart()to__init__(with optionalloopparameter + fallback for sync test fixtures), then enforced the loop-thread invariant at the top of_emitviaRuntimeError(sopython -Ocannot strip the guard). Check sits after the existing_current-is-Noneearly return so FP21-L's no-op-on-cleared-current contract is preserved. - A2: wrapped the
recycle_filecritical section in a stdlibos.O_EXCLlockfile atrecycle_root / f"{session_id}.lock"— serialises parallel sessions in the samesession_idwithout pulling afilelockdep. Orphan recovery threshold 60 s (~1200x the same-fs p99); same-process contention falls through to a 10 ms retry-sleep. - A3: under A2's lock, the
target_dir_existedsnapshot is race-free; neither rollback path (manifest-write OSError, post- move OSError) can rmdir a directory another session relies on. Inline comments at the snapshot site and the lock-acquire credit the invariant.
B — Correctness regex + extraction + typed errors (B1–B5) a85307d:
- B1: rewrote
_LICENSE_RE's developer capture from.+?to[^()]+?— nested-parens inputs like"Atari (JSA III) (Williams license)"now correctly bind publisher="Atari (JSA III)", developer="Williams"instead of mis-binding to"Atari"/"JSA III) (Williams". - B2: tightened
REGION_REwith a two-branch form — after the region token, allow either (whitespace + comma/close-paren/EOL —(World),(USA, Set 2)) or (whitespace + non-uppercase char —(World 910411),(Europe v2.1)). Rejects(World Heroes 2)becauseHeroesstarts with an uppercase H. The lookahead branch was added during testing after the initial single-branch form broke real MAME(Region YearOrVersion)patterns. - B3: switched
Machine.description's source fromdescription_elem.textto"".join(description_elem.itertext()).strip()— mixed-content<description>Foo <i>bar</i> baz</description>now yields"Foo bar baz"rather than truncating at"Foo ". Defensive (MAME DATs don't currently ship mixed-content). - B4: wired
logger.warning(msg)alongside the existingFilterResult.warnings.append(msg)at the three override- rejection paths infilter/runner.py. Dual-channel contract perfilter/spec.md§ Phase C. - B5: wrapped the
sessions.sessions[sessions.active]subscript in_apply_sessionand re-raised bareKeyErrorasSessionsError(FilterError). Reachable only via Pydantic v2model_copy(which skips validators); direct construction is blocked by the existingmodel_validator.
C — Boundary hardening (C1/C2) 0281695:
- C1: extended
_validate_pathsto gatepaths.retroarch(POSIXos.access(p, os.X_OK)/ Windowsshutil.which) andpaths.retroarch_core(.exists()on both platforms — cores aredlopen/LoadLibrary'd, not directly executable). Closes the PATCH-config → launch chain — pre-fix a malicious PATCH could landpaths.retroarch=/usr/bin/evil-thingwhichapi/routes/games.py:275would then hand tosubprocess.run. - C2: replaced
media_proxy's hardcodedmedia_type="image/png"withmimetypes.guess_type(str(path))[0] or "image/png"(suffix- sniffed) and addedCache-Control: public, max-age=2592000, immutableper design § 6.3 ("Cache is permanent by default"). Pre-fix every page-load re-fetched libretro thumbnails despite the permanent on-disk cache.
D — CLI exit-code + error-surface drift (D1/D2/D3) 8fe7641:
- D1: wrapped
uvicorn.runintry/except KeyboardInterrupt: return 130(defence-in-depth — uvicorn currently catches Ctrl-C internally) and changed the trailing return from 0 to 130 so the function honours POSIX convention regardless of which side ends up catching the signal. - D2: narrowed the bare
except Exceptionaroundcreate_app()to(ConfigError, ParserError, FilterError). Programmer errors (RuntimeError, AttributeError, ...) now propagate as tracebacks instead of being squashed into a one-line stderr message. Per coding-standards § 9 typed-error hierarchy: the traceback IS the actionable signal. - D3: lifted the three inline imports (asyncio, httpx,
mame_curator.updates) in
_cmd_refresh_inisinto atry/except ImportErrormirroring_cmd_serve's guard. Defence-in-depth pattern consistency — httpx is a top-level dep so the ImportError path only fires in exotic install states (pip install --no-deps, broken wheel, partial editable install).
E — Design § 6.7 deferral (E1) 8dcae9d:
- E1: new ADR at
docs/decisions/0004-ini-refresh-trust-model.mdrecording the wizard-vs-refresh split (§ 6.6 promised mirrors + sha256 for the wizard bootstrap; § 6.7 runtime refresh stays silent on integrity), current refresh trust posture (HTTPS-only, AntoPISA repo, no per-file sha256), and the post-v1 hardening path. One-line cross-link added to design.md § 6.7 so the next reviewer doesn't re-raise the conflated flag.
R1 — Closing-review fold-in (3 corrections) 72505d8:
- R1.1: C2 tests were dead —
if status != 200: returnshort- circuited the cache-control assertion without raising, and the sniff test was a barepytest.xfailwith no body (both would have passed against pre-fix code, failing acceptance criterion 9's red-pre-fix / green-post-fix demand). Rewrote both tests using the existingtests/api/test_routes_media.pyrespx mock pattern. - R1.2: D2's narrowed-except is semantically dead at the
immediate call site —
create_appis currently a pure FastAPI factory and config validation happens inside the async lifespan. Amended the inline comment to name the dead-code constraint as defence-in-depth for a future refactor. - R1.3: FP28-hardening-correctness.md § B2 spec text described a single-branch regex tightening; updated the spec body to document the two-branch lookahead form that shipped, naming the regression that drove the refinement.
DS04 — Test-suite quality sweep (closed 2026-05-15)
37 sub-fixes sourced from the 2026-05-15 5-lane test-suite audit
(parser+filter / copy / api+media+downloads / frontend components /
frontend pages+e2e) on commit 06fe3b8 (post-FP27). Spec at
docs/specs/DS04-test-suite-quality.md; cold-eyes loop converged
on a single pass (10 findings folded inline); closing review surfaced
3 comment-drift findings folded as Cluster R1. Shipped across
5 commits (dca57b2..d5918a0).
Patterns addressed: dead-spec coverage (FP25-C rollback tests
contradicting copy/spec.md:260's FP21-D supersession), vitest
prototype / global-state pollution leaks, unnecessary I/O (50k JSONL
twice, 6 MiB string allocations, 2.5 s Playwright sleep, 2 MB YAML
writes), FP##-named duplicate tests subsumed by canonical files, an
empty Hypothesis strategy, 11 redundant afterEach(() => cleanup())
calls under vitest auto-cleanup, and a hardcoded /tmp/ path with
# noqa: S108.
T1a — Tier 1 backend mechanical batch dca57b2:
- T1.1: deleted two FP25-C rollback tests in
tests/copy/test_fp25_recyclebin.py. The "rollback returns file to original" premise was retired by FP21-D's manifest-first ordering (copy/spec.md:260); the tests passed today only becausesrc.exists()was trivially true on the post-FP21-D path. Two FP25-F tests remain. - T1.4: replaced
JobManager(history_dir=Path("/tmp/unused")) # noqa: S108intests/api/test_fp21_fixes.py:233withtmp_path. - T1.5: collapsed three iterparse-OSError tests in
tests/parser/test_listxml.py(one perparse_listxml_*callable) to one@pytest.mark.parametrize. - T1.6: relocated
test_listxml_cloneof.py+listxml_cloneof.xmlfixture fromtests/filter/totests/parser/; added alistxml_cloneoffixture totests/parser/conftest.py. - T1.7: removed the dead
monkeypatch.setattr(executor, "copy_one", ...)intests/copy/test_runner.py.runner.py:17importscopy_oneat module level, so only the runner-module patch is load-bearing. - T1.9: dropped a bare
time.sleep(0.1)after copy abort intests/api/test_routes_copy.py; no assertion was gating the wait. Removed the now-unusedimport time. - T1.10: hoisted
_plant_50k_activity_logfrom per-test helper to a@pytest.fixtureintests/api/test_routes_activity.py; dropped a stale comment that claimed the planter wrotef"/tmp/{i}"(actual format isf"sample://{i}"). - T1.13: parametrized two
retroarch_configuredsetup-check tests intests/api/test_routes_stubs.pyover(has_retroarch, has_core, expected); added the missingcore_onlycase for completeness.
T1c — Tier 1 frontend batch 908df0c:
- T1.2:
LibraryGrid.test.tsxbeforeAllnow captures originalclientHeight/clientWidth/getBoundingClientRectdescriptors and restores them in a newafterAll. The prior stub leaked the 1200×600 dimensions into every later-running test file. - T1.3: dropped a nested
beforeEach/afterEachinuseCopySession.test.tsxthat bypassedvi.stubGlobal(sovi.unstubAllGlobalscouldn't restore) and added a redundant cleanup. The file-level setup at line 57 already covers nested describes. - T1.8: collapsed three
LibraryGriddata-columnsformula tests to oneit.eachtable over(layout, hint, expected), adding the'auto'case the original rerender-based test exercised inline. - T1.11: dropped
page.waitForTimeout(2500)infrontend/e2e/fp25-ux-walkthrough.spec.ts. Playwright'sexpect(toasts).toHaveCount(1)auto-polls up to 5 s — comfortably covers the 1500 ms dedup window without a hardcoded sleep. - T1.12:
BackupTab.test.tsxsize-cap test stubsFile.sizeviaObject.definePropertyinstead of allocating a 6 MiB string. - T1.14: replaced the tautological
querySelectorAll('[role="button"]').length < 3000assertion in theLibraryGridvirtualization test with a non-vacuous spacer-height check (≥ 10,000 px for a 3,000-card grid at 280 px row pitch / 5 cols).
T2a — Tier 2 backend batch 9817c56:
- T2.9: deleted
test_copy_one_cleans_tmp_on_keyboard_interruptfromtests/copy/test_fp01_fixes.py. The equivalent attest_fp02_fixes.py:317uses aprogress=callback and exercises the same_chunked_copywrite path; post-FP27 B1 both branches funnel through it. - T2.10: deleted
test_recycle_same_name_same_second_does_not_clobberfromtest_fp01_fixes.py. The equivalent attest_fp02_fixes.py:158exercises a strongersession_id-distinguishing assertion; the canonicaltest_recyclebin.py:95pins the dir-uniqueness contract. - T2.11: deleted
test_copy_error_str_renders_path_suffix+test_copy_error_str_without_pathfromtest_fp01_fixes.py. Subsumed bytests/copy/test_errors.py:15-46which iterates over everyCopyErrorsubclass and additionally locks the FP07 A4 control-byte repr contract. - T2.14: dropped the
@given(st.fixed_dictionaries({}))decorator intests/api/test_routes_config.py:126. The strategy only ever generated{}, so the test wasn't actually Hypothesis-driven. Removed four unused Hypothesis imports. - T2.16:
test_overrides_oversized_yaml_rejected+test_sessions_oversized_yaml_rejectedswap 2 MB valid-YAML payloads forb"0" * (1024 * 1024 + 1). The 1 MiB pre-parse cap fires on byte count alone. - T2.17: dropped the
elapsed < 5.0defence-in-depth checks intests/parser/test_dat.py's billion-laughs test. Thelen(desc) < 1000length assertion is the strong signal; wall-time thresholds on CI flake. - T2.19:
test_copy_progress_callback_emits_chunkswrites its 3 MiB source file intotmp_pathinstead of the module-scopedsource_dir, sobig.zipdoesn't leak into other tests that iterate over the shared fixture.
T2b + T3 — Tier 2 frontend dedup + Tier 3 polish 0011eb8:
- T2.12: collapsed two near-identical 70-line
retroarch_configuredtests inSettingsPage.test.tsxto oneit.eachtable over(configured, expectedText). - T2.13: dropped three unit-duplicate tests from
frontend/e2e/cart-flow.spec.ts(expand chevron, remove row, Copy-disabled). All covered deterministically at unit level byCartBar.test.tsx+CartPanel.test.tsx. Kept only the +Add → footer-updates → ✓Added → banner-dismiss integration scenario. Also removed the surviving test'spage.waitForSelector(locator auto-wait covers it). - T3.1: removed 11 redundant
afterEach(() => cleanup())blocks acrossFiltersSidebar.test.tsx,ChipListEditor.test.tsx,DragReorderList.test.tsx,BackupTab.test.tsx,FsBrowser.test.tsx,SnapshotsTab.test.tsx,YearRangeEditor.test.tsx,useConfig.test.tsx,useFs.test.tsx,useCopySession.test.tsx,useValidateCart.test.tsx. Vitestglobals: trueenables RTL auto-cleanup. Where theafterEachwas load-bearing for other work (mock-clear), kept the block and dropped only the redundant cleanup line. - T3.2:
FeaturedTilesRow.test.tsxswappedgetByText('Capcom Classics').closest('button')forgetByRole('button', { name: 'Capcom Classics' })— RTL idiom + a11y-regression detector. - T3.3: deleted the
pytest.skipplaceholdertest_no_listxml_self_parents_every_machineintests/api/test_routes_games.py. A skip-only body inflates the test count without proving anything; the canonical coverage lives directly above attest_cloneof_map_collapses_winners(FP23 regression). - T3.4: tagged four tracemalloc-based streaming tests (
test_routes_activity.py× 2,test_cache.py,test_downloads.py) with@pytest.mark.slow; registered the marker inpyproject.toml. - T3.5: deleted
frontend/src/test/fixtures.ts— 7-line doc-only stub promising a fixture that didn't exist. - T3.6:
CmdKPalette.test.tsxSECTION_ORDER export check now usesexpect(...).toBeDefined()instead ofthrow new Error(...). - T3.7: dropped the tautological
cart.getAttribute('href')assertion inAppShell.test.tsx. - T3.8:
AppShell.test.tsxactive-link assertion switched from afont-mediumTailwind class check toaria-current="page"(a11y contract). - T3.9: deleted
frontend/src/components/__tests__/EscOverlayBehavior.test.tsx. The suite regression-locked Radix's built-in<Dialog>/<AlertDialog>Escape-key behaviour — library-coverage, not project code. - T3.11:
playwright.config.tsswitchedtrace: 'retain-on-failure'totrace: 'on-first-retry'. - T3.12: pinned the surviving outcome in
HelpPage.test.tsx:160FP25-J test (sanitizer keeps<img>withsrc=null); dropped the early-return branch that made the assertion vacuous on the alternate outcome. - T3.13: clarified the legacy-mtime fallback docstring in
tests/copy/test_recyclebin.py'stest_recycle_retention_purges_old_entries.
Cluster R1 — closing-review fold-in d5918a0: three comment-only corrections from the post-DS04 review on the changeset itself.
- R1a: rewrote T3.3's
audit-trailcomment to point at the actual canonical test (test_cloneof_map_collapses_winnersin the same file) instead of a non-existentapi/routes/spec.md. - R1b: fixed the row-pitch number in
LibraryGrid.test.tsx's spacer-height comment (280px, not~320px). - R1c: corrected the HTML rationale in
AppShell.test.tsx(hrefis inert on<button>, not "doesn't exist").
Out of scope / deferred:
- T1.15 (audit's
_os.utimelegacy-fallback rewording) reframed to T3.13 — the legacy-mtime code path is still live and correctly exercised. - T2.1–T2.4 (helper hoisting
_machine/_plan/_seed_existing_playlist/_entry/_make_jobto conftest) — the helpers are duplicated across 2–4 sites each, but file-cap acceptance criterion isn't met by helper hoisting alone (the two over-cap files need structural splits). Deferred to a future sweep. - T2.7 (extract 50-line YAML literal from
tests/api/conftest.pyto a fixture file) — same rationale. - T2.18 (remove 6 Linux-only OSError-skip clauses) — audit was incorrect; CI matrix is multi-OS (
ubuntu-latest, macos-latest, windows-latestper.github/workflows/ci.yml:20). The OSError-skip clauses defend against Windows/macOS filesystem behaviour and are load-bearing. Dropped from scope. - T3.10 (collapse
SettingsPage.test.tsx:506-587DAT-confirm cluster) — the four tests assert different aspects of the confirm-flow and a structural collapse risks regression. The file remains over the hard cap and is covered by the conditional[mame-curator-1034]roadmap follow-up. - Three conditional roadmap follow-ups (
[mame-curator-1034/1035/1036]) opened at DS04-spec-time for files that might stay over-cap after the sweep. Post-DS04 sizes:SettingsPage.test.tsx686→665,tests/copy/test_runner.py528→526,tests/parser/test_dat.py453→447. All three stay over-cap (685 → 665 still > 500; 526 still > 500; 447 still > 300 soft cap). Entries remain📋for a future sweep.
Acceptance result: 552 backend tests → 546 (-6 net: -2 FP25-C, -2 from parametrize collapses, -1 dup KeyboardInterrupt, -2 dup CopyError str, -1 pytest.skip placeholder, +2 from added retroarch core_only case + various). Frontend 280 → 278 (-2 from EscOverlayBehavior deletion; T1.8 + T2.12 + T3 net neutral). Coverage 86.94% (unchanged within tolerance). Three file caps still over hard cap, tracked as conditional follow-ups.
FP27 — Tier 1 review fold-in: zombie features + data integrity (closed 2026-05-14)
16 sub-bullets sourced from the 2026-05-14 11-lane /indie-review
Tier 1 partition: 9 zombie-feature reconciliations (with A6 split
a/b/c = 11 sub-fixes), 5 data-integrity hardening fixes, 2 doc-drift
fixes, plus closing-review Cluster R1 (4 fixes on the FP27 surface
itself). Spec at docs/specs/FP27-zombie-features-data-integrity.md;
cold-eyes review converged on loop 5 (0 residual findings). Shipped
across 5 commits (cfe612c..976b119).
T1a — A1/A2/A7/A9/C1/C2 mechanical batch cfe612c:
- A1:
filter.ConfigErrordeleted (noraisesites insrc/; PydanticValidationErrorcovers reachable validation). - A2:
copy.PreflightErrordeleted (FP07:101 flagged it three releases ago; noraisesites have appeared). - A7:
--versionwired (argparse action="version");cli/spec.mdcaveat dropped. - A9:
parse_listxml_bios_chain+BIOSChainEntryexported viaparser.__all__;parser/spec.mddocuments both. - C1:
CLAUDE.mdarch diagram drops stale(P04 — next); P04 + P05 + P07 marked ✅. - C2:
help/+setup/ghost-module rows removed fromCLAUDE.mdREADME.mddiagrams; one-line annotation points at the real surfaces (api/routes/help.pyandapi/routes/stubs.py).
T1b — A3 recyclebin activity events 65615fb: recycle_file +
purge_recycle append FILE_RECYCLED / RECYCLE_PURGED
ActivityEvent rows via the existing copy.activity.append_activity
writer + typed *Details constructors. Sentinel
session_id="_purge" for system-scoped purge action. Append
failures logger.exception (soft failure; FS state is primary
contract).
T1c — Frontend Tier 1 batch 4c54be4:
- A4:
useCopySession.resolveConflictremoved;CopyModal's three conflict buttons become a single read-only banner pointing at abort + restart with updatedappend_decisions(the only real resolution path — there is no/api/copy/resolve-conflictendpoint and post-v1 work would add one). - A5: CmdK
'games'+'settings'sections dropped at four lockstep call-sites (type union,SECTION_ORDER,groupedinitializer,strings.cmdK.sections); palette hosts only'actions'+'help'. - A6a: design-spec
Escbullet credits Radix<Dialog>/<AlertDialog>primitives (which deliver the behavior ambiently). Regression-lock atEscOverlayBehavior.test.tsxpins the empirical behavior for both primitives. - A6b:
/wired inApp.tsx; seconduseKeyboardbinding focuses#filters-searchon the library FiltersSidebar. - A6c: design-spec chord cohort (
?,g …,j/k,o/Enter,a,n) struck with a note pointing at a P14-class follow-up for the chord engine + focused-card model. - A8:
strings.tsorphan-key sweep cleared. DeletedhistoryEmpty,resetConfig,launchNotConfigured. Newfrontend/src/__tests__/strings.test.tsruns the sweep with aDYNAMIC_ACCESS_PARENTSallowlist enumerating 20 legitimatestrings.<parent>[<var>]patterns so future drift gets caught.
T2 — Tier 2 data integrity 2d9078e:
- B1:
copy_onefsync gap closed._chunked_copyaddsfout.flush(); os.fsync(fout.fileno())inside thewithblock; both write paths funnel through_chunked_copy; addsfsync_parent_dir(dst)afteros.replace. - B2:
restore_snapshotstage-then-promote. Snapshot bytes land in<snap_dir>/_restore_staging/<name>first; live-target replaces run only after every staging write succeeds. - B3:
download()streams chunks straight to.tmpsibling ofdest. Sha256 incremental; on cap-abort or mismatch, close-then- unlink the.tmp(Windows-safe order);fsync+ parent-dir fsync wrap theos.replace. - B4:
fetch_with_cacherejects non-http(s) schemes before any network call; addsmax_bytescap (default 16 MiB) viaclient.stream("GET", url)+aiter_bytes(64*1024)writing to.tmpsibling. - B5:
GET /api/activitystreams the JSONL line-by-line. Replacesread_text(...).splitlines()withopen()+deque(maxlen=page * page_size); slice formulalist(reversed(deque))[start:start+page_size]. Per-request RAM scales with the deque, not the file size.
Cluster R1 — closing-review fold-in 976b119: closing
/indie-review on the FP27 surface surfaced four findings on the
changeset itself.
- R1a:
downloads.py— hoistedtmpbinding above the attempt loop + widened the cleanupexceptto(httpx.HTTPError, OSError)so a flush/fsync OSError on the success path can't orphan a.tmp. - R1b: frontend
/binding —e.preventDefault()hoisted to the first handler line so Firefox's typeahead-find never wins off-route (/help,/settings, …). - R1c: drive-by — removed a redundant inner
import shutilinapi/persist.py:_prune_old_snapshots(the top-levelimport shutiladded for B2 made it ruff F811-adjacent dead code). - R1d:
EscOverlayBehavior.test.tsxregression-lock tightened to cover plain<Dialog>in addition to<AlertDialog>— the design-spec line credits both Radix primitives.
Deferred (filed for follow-up, not Tier 1):
/api/activitydeque preallocation DoS on unboundedpageparameter — file for FP28 / DS02.ActivityLogErrorobservability — caller can't see audit-trail drop on append failure (spec acknowledged as soft failure; post-v1 follow-up if needed).--versionsubparser propagation — currently top-level only, matches A7 spec; subparser inheritance is a follow-up.
Gate at close: 551 backend tests + 279 frontend tests + ruff + ruff
format + mypy + bandit + eslint + tsc all green. Tag:
FP27-complete.
FP21 — /indie-review Tier 2 hardening sweep (closed 2026-05-11)
20 sub-bullets across filter/, copy/, api/, downloads.py,
run.sh, and the frontend, sourced from the 2026-05-04 multi-agent
review's Tier 2 fold-in. Real-bug class — manifests on common paths
but not a security hole or silent-loss vector. Shipped across 5
commits:
- Filter A/B/C
8363996—picker.explain_picknow records the FIRST decisive tiebreaker per opponent (with union across opponents) instead of every tiebreaker that returned<0against any opponent;tests/snapshots/filter_smoke.jsonregenerated.drops._deviceuses strict-identitym.runnable is Falseso a future widening tobool | Nonedoesn't flip "unknown" into DEVICE.filter/spec.mdclarifies the per-opponent first-decisive semantics and the typed- error contract (loader →SessionsError; direct construction → PydanticValidationError). - Copy D/E/F/G
e7a88f1—recyclebin.recycle_filewrites a per-file<basename>.manifest.json(was a per-dirmanifest.jsonthat multiple files in the same session overwrote), and writes the manifest BEFORE moving the file so the source is intact under any single-step failure.preflight.preflightincludes BIOS-chain zips intotal_neededand subtractsalready_copiedso the free-space gap reflects whatrun_copyactually transfers.purge_recycledecides eligibility from the latestrecycled_atacross manifests (legacymanifest.jsonfallback supported), and accumulatesbytes_freedonly after a successfulrmtree.executor.copy_onewraps the initialsrc.stat()intry/except FileNotFoundError → SKIPPED_MISSING_SOURCEinstead of FAILED. - API H + J
f24a458—routes/media.media_proxyreturnsFileResponse(path)instead of syncpath.read_bytes()so the asyncio event loop interleaves under thumbnail fan-out.routes/games.launch_gameraises typedRetroArchNotConfiguredError(422)andRomFileNotFoundError(404);strings.tsbyCode entries land beside the new codes — closes FP22-D that was deferred so the strings.ts no-dead-entry contract held. - API I/K/L/M/N/O
0311040— lifespan shutdown logs a WARNING onthread.is_alive()after join timeout (was silent detach).JobManager._events_iteratorsnapshots history to local tuples beforeheapq.mergeand registers the subscriber before draining — fixes a realdeque mutated during iterationrace and a "lost events between replay and append" gap. L investigated and ruled non-reachable; defensive guard pinned with a test.persist.snapshot_filesprunes oldest siblings beyondMAX_SNAPSHOTS = 200.routes/config.patch_configvalidates the body through a newAppConfigPatch(extra='forbid') per spec line 647, withdeep_mergedepth-capped at 10 as defence-in-depth.routes/config.import_configdropsdata/import.in_progressduring the 4-file batch so a startup-time check can detect a half-applied import. - Downloads P + run.sh Q + frontend R/S/T
b6c6728—downloads.downloadstreams viaclient.stream+aiter_bytes, summing againstDEFAULT_MAX_BYTES = 100 MBwith Content-Length pre-check.run.shpinscurl --proto '=https' --tlsv1.2on the uv installer pipe.useAlternatives'useOverride+useLaunchGamebaketoastApiErrorintoonError.useKeyboardstores bindings in a ref so the listener registers once per lifetime — chord shortcuts (g l) now survive re-renders.LibraryGridadds composite-grid semantics:role="grid"+role="row"+role="gridcell"with rovingtabindex, arrow /j/k/o/ Enter / Home / End nav, andvirtualizer.scrollToIndexon focus moves.
FP25-C envelope superseded: the move-then-rollback approach is
replaced by FP21-D's write-then-move ordering. RecycleError.recycled_orphan
remains as a vestigial field (never set under the new ordering) for
backward compat with FP26-P callers.
Test totals: 523 backend / 273 frontend pass; coverage 86.79%; ruff + ruff format + mypy + bandit + eslint + tsc all clean.
FP26 — FP25 closing-review fold-in + UX e2e walkthroughs (closed 2026-05-11)
21 sub-bullets sourced from FP25's closing /audit + 4-lane
/indie-review (5 Tier 1 + 12 Tier 2 + 15+ Tier 3) plus the user's
mid-session Playwright UX walkthrough scope-add. Shipped across
five commits:
- Tier 3 (Playwright UX walkthroughs)
dddae88— newfrontend/e2e/fp25-ux-walkthrough.spec.ts(4 cases): cold-start outage → one Sonner toast (FP25-G); LibraryErrorPanel sticky- panel + Retry-disabled flow (FP25-H, captured FP26-V buggy behavior); HelpPage scoped DOMPurify end-to-end (FP25-I/J — no<script>survives,target="_blank"carriesrel="noopener noreferrer",data:URL stripped); settings restore failure persistent alert (FP25-K(12) UX shape). - FP26-V (NEW Tier 1)
8b70f34—LibraryPagekeeps the error panel mounted while a refetch from an errored state is in flight. React Query v5 resetsisErrorto false during refetch; the old{games.isError ? <Panel/> : <Grid/>}ternary unmounted the panel, so FP25-H'sdisabled={isFetching}/ "Retrying…" affordance never reached the user's screen. Fix:games.isError || (games.isFetching && errorUpdatedAt > dataUpdatedAt). The Playwright walkthrough caught it; unit tests didn't because they rendered the panel directly withisFetching=true, bypassing the host's conditional. - Tier 1 + L + H batch
a54dd10— FP26-A strengthened world_lock test sufficiency via theasserted_set_worldmonkey-patch (per-route + cross-route concurrent test both asserttracker.heldat everyset_worldcall); FP26-B wrappedmkdir(parent)inActivityLogErrorenvelope; FP26-C fixed FP25-F's vacuous assertions (nowrglobover the whole recycle tree); FP26-D extended FP25-E skipif to "skip unless linux" (macOS fork hazard); FP26-H added the 0-byte-write defensive-branch test; FP26-L dropped the dead FP25-K(12)restore.isPending ? null : ...conditional (React Query 5 already clearserroronmutate()). - Tier 2 batch
1653737— FP26-E P04 spec_deactivateenumeration; FP26-F best-effort parent-dir fsync on first activity append (_atomic._fsync_parent_dirrenamed to publicfsync_parent_dirper Rule of Three); FP26-Gcopy/spec.mddrift cleanup (FP25-C "open" stale text rewritten, broken§ Errors envelopereference fixed,ActivityLogErroradded to enum); FP26-IqueryClient.test.tsxcalls_resetApiErrorToastDedupForTests()in beforeEach; FP26-J/K apiErrorToast docblock acknowledges deliberate unboundedness + names the rejectedtoast({id})alternative; FP26-M allowlist-004 line citation refreshed to grep-instruction +helpSanitizer.sanitize(...)wording; FP26-N_TrackingLockduck-type rationale; FP26-PRecycleError.recycled_orphanattribute for double-failure machine-readable signal.
Final five-gate close: 504 backend tests (1 skipped) / 273
frontend tests / 9 e2e specs / coverage 87% / ruff + ruff format +
mypy + bandit (0 findings all severities) / ESLint + tsc clean.
frontend/dist/ rebuilt.
Workflow lesson saved: e2e walkthroughs catch a class of bug unit tests miss — host-component conditionals that unmount the child during the very state the child is meant to handle. Filed via the user's mid-session "use Playwright to walk through features" direction; FP26-V is the canonical example. Pattern: any feature whose user contract is "X becomes visible while Y is pending" needs an e2e walkthrough exercising the HOST's render condition, not just the child component in isolation.
FP20 — /indie-review Tier 1 security + data-loss fold-in (closed 2026-05-11)
The 2026-05-04 multi-agent indie-review surfaced 12 sub-bullets across 10 lanes — parser XXE/zip-bomb (A), copy non-atomic writes (B), API mutation lock not installed (C), sandbox allowlist admits stale paths (D), help-dir env-override symlink (E), download URL scheme allowlist (F), useApiQuery silent-failure path (G), GameCard aria-label clobber (H), LibraryPage error-panel gap (I), SnapshotsTab restore-error surface (J), FsBrowser Esc-closes-everything (K), HelpPage DOMPurify config hardening (L). All 12 shipped across 14 commits on 2026-05-11:
- A
c3ee50c— parser/dat.py + parser/listxml.py + tests; explicitresolve_entities=False / no_network=True / huge_tree=False / load_dtd=Falsehardened-iterparse kwargs at all four call sites, plus a 256 MiB cap onzf.getinfo(member).file_sizebefore extraction. - B
6a12a93— copy/activity.py (os.open + os.writebypasses the BufferedWriter split risk); copy/recyclebin.py + copy/playlist.py routed through_atomic.atomic_write_text(Rule-of-Three honoured). - C
61fbc68—app.state.world_lock = asyncio.Lock()installed inapi/app.pylifespan;patch_config,restore_config_snapshot,import_config,fs_grant_root,fs_revoke_rootconverted toasyncand wrapped inasync with. - D
52a112c—compose_allowlistfiltersgranted_rootsto entries that satisfy bothexists()andis_dir(); dropped entries emit INFO log naming the resolved path. - E
73f2df8—_help_dir()resolves both branches (override + package-relative) so callers operate on the canonical path. - F
c49225b—download()rejects schemes outside{http, https}via_check_schemeapplied to the primary URL and every mirror before any HTTP attempt. TypedInvalidUrlError(DownloadError). - G
76a3010—createAppQueryClient()factory wiresqueryCache: new QueryCache({ onError: toastApiError })so every failing query funnels through the global toast helper. - H
7cc8796— GameCard drops thearia-labelclobber on its wrapper; anaria-labelledbyto a per-cardid-bearing<h3>plus decorativealt=""on the box-art image. - I
4b4faca— newLibraryErrorPanel(alert role, title + hint + Retry button); LibraryPage renders the panel in place of the grid whengames.isError, withgames.refetch()on Retry. - J
3bb01ef—SnapshotsTab.restoreErrorprop renders a persistent alert above the snapshot list (mirrorsBackupTab.error);App.tsxSettingsRoute derives the message fromrestore.error(ApiError.detail preferred). - K
e149c1c—FsBrowserrenders only one dialog layer at a time — when sandbox-blocked, the browse Dialog is unmounted and the AlertDialog is the sole open layer. - L
c9e61b5+d819181— HelpPage DOMPurify hardened:ALLOWED_URI_REGEXP = /^(?:https?|mailto):/i,FORBID_TAGS = ['style', 'form'],FORBID_ATTR = ['style'], aforceKeepAttrhook preservingtarget="_blank", anafterSanitizeAttributeshook settingrel="noopener noreferrer", and adata:src strip for IMG/SOURCE/AUDIO/VIDEO/TRACK closing theDATA_URI_TAGSbypass.
Closing /audit (semgrep + gitleaks on the FP20 surface plus CI-clean
ruff/mypy/bandit/eslint/tsc) returned a single allowlist-004 re-
confirmation; the 5-lane /indie-review surfaced 1 Tier 1 spec-
violation (world_lock covers only 5 of 7 spec-required routes), 7
Tier 2 hardening gaps, and 10 Tier 3 polish items. All 11 batched into
FP25 for the closing-review fold-in. FP20 stays open until FP25
closes; FP20-complete tag will fire then.
FP25 — FP20 closing-review fold-in (closed 2026-05-11)
11 sub-bullets sourced from FP20's closing /audit + 5-lane
/indie-review (1 Tier 1 / 7 Tier 2 / 10 Tier 3 polish items grouped
into K). All 11 shipped per-sub-bullet TDD across 8 commits:
- A
d617cd6—world_lockon the remaining 7 mutation routes (api/routes/curate.pyoverrides POST/DELETE + sessions POST/DELETE, activate, _deactivate;api/routes/games.py:put_notes). Converts each toasync def, dropsDepends(get_world), re-readsrequest.app.state.worldinsideasync with request.app.state.world_lock. New acceptance test fires twoasyncio.gather-ed cross-route mutations and asserts both edits land. Closes the data-loss class on concurrent PATCH + sessions/overrides/notes races. - B
87b32de—copy/activity.pydurability + typedActivityLogError.append_activitynow loops on short writes (POSIX permits short returns on regular files), issues a best- effortos.fsync(suppressed on tmpfs / some networked mounts), and wrapsos.open/os.writefailures in the newActivityLogError(CopyError)envelope.copy/spec.md§ Activity log updated to name the new semantic explicitly. - C + F
f569953—copy/recyclebin.pymanifest atomicity envelope. The manifest write now runs inside try/except: onOSError,shutil.moverolls the recycled file back to its original path (all-or-nothing) and the empty target_dir is cleaned up if this call created it. FP25-F tests monkeypatchos.replaceinsideatomic_write_textto fail and assert nomanifest.json/ no.tmpfiles remain. - D
d210aa6—_atomic.atomic_write_*perm-mode pinned at 0o644 viaos.fchmod(tmp.fileno(), 0o644). Parity withcopy/activity.py:append_activity'sos.open(..., 0o644); the pre-FP25-D 0o600 default left half the data dir owner-only. - E
efd6b6b— concurrent-append property test for the activity log. Fork two child processes each appending 20 × 6 KiB events, assert every resulting JSONL line parses cleanly and the per-child counts match. Exercises POSIX O_APPEND atomicity end-to-end. - G
32d66cb—toastApiError1500 ms dedup window keyed on(code, detail). Cold-start outages (9+ near-simultaneous query failures) now collapse to one toast; distinct errors still surface separately; >1.5 s later the same key re-toasts. - H
84c55cb—LibraryErrorPanelRetry disabled whileisFetching. OptionalisFetching?: booleanprop swaps the label to "Retrying…" anddisableds the button so a frustrated user can't queue redundant refetches. - I + J
b21fe08— HelpPage scoped DOMPurify instance viaDOMPurify(window)so thetarget="_blank"forceKeepAttr, the rel-injection, and the data:-URL strip don't leak to the global singleton. J strengthens the FP20-L data-URL test to a deterministic outcome (either<img>absent OR<img>with no src) instead of the vacuous pre-FP25-J assertion. - K
19cc9b2— 12-item doc + comment cleanup batch: parser nosec rewrite (1), zip-bomb cap comment (2), Billion Laughs timing relax to 5 s (3),_atomic.pynoqa SIM115 reason (4),help.pydrop redundant.resolve()(5),fs.pydedupe FP20-D INFO log via module-level seen-stale set (6), GameCardaria-labelledbyid uniqueness invariant doc (7), queryClient testtoHaveBeenCalledTimes(1)(8), SnapshotsTabrestoreErrorJSDoc lifetime contract (9), HelpPage tagName casing comment (10), drop deadel.getAttribute?.optional chain (11), App.tsx clearsnapshotRestoreErrorwhilerestore.isPending(12).
Tests at FP25 step 4 close: 502 backend (1 skipped) / 273 frontend /
coverage 87.x% / ruff + ruff format + mypy + bandit / eslint + tsc
clean. frontend/dist/ rebuilt.
Closing /audit returned clean across ruff + ruff format + mypy +
bandit + semgrep (0 results on 65 Python files) + gitleaks ([])
- ESLint (errors=0 warnings=0) + tsc. The 4-lane
/indie-reviewsurfaced 5 Tier 1, 12 Tier 2, and 15+ Tier 3 findings, focused on test sufficiency (the FP25-A acceptance test passes even without the lock; FP25-F asserts inside an always-false branch), one typed-error envelope hole (mkdir(parent)inactivity.pyescapesActivityLogError), and a macOS-fork hazard in FP25-E. User also added a fifth scope item: Playwright e2e walkthroughs that validate the FP25 user-facing changes end-to-end. All 5 Tier 1 + 12 Tier 2 + Playwright walkthroughs batched intoFP26; FP25 stays open until FP26 closes.
FP22 — Launch button gates on RetroArch config (closed 2026-05-08)
User reported a 422 on POST /api/games/{name}/launch after
clicking Launch with paths.retroarch / paths.retroarch_core
unset in config.yaml. The Launch button shipped unconditionally
(FP19) and the Setup banner didn't track RetroArch state, so the
gap only surfaced via a toast after click. FP22 closes that gap.
- A
/api/setup/checknow returnsretroarch_configured: bool— the AND of the two paths being non-null. Pydantic + TS + Zod mirrors in lockstep; three new pytest cases cover default-false, one-of-two-set, and both-set. - B AlternativesDrawer accepts a
retroarchConfigured?: booleanprop. The Launch button disables when it's anything other than strictlytrue(soundefinedwhile theuseSetupCheckquery loads also gates), and an inline hint links to/settings?tab=pathswhen the prop isfalse. Three new vitest cases. - C Settings page Setup banner gains a "RetroArch: configured" / "RetroArch: not configured" line, mirroring the existing INI status line. Two new vitest cases.
- D Friendly toast copy for the 422 envelope was deferred to
FP21 § J — the typed
RetroArchNotConfiguredErrorfrom that fix-pass carries thecodefield; the byCode mapping lands there beside the code it describes (strings.ts forbids dead byCode entries).
Tests: 458 backend (1 skipped) / 246 frontend / coverage 87.00% /
ruff + mypy + bandit + types-sync clean / eslint + tsc clean.
frontend/dist/ rebuilt.
FP24 — P15 closing-review fold-in (closed 2026-05-08)
P15's closing /audit returned 4 actionable lint findings; the
8-lane /indie-review returned 30+ cross-cutting and per-lane
findings batched into three tiers. All Tier 1 (A–G + Q + S),
Tier 2 (H–Z), and Tier 3 (AA–LL) closed across 13 commits.
Tier 1 — user-visible blockers:
- A
JobEvent.payloadkeys aligned with the typed contract (files_total/bytes_total); copy progress bar now updates. - B CartBar's GB figure dropped — it showed the filtered
library's bytes, not the cart's; per-cart byte sum is a
v1-deferred concept (no per-row byte data on
GameCard). - C AppShell Cart NavLink (which shadowed Library on
/) becomes a button; cart-expanded state lifts toShellWithPaletteso the button can open the panel from any route. - D
OnboardingBannerderives visibility from props instead ofsetStateinuseEffect(eslint hard error fixed). - E + Q
GameCard's outer<button>becomesrole="button"div + onKeyDown (Enter/Space); the focus-visible ring lives on the wrapper so it actually paints. - F
ValidateRequest.short_namesbounded to 10,000 items at 64 chars each; user-controlled lists can no longer pressure server memory. - G + S
useCartexposesisStorageBrokenandaddAllreturns{added, truncated}soLibraryPagefiresstorageUnavailableToastandmaxCartReachedToast.
Tier 2 — hardening:
- H/I/J/K/L SSE lifecycle in
useCopySession: orphan stream closure on second start, transient-error reconnect preservation, unmount race guard, malformed-payload try/catch, conflict-resolve param logged not silently dropped. - M/N/T/U/V
LibraryPage:handleBulkAdditerates all filter pages; double-click guard on Copy/Dry-run; cart auto- clear effect's eslint-disable now documents the stable-ref invariant;fetchTileCountrouted throughapiRequest;cardswrapped inuseMemoso downstream identity stays stable. - O/P/R/W/X/Y/Z + AA partial Cart UX + accessibility:
AlertDialog confirm on Clear-all; tile button explicit
aria-label;useCart.readInitialvalidateschosenVarianttype; CartBar disclosure-pattern aria; "Add all 0" suppressed; banner roles fixed (ListxmlBanner → status, OnboardingBanner → none); tile counts no longer flash 0 during load; hardcoded strings for+AddandCart contentsextracted.
Tier 3 — debt:
- BB
listxml_availablezombie field deleted fromSetupCheckschema + types + tests. - CC
_probe_path'skindparameter now drivesis_dir/is_filechecks instead of being silently discarded. - DD
Badge.BIOS_MISSINGnow appended in_badges()when the machine's parent appears inworld.bios_chain. - EE
schemas.pyover the 500-line cap split intoschemas_setup.py+schemas_fs.pywith re-exports for back-compat. - FF
dryRunConfirmDeferreddead string deleted. - GG
FeaturedTile/FeaturedTileQuerytypes hoisted tostrings.ts(single source of truth). - HH Tile-count
page_size=1rationale documented. - II Cmd+K kbd label adapts to platform (⌘K on macOS, Ctrl+K elsewhere).
- JJ eslint
argsIgnorePattern: '^_'accepts the project's underscore-prefix convention. - LL
handleTileSelecttoggle-off preserves non-tile-driven filter state (search box, letter, only-X toggles). - KK partial Cart-flow e2e adjusted to FP24-Y / FP24-B; five additional e2e cases deferred (Vitest unit coverage already pins those contracts; finding accepted Vitest-only).
Plus two ride-alongs: HelpRoute setState-in-effect (eslint
blocker that fell out of the FP24 lint sweep, not in the
original enumeration) — fixed by deriving selectedSlug from
the URL and routing onSelect through setSearchParams.
455 backend tests / 240 frontend tests / ruff + mypy + bandit clean / eslint + tsc clean / coverage 86.93%.
P15 — Cart and curated library (closed 2026-05-08)
User feedback 2026-05-07: opening the app showed 21,049 cards
with no clear path to pick a few games and copy them. P15 turns
the dead bottom-bar into a cart-first selection model with
per-game +Add, featured INI-derived tiles, sticky cart-bar
with expand-up panel, and a live SSE-driven Copy flow. Plan at
docs/plans/P15-cart-curated-library.md;
spec at docs/specs/P15-cart-curated-library.md.
Backend (B1–B5):
- B1
tests/filter/test_runner.py::test_cloneof_map_empty_self_parentstests/api/test_routes_games.py::test_cloneof_map_collapses_winnerspin the FP23 fix at the test level: non-emptycloneof_map⇒ winners strictly less than machines.
- B2
/api/setup/checkextended withcloneof_map_size: inton the existingreference_files.listxmlblock. (Thelistxml_availableflag added during planning was deleted in FP24-BB once the empty-parse banner branch derived its state fromexists+cloneof_map_sizedirectly.) - B3
GamesPage.total_bytes: intpopulated server-side as the sum over the samefilteredslice that producestotal; bottom-bar GB figure now reflects post-collapse winners. - B4
POST /api/games/validateaccepts{ short_names: string[] }(bounded 10,000 / 64-char per item via FP24-F) and returns{ existing, missing }againstworld.machines— set lookup, no pagination. - B5
UiConfig.cart_clear_on_copy: Literal['always','on_success','never']defaults to'on_success'.
Frontend (F1–F14):
- F1 TypeScript mirrors for the five backend fields above.
- F2
useCart— localStorage-backed (mame-curator:cart:v1),add/remove/addAll(returns{added, truncated}after FP24-S) /setVariant/clear,isStorageBrokenprobe (FP24-G) for private-browsing modes, MAX_CART_SIZE = 10,000 ceiling. - F3
useValidateCartmutation hook for pre-Copy reconcile. - F4
strings.tsadditions:FEATURED_TILEScatalogue (Capcom Classics / Beat 'em Ups / Run & Gun / Best of 1992 / SHMUPS Vertical), onboarding copy, cart strings, listxml banner copy. - F5
OnboardingBanner— dismissible,localStorage-keyed (mame-curator:onboarding-dismissed:v1); auto-dismisses on first add. Visibility derived from props (FP24-D). - F6
FeaturedTilesRow— horizontal tile buttons; each tile fetches its count via/api/games?page_size=1(page_size=0 not supported by backend); 5-min react-query staleness. - F7
CartBar— replacesActionBar.tsx; collapsed shows🛒 N games · [Dry-run] [Copy] [⌃];[Add all M]appears when a featured tile is active. (Per-cart GB figure dropped in FP24-B — no per-row byte data onGameCard.) - F8
CartPanel— expand-up panel listing cart items with per-row✕remove +Clear all(AlertDialog confirm via FP24-O). - F9
GameCard+Addaffordance + cart-aware "✓ Added" state. Outer<button>becamerole="button"div + onKeyDown in FP24-E/Q to avoid nested-button DOM. - F10
useCopySessionSSE hook — job_started → progress → job_completed / job_failed lifecycle, transient-error reconnect preservation (FP24-I), unmount race guard (FP24-J), malformed-payload try/catch (FP24-K). Used byLibraryPage'sonCopyroute. - F11
LibraryPageend-to-end cart wiring:+Addcallscart.add; featured tile click sets filter +bulkAddTotal; bulk-add paginates the full filter result (FP24-M); pre-Copy validate drops orphans with a single toast; cart auto-clear onsucceeded(percart_clear_on_copyconfig). - F11.1
ListxmlBannerempty-parse branch — banner now renders when listxml exists butcloneof_map_size === 0, closing the gap FP23 left. - F12 Top-nav reshape — left rail → horizontal nav with
Library | 🛒 N | Settings | Help | ⋯ More(Sessions / Activity / Stats under "More"). URL paths preserved so deep links still work. Cart NavLink became a button in FP24-C (was shadowing Library on/). - F13
SettingsPagecart_clear_on_copySelect alongsidedefault_sortin the Display Card. - F14 Playwright
cart-flow.spec.ts: banner dismiss → tile filter → bulk-add → expand-panel → Copy. (Five additional E2E cases the FP24-KK finding named were deferred — Vitest unit coverage already pins those contracts.)
Closing /audit (4 actionable lint findings) + 8-lane
/indie-review (30+ findings across Tier 1/2/3) folded into
FP24, closed in 13 commits 2026-05-08. Plus two ride-alongs
(HelpRoute setState-in-effect, eslint argsIgnorePattern).
455 backend tests / 240 frontend tests / ruff + mypy + bandit clean / eslint + tsc clean / coverage 86.93%.
FP23 — Parent/clone collapse listxml fix + DryRun wiring (closed 2026-05-07)
Discovered during the P15 cart-and-curated-library brainstorm:
the running v1.2.0 app showed 21,049 cards in the Library bottom-
bar with the 1942 family appearing 7 times across regions /
revisions / bootlegs / hacks. Round 1 of the P15 spec cold-eyes
review caught the mis-diagnosis ("the picker isn't wired" —
wrong) and pointed at the real cause: cloneof_map={} at world-
load time, so filter/runner.run_filter groups by self and every
machine becomes its own winner.
Per ADR-0002,
parent/clone relationships are stripped from Pleasuredome DATs
and must come from MAME -listxml. The user's config.yaml had
paths.listxml: null since v1.0.0 — silent failure (FP18's
setup banner counts INIs but not listxml).
- MAME 0.287 listxml installed (302 MB, 27,604 cloneof
entries; 3 versions newer than the user's 0.284 DAT — cloneof
for old arcade titles is stable across this drift). Library
bottom-bar drops 21,049 → 10,591 after restart;
/api/games/1942/alternativesreturns the parent + 7 clones, matching the original screenshot exactly. ListxmlBanner.tsx(3 unit tests) renders above the Library grid whensetupCheck.reference_files.listxml.exists === falseso future users see the silent-failure state explicitly. Closes the gap that let the bug ship for 23 days.useDryRunhook (POST /api/copy/dry-run) wired to the previously-no-oponDryRunhandler inLibraryPage; opens the existingDryRunModalwith the report on success. P15 swaps theselected_namessource fromcards→cart.items— modal contract unchanged so the hook keeps working through the cart redesign.onCopystays a no-op stub — full Copy lifecycle (SSE + conflict resolution) is genuinely P15-scale (~500 lines + tests) and fits naturally with the cart-driven input swap.
446 backend tests / 188 frontend tests / ruff + mypy + bandit clean / coverage 86.66%.
Planned — DS03 Dependency freshness sweep
User request 2026-05-08: ensure every external library in
pyproject.toml and frontend/package.json is on its latest
stable release, per global rule § 5 ("Use the latest external-
library version, with current idioms"). DS03 is a dedicated
single-coordinated-bump sweep covering backend deps, frontend
deps, and pinned GitHub Actions versions; runs the full CI
matrix once for the whole bump rather than piecemeal upgrades.
Idiom-modernisation rewrites are out of scope (separate fix-
pass if a major version's new idioms surface drive-by). See
ROADMAP.md DS03.
Planned — FP22 Launch button gates on RetroArch config
User reported 2026-05-04 that clicking Launch on a game with
RetroArch unconfigured returns 422 with no in-app guidance to
fix. Roadmap'd as FP22: gate the Launch button on a setup-check
flag, surface RetroArch state in the Setup banner, and route the
422 through strings.errors.byCode for friendlier copy. See
ROADMAP.md FP22.
Planned — /indie-review 2026-05-04 fold-in
10-lane multi-agent independent code review surfaced findings batched into three tiered phases:
- FP20 — Tier 1 (security + data-loss): parser XXE / zip-bomb
hardening, copy activity-log + recyclebin manifest atomicity,
missing
app.state.world_lock,compose_allowlistnon-existent-path admission,help.pyenv-override resolve,download()URL scheme allowlist, globaluseApiQuerytoast,GameCardaria-labelclobber,LibraryPagequery-error surface,SnapshotsTabrestore inline error,FsBrowserEsc-closes-everything,HelpPageDOMPurify config. - FP21 — Tier 2 (hardening):
explain_pickdecisive semantics,Sessiontyped-error drift, recyclebin manifest per-file shape, preflight free-space includes BIOS, TOCTOU source-vanish → SKIPPED,media.pyFileResponseevent-loop unblock,launch_gametypedApiException, SSE register-before-replay race, late-progress drop, snapshot LRU,AppConfigPatchPydantic, cross-file import staging, download streaming + cap,useLaunchGame/useOverridebaked-inonError,useKeyboardref-based handler,LibraryGridWAI-ARIA grid pattern. - DS02 — Tier 3 (structural debt): file-cap splits across 5
files, CI gate for caps, i18n leaks, a11y polish (skip-to-main,
aria-busy on Launch, slider thumb labels),
parse_listxml_ bios_chainspec orphan,_atomicmkdir contract, Settings tab URL state,apiRequestVoidasymmetry, spec doc sync.
See ROADMAP.md FP20, FP21, DS02 blocks for finding-level
detail with file:line cites.