main
17
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ef7b8aa878 |
feat(icons): render the app icons from the Movie Loop mark
frontend-ci / secrets-scan (push) Successful in 4s
frontend-ci / sast (push) Successful in 7s
frontend-ci / fs-scan (push) Successful in 14s
frontend-ci / typecheck (push) Successful in 24s
frontend-ci / lint (push) Successful in 26s
frontend-ci / test (push) Successful in 32s
frontend-ci / build (push) Successful in 38s
frontend-ci / push (push) Skipped
frontend-ci / lint (pull_request) Successful in 24s
frontend-ci / test (pull_request) Successful in 31s
frontend-ci / secrets-scan (pull_request) Successful in 5s
frontend-ci / sast (pull_request) Successful in 8s
frontend-ci / fs-scan (pull_request) Successful in 15s
frontend-ci / typecheck (pull_request) Successful in 14s
frontend-ci / build (pull_request) Successful in 38s
frontend-ci / push (pull_request) Skipped
Safari, the iOS home screen, Android install and push notifications still showed the old film-reel artwork beside the new theme colour. The PNGs are now rendered from the SVG mark by `npm run icons` (Inkscape, no image library in node_modules): the bare mark for the 32px favicon, the mark on the stage colour for app icons, and a maskable variant with the extra margin Android's adaptive crop needs. |
||
|
|
8d0b7f39ee |
feat(ui): give the app one visual identity as Movie Loop
Gold is the primary in both themes, Bricolage Grotesque carries the wordmark, titles and the score, and Movie A / Movie B wear the blue and magenta of three-strip Technicolor on their posters instead of colouring whole rows. The loop closes on screen - connectors light, a gold line draws back to Movie A, the row glows, then the result arrives - in place of confetti, on every play mode, scaled to the chain's length. Every page shares one header and hairline lists; copy is sentence case with one verb per flow, and the Home hero shows a real finished loop. Also clamps link counts at zero: an opponent who had not moved yet showed "-1 links". |
||
|
|
da32e49b0b |
fix: push subscription payload; CSP hash computed from the shipped page (2.2.3)
frontend-ci / lint (push) Successful in 24s
frontend-ci / test (push) Successful in 32s
frontend-ci / secrets-scan (push) Successful in 4s
frontend-ci / sast (push) Successful in 6s
frontend-ci / fs-scan (push) Successful in 11s
frontend-ci / typecheck (push) Successful in 14s
frontend-ci / build (push) Successful in 38s
frontend-ci / push (push) Skipped
frontend-ci / test (pull_request) Successful in 34s
frontend-ci / secrets-scan (pull_request) Successful in 4s
frontend-ci / sast (pull_request) Successful in 6s
frontend-ci / fs-scan (pull_request) Successful in 13s
frontend-ci / typecheck (pull_request) Successful in 23s
frontend-ci / lint (pull_request) Successful in 26s
frontend-ci / build (pull_request) Successful in 48s
frontend-ci / push (pull_request) Skipped
Enabling push in production failed with "Validation failed": the browser's PushSubscription.toJSON() carries expirationTime, and it was sent verbatim to a schema that is strict about unknown fields. The client now sends exactly endpoint + keys. The test doubles had been shaped like the schema rather than like a browser, which is why 1,112 tests said nothing. The CSP hash for index.html's inline theme script was hardcoded in the container's start-up script behind a comment asking for it to be regenerated by hand, and claiming a test asserted it; none did. The bytes are what the browser hashes, so an edit or a checkout with different line endings would have silently blocked the page's own script. The hash is now emitted by `npm run build` from the index.html that is actually shipped, copied into the image, and read at start; the container refuses to start without it. Verified against the built image: header hash equals served-script hash. For the record: a CSP console message in production is what led here, and the served page turned out to be correct - that message was a browser extension's injected script, which the policy is right to refuse. |
||
|
|
2b13d46254 |
fix: what a live play-through found - stale search, hint cost, empty day (2.2.2)
frontend-ci / secrets-scan (push) Successful in 4s
frontend-ci / sast (push) Successful in 8s
frontend-ci / lint (push) Successful in 23s
frontend-ci / typecheck (push) Successful in 24s
frontend-ci / fs-scan (push) Successful in 18s
frontend-ci / test (push) Successful in 37s
frontend-ci / build (push) Successful in 45s
frontend-ci / push (push) Skipped
frontend-ci / secrets-scan (pull_request) Successful in 3s
frontend-ci / sast (pull_request) Successful in 7s
frontend-ci / fs-scan (pull_request) Successful in 14s
frontend-ci / typecheck (pull_request) Successful in 23s
frontend-ci / lint (pull_request) Successful in 28s
frontend-ci / test (pull_request) Successful in 31s
frontend-ci / build (pull_request) Successful in 38s
frontend-ci / push (pull_request) Skipped
Played end to end against a clean-install dev stack: Quick Play, a Custom Game loop closed and scored server-side, a hint, an undo, every anonymous route. Four things the 1,112 unit tests had not caught. Picking an actor flips the search to movie mode while the 300ms debounce still holds the actor's name, so a movie search for "Daniel Craig" ran and opened a listbox under an empty input; an empty input now means nothing to search. The hint button said -150 while the server charges 200. A fresh install's empty daily-challenge day (a 404 until the midnight job) read as "Failed to load". And every abandoned request logged "[API Error] canceled", two per page change. |
||
|
|
c9291243be |
fix: cover the frontend to the 90% bar and fix what the tests found (2.2.1)
WP-19. From 105 tests / 14% lines to 1,112 tests / 99% lines and 97% branches across 93 files, with every source file above the codified 90% thresholds on its own - no per-file exceptions. Written risk-first: real Zustand stores seeded via setState, the axios client mocked with an unrouted-request tripwire, the socket factory mocked with a controllable fake, so no test can reach a backend and the stores are covered by the pages that use them as well as directly. The tests found six defects, each pinned first with it.fails and then fixed: a failed join to an open Game Night room was still silent (the error only rendered inside the password prompt); the remove-friend button had no accessible name; signing out from Profile landed on /login because the page's auth guard re-ran; the sign-in page neither redirected a signed-in user nor read the ?expired=1 the API client has sent since WP-10; and Quick Play / Endless threw out of their click handlers on a TMDB failure while every start page navigated to /game whether or not the game had started. startGame now reports its outcome and the pages branch on it. One toolchain finding worth keeping: vi.useFakeTimers() combined with @testing-library/user-event deadlocks under this vitest/jsdom combination; tests that need both use real timers and waitFor. |
||
|
|
5a7e8cebc3 |
feat: repo hygiene - push opt-in, paged admin jobs, shared game screen (2.2.0)
WP-17. Minor: push notifications become usable and nothing a player could reach was removed. The admin console's jobs need backend 3.0.0. Push (M35): the whole browser half - the service worker's handlers, the subscribe/unsubscribe calls, the server's VAPID key - had shipped with nothing calling it, so no browser ever held a subscription. The toggle lives in Profile because the permission prompt has to come from a click. The old isPushSubscribed awaited serviceWorker.ready, which never settles with no worker registered; it uses getRegistration() now. Admin: each job pages and the button becomes Continue with the server's cursor; the recalculate card also stopped printing "undefined leaderboard entries" for a field the server had not sent for several releases. The three copy-pasted job cards are one component. Consolidation: the four game screens share use-game-screen and one search panel; the review page's static chain is ChainDisplay in a static mode (21 rendered states verified byte-identical); formatTime and error extraction exist once; Endless and quick play share a franchise-aware picker instead of two divergent sort(() => Math.random() - 0.5) copies. The dead api/index barrel and the fourteen functions only it referenced are gone. noUncheckedIndexedAccess is on. Whiplash's TMDB id is corrected and every preset title is checked against TMDB. |
||
|
|
889cdda83f |
feat(a11y): make the app usable from a keyboard and a screen reader
eslint-plugin-jsx-a11y joins the lint step and found 19 violations on arrival - 15 orphaned labels, a clickable div, a combobox missing its required attributes, an autoFocus. All fixed, none suppressed, and the rule set stays enforcing so the next one fails CI. The two hand-rolled overlays were the worst of it. The notification bell was a div with div-onClick rows: nothing focusable, nothing announced, no way to close it from the keyboard. It is a real popover now - the trigger reports its state and the unread count, the panel is a labelled dialog, every row is a button with its actions beside it rather than nested inside it, Escape closes and returns focus. The Challenge Friend overlay had no role, no focus trap and no Escape, so Tab went straight through it into the page behind; it is the UI kit's Dialog now, which supplies all of that. Every form control has a label, including the three change-password fields that had only placeholders and a game-night join-password field that had nothing at all. Every icon-only button says what it does, including three versus buttons whose content collapsed to a bare spinner while loading. The search combobox follows the ARIA 1.2 pattern, with the role on the input; verified live that aria-expanded flips and aria-controls resolves to the listbox. State that changed silently is announced: link validation, both lobby countdowns (assertively - a 3-2-1 that launches the game is not something to miss), the game-night player list. The game header's "Passed Movie B" read identically in both states and only the icon differed, so a screen reader heard the wrong thing until B was reached. A skip-to-content link on every page, and a global reduced-motion rule that collapses every animation when the OS asks; the confetti overlay does not render at all under it. Addresses audit section 2.8. |
||
|
|
e67692006f |
perf: load pages on demand, split vendor chunks, and replace the 888 KB favicon
App.tsx imported all 24 pages statically, so a first-time visitor downloaded the admin panel, three multiplayer modes and the preset-pair table before the landing page could render - one 682 KB chunk. Every page except Home is now React.lazy behind a Suspense boundary: 56 chunks, pages at 2-11 KB. Verified in a browser against the production build - the landing page fetches only the app and vendor chunks, a navigation fetches that route's chunks and renders, and a deep link into an authenticated route lazy-loads it, redirects to sign-in, and lazy-loads that too. Vendor code is split into vendor-react, vendor-router, vendor-ui and vendor-net via manualChunks. The main chunk was 95% dependencies, so the first load is not much smaller - the dependency set is the load - but the chunk that changes on every deploy is now 50 KB (14 KB gzipped) rather than 682 KB. A returning visitor after a deploy re-downloads the app, not React. The favicon was 888 KB: PNG data under a .jpg name, 768x768, with a fully opaque alpha channel, fetched on every page load. It is a 2.6 KB 32px PNG now, with a 180px apple-touch-icon, and manifest.json points at real 192px and 512px PNGs with the correct type instead of declaring the same mislabelled file at both sizes. The service worker's notification icon pointed at a /favicon.ico that never existed. The useGameStore selector item from M26 was already done in WP-10. Addresses M26. |
||
|
|
2bdbf5fbc3 |
test: add vitest and the regression net for the multiplayer completion path
There was no test runner. This adds vitest with @testing-library/react and jsdom, a `test` job in CI, and the first 51 tests. They start where the audit's most user-visible defect lived. C1: every multiplayer mode started a game through startGameFromVersus, which never created a backend session, so `score` was only ever set inside `if (sessionId)` and the completion modal spun on "Calculating score..." forever, with no way out and an opponent waiting on the other side. The store tests pin the repaired path end to end - a multiplayer start creates a session, closing the loop lands on a score or a visible error, never the silent null - along with the generation guard that keeps a slow session from game A out of game B, and the serialised undo. The chain-validation hook is covered in both its server-validated and anonymous client-side forms. Coverage thresholds of 90 on every metric are codified in vite.config.ts. Two files are tested, at 100%/97.9% and 100%/91.4% branches, so `npm run test:cov` fails honestly on the whole-tree number while CI runs the plain `npm test`; the gap is real and is the next frontend work. The tests intercept the axios client rather than the API modules. An early version mocked `@/api/games` directly and one test sent a real request to the development backend: a second concurrent dynamic `import()` of a mocked aliased module resolved to the real file under vitest. The client is imported statically, which vitest mocks reliably, and the routing carries a tripwire that fails loudly on any unrouted request so a bypass can never reach the network again. Addresses L1. |
||
|
|
ca3862d5a7 |
harden(docker)!: run nginx unprivileged and stop dropping security headers
BREAKING CHANGE: the production container listens on 8080 instead of 80. nginx now runs as an unprivileged user and cannot bind a privileged port without CAP_NET_BIND_SERVICE, which compose drops. Anything routing to this container must be repointed - in the homelab Traefik config, traefik.http.services.movieloop.loadbalancer.server.port 80 -> 8080. nginx's add_header is not additive across levels: a block declaring any add_header of its own discards every header inherited from its parent. Both /assets/ and /config.js declared one, so X-Frame-Options, X-Content-Type-Options and Referrer-Policy were silently absent from every JavaScript and CSS response. The headers now come from a snippet included in each location - verified 6/6 on /, on a hashed asset bundle, and on /config.js. Adds a CSP, HSTS and Permissions-Policy. connect-src is derived at container start from API_URL, because the API is deliberately not same-origin in production; asserting same-origin would have broken the deployment outright. The inline theme script is allowed by SHA-256 hash rather than unsafe-inline, which would have bought almost nothing - editing that script means regenerating the hash. config.js is also no longer rendered with envsubst, which does no escaping: an API_URL containing a double quote closed the JS string literal and the remainder executed on every page load. Addresses M31, M34. |
||
|
|
92d7dfdef3 |
fix(frontend): repair session teardown and effect lifecycles
Closes WP-10 of the audit: M10-M16, M18, M19, M20 and the client half of H11. Steps 5 and 7 of the package were already done in WP-1. An expired token left the app half signed in: the interceptor only wrote to the console, so the nav still showed the user's name while every request failed. A 401 now clears the token and navigates to sign-in - a full navigation rather than a router push, so in-memory state and open sockets go with the page, and the API client does not have to reach into the stores and join an import cycle with them. A 401 from /auth/login is excluded: there, it is an answer. Signing out disconnected the notifications socket and nothing else. Chat, versus and game night stayed open holding a token that had just been removed, and the friends and game stores kept their contents, so signing in as someone else on the same tab showed the previous person's friend list. Teardown now lives in one place, because the failure mode was precisely that adding a socket did not mean remembering to close it. All four connect() guards tested `.connected` rather than existence, so a socket that was mid-reconnect was replaced without being closed and the orphan went on handling every event a second time. disconnect() likewise cleared only the socket, leaving matchId, result and score behind - the next match opened showing the previous match's completion modal. Effects depended on the whole `user` object, which loadUser() rebuilds on every call, so they re-ran when nothing had changed. In AsyncVersusGame that effect's cleanup calls resetGame(), so reloading a profile mid-match reset the board. They depend on the id now. The same effect also reset the store while its fetch was in flight, so init() then wrote the match back into what had just been cleared; it aborts before resetting. Page reads take an AbortSignal and their effects abort on unmount. Several already had a `cancelled` flag, which stops the state write but lets the request run to completion; the flag stays to guard the `finally` blocks. Undo was fire-and-forget: rapid clicks put overlapping POSTs in flight against a server that removes one link per call, and a failure was logged while the local chain had already been shortened. Undos are serialised and the server's chain - which it was already returning and the client was discarding - is now authoritative. The runtime API setting was substituted into a JS string literal by envsubst, which does no escaping, so a value containing a quote ended the literal and the remainder executed on every page load. It is escaped at render time (verified under busybox with the production values and with an injection payload, which comes out inert), and a malformed value is rejected on read. Note this deliberately does not assert same origin, as the plan suggested: the frontend and API are served from different subdomains in production, so that assertion would break the deployment. The Endless difficulty selector is removed rather than wired. Difficulty is derived from cast analysis on the server and Endless builds its pairs in the browser, so there was nothing to wire it to - it silently did nothing while telling the player their choice mattered. Verified in the running app: an invalid token clears and redirects to /login?expired=1 without looping, while a wrong password shows "Invalid email or password" on the form and stays put; sign-out clears the token, returns to sign-in and leaves no trace of the previous user; Endless renders without the control and keeps its real filters. No console errors beyond the 401s those checks deliberately provoked. |
||
|
|
e747bc2f3b |
fix(versus): handle a withheld movie pair on the async leaderboard
Fallout from the backend's 2.0.0, found by checking the frontend against a running backend rather than by compiling it. The async leaderboard declared `movieATitle` and `movieBTitle` as required and rendered them directly. The backend now withholds the pair from anyone who has not played that match - the pair is the puzzle - so for a non-participant both are absent and the heading rendered as a bare "↔" between two blanks. TypeScript could not catch this: these API types are hand-written rather than generated from the backend, so a field the server stops sending still compiles. The types now say the titles are optional, which is the truth, and the page explains that the movies are hidden until you play. Every path the UI offers to this page belongs to a participant, so this was only reachable by opening the URL directly - but the type was wrong either way, and a wrong type is what hides the next one. |
||
|
|
5cffbc5790 |
chore(release): 1.2.2
frontend-ci / secrets-scan (push) Successful in 3s
frontend-ci / lint (push) Successful in 15s
frontend-ci / typecheck (push) Successful in 17s
frontend-ci / sast (push) Successful in 9s
frontend-ci / fs-scan (push) Successful in 12s
frontend-ci / build (push) Successful in 36s
frontend-ci / push (push) Skipped
frontend-ci / secrets-scan (pull_request) Successful in 4s
frontend-ci / sast (pull_request) Successful in 5s
frontend-ci / fs-scan (pull_request) Successful in 10s
frontend-ci / lint (pull_request) Successful in 16s
frontend-ci / typecheck (pull_request) Successful in 17s
frontend-ci / build (pull_request) Successful in 37s
frontend-ci / push (pull_request) Skipped
|
||
|
|
baeee32d3a |
chore(release): 1.2.1
frontend-ci / lint (push) Successful in 17s
frontend-ci / typecheck (push) Successful in 15s
frontend-ci / secrets-scan (push) Successful in 5s
frontend-ci / sast (push) Successful in 13s
frontend-ci / fs-scan (push) Successful in 12s
frontend-ci / build (push) Successful in 36s
frontend-ci / push (push) Skipped
frontend-ci / typecheck (pull_request) Successful in 13s
frontend-ci / secrets-scan (pull_request) Successful in 4s
frontend-ci / sast (pull_request) Successful in 6s
frontend-ci / fs-scan (pull_request) Successful in 11s
frontend-ci / lint (pull_request) Successful in 13s
frontend-ci / build (pull_request) Successful in 35s
frontend-ci / push (pull_request) Skipped
|
||
|
|
b29361b03b |
chore(release): 1.2.0
Minor: the completion, hint and match-submission contracts change shape, and a new hint endpoint is added. Old clients are rejected loudly rather than silently mis-scored. |
||
|
|
b873e44dd0 |
chore(release): 1.1.0
Supersedes the 1.0.x release commits on this branch. Those were held back on the grounds that a v1.1.0 git tag already existed - which was wrong: the three local tags (v1.0.0, v1.1.0, v1.1.1) were never pushed, and the remote has no tags at all, so nothing was actually taken. Minor is the correct bump. Requests that previously failed now succeed - duplicate chain links are accepted, and validation responses carry a per-field errors[] array - which is backward-compatible new behaviour rather than a fix alone. Both repos are set to 1.1.0 so the pair stays aligned. Still no tag; the stale local-only tags are worth deleting to avoid confusing `git describe`. |
||
|
|
258c9cfb83 |
chore(release): 1.0.2
Adds the changelog this repo was missing. Git tags currently run ahead of package.json (v1.1.1 exists while package.json was 1.0.1), so the tag line and the package version still need reconciling separately - no tag is created here. |