diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1fec4b4..c9dddded 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -167,6 +167,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
now cross the bridge in a form Android supports, and an export that fails
says so with the reason instead of silently stopping. Requires the matching
LapWing build.
+- **A drag log could overwrite a course's lap snapshot.** Opening a drag (or
+ waypoint) session right after a circuit session kept the previous course
+ selected, so assigning an engine or saving a snapshot could replace that
+ course's real fastest lap with a 10-second drag run — and cloud-sync it.
+ Course-less sessions now clear the selection, and drag sessions can never
+ save a course snapshot.
+- **Incomplete drag runs no longer count as "fastest"** in the video overlay's
+ best-lap readout, the Overlays menu trophy, the external-reference picker,
+ lap snapshots, or the course outline generated from a session.
+- **Video overlay gauges kept the old range after a unit toggle.** Switching
+ MPH/KPH (or metric distance) left analog, bar, graph and bubble overlays
+ scaling the new values against the previous unit's range, in both the
+ preview and exported videos.
+- **Android app: an export could use the wrong session's video.** If the
+ app's background copy of a video finished after you had switched sessions,
+ the next export burned the new session's overlays onto the old session's
+ footage. A copy now only attaches to the session (and video) it started
+ for.
+- **Android app: Settings said "not cached yet" forever.** The offline
+ readiness row looked for a web service worker the app never uses. It now
+ shows that everything is built into the app and works with no signal.
+- **Android app: deleting a session now frees its video copy.** The app's
+ stored copy of a session's video (often gigabytes) was left behind in app
+ storage when the session was deleted.
+- **Offline extras now update.** The bundled sample logs and logger photos
+ that download in the background for offline use were cached once and never
+ refreshed, so a device kept the old copy forever after an update. Each one
+ is now checked against the current release and re-downloaded when it
+ changes, and files a release no longer ships are cleaned up.
## [4.1.0] - 2026-08-24
diff --git a/CLAUDE.md b/CLAUDE.md
index 704532c2..25d2af35 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -151,7 +151,9 @@ src/
│ ├── buildInfo.ts # Build version/hash/branch stamp + isPreviewBuild()
│ ├── analytics.ts # ★ Anonymous usage stats (plan 0030): PostHog, cookieless, dynamic-imported only when the gate passes (key baked in + web + not iframe/`?nosw=1` + no DNT/GPC + `sendUsageStats` on) — keyless builds drop the import entirely. Pageview/pageleave only; `scrubEvent` (before_send) strips query/hash and placeholders `/s/:token` + `/driver/:username` from every URL property. Privacy.tsx wording mirrors its config — change both together
│ ├── versionCheck.ts # ★ "Update available" signal: compares buildInfo vs the build-emitted, uncached /version.json (independent of the SW's own update detection) → main.tsx update toast
-│ ├── offlineWarmup.ts / offlineReadiness.ts # ★ Two-stage offline cache (plan 0027): the precache install is all-or-nothing, so `vite.config.ts` holds the heavy public dirs (DEFERRED_ASSET_DIRS = samples/, loggers/) OUT of it and emits `offline-assets.json`; offlineWarmup writes them into the `app-deferred-assets` cache afterwards via `cache.add` (NOT fetch — an uncontrolled first-visit page bypasses the SW), where a failure costs only that asset. offlineReadiness is the pure state → useOfflineReadiness → SettingsModal row
+│ ├── offlineWarmup.ts / offlineReadiness.ts # ★ Two-stage offline cache (plan 0027): the precache install is all-or-nothing, so `vite.config.ts` holds the heavy public dirs (DEFERRED_ASSET_DIRS = samples/, loggers/) OUT of it and emits `offline-assets.json` (url + content-hash revision per file); offlineWarmup writes them into the `app-deferred-assets` cache afterwards with `cache.put` (NOT a plain fetch — an uncontrolled first-visit page bypasses the SW), stamping each with its revision so an updated asset is re-fetched (the CacheFirst route alone never would) and pruning files no longer shipped; a failure costs only that asset. offlineReadiness is the pure state (incl. `native` for the Tauri shell) → useOfflineReadiness → SettingsModal row
+│ ├── nativeVideoExport.ts / nativeVideoStore.ts / nativeBytes.ts # ★ Native-shell video bridge (plan 0024, → docs/subsystems.md): nativeVideoExport stages a hardware-transcoded overlay export over `video_export_*` IPC (null = shell can't, caller falls back to the WebView exporter); nativeVideoStore copies a session's picked video into the shell's app-data store (`video_store_*`) so it replays/exports without re-picking — deleted with its session by fileStorage.deleteFile, listed/pruned by plugins/native-storage; nativeBytes = the base64-chunk upload encoding (Android's WebView has no raw IPC bodies)
+│ ├── pwaInstall.ts / persistentStorage.ts # Offline durability (plan 0026): pwaInstall decides the install nudge (iOS never fires beforeinstallprompt → hand-held Home Screen hint, snoozable) for InstallPrompt; persistentStorage asks navigator.storage.persist() once at boot (main.tsx) so the precache + IndexedDB stop being evictable
│ ├── debugConsole.ts # ★ On-screen debug console (`?dbg=true`) — mobile/PWA has no dev tools
│ ├── units.ts # ★ Pure unit conversions for the 3 imperial/metric toggles
│ ├── i18n/ # ★ i18next config/init/format (→ docs/i18n.md)
@@ -161,6 +163,7 @@ src/
│ ├── (framework) # types, registry, index, panels, mounts, fileSources, storage + hosts
│ ├── cloud-sync/ # ★ First-party plugin: Supabase file + garage sync (→ docs/backend.md)
│ ├── tools/ # ★ First-party plugin: Tools tab (kart seat-position viz; phone Lap Timer; pill alignment calculator — plan 0011)
+│ ├── native-storage/ # First-party plugin, native shell only: Profile-tab "Videos on this device" panel over lib/nativeVideoStore (plan 0024) — also what gives a native build without cloud a Profile tab
│ └── coaching/ # Gitignored slot for the AI coach (npm pkg in production)
├── types/racing.ts # ★ Core types: GpsSample, ParsedData, Lap, Course, Track, …
├── contexts/ # SettingsContext, SessionContext, PlaybackContext, DeviceContext, AuthContext
@@ -284,9 +287,11 @@ A plugin default-exports `{ id, name, version?, priority?, setup?(ctx) }`. In
- **File sources** (`fileSources.ts`, `FILE_SOURCES_POINT`): feed *remote* files
into the host browser as inline `cloud` rows without coupling the host to cloud.
-First-party plugins: **cloud-sync** (Supabase file + garage sync → `docs/backend.md`)
-and **tools** (Tools tab: kart seat-position visualizer + phone Lap Timer built on
-`lib/gps/`). New slots/points are just new strings — no framework change.
+First-party plugins: **cloud-sync** (Supabase file + garage sync → `docs/backend.md`),
+**tools** (Tools tab: kart seat-position visualizer + phone Lap Timer built on
+`lib/gps/`) and **native-storage** (native shell only — the Profile tab's "Videos
+on this device" card over `lib/nativeVideoStore`; its `setup` returns early off
+native, so the web build contributes nothing). New slots/points are just new strings — no framework change.
> ## ⚠️ SUPER IMPORTANT — coach source differs by branch (DO NOT MERGE BLINDLY)
>
diff --git a/README.md b/README.md
index 474d0c9b..1c2286df 100644
--- a/README.md
+++ b/README.md
@@ -655,7 +655,7 @@ Built on the shoulders of these incredible open-source projects and free service
- [React](https://react.dev) · [Vite](https://vite.dev) · [TypeScript](https://www.typescriptlang.org) · [Tauri](https://tauri.app) (native shell IPC, native-only)
- [Tailwind CSS](https://tailwindcss.com) · [shadcn/ui](https://ui.shadcn.com) · [Radix UI](https://www.radix-ui.com) · [Lucide Icons](https://lucide.dev)
-- [Archivo](https://github.com/Omnibus-Type/Archivo) (LapWing brand display face) · [Inter](https://rsms.me/inter/) · [JetBrains Mono](https://www.jetbrains.com/lp/mono/) (OFL fonts) · [Fontsource](https://fontsource.org) (self-hosted, offline-ready)
+- [Archivo](https://github.com/Omnibus-Type/Archivo) (LapWing brand display face, self-hosted from `src/assets/fonts/` with its licence in `OFL.txt`) · [Inter](https://rsms.me/inter/) · [JetBrains Mono](https://www.jetbrains.com/lp/mono/) (OFL fonts) · [Fontsource](https://fontsource.org) (self-hosted, offline-ready)
- [Leaflet](https://leafletjs.com) · [CARTO basemaps](https://carto.com) · [Esri World Imagery & Wayback](https://livingatlas.arcgis.com/wayback/) (satellite + historical imagery dates)
- [TanStack Query](https://tanstack.com/query) · [Sonner](https://sonner.emilkowal.dev) · [react-resizable-panels](https://github.com/bvaughn/react-resizable-panels) · [dnd kit](https://dndkit.com) (sector list drag-to-reorder)
- [i18next](https://www.i18next.com) · [react-i18next](https://react.i18next.com) (internationalization)
diff --git a/docs/plans/0022-drag-mode.md b/docs/plans/0022-drag-mode.md
index 7c97a3bf..c3f190ce 100644
--- a/docs/plans/0022-drag-mode.md
+++ b/docs/plans/0022-drag-mode.md
@@ -106,6 +106,15 @@ convention) re-runs the detector and re-maps silently; a real track/course
restore shadows a stale drag field; assigning a course clears the field.
Distance switching is a pure re-map of the held `DragRun[]` — no re-detection.
+Course-less pre-application (drag runs, and the waypoint fallback) goes
+through `applyCourselessLaps`, which **clears the course selection** first. The
+loader carries the previous file's selection into a new load, and a drag
+session left holding it kept `canSnapshot` live — a 10-second run could replace
+that course's snapshot and cloud-sync it (4.2.0 release review A1).
+`useLapSnapshots` also refuses drag sessions outright (`snapshotEligible`), and
+every "fastest" pick in the app goes through `fastestRankedLap`, never a bare
+min-reduce (A2).
+
### UI
`TrackPromptDialog` precedence: course-step > drag > waypoint > no-track (title
diff --git a/docs/plans/0024-native-export-bridge.md b/docs/plans/0024-native-export-bridge.md
index 9c28e40a..fbeafd9a 100644
--- a/docs/plans/0024-native-export-bridge.md
+++ b/docs/plans/0024-native-export-bridge.md
@@ -38,7 +38,8 @@ seamlessly; web and desktop behavior is byte-identical to before.
The per-frame invariant work found while profiling the export path is now
cached per session via `WeakMap` on array identity (no invalidation, no
leaks): per-source ranges (`memoRange`, validated against the pace/braking
-arrays for the special sources), map bounds (`memoMapBounds`), the pace bar's
+arrays for the special sources and against the data-source defs, which close
+over the unit toggles — a MPH/KPH switch must re-range), map bounds (`memoMapBounds`), the pace bar's
scale (`memoPaceMax`), and the digital widget no longer resolves its value
twice per draw (`digitalBox`). This speeds up preview, the WebView exporter,
and native layer generation alike — the draws' output is bit-identical.
@@ -141,3 +142,16 @@ export shortcut goes away. The pure parts — ordering, totals, labels,
applying a removal, byte formatting — live in `deviceVideos.ts` with tests;
the panel is a thin view.
+## Follow-up 4 (landed): release-review hardening (4.2.0)
+
+- **Stale copy, wrong session.** `loadRecording`'s background copy set
+ `nativeStoredKey` whenever it resolved, so a copy that finished after a
+ session switch attached session A's key to session B and the export paired
+ A's footage with B's overlays. `useVideoSync` now adopts a key only through a
+ latest-wins gate (`lib/latestGate.ts`) invalidated on every session/video
+ change; the async restore lookup is gated the same way.
+- **Deleted sessions leaked their copy.** `deleteNativeStoredVideo` had no
+ callers. `fileStorage.deleteFile` now drops the session's stored copy,
+ best-effort, next to its weather cache.
+- **Tests.** `nativeVideoExport.test.ts` pins the IPC sequence, the stale-key
+ retry and the fallback-vs-error classifier.
diff --git a/docs/plans/0027-precache-resilience.md b/docs/plans/0027-precache-resilience.md
index 2af9575a..2b27838c 100644
--- a/docs/plans/0027-precache-resilience.md
+++ b/docs/plans/0027-precache-resilience.md
@@ -91,13 +91,37 @@ The route deliberately has no `ExpirationPlugin`: entries written by the client
aren't in the plugin's own index, and a fixed set of build assets has nothing to
expire.
+### Revision the deferred cache (follow-up, 4.2.0 release review PERF-1)
+
+Moving `samples/` and `loggers/` out of the precache also moved them out of
+Workbox's per-file revisioning, and the runtime route is `CacheFirst` with no
+expiry — so an updated sample log or logger photo would never reach a device
+that had already cached the old bytes. The fix keeps the cache key a plain URL
+(the app requests plain URLs and the route serves them) and moves the revision
+into the data:
+
+- `offline-assets.json` lists `{ url, revision }`, where `revision` is the first
+ 16 hex chars of the file's SHA-256, computed by `deferredAssetManifest`.
+- The warm-up stores each response with an `X-Asset-Revision` header
+ (`fetch(…, { cache: "reload" })` + `cache.put`, so a refresh never gets the
+ old bytes from the HTTP cache) and treats an entry whose stamp doesn't match
+ the manifest as missing. Entries the service worker's own route cached on a
+ miss carry no stamp, so they are replaced once.
+- After a run it prunes entries whose path the manifest no longer lists — but
+ never against an empty list, which is what an unreadable manifest degrades to.
+- The readiness count uses the same revision check, so a stale cache reads as
+ "preparing" rather than "ready".
+
### Make readiness visible
The deeper failure was that none of this was observable — you found out the cache
was incomplete by losing signal and refreshing. `lib/offlineReadiness.ts` (pure)
plus `hooks/useOfflineReadiness` and a Settings row report one of
`unsupported` / `not-ready` / `preparing` / `ready`, with a *Finish download*
-button. `controlled` alone decides whether the app loads offline — the shell is
+button. A later `native` state (checked first, via `isNativeApp()`) covers the
+Tauri shell: its WebView exposes `navigator.serviceWorker` but the shell never
+registers one, so it used to read `not-ready` forever; native is offline by
+construction and the row says so. `controlled` alone decides whether the app loads offline — the shell is
precached as a unit, so a controlling worker means install completed; the
deferred count only separates "ready" from "preparing".
diff --git a/docs/subsystems.md b/docs/subsystems.md
index 0db8f910..8e6f6751 100644
--- a/docs/subsystems.md
+++ b/docs/subsystems.md
@@ -713,3 +713,61 @@ GPS-derived), `lat_g_native`/`lon_g_native` (logger-native), `accel_x/y/z` (raw
IMU) — which coexist on a sample and must never collapse. `fieldResolver.ts` is the
settings-facing adapter. `toChannelKey()` is the idempotent shim that migrates
legacy display-name keys persisted in graph-prefs / saved overlay configs on load.
+
+---
+
+## Native video bridge (`nativeVideoExport` / `nativeVideoStore` / `nativeBytes`)
+
+Native shell only (`isNativeApp()`); every entry point is a no-op or `null` on
+the web, so callers invoke them unconditionally. All IPC goes through the lazy
+`lib/loggers/native/ipc.ts` loader, so `@tauri-apps/api` stays off the web
+payload. Shell-side contract: LapWing `docs/video-pipeline.md`. Design record:
+[`plans/0024-native-export-bridge.md`](plans/0024-native-export-bridge.md).
+
+- **`nativeBytes.ts`** — bytes *into* the shell travel as base64 strings in
+ ordinary JSON args, in `NATIVE_CHUNK_BYTES` (4 MB) chunks. Android's WebView
+ cannot hand Tauri a raw request body, so a `Uint8Array` arg degrades to a JSON
+ number array (and raw-body commands reject it outright) — that is how the
+ first on-device exports failed on their first chunk. Bytes *back* (raw
+ `tauri::ipc::Response`) are unaffected.
+- **`nativeVideoExport.ts`** — `startNativeVideoExport()` resolves `null` when
+ the shell can't export (web, multi-chunk playlist, desktop stub's
+ `unsupported:` sentinel, or a shell that doesn't know the command), and the
+ caller (`VideoPlayer`) falls back to the in-WebView WebCodecs exporter. Any
+ other `video_export_begin` failure is reported, never silently swallowed.
+ Sequence: `begin` (trim/size/bitrate; `sourceKey` when the session has a
+ stored copy, retried without it if the key is stale) → `push_source` chunks
+ (skipped with a stored source) → `push_overlay` PNG layers rendered by the
+ same scene renderer as the preview (plan 0023) at 15 Hz → `run` (progress
+ channel) → `save` (gallery) or `collect` (bytes back) → `dispose`, always.
+- **`nativeVideoStore.ts`** — the web remembers a session's video through a
+ `FileSystemFileHandle`; Android has none, so `useVideoSync` copies the picked
+ file into the shell's app-data store in the background (`video_store_*`) and
+ plays/exports from that copy on the next open. The copy is async and can
+ outlive a session switch, so its key is only adopted through a latest-wins
+ gate (`lib/latestGate`) — otherwise an export could pair one session's footage
+ with another's overlays. Stored copies are gigabytes: `fileStorage.deleteFile`
+ deletes a session's copy with the session, and the **native-storage** plugin's
+ "Videos on this device" card lists/removes them (`removeNativeStoredVideo` /
+ `clearNativeVideoStore` announce `NATIVE_VIDEO_STORE_CHANGED` so a session
+ streaming the deleted copy unloads).
+
+---
+
+## Offline durability (`pwaInstall` / `persistentStorage` / offline cache)
+
+Design records: [`plans/0026-ios-offline-install.md`](plans/0026-ios-offline-install.md)
+and [`plans/0027-precache-resilience.md`](plans/0027-precache-resilience.md).
+
+- **`persistentStorage.ts`** — `requestPersistentStorage()` runs once at boot
+ (`main.tsx`) and asks `navigator.storage.persist()` so the precache and the
+ IndexedDB sessions stop being evictable (WebKit clears script-writable storage
+ after ~7 days without a visit). Never throws; a denial keeps default storage.
+- **`pwaInstall.ts`** — pure install-nudge decision for `InstallPrompt`:
+ `installed` (standalone — nothing to offer), `ios-manual` (iOS never fires
+ `beforeinstallprompt`, so the Home Screen steps are shown by hand), or
+ `prompt-capable` (wait for the real event). The hint snoozes for 30 days.
+- **`offlineWarmup.ts` / `offlineReadiness.ts`** — the heavy public dirs are kept
+ out of the all-or-nothing precache and warmed afterwards into a revisioned
+ runtime cache; the Settings row reports `native` / `unsupported` /
+ `not-ready` / `preparing` / `ready`. Details in plan 0027.
diff --git a/src/assets/fonts/OFL.txt b/src/assets/fonts/OFL.txt
new file mode 100644
index 00000000..de1cee8e
--- /dev/null
+++ b/src/assets/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The Archivo Project Authors (https://github.com/Omnibus-Type/Archivo)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/components/ExternalRefBar.tsx b/src/components/ExternalRefBar.tsx
index e092ea8f..8d340270 100644
--- a/src/components/ExternalRefBar.tsx
+++ b/src/components/ExternalRefBar.tsx
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { FileEntry } from '@/lib/fileStorage';
-import { formatLapTime } from '@/lib/lapCalculation';
+import { fastestRankedLap, formatLapTime } from '@/lib/lapCalculation';
import { FileSearch, Loader2, X, Trophy } from 'lucide-react';
interface ExternalRefBarProps {
@@ -134,8 +134,8 @@ export function ExternalRefBar({
{!loading && !error && stage === 'laps' && (