Files
TehRiehlDeal ef7b8aa878
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
feat(icons): render the app icons from the Movie Loop mark
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.
2026-09-12 22:52:39 -07:00

560 lines
28 KiB
Markdown

# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> History prior to 1.1.0 has not yet been backfilled from git.
## [Unreleased]
## [2.4.0] - 2026-09-12
### Added
- The app icon: Safari, the iOS home screen, Android install and push
notifications now show the Movie Loop mark on the stage colour instead of
the old film-reel artwork, with a maskable variant so Android no longer
letterboxes it. `npm run icons` re-renders every PNG from the SVG sources
with Inkscape.
## [2.3.0] - 2026-09-12
A look-and-feel pass. The app now has one visual identity - Movie Loop - with
the closed loop as its only bold element.
### Added
- A display typeface (Bricolage Grotesque) for the wordmark, page titles, the
puzzle statement and the score, alongside Geist for everything else, and a
declared type scale with a 12px floor.
- A logo mark - an open ring with the missing link in the gap - beside the
wordmark, on the sign-in and 404 pages, in the completion dialog, and as an
SVG favicon.
- The loop closes on screen: connectors light up top to bottom, a gold line
draws from the last row back to Movie A, the closing row glows, and the
result arrives after - in solo, versus, async and game-night play alike. The
sequence scales with the chain's length. Under reduced motion the finished
loop simply appears and the result opens at once.
- The Home page shows a real finished loop (Speed to Apocalypse Now and back)
in place of the how-to-play card; the ? in the header keeps the full rules.
- Posters and photos fall back to placeholder art if TMDB no longer serves
them, instead of showing a broken image.
- `PageHeader`, `MovieMarker` and `LogoMark` components; difficulty badges now
carry ordinal pips.
### Changed
- Gold is the primary colour in both themes; it no longer flips to indigo in
light mode. The dark background is a true indigo-slate rather than near-black.
- Movie A and Movie B are identified by the poster's ring and an A/B chip, in
the blue and magenta of three-strip Technicolor, instead of colouring whole
rows. Green is reserved for online/ready states and no longer means "correct".
- The timer, difficulty badges, trophies, win/lose states and score lines use
the palette tokens instead of stock red/green/yellow.
- The game screen states the puzzle in a sentence ("Toy Story to Cast Away and
back.") with the timer, link count and next objective on one line; the two
movie cards that repeated the top of the chain are gone. Posters in the chain
are larger.
- Home, Daily, Leaderboard, Profile, Achievements, Endless, Custom, Login, Game
review, Versus, Game night, Friends and Admin share one left-aligned page
header, hairline lists instead of card grids, and sentence-case copy. Profile
shows the streak large and the rest of the record as a ledger, with account
settings last.
- Copy: "Quick Play" is "Play now", "Ranks" is "Leaderboard", "Passed Movie B"
is a changing objective ("Next: reach …", "Next: back to …", "Loop closed"),
"Connected!" is "Linked", "Congratulations!" is "Loop closed.", and empty
states say what to do next.
- The daily page shows the "not generated yet" state as an ordinary notice
rather than red error text.
### Fixed
- An opponent who has not moved yet showed "-1 links"; an empty chain now
counts as zero.
### Removed
- The confetti overlay; the loop-closing sequence replaces it.
- Stock Tailwind palette colours from game components, uppercase table headers
and labels, and the `text-7xl`/`text-8xl` sizes.
## [2.2.3] - 2026-09-12
The first production deploy of the push opt-in.
### Fixed
- **Enabling push notifications no longer fails with "Validation failed".**
The browser's `PushSubscription.toJSON()` includes `expirationTime`, and it
was being sent verbatim to a server schema that is deliberately strict about
unknown fields. The client now sends exactly what the server accepts -
`endpoint` and `keys` - and refuses an incomplete subscription rather than
sending it. (The server also accepts the field now; either side alone fixes
it.) Nothing had ever called this path before 2.2.0, and the test doubles
were shaped like the schema rather than like a browser.
### Changed
- **The CSP hash for the inline theme script is computed at build time** from
the `index.html` that is actually shipped, copied into the image and read at
start, instead of being hardcoded in the container's start-up script behind
a comment asking for it to be regenerated by hand. The bytes are what the
browser hashes, so an edit to the script - or a checkout with different line
endings - would have silently blocked the page's own theme script. The
container now refuses to start without the file rather than serve a policy
that blocks its own page. Verified against the built image: the header's
hash equals the hash of the served script. (Investigating a CSP console
message in production is what surfaced this; the served page turned out to
be correct, and that message came from a browser extension's injected
script, which the policy is right to refuse.)
- Every text file is checked out with LF on every platform (`.gitattributes`).
## [2.2.2] - 2026-09-12
Found by playing the app end to end against a clean-install development stack
before the first push - a Quick Play, a Custom Game loop closed and scored, a
hint, an undo, and every anonymous route.
### Fixed
- **No more stale results after picking an actor or movie.** Selecting an
actor clears the input and flips the search to movie mode, but the 300ms
debounce still held the actor's name, so a *movie* search for "Daniel Craig"
ran and opened a listbox of results under an empty box. An empty input now
means nothing to search, whatever the debounce holds.
- **The hint button says what a hint costs.** It read "-150 pts"; the server
charges 200 (150 is the duplicate penalty).
- **A day with no daily challenges yet reads as what it is.** The API answers
404 before the midnight job has run - a fresh install, or a failed job - and
the page called that "Failed to load today's challenges", which reads as an
outage. It now says they have not been generated yet and when they will be.
- Requests the page abandoned on purpose (navigating away mid-fetch aborts
them) are no longer logged as `[API Error] canceled` - two per page change,
burying the real ones.
## [2.2.1] - 2026-09-12
The frontend test foundation (WP-19: the frontend half of L1), and the defects
it turned up.
### Fixed
- **Joining an open Game Night room that refuses you shows why.** The error was
set but only rendered inside the password prompt, which a passwordless join
never opens - WP-10 fixed the password path and left this one silent.
- **The remove-friend button has an accessible name** ("Remove <name> as a
friend"). It rendered only an icon, the one icon-only control the
accessibility pass missed; a screen reader announced "button" next to a
destructive action.
- **Signing out from Profile lands on the home page**, as the button intends.
The page's own auth guard re-ran the moment the user cleared and sent them
to the sign-in screen instead.
- **The sign-in page redirects someone who is already signed in** to their
profile, and **says so when the session expired**: the API client has sent
expired sessions to `/login?expired=1` since WP-10, and nothing read it, so
being signed out by an expired token looked identical to signing out.
- **Starting a game reports failure instead of losing it.** Quick Play and
Endless awaited the TMDB pick with no catch - a failed request, or a page
with fewer than two usable films, was an unhandled rejection with nothing on
screen - and the button only disabled itself during `startGame`, so a second
click during the pick started a second game. Every start page (Quick Play,
Endless, Custom, Daily) also navigated to `/game` whether or not the game
had started; a failure bounced off that page's idle redirect with the
store's message never displayed. `startGame` now reports `'started'` or
`'failed'`, and each page shows the reason and stays put.
### Changed
- `npm run test:cov` is what CI runs: the 90% thresholds codified in WP-14 are
enforced, having been cleared - 14% to 99% lines, 12% to 97% branches, 105
to 1,112 tests across 93 files. Every source file is above the bar on its
own; there are no per-file exceptions.
- The pair picker shuffles by random key instead of an in-place Fisher-Yates,
removing three index guards that could not be reached.
## [2.2.0] - 2026-09-11
Repository hygiene (WP-17: L2, L4, M9, M35, the frontend half of the audit's
section 3 prune list). Minor: push notifications become usable, and nothing a
player could reach was removed. The admin console's maintenance jobs need
backend 3.0.0.
### Added
- **Push notifications can be turned on.** Profile → Notifications has the
toggle. The whole browser half of the push stack - the service worker's
`push` and `notificationclick` handlers, the subscribe/unsubscribe calls,
the server's VAPID key - had shipped for months with nothing calling it, so
no browser ever held a subscription (M35). The prompt is asked from a click,
as browsers require, and a refusal or a server without VAPID keys is
explained in place rather than failing silently. If a subscription cannot be
registered with the server, the browser-side one is rolled back so the two
never disagree.
- The admin console's "Backfill Game Owners" card, for the job that re-attaches
legacy games to their players. It reports how many were ambiguous - more
than one player matched, so the server made no guess - alongside the count
it re-attached.
- Tests for the push library, the subscription hook, the notifications
section and the pair picker (45 tests; 105 in total).
### Changed
- Admin maintenance jobs continue where they left off. Each one processes a
page and, when more remains, the button becomes **Continue** and passes the
server's cursor back. The three near-identical job cards are one component.
The recalculate card also stopped printing "undefined leaderboard entries":
its type declared a field the server had not sent for several releases.
- Endless mode and quick play pick pairs through one franchise-aware picker.
Endless had its own copy with no franchise check, so it could serve *The
Fellowship of the Ring* against *The Two Towers*; both copies also shuffled
with `sort(() => Math.random() - 0.5)`, which is neither uniform nor a valid
comparator.
- The four game screens share one hook for the store wiring and label
derivation they had each copied, and one search panel; the review page's
read-only chain display is the live `ChainDisplay` in a static mode rather
than a second component that had drifted from it. Verified byte-identical
DOM across 21 rendered states before and after.
- `formatTime` and error-message extraction each exist once (`lib/format`,
`lib/error`) instead of five and three times.
- `noUncheckedIndexedAccess` is on. Indexing now types as possibly undefined
and the code handles it, with no non-null assertions added. Along the way,
taking an initial with `[0]` became `.charAt(0)`, which also stops an empty
display name from throwing.
- The package is named `movie-loop-frontend`; it had kept the scaffold's
`frontend-temp`.
- CI refuses to release a version whose `v<version>` git tag already exists on
origin, before it builds anything, for the same reason the backend does.
- The stock Vite README is replaced with one that describes this app.
### Fixed
- *Whiplash* in the preset pairs is TMDB id 244786; the table had 274870, a
different film. Every preset title is now checked against what TMDB serves
for its id.
- Checking whether push is subscribed no longer awaits
`navigator.serviceWorker.ready`, which never settles when no worker is
registered - the check hung on any page load before registration.
- `getErrorMessage` returns its fallback for a server error carrying an empty
message array, rather than `undefined`.
### Removed
- The dead `src/api/index.ts` barrel and the fourteen API functions it alone
referenced, three orphaned types, the Vite scaffold SVGs, the unused
`scroll-area` primitive and four unused dialog/sheet exports, and the
`_onGameStart` escape hatch that two stores read but nothing ever set.
Nothing a user could reach.
## [2.1.0] - 2026-09-11
Accessibility (WP-16: audit section 2.8).
### Added
- `eslint-plugin-jsx-a11y` (recommended rules) in the lint step, so an orphaned
label, a clickable `<div>` or a missing ARIA attribute fails CI instead of
shipping. It found 19 violations on arrival; all are fixed, none suppressed.
- A "Skip to content" link on every page, visually hidden until focused, that
moves focus to the main region.
- Live regions where state used to change silently: link validation announces
"Validating", a rejection (as an alert) and "Connected"; the versus and
game-night lobby countdowns are assertive timers, so a screen-reader user is
no longer dropped into a running match with no warning; the game-night
player list announces players finishing or forfeiting.
- Tests for the notification popover's keyboard contract (9 tests).
### Changed
- **The notification bell is a real popover.** It was a `<div>` with
`<div onClick>` rows: nothing in it was focusable, nothing announced, no way
to close it from the keyboard. The trigger now reports its state and the
unread count in its name (`Notifications, 3 unread`), the panel is a labelled
dialog the trigger controls, every notification is a button, the per-row
controls have real names rather than tooltips, Escape closes and returns
focus to the trigger, and the unread dot has text behind it.
- **The Challenge Friend overlay is the UI kit's `Dialog`.** It was a fixed
`<div>` with no role, no focus trap and no Escape - Tab went straight through
it into the page behind. Base UI supplies the dialog role, `aria-modal`,
focus trapping, Escape and focus restoration.
- The search combobox follows the ARIA 1.2 pattern: `role="combobox"` with
`aria-expanded`/`aria-controls` on the input itself, rather than the 1.1
wrapper-`div`-with-`aria-owns` form that current screen readers announce
inconsistently. Verified live: the state flips and `aria-controls` resolves
to the listbox.
- Every form control has a label. Sign-in/sign-up, the three change-password
fields (which had only placeholders, and placeholders vanish once you type),
the admin challenge form, custom game, game night's room form (including a
join-password field that had nothing at all), the versus lobby form, the
friend search and the chat message box. Labels above groups of buttons
("Difficulty", "Time Limit", "Movie A") are group labels via
`role="group"`/`aria-labelledby` rather than a faked `htmlFor`.
- Every icon-only button has a name that says what it does: chat send, back,
pagination, display-name save/cancel, notification actions, and three versus
buttons whose content collapsed to a bare spinner while loading.
- The game header's "Passed Movie B" read the same text in both states - only
the icon and colour changed - so a screen reader heard the wrong thing until
B was reached. It now announces "Movie B reached" or "not yet reached", and
the online indicator on the friends list has text behind the green dot.
- `autoFocus` is gone from the friend search. It skipped screen-reader users
past the page heading; the field is one Tab away.
- **Reduced motion is respected globally.** A single stylesheet rule collapses
every animation and transition when the OS preference is set; the confetti
overlay does not render at all under it, since a still frame of confetti has
no meaning.
## [2.0.1] - 2026-09-11
Bundle and asset budget (WP-15: M26).
### Added
- A test suite, where there was none: vitest with `@testing-library/react` and
jsdom, run by `npm test`, with coverage via `npm run test:cov`. The first
tests are the regression net for the audit's C1 - every multiplayer mode
used to leave the completion modal on "Calculating score..." forever because
no backend session was ever created - and for the chain-validation state
machine in both its server-validated and anonymous client-side forms. 51
tests. Coverage thresholds of 90% (lines, branches, functions, statements)
are codified in `vite.config.ts`; the suite does not meet them yet, so
`test:cov` fails honestly while CI runs the plain `npm test`. Tests intercept
the axios client rather than the API modules, because a second concurrent
dynamic `import()` of a mocked aliased module resolved to the real file under
vitest - measured, and it reached the development backend once.
- A `test` job in CI.
### Changed
- **Every page except Home loads on demand.** `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. Routes are now `React.lazy` behind a `Suspense`
boundary, giving 56 chunks with pages at 2-11 KB each. Verified in a browser
against the production build: the landing page fetches only the app chunk and
vendor chunks; navigating to a route fetches that route's chunks and renders;
a deep link into an authenticated route lazy-loads it, redirects to sign-in,
and lazy-loads that too.
- **Vendor code is split into separately cached chunks** (`vendor-react`,
`vendor-router`, `vendor-ui`, `vendor-net`) via `manualChunks`. The first
load is not much smaller - the main chunk was 95% dependencies, and 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, so a returning visitor after a
deploy re-downloads the app, not React. Vite's 500 KB chunk warning no longer
fires.
### Fixed
- The favicon was an 888 KB image - PNG data under a `.jpg` name, 768x768 with
a fully opaque alpha channel - fetched on every page load. It is now a 2.6 KB
32px PNG, with a 180px `apple-touch-icon` for iOS home screens.
- `manifest.json` declared that same file as both its 192x192 and 512x512 icon,
typed `image/jpeg`. It now points at real 192px and 512px PNGs with the
correct type, so installs get a correctly sized icon instead of a mislabelled
one the browser has to rescale.
- The service worker's push notifications referenced `/favicon.ico`, which never
existed; a push with no icon in its payload rendered blank. They use the 192px
icon.
### Known issues
- The `useGameStore()` selector item from M26 was already resolved in WP-10;
the chain-validation hook subscribes per field.
- Not done, recorded for later: `socket.io-client` (~90 KB of `vendor-net`)
ships to anonymous visitors because the page chrome's notification bell
reaches the notification store, which imports it statically. Loading the
socket client on demand would mean an async `connect()` in four stores - a
change in its own right, not a bundle setting.
## [2.0.0] - 2026-09-10
Container and response-header hardening (WP-13: M31, M34).
### ⚠ Breaking
- **The production container now listens on 8080, not 80.** nginx runs as an
unprivileged user, and a non-root process 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 that means
`traefik.http.services.movieloop.loadbalancer.server.port` changes from `80` to
`8080`. The published mapping in `docker-compose.prod.yml` is updated here, but
the Traefik label lives outside this repo and must be changed by hand.
### Fixed
- Security headers are no longer dropped from most responses. 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. `location /assets/`
and `location = /config.js` each declared one, so `X-Frame-Options`,
`X-Content-Type-Options` and `Referrer-Policy` were silently absent from every
JavaScript and CSS response - which is most of what a browser loads. The
headers now come from a snippet included in each location. Verified against the
built image: 6 of 6 headers present on `/`, on a hashed `/assets/*.js` bundle,
and on `/config.js`.
- `config.js` no longer interpolates `API_URL` unescaped. This used `envsubst`,
which does no escaping, so a value containing a double quote closed the
JavaScript string literal and everything after it became executable script on
every page load. The value is now escaped when rendered. Operator-supplied, so
not a likely attack - but the page hands its auth token to whatever origin that
value resolves to, and a malformed value is now inert rather than executable.
### Added
- A Content-Security-Policy, plus `Strict-Transport-Security` and
`Permissions-Policy`. With the JWT in `localStorage`, CSP is the main
compensating control against injected script and there was none. `connect-src`
is derived at container start from `API_URL`, because the API is not
same-origin in production - the app is served from one subdomain and the API
from another, so naming it explicitly is required or every request and
websocket would be blocked. The inline theme script in `index.html` runs before
first paint to avoid a flash of the wrong theme and so cannot move to a file;
it is allowed by SHA-256 hash rather than by `unsafe-inline`, which would have
bought almost nothing. **If that script is edited its hash must be regenerated
in `docker/40-render-config.sh` or the page will not apply its theme.**
### Changed
- The production image is based on `nginxinc/nginx-unprivileged:alpine` and runs
as uid 101 rather than root. It serves static files and never needed root.
Verified in the built image: `uid=101(nginx)`.
- `envsubst` is no longer installed, since the entrypoint renders `config.js`
itself in order to escape the value.
## [1.3.0] - 2026-09-10
Session handling and effect correctness.
### Fixed
- An expired or revoked session no longer leaves the app half signed in. The
response interceptor only wrote to the console, so the nav still showed your
name while every request quietly failed. A 401 now clears the token and
returns you to sign-in. Signing in with the wrong password is unaffected -
that is an answer, not an expired session.
- Signing out ends the whole session. It disconnected the notifications socket
and nothing else, leaving the chat, versus and game night sockets open with a
token that had just been removed, and the friends and game stores populated -
so signing in as someone else on the same tab showed the previous person's
friend list and pending-request badge.
- Leaving a match no longer carries its result into the next one. Disconnecting
cleared only the socket, so the match id, result and score survived and the
next match opened showing the previous match's completion modal.
- Reconnecting no longer duplicates everything. All four sockets tested whether
the existing connection was *connected* rather than whether it existed, so one
that was mid-reconnect got replaced without being closed; the abandoned socket
kept its own handlers and delivered every event a second time.
- Game night spectators are detected again. The check decoded the session token
with `atob`, which rejects the base64url encoding tokens actually use, so it
threw inside a socket handler and the spectator flag was never set.
- Reloading your profile no longer discards a game in progress. Effects depended
on the whole user object, which is rebuilt on every profile load, so they
re-ran when nothing had changed - and in an async match the re-run reset the
board mid-game.
- Undo is applied in order and no longer drifts from the server. Rapid undos
fired overlapping requests at a server that removes one link per call, and a
failure was only logged while the local chain had already been shortened.
- Leaving a page cancels its in-flight requests instead of letting them land
afterwards.
- The friends page no longer removes other components' socket listeners when it
unmounts.
- Copying a result no longer leaves a timer running after the page closes.
- Share links point at the deployment that produced them, rather than always at
production.
### Security
- The runtime API setting is escaped when written into the page. It was
substituted raw, so a value containing a quote ended the string and the rest
became executable JavaScript on every page load - and the page hands its auth
token to whatever that value resolves to. A malformed value is now also
rejected on read, falling back to the default.
### Removed
- The difficulty selector in Endless mode. It was never applied to anything:
difficulty is derived from cast analysis on the server, and Endless builds its
pairs in the browser, so the control silently did nothing. The genre and
decade filters are unaffected.
## [1.2.3] - 2026-09-10
### Fixed
- The async challenge leaderboard no longer shows an empty heading to anyone
who has not played that match. The backend now withholds the movie pair from
non-participants - the pair is the puzzle - so the two titles arrive absent
rather than empty, and the page rendered a bare separator between nothing.
It now says the movies are hidden until you play, and the type reflects that
they are optional.
## [1.2.2] - 2026-09-09
### Fixed
- Shell scripts and Dockerfiles are pinned to LF so a Windows checkout no longer
copies CRLF line endings into the container image. `docker/40-render-config.sh`
was affected; nginx sources it rather than executing it, so nothing was visibly
broken, but the file was wrong in the image.
## [1.2.1] - 2026-09-09
### Fixed
- All high-severity dependency advisories cleared (`npm audit`: 23 -> 0),
entirely within existing semver ranges.
- Five further `set-state-in-effect` violations caught by the updated
react-hooks plugin, including search state that was cleared on every
keystroke below the search threshold, and an async submit guard that re-ran
its own effect.
### Changed
- In-range dependency updates, notably react 19.2 -> 19.3 and
`@base-ui/react` 1.2 -> 1.8.
- `shadcn`, `tailwindcss`, `@tailwindcss/vite` and `tw-animate-css` moved to
devDependencies. They are build-time only - `shadcn` is a scaffolding CLI
with no runtime imports - but as runtime dependencies they pulled `msw`,
`@babel/core` and `ts-morph` into the production dependency tree that
vulnerability scanning reads.
## [1.2.0] - 2026-09-09
### Changed
- Game completion, hints and all versus submissions follow the server's
authoritative scoring contract: completion posts no payload, hints go through
the server (which also records them), and match submissions send a session id
rather than a score.
### Fixed
- Stale hints are cleared when the chain changes; the previous movie's cast
used to stay on screen after picking an actor another way.
## [1.1.0] - 2026-09-09
### Fixed
- Versus, Game Night and Async Versus can complete a match again. None of the
three ever created a backend game session, so no score was produced, nothing
was submitted, the opponent was never notified, and the completion modal had
no way out.
- Chain links may be reused. Repeating an actor or movie was rejected outright
instead of being allowed and penalised, which could strand a player mid-chain.
Reuse now warns that it costs points, and the penalty appears in the score
breakdown.
- A failed async score submission is no longer retried on every render.
- Confetti no longer re-randomises mid-flight.
- Page data fetches are cancelled on unmount, so a late response cannot write
into a view the player has left.
- Profile stats and history load independently, fixing a guard that could stop
the stats request from ever re-running.
### Changed
- The dev container starts the Vite server correctly and now picks up source
edits, so hot reload works.
- The lint CI job fails on warnings, and the two React hook rules that had been
demoted to warnings are back at error.