- src/data/events.ts: useUserEvents() TanStack Query (5-min stale,
sort -starts_at). EventSummary type is a Pick of EventRow.
- src/live/active-event.ts: useActiveEventOrchestration() returns the
swap fn — unsubscribe previous + clearForEvent + subscribe new +
applySnapshot on success + persist to localStorage. Out-of-order
safety via per-call version counter. Plus readSavedActiveEventId().
- src/ui/components/event-picker.tsx: <EventPicker> dropdown.
useState + click-outside; rows show name + date + discipline.
- src/live/index.ts: re-exports active-event helpers.
- src/routes/_authed/monitor.tsx: auto-select effect (one-shot via
initializedRef, gated on events loaded + WS connected); renders
<EventPicker> wired to setActiveEvent.
Deviations:
1. Vanilla div + useState dropdown instead of shadcn Popover —
no new shadcn primitive add; easy to swap later for keyboard nav.
2. Auto-select gated on connectionStatus === 'connected' so the
subscribe call gets the snapshot path (not 'not-connected').
3. Logout-clears-saved-event-id deferred to a small Phase 1.8
follow-up; documented in task risks.
Bundle: 395KB / 120KB gz (~1KB up from 2.6).
- src/map/core/map-pref-store.ts: trailMode preference
('none' | 'selected' | 'all', default 'selected') + setter, persisted.
- src/map/layers/map-trails.tsx: <MapTrails /> side-effect-only.
Single GeoJSON source + single line layer. Builds one LineString
Feature per device whose trail has >= 2 points; filtered by mode.
Per-device flat colour via deterministic deviceId hash mod a
6-entry palette.
- src/map/core/trails-toggle.tsx: <TrailsToggle /> floating card
below <BasemapSwitcher />. Three buttons (None / Selected / All).
- src/routes/_authed/monitor.tsx: renders <TrailsToggle /> +
<MapTrails /> *before* <MapPositions /> so the line layer is added
to the map first and renders underneath the symbol layers.
Speed-coloured-per-segment deferred to Phase 3 polish per the task
spec's open-question decision; flat-colour-per-device for v1.
Bundle: main 394KB / 120KB gz — no change from 2.5.
- src/data/devices.ts: useDevicesById() — TanStack Query (5-min stale)
returning Map<deviceId, Device>. deviceLabel() formats human-readable
titles ("FMB920 #3458").
- src/map/layers/map-positions.tsx: <MapPositions /> side-effect-only.
Two GeoJSON sources (clustered non-selected + unclustered selected).
Five layers: non-selected symbol + direction + cluster-bubble,
selected symbol + direction. Click handlers: marker -> selectDevice,
cluster -> getClusterExpansionZoom + easeTo. Hover toggles cursor.
- src/routes/_authed/monitor.tsx: renders <MapPositions /> inside
<MapView>.
Schema overhaul in src/auth/client.ts:
- Made Schema SDK-compatible. Each entry is an *array* of row types
(devices: DeviceRow[], not just DeviceRow). RegularCollections<Schema>
filters on array-like values; non-arrays collapse to never which
broke readItems('devices', ...) with keyof Schema = never.
- Spelled out the composed client type as DirectusClient<Schema> &
RestClient<Schema> & AuthenticationClient<Schema> — without the
explicit annotation Schema didn't flow through .with(...) chain to
request() call sites.
- Added DeviceRow + EventRow types; exported via @/auth.
useDevicesById uses readItems<Schema, 'devices', Query<Schema, DeviceRow>>
— explicit generics because the SDK doesn't infer Schema from the
receiver's type at call sites.
Deviations:
1. Spec referenced device.kind for category — Phase 1 schema doesn't
have it yet; everything maps to 'default'. Refine when kind lands.
2. Cluster bubble uses 'default-neutral' sprite instead of a dedicated
'cluster-background' (not in 2.3's registry). Swap in 3.8.
3. getClusterExpansionZoom is Promise-based in maplibre-gl 5.x (was
callback-style); used .then().
Bundle: main bundle 394KB / 120KB gz, ~1KB up from 2.4. /monitor
chunk includes the new layer module (~10KB).
Eight files under src/live/:
- constants.ts: throughput-discipline numbers (MAX_TRAIL_LENGTH=200,
reconnect backoff [1s/2s/4s/8s/16s, 30s ceiling], STALE_CONNECTION_MS).
- protocol.ts: zod discriminatedUnion('type', ...) for inbound
(subscribed / unsubscribed / position / error). PositionEntry +
SubscribeResult types per processor-ws-contract.
- connection-store.ts: Zustand store with status state machine.
- position-store.ts: Zustand store with latestByDevice + trailsByDevice
Maps, applySnapshot/applyPositions/clearForEvent/selectDevice
actions. Ring-buffer cap on trails; same-coordinate dedup.
- coalescer.ts: ~30-line rAF coalescer. Per-device buffer; flushes once
per animation frame regardless of receive rate.
- ws-client.ts: state machine (idle / connecting / connected /
reconnecting / closed) with exponential backoff, re-subscribe on
reconnect, stale-connection check, subscribe correlation via
pending-map + 5s timeout. URL resolution helper toAbsoluteWsUrl()
for same-origin path inputs.
- bootstrap.tsx: <LiveBootstrap> creates the client when authenticated,
wires positions through the coalescer to the position store, tears
down on logout. getLiveClient() exposes the singleton for 2.7.
- index.ts: barrel re-exports.
main.tsx wraps <App /> in <LiveBootstrap> alongside <AuthBootstrap>.
Deviations:
1. Skipped the setStatus helper inside createLiveClient; conditional
Parameters<> generics were hostile. Direct
useConnectionStore.getState().setStatus(...) at the ~6 call sites.
2. subscribe() adds to the subscriptions Set even when not connected
(so it replays on reconnect). Caller handles 'not-connected' by
waiting for connection-store status transition.
3. onPosition returns an unsubscribe fn (Set-based). Multi-handler is
free; lets future debug panels/tests attach.
Bundle: src/live/ adds ~15KB raw to the main bundle (mostly zod's
discriminated-union runtime). Total 393KB / 120KB gz.
- src/map/assets/icons/: 9 placeholder SVGs (background plate, direction
arrow, 7 categories: rally-car / quad / ssv / motorcycle / runner /
hiker / default). Hand-authored simple silhouettes; replaced in
Phase 3.8 with the branded set.
- src/map/core/categories.ts: SpriteCategory/SpriteColor types,
mapCategoryToSprite() normaliser, inferColor() helper.
- src/map/core/sprite-preload.ts: idempotent preloadSprites() with
memoised promise, whenSpritesReady() alias, getSpriteRegistry()
read-only access, installSprites(map) for the mapReady flow.
Composition pipeline draws the plate + composites a tinted icon
centred on it (category sprites) or tints the arrow alone (direction
sprites). Tint via canvas globalCompositeOperation: 'source-in'.
- src/main.tsx: void preloadSprites() fired at boot; the promise is
memoised so mapReady flow awaits the same instance.
- src/map/core/map-view.tsx: onStyleData() awaits whenSpritesReady()
AND _map.loaded() before installing sprites and flipping mapReady
true. Sprites reinstall on every style swap.
Registry: 7 categories x 4 colours + 4 direction-only entries = 32
total. ~160KB in memory.
Deviations:
1. Direction sprites have no plate (it's a separate symbol layer in
2.5 overlaid on the device sprite; double-plate would look wrong).
2. Hardcoded the design-system palette (#2E8C4A / #E8412B / #0E0E0C /
#2563C8) directly. When 3.8 lands, these rebind to TRM tokens via
CSS variables.
- pnpm add maplibre-google-maps (runtime; lazy-imported on Google use).
- src/map/core/styles.ts: BasemapDescriptor model with four entries
(Esri Satellite / OpenTopoMap / OSM / Google Satellite).
Google entry gated on cfg.googleMapsKey via available(cfg).
styleCustom() builds a single-raster-source MapLibre style with a
glyphs URL for future label rendering.
ensureGoogleProtocol() lazy-loads maplibre-google-maps + addProtocol
on first use; _googleRegistered guards re-registration.
- src/map/core/map-pref-store.ts: Zustand + persist middleware
(key: trm-map-prefs, version: 1). Holds basemapId + setBasemap.
- src/map/core/basemap-switcher.tsx: floating control top-right of
<MapView>. Filters by available(cfg), highlights active, applies via
getMap().setStyle(). Mount-time bootstrap (no UI state) + user-driven
onClick (with switching state) split to satisfy React 19's
react-hooks/set-state-in-effect rule.
- src/routes/_authed/monitor.tsx: renders <BasemapSwitcher /> as a
child of <MapView>.
- src/vite-env.d.ts: ambient module declaration for
maplibre-google-maps (no .d.ts ships in the package).
Deviations:
- BasemapDescriptor.style is buildStyle(cfg): StyleSpecification, not
the spec's StyleSpecification|string union. Google needs the cfg key
at build time; uniform function shape across all entries is cleaner.
- Bootstrap apply path doesn't use the switching UI state (no visual
feedback needed during the silent first-paint flip from OSM to user
preference). User-click path keeps setSwitching for the in-flight
indicator.
Bundle: MapLibre is now its own chunk (1MB / 273KB gz), shared across
maplibre consumers. Main bundle 371KB / 114KB gz.
- pnpm add maplibre-gl + -D @types/geojson.
- src/map/core/styles.ts: defaultStyle (OSM raster bootstrap; 2.2
replaces with the basemap-switcher descriptor table).
- src/map/core/map-view.tsx: module-level Map singleton lazily created
on first <MapView> mount, attached to a class="trm-map-host" detached
<div> that React refs append/remove on mount/unmount. Style-data
lifecycle flips mapReady false on every styledata event, polls
loaded() at 33ms intervals, flips ready true once the style is
loaded — the canonical MapLibre style-swap dance.
- Exports getMap()/getMapReady()/subscribeMapReady()/useMapReady (via
useSyncExternalStore for SSR-safe + concurrent-safe reads). getMap()
throws if called pre-mount; the explicit failure mode beats a
null-able top-level export.
- src/routes/_authed/monitor.tsx: new /monitor route, full-viewport
<MapView /> for 2.1 (no children — subsequent tasks plug in here).
- src/routes/_authed/index.tsx: home-page card now links to /monitor.
- eslint.config.js: override for src/map/** + src/live/** disables
react-refresh/only-export-components. Same pattern as the existing
overrides for shadcn primitives and route files.
Deviation: spec sketched a top-level `map` constant export; implemented
as `getMap(): MapLibreMap` (a function) so the singleton stays lazy
until <MapView> mounts. Top-level constant would either force eager
init (breaks SSR/tests) or be nullable (footgun). The function form
throws a clear error if called pre-mount.
Bundle: /monitor lazy chunk is 1MB raw / 274KB gzipped (MapLibre + CSS).
Other routes unaffected. Vite chunk-size warning is harmless.
Nine task files matching Phase 1's shape (Goal / Deliverables / Spec /
Acceptance / Risks / Done). README updated with full sequencing diagram,
files-modified outline, tech stack additions, design rules, and phase
acceptance.
| # | Task |
| --- | --------------------------------------------------------------------- |
| 2.1 | MapView singleton + mapReady gate |
| 2.2 | Tile-source switcher (Esri / OpenTopoMap / OSM / optional Google) |
| 2.3 | Sprite preload — 7 racing categories x 4 colour variants |
| 2.4 | WS client + rAF coalescer + Zustand position store + connection store |
| 2.5 | MapPositions — clustered + selected sources |
| 2.6 | MapTrails — bounded ring buffer, polyline rendering |
| 2.7 | Event picker — TanStack Query + WS subscription orchestration |
| 2.8 | Camera control trio — default-fit / selected-follow / one-shot |
| 2.9 | Connection status + per-device last-seen indicators |
Sequencing: 2.1 and 2.4 are parallel foundations (singleton vs data
pipeline). Once both land, 2.5 / 2.6 / 2.7 / 2.9 fan out independently.
2.2 / 2.3 only need 2.1. 2.8 sits at the end on top of 2.1 + 2.5.
Each task documents its deliverables down to file paths + interface
shapes, includes concrete code sketches in the Specification, lists
explicit out-of-scope items, and surfaces risks for the implementer
to think about. An agent (or future me) can pick up any single task
and ship it without re-deriving the design from the wiki.
Resolved Phase 2 design decisions baked into the task files:
- Trails: flat-colour-per-device for v1, defer speed-coloured segments
to a Phase 3 polish task.
- Cluster params: 14/50 (traccar default); tune after seeing real data.
- Event picker placement: top-left dropdown.
- Multi-event: out — single-select, one event at a time.
- Stale-position visual: fade icon opacity; defer warning badges.
A design handoff bundle generated by Claude Design (claude.ai/design)
on 2026-05-02. Defines the Bloomberg/F1-pit-wall aesthetic for TRM:
- ink-on-paper base + race-flag red accent (#E8412B)
- square-edged everything, sharp printed offset shadows
- mono numerics (JetBrains Mono) for any changing value
- Goldplay (real licensed font, three weights in bundle fonts/)
- four surfaces designed: dashboard / leaderboard / mobile / marketing
(SPA scope is the first two)
The bundle is committed in-tree at TRM_Design_System-handoff/ so 3.8
has the full source material when it picks the work up. Includes:
- Top-level + project READMEs (the design spec)
- chats/chat1.md (intent + iteration history)
- colors_and_type.css (token set, drop-in for Tailwind 4 @theme)
- fonts/ (Goldplay regular/semibold/bold)
- ui_kits/ (HTML prototypes per surface)
- preview/ (per-token visual reference cards)
Updated phase-3-dogfood-readiness/README.md task 3.8 row to point at
the bundle and document the recommended approach (retheme shadcn via
CSS variable overrides + Tailwind 4 @theme, not replace).
Why deferred: foundational tokens are non-blocking for Phase 1 (login
+ placeholder home) and Phase 2 (live map without chrome). Applying
them now would either delay dogfood-blocking work or land partial
styling that gets reworked when 3.8 lands the full pass.
Two CI gaps surfaced on first push:
1. typecheck failed because tsc -b ran before vite build, but
src/routeTree.gen.ts is only generated by the Vite plugin during
build. tsc has nothing to typecheck against.
Fix: install @tanstack/router-cli and chain `tsr generate` before
tsc in the typecheck and build scripts. Now any environment that
runs typecheck cold (CI, fresh clone) generates the route tree
first.
Also added a top-level `route-tree` script so the same command is
reusable elsewhere if needed.
2. format:check would fail on Windows working trees because
git autocrlf (default on Windows) checks files out with CRLF, but
.prettierrc pins endOfLine: "lf". Locally the format:check
intermittently passed/failed depending on whether files had been
recently auto-formatted.
Fix: .gitattributes with `* text=auto eol=lf` enforces LF in every
working tree. Plus explicit overrides for binary blobs (images) and
the route tree file. `git add --renormalize .` brought the index in
line with the new policy; no actual file content changed.
CI on the next push should now see the same green gates the local
working tree shows.
- Dockerfile: three-stage (deps / build / runtime). deps stage runs
pnpm fetch with BuildKit cache mount; build stage runs vite build
to produce dist/; runtime stage is nginx:1.27-alpine serving the
bundle. HEALTHCHECK via wget against localhost.
- nginx.conf: gzip on text assets; /assets/ long-cache (hashed
filenames immutable); /config.json no-cache (volume-mountable
override in stage/prod); /index.html no-cache; SPA routing fallback
via try_files ... /index.html.
- .dockerignore: keeps the context small (node_modules, dist, env,
.git, .gitea, .planning, *.md except README, .claude, .vscode).
- .gitea/workflows/build.yml: matches trm/processor shape with
format:check added between lint and test. Path filter excludes
.planning and pure-markdown changes. Steps: checkout, Node 22,
pnpm@latest-9, install --frozen-lockfile, typecheck, lint,
format:check, test, buildx, registry login, build & push
trm/spa:main, Portainer webhook.
Deviations from spec:
- Push :main tag only (not :main + per-commit SHA). Matches the
other repos; SHA-pinning happens via *_TAG env vars in
trm/deploy. SHA tagging is a cross-repo refactor for later.
- Pin pnpm@latest-9 (matching existing repos), not pnpm@latest
from the spec. Reproducibility win for CI.
Smoke: typecheck/lint/format:check/build all green locally. Local
docker build not run (Docker unavailable on this machine); CI is
the gate.
Required for first deploy (1.10 covers the rest):
- REGISTRY_USERNAME / REGISTRY_PASSWORD / PORTAINER_WEBHOOK_URL
secrets in the Gitea repo settings.
- SPA service block in trm/deploy/compose.yaml.
When import.meta.env.DEV is true, the login form's email and password
fields are populated from the corresponding env vars. Shaves the manual
re-typing during dev iteration.
Production builds get empty strings regardless of build-time env values:
the prefill is gated on import.meta.env.DEV (which Vite replaces with
literal `false` at build time, so the surrounding ternary tree-shakes
and the env values can't bleed into the prod bundle even if accidentally
set in the build env).
Files:
- src/vite-env.d.ts (new): ImportMetaEnv augmentation with the four
VITE_* vars we use (admin email/password, dev directus/processor URLs).
Gives proper typing under strict mode.
- src/ui/pages/login.tsx: devDefaults computed once at module scope from
import.meta.env. Form's defaultValues uses it.
- .env.example: documents VITE_ADMIN_EMAIL and VITE_ADMIN_PASSWORD with
examples; notes the prod-ignore guarantee.
- .gitignore: adds *.env (defensive — complements the existing *.local
pattern). .env.example stays committable (doesn't end in .env).
- src/auth/logout.ts: performLogout({ queryClient, navigate? })
orchestration. Calls store.logout(), clears query cache, navigates
to /login. Server-call failures swallowed; local state still flips
to anonymous.
- src/auth/cross-tab-sync.ts: startCrossTabSync(). Subscribes to the
auth store and bumps trm-auth-version in localStorage on status
transitions; listens for storage events and re-runs initialize()
when another tab bumps. Idempotent via module-level guard. No-ops
if localStorage is unavailable.
- src/ui/components/logout-button.tsx: <LogoutButton> wraps shadcn
Button. Pass-through props but locks onClick / disabled / children.
Local isLoggingOut for "Signing out..." indicator + double-click
guard.
- src/auth/bootstrap.tsx: <AuthBootstrap> now calls startCrossTabSync()
alongside the existing init steps. Single mount point.
- src/auth/index.ts: re-export performLogout + startCrossTabSync.
- src/routes/_authed/index.tsx: replaced the inline sign-out button
with <LogoutButton />. The existing useEffect on 'anonymous' status
handles navigation when cross-tab sync fires.
Bonus: tsconfig.app.json now has "strict": true. TanStack Router emits
a conditional-type error ("strictNullChecks must be enabled") in
editors when strict is off; tsc -b was lenient. Caught in 1.7's
review. Typecheck remained green after enabling.
TanStack Router file-based routes:
- vite.config.ts: TanStackRouterVite plugin (target: react,
autoCodeSplitting: true) before the React plugin.
- .gitignore: src/routeTree.gen.ts (auto-generated).
- src/lib/query-client.ts: module-level QueryClient singleton.
- src/routes/__root.tsx: root route, QueryClientProvider, Suspense-
wrapped router + query devtools (dev-only via import.meta.env.DEV).
- src/routes/login.tsx: public route. validateSearch schema accepts
optional redirect. LoginRoute wraps <LoginPage> with onAuthenticated
navigating to redirect ?? '/'.
- src/routes/_authed/route.tsx: pathless protected layout. beforeLoad
redirects to /login on 'anonymous'; falls through on 'unknown' /
'authenticating' so hard reload doesn't flash the login page.
- src/routes/_authed/index.tsx: placeholder home with sign-out button.
Cross-tab logout effect bounces to /login on store transition.
- src/App.tsx: replaced — now creates the router and renders
<RouterProvider />. The interim status-branching logic from 1.6 is
gone (the route tree handles it).
- eslint.config.js: override for src/routes/** disabling
react-refresh/only-export-components — TanStack Router's file-based
pattern intentionally co-exports Route alongside components.
Code-splitting working: login chunk 37KB, home + auth 17KB, card
primitive 31KB, all loaded lazily; main bundle 353KB.
Deviations:
1. No top-level useRequireAuth hook — per-page useEffect on store
transitions is just as effective and only used in one place.
2. ESLint override for src/routes/** matches the existing pattern for
src/ui/primitives/**.
3. Login page's auto-navigate effect from 1.6 is removed — the route
wrapper owns navigation via onAuthenticated.
src/ui/pages/login.tsx — LoginPage component:
- Centred card on muted background.
- shadcn Form + FormField with react-hook-form + zodResolver.
FormMessage renders field-level zod errors automatically.
- Email + password with proper autocomplete attrs for password managers.
autoFocus on email.
- Submit calls useAuthStore.getState().login(); errors render in a
destructive Alert above the form.
- In-flight via the auth store's 'authenticating' status (cross-tab safe).
- onAuthenticated callback fires on store transition to authenticated;
1.7 wires this to a router redirect.
src/App.tsx branches on auth status:
- unknown / authenticating -> "Signing you in..." placeholder
(avoids flashing the login page on hard refresh while the boot probe
runs)
- anonymous -> <LoginPage />
- authenticated -> home placeholder (1.7 replaces with router shell)
Deviation: skipped src/routes/login.tsx — requires the router plugin
to have generated routeTree.gen.ts, which is 1.7's territory. The page
works standalone via App.tsx's status branch.
Search-param redirect (?redirect=...) also deferred to 1.7 — no router
yet to expose useSearch. onAuthenticated callback is the seam.
Six files under src/auth/:
- client.ts: createDirectus<Schema>().with(authentication('cookie', ...))
.with(rest(...)). Lazy singleton via initDirectusClient(url) +
getDirectus() + useDirectus() (React-side helper). DirectusUser type;
empty Schema (grows in Phase 2+).
- store.ts: Zustand store with discriminated AuthState
(unknown / anonymous / authenticating / authenticated). Actions:
initialize, login, logout, setUser. humanizeAuthError maps Directus
error codes (INVALID_CREDENTIALS / INVALID_OTP / USER_SUSPENDED) to
user-facing strings.
- guard.ts: useRequireAuth (redirect to /login on anonymous) and
useRequireRole (bounce to / on role mismatch). Uses TanStack Router's
useNavigate; effective once 1.7 wires the router.
- bootstrap.tsx: <AuthBootstrap> component runs initDirectusClient +
initialize() once after the runtime config is loaded.
- index.ts: barrel re-exports.
main.tsx wraps App in <RuntimeConfigProvider><AuthBootstrap>...
App.tsx shows the auth status as a smoke indicator before 1.6/1.7.
Deviations:
1. Split getDirectus into initDirectusClient(url) + getDirectus()
because the runtime config is a React context (per 1.4), not a
Zustand store. <AuthBootstrap> bridges from React land to the non-
React getter.
2. Added <AuthBootstrap> as a discrete component rather than putting
the bootstrap effect in App.tsx — App.tsx gets replaced by the
router in 1.7, but bootstrap needs to keep running.
Three-file config module under src/config/:
- schema.ts: RuntimeConfigSchema (directusUrl, liveWsUrl, businessWsUrl,
optional googleMapsKey, env enum). Custom UrlOrAbsolutePath validator
accepts http(s)/ws(s) URLs plus paths starting with /.
- load.ts: loadConfig() fetches /config.json with cache: 'no-store',
safeParse, throws ConfigValidationError on schema failure.
- context.ts: RuntimeConfigContext + useRuntimeConfig() hook (no JSX).
- provider.tsx: RuntimeConfigProvider — loading / ready / error states.
Single retry after 500ms on network failure; renders zod issues on
validation failure for debuggability.
public/config.json: committed dev defaults (relative paths matching the
Vite proxy from 1.3).
src/main.tsx wraps App in <RuntimeConfigProvider>.
README gains a Runtime config section with the schema table.
Deviation: spec sketched provider+hook in one context.tsx. Split into
context.ts (hook only) and provider.tsx (component only) to satisfy
react-refresh/only-export-components without adding an eslint override.
Same-origin dev proxy via Vite's server.proxy:
- /api/* -> ${VITE_DEV_DIRECTUS_URL}/* (Directus REST + GraphQL)
- /ws-business -> ws://.../websocket (Directus business-plane WS)
- /ws-live -> ${VITE_DEV_PROCESSOR_WS_URL} (Processor live WS, Phase 1.5)
WS proxy targets derive ws:// scheme from the http(s) Directus URL.
loadEnv reads VITE_DEV_* overrides; sensible localhost defaults.
tsconfig.app.json: noUncheckedIndexedAccess + noImplicitOverride.
.env.example documents the two override vars; .env.local is gitignored
via the existing *.local pattern.
README "Local dev" section: proxy table + override instructions + port
collision workaround.
Deviation: spec called for .env.dev.example/.env.dev.local but Vite's
dev mode is "development" not "dev", so mode-specific files would be
.env.development.* and Vite wouldn't auto-load .env.dev.local. Switched
to the standard Vite .env.example/.env.local convention. Documented in
the task's Done section.
Plus: backfill 1.2 commit SHA (9918418) in its Done section.