Living execution doc. Direction lives in STEERING.md; this file tracks where the code actually is, what was learned, what's next, and questions queued for Alex.
Last session: 2026-07-09 (initial scaffold).
Ring 1 (browser) is fully working and verified:
- Stack: Vite 8 (Rolldown — prod build ~400 ms) + Ionic React + TypeScript 7 + vitest 4, plain CSS, PouchDB, prettier with Voyager's config. pnpm;
pnpm-workspace.yamlpins react-router/react-router-dom out of updates (v5 required by @ionic/react-router). Vite 8 needs the full dep graph inoptimizeDeps.include+holdUntilCrawlEndor Ionic's lazy Stencil chunks 504 ("Outdated Optimize Dep") on cold starts. - Recording engine seam (
src/engine/types.ts):RecordingEngineinterface withgetSnapshot / start / stop / on("fix"|"status"|"error")(typed nanoevents emitter,onreturns unsubscribe). UI is fully stateless against it — it rehydrates fromgetSnapshot()on every mount, exactly as the future native plugin will require. - State machine:
idle → acquiring (GPS accuracy gate) → armed (waiting for takeoff) → recording. Takeoff auto-detected (sustained ≥5 m/s for 5 fixes), start backdated to the beginning of contiguous movement (≥1.5 m/s). Accuracy gate: ≤10 m horizontal, ≤15 m vertical, 3 sustained fixes; inaccurate fixes are excluded from takeoff detection and backdating. - Simulator source (
src/engine/simulatorSource.ts, replaced the separate mock ENGINE 2026-07-10 per Alex — replay/detection must be source-agnostic): the deterministicFlightSimulatoras aPositionSourcefeeding the real engine throughwithWebCore— mock and real GPS share the entire engine (WAL, replay, takeoff/landing/ended, waypoints). The source owns its session persistence (fresh watch = new flight; asincewatch resumes the same deterministic flight for reload drills). Time compression via?mock-speed=N(default 60×); the simulated pilot stops in place after 2 h (SIM_FLIGHT_END_S), so flights land and finalize hands-free — e2e proves arm→logbook with zero interaction at 6000×. - Flight simulator (
src/flight/simulator.ts): seeded/deterministic, incremental; phases: GPS acquiring → standing → launch run → climbout → cruise wander. This is the universal test fixture. - Pure flight modules (
src/flight/):takeoff.ts(portable reference for the native port),stats.ts,format.ts(imperial/metric),gpx.ts+download.ts(export, wired to the flight detail page). - UI: Fly (idle / acquiring / armed / recording instrument tiles — AGL "above launch" (launch-relative, from the backdated first fix) + MSL like PPG Flyer, hold-to-stop guard, "To launch" tile: arrow rotated by relative bearing + distance home via pure
nav.ts— waypoint nav later reuses the same math), Logbook (list, slide-to-delete, lifetime totals), Flight detail (stats card, editable name/notes, GPX export download, guarded delete), Plan (stub), Settings (units). PPG Flyer-style: dark, huge color-coded numerals, giant buttons. Ionic forced tomode: "ios"everywhere (iOS-first, WYSIWYG in browser dev). Tab bar hides during acquiring/armed/recording (KISS in flight) — App subscribes to engine status; enginestart()/stop()emit status events so the hide survives reloads. - Storage: PouchDB (
pouchdb-browser, adopted 2026-07-09 after brainstorm with Alex — Apache incubation sealed it): flight metadata asflight:{uuid}docs, immutable track as gzipped attachment (track.json.gz, CompressionStream), settings assetting:{key}docs,auto_compaction: true,revs_limit: 25. Helpers insrc/storage/db.ts(saveFlight/listFlights/getFlight/getTrack/updateFlight/deleteFlight/getSetting/setSetting) — UI never touches PouchDB directly. Tests run on fake-indexeddb withself/FileReadershims (src/test-setup.ts). Future sync = plain CouchDB replication (self-host free, paid hosted later); iCloud/Dexie Cloud rejected (not FOSS-compatible). Dexie was removed. - Tests: 37 unit tests (vitest) incl. simulator determinism, takeoff golden cases, stats, GPX, PouchDB storage round-trips; 7 Playwright e2e incl. reload kill drill, armed-cancel, hold-interrupt, logbook→detail→export→delete journey, pin long-press flow, and two slow-style map-race regressions (delayed locally-fulfilled style + pageerror watchdog — the pattern that catches "Style is not done loading" ordering bugs; written failing-first per Alex). Simulator home field: Dane County WI (43.075, −89.55, 300 m). All green.
- src-tauri: config scaffold with identifier
app.wingover.wingover, bundle disabled, not yet compiled (ring 2). - Repo: git initialized on
main, all files staged, no commit yet (waiting for Alex's OK). AGPL-3.0 LICENSE, README.
Stack: MapLibre GL JS renderer. Street = OpenFreeMap (keyless). Satellite = MapTiler satellite-v2 raster + OpenFreeMap labels composited (hybrid). Toggle persisted in settings. MapTiler key: VITE_MAPTILER_KEY build-time env (origin-restricted; self-builders use their own free key; documented in README) + settings override; no key → satellite toggle hidden. All style/tile URLs are config (settings-overridable) so self-hosted tiles and future PMTiles offline slot in without code changes. maplibre-gl loaded via dynamic import (own chunk). E2e blocks tile network (page.route abort) — tests assert UI/markers, never imagery.
- M1 — Map foundation (done 2026-07-09):
src/ui/map/—MapView(React wrapper, maplibre dynamically imported,?map-style=blanktest seam),config.ts(street/satellite/hybrid style resolution, key resolution: settings →VITE_MAPTILER_KEY→ built-in restricted key),ViewToggle. Tauri window UA set toWingoverApp/1.0(matches MapTiler key restriction; PWA origin wingover.app). Note: satellite 403s from localhost unless Alex allowlistslocalhost:5173in the MapTiler dashboard. - M2 — Plan tab (done 2026-07-09, simplified per Alex): full-screen map, long-press (500 ms, move/pinch-cancelled, implemented in MapView — MapLibre has no native long-press) = drop pin, tap pin = delete it — no edit sheet, no names/notes UI (fields remain in the Pin model for possible future use). Plain tap does nothing (no accidental pins). Center-on-me, fit-to-pins on open, view toggle persisted. Pin helpers in db.ts with unit tests; e2e covers long-press×2 → tap-noop → reload-persist → tap-delete×2 with tile traffic blocked.
- M3 map — Flight detail redesign (done 2026-07-09): full-screen map with track polyline, launch (green) / landing (red) markers, fit-bounds under a translucent 6-tile stats overlay (shared
src/ui/components/Tile), view toggle; name/notes editing moved to the (...) sheet → "Edit name & notes" alert. Old list layout gone. - M3 replay — scrubber + graphs (done 2026-07-21): integrated replay pane on the host maps (zoomable barogram scrubber, speeds, draw-along default) plus destructive clip tools — trim (rewrites the track, cut fixes gone) and split into two flights — entered from the (…) sheet on both hosts
- M4 core — In-flight moving map (done 2026-07-09, then refined per Alex): full-screen map with everything overlaid at opacity — compact instrument tiles (drag-through via
pointer-events: none), global flight-controls stack right side (follow / track-up / view / compact red hold-to-stop, all.map-buttonsized). Live track line beneath labels, aircraft arrow glides between fixes (rAF interpolation, duration adaptive to fix cadence so it never trails at high mock speeds), follow centers in the visible region via camerapadding.topmeasured from the tile grid. Attribution auto-collapses after 6 s once per app launch. Distances everywhere show .xx precision. Rehydrates on reload; e2e asserts map + marker during recording. - Logbook composite map (done 2026-07-09): Logbook (...) menu → "View all flights on map" (
/logbook-map,AllFlightsMapPage) — every track in one GeoJSON source, per-feature color ramp oldest→newest (hsl 290→175), gradient legend, fit-to-all, view toggle. Layer adds have a triple fallback (style.loadevent /isStyleLoaded()/once("idle")) — the first two alone raced and drew nothing; same hardening applied to flight detail. Attribution now re-collapses onstyledata(style switches re-expanded it after the one-shot linger was consumed). Detail fit-bounds top padding is now measured from the tile grid (+28 px), not guessed. Saved toast presents from top. - M4b — Waypoint routes + in-flight waypoint UX (direction from Alex 2026-07-10): pins form an ORDERED route (createdAt order). Plan-view route DONE (2026-07-10): dashed blue line (#4cc2ff,
plan-routelayer, eventually-consistent ensure pattern) through pins in creation order; pins use the logbook endpoint colors (first green, last red, middles blue) and the tail pin is enlarged + haloed (.pin-tail) to show what a tap deletes and where the next long-press extends from;data-route-coordse2e seam, covered in plan.spec. Note: MapView modifier classes now go through classList — a React className write was clobbering maplibre's own container classes (.maplibregl-mapdidn't exist in the DOM). Still to do: lines between the flight's waypoints on the live map. In-flight UX must be barebones: long-press = add next waypoint, one button = remove next waypoint, nothing else; the Plan tab can use normal iOS controls. Model already in place: flight owns its waypoints (seeded from plan at start,snapshot.waypoints); wire additions throughengine.addWaypoints+annunciator.setWaypoints. Target tiles ("Distance/Direction to launch",nav.tsmath ready) swap to "…to waypoint" while one is set. Reached-markers + next-waypoint stats: derive as a fold over session fixes (golden-pinned semantics, design discussed 2026-07-10). Open: should announcements become sequence-aware (announce only the NEXT waypoint; skip semantics for the remove button)?
-
GPX import (done 2026-07-09): Logbook (...) menu → "Import GPX files" (multi-select).
src/flight/gpxImport.tsparses GPX (built against Alex's real PPG Flyer exports: trk>name launch-site names, 1 Hz,<rte>ignored), dedupes burst duplicates (<500 ms apart — real files contain 44 ms doubles that would fake 2000 m/s), derives speed/course/climb. Provenance for future undo: flights getsource: "gpx-import",sourceFilename,importBatchId(one uuid per import operation),importedAt. Validated by importing all 309 real exports through the UI in Chromium: 16.9 s, zero errors. Unit tests use happy-dom (per-file pragma; note: happy-dom leaks on hundreds of parses — bulk validation belongs in a real browser). E2e drives the filechooser with a fixture file. -
Folder structure = abstraction layers (2026-07-10, per Alex):
src/ui/is now the ONLY React world (App, pages/, components/, map/, settings/, useFlightActions, download);src/flight/is pure flight math only (fixes in, decisions out — no IO, no React);src/engine/is the headless runtime (engine, WAL, core.rs twin, sources, platform, simulator, andsession.ts— the startFlight action);src/storage/is PouchDB +importGpx.ts. The doctrine boundary is now a directory boundary: React never appears outsidesrc/ui/, and nothing insrc/ui/holds business logic. Also deleted:tauriSource.ts+ its tests and the stocktauri-plugin-geolocation(Cargo dep, lib.rs registration, capabilities) — fully superseded by the in-repo wingover plugin; gen/schemas regenerated without it. -
Landing is engine lifecycle, not events (2026-07-10, per background-parity doctrine — see STEERING.md "Background parity"):
EngineStatusgainedlanded(touchdown detected, recording continues, dismissible) andended(grace expired — flight of record is FINAL). Both are PURELY DERIVED from WAL data (takeoffIndex + landingIndex + fix timestamps; grace = LANDING_GRACE_MS of fix time, never wall clock) — rehydration or burst replay lands in exactly the state live delivery would.endedis durable: the finalized flight waits in the WAL until collected — FlyPage persists FIRST (deterministic idrecorded-<startedAt>, conflict-idempotent) THENstop()clears; a crash between the two repeats collection harmlessly on next launch. This closed a real hole: the earlier onFlightEnd-event design cleared the WAL before the consumer saved, so a webview death in that window lost the whole flight.stop()doubles as collect; trim = slice(takeoff..touchdown), stationary tail discarded. FlyPage prompt = projection ofstatus === "landed"with countdown from latest.timestamp − landingAt (no Date.now, no interval). Route note:/logbook/:idhas a regex guard (protects/logbook/map) — extended forrecorded-\d+ids; new id families must be added there. Test note: engines leave fire-and-forget WAL writes — tests need per-test IDBFactory + afterEach stop() drain or writes bleed across tests. Burst-replay e2e ("backgrounded landing") emits flight+landing+tail in one stream and asserts hands-free retroactive finalization — burst-replay IS the backgrounding drill. -
Landing auto-detection (done 2026-07-09, superseded by the engine-owned version above):
src/flight/landing.ts— ≤1.0 m/s sustained 15 fixes (track exists only post-takeoff, so no taxi false-positive; stationary wind-hover is why it prompts instead of auto-stopping). FlyPage shows a gloves-sized prompt "Looks like you landed" [Still flying / Stop & save] with a 30 s countdown that auto-stops (?land-timeout-msseam). Dismiss re-arms only after movement resumes. Also: real GPS engine landed (src/engine/engine.ts+ IndexedDB WAL inwal.ts, kill-drill e2e via stubbed watchPosition ine2e/real-engine.spec.ts— CDP setGeolocation can't supply altitude/speed). Engine choice (revised 2026-07-10): real GPS everywhere by default — native queue under Tauri, navigator.geolocation in any browser; the simulator is strictly opt-in via?mock-speed=N(?engine=realseam deleted). -
Reload-while-armed e2e kill drill (done 2026-07-09, in
real-engine.spec.ts— armed session survives reload and still auto-takes-off) -
GPS error surfacing (done 2026-07-09):
onErrorchannel on the engine seam; real engine classifies permission-denied vs unavailable; armed screen shows an actionable banner, cleared by the next fix. Also.tool-versions(Voyager pattern) now drives CI node version; pnpm pinned via packageManager. -
Ring 2 gate cleared (2026-07-09):
cargo checkgreen on Linux (webkit2gtk-4.1/jscore/soup3 all present on this machine).@tauri-apps/cliadded; placeholder icon set generated fromapp-icon.png(dark field + cyan aircraft arrow — replace with real branding later).tauri devwindow launch still untried: its beforeDevCommand fights the running Vite server on strict port 5173 — run when the dev server is down. -
eslint + CI (done 2026-07-09): flat config modeled on Voyager (tseslint, prettier, react-hooks, perfectionist import sorting).
sortSideEffects: false— REQUIRED, not style: the autofix once alphabetized side-effect CSS imports, putting theme.css before Ionic's css and MapView.css before maplibre's (attribution bg + 1px scrollbar regressions; cascade order is load-bearing, now e2e-guarded in record.spec). React Compiler adopted 2026-07-09 (babel-plugin-react-compiler via @rolldown/plugin-babel + reactCompilerPreset — @vitejs/plugin-react v6 is oxc-based, no babel option). Full react-hooks compiler strictness ON, and@eslint-community/eslint-comments/no-usebans ALL eslint directive comments (Alex directive: never disable, fix the code). The prop-mirror-ref idiom is GONE: every entry point invoked from outside React (rAF playhead tick, MapLibre listeners, engine subscriptions) is auseEffectEvent— old captured wrappers dispatch to the latest render's body, which is what the mirrors hand-rolled. Rules that shaped the refactor: effect events cannot be passed to children (map handles flow throughmapContextstate + a[mapContext]effect calling a setup effect event) and listener registration racing an already-loaded style needs the idempotent-ensure guard (isStyleLoaded()check next to thestyle.loadlistener). React pinned ≥19.2 for stableuseEffectEvent. TypeScript pinned to ^6.0.3 (matching Voyager): typescript-eslint 8.x cannot parse against TS 7's API — same compat-pin class as react-router; inpnpm-workspace.yamlignoreDependencies. CI:.github/workflows/ci.yml— lint, vitest, build (includes tsc), Playwright e2e with report artifact on failure. -
Native seam switch (done 2026-07-09):
PositionSourceinjected intoEngine(navigator adapter +tauriSource.tsover tauri-plugin-geolocation, permission flow unit-tested); engine auto-selects the Tauri source under__TAURI_INTERNALS__. iOS config + Info.ios.plist + capabilities prepped; see MAC-SESSION.md for the on-Mac runbook and the background-delivery risk (plugin may need vendoring for allowsBackgroundLocationUpdates).
-
The aircraft is a custom WebGL layer — this is load-bearing. Three rendering approaches failed before this: (1) DOM Marker — positioned by a separate mechanism from the canvas, desyncs under per-frame
jumpTo/zoom (upstream maplibre #6494, #2190); (2) symbol layer + per-framesetData— point data routes through the GeoJSON worker, rendering 1-2 frames behind the synchronous camera transform (lags while following) and jittering even with a static camera. (3 = final)CustomLayerInterface:render(gl, matrix)is called synchronously inside the map's render pass with the current frame's projection matrix — the triangle (12 vertices, CPU-transformed, two draw calls for outline+fill) draws with the same transform and same frame as tiles and track line. Desync has no mechanism.renderFramejust updatesdisplayRefand callsjumpTo(following) ortriggerRepaint(unpinned); the layer reads the ref at render time. Re-added on everystyle.loadlike other runtime layers. The track line's LAST SEGMENT is also drawn by the custom layer (the "live tail"): the GeoJSON line body re-uploads with backpressure (neversetDatauntil the worker confirmed the previous upload viaisSourceLoaded, plus one frame of hysteresis), and the custom layer draws from the last confirmed vertex to the aircraft every frame. Without this the line visibly leads the arrow early in a flight (arrow glide lags newest fix) and trails it on long flights (full-track worker rebuild grows with length — measured 165px of divergence at z15, worker saturated 72% of frames). Backpressure bounds the uncommitted tail to ~10-30 fixes even at 100 fixes/s with 14k points. Custom-layer vertices are anchor-relative (anchor = aircraft position) with the anchor translation folded into the projection matrix in float64 — world-space Float32 quantizes at ~2.4m, which is many pixels at z17+. The aircraft is a PLAYHEAD that travels along the recorded track polyline, chasing the newest fix with an exponential rubber-band (steady-state lag ≈ CHASE_MS real-time regardless of fix rate); the line — committed body AND tail — is derived from the fixes the playhead has passed, never from raw fix count. Playback is constant-velocity, not proportional pursuit (Alex report at 1 Hz: speed "maxes and wanes every second"): exponential chasing makes velocity proportional to remaining distance — 2.3x speed oscillation per fix cycle at 1 Hz (measured; invisible at 40x mock because the fix interval is far below the time constant — probe at real rates, not just compressed). Each arriving fix starts a leg toward its timestamp at a fixed track-time RATE (rate-based, not wall-clock, so starved frames advance proportionally instead of stalling and teleporting — wall-clock legs failed the hunter at low fps), with duration = max(learned arrival interval x 1.15 pad, backlog / 1.5x max catch-up) and a real-time (backlog/production-rate) snap threshold. Measured at 1 Hz: speed variation p95/p5 down from 2.28 to 1.17 (cv 0.26 -> 0.05). The rendered heading is a separate exponential low-pass (COURSE_SMOOTH_MS) over the playhead's segment-lerped course — the lerp alone is only C0-continuous and its rate kinks at every fix boundary, which reads as heading snaps, worst in track-up where the whole map rotates; smoothing once and feeding both the arrow rotation and the camera bearing keeps the arrow locked screen-up in track-up. This makes line-ahead-of-arrow and line/tail forking impossible by construction (the earlier glide-to-latest design let the line data race ahead of the animating arrow, and its corner-cutting chords forked visibly from the committed line). Commits are also time-gated (COMMIT_INTERVAL_MS) to reduce whole-source re-tile churn. There is deliberately NO distance-based snap guard: at mock time compression the playhead legitimately trails hundreds of simulated meters, and a meters guard made the aircraft teleport once per second; frozen-tab/reload catch-up happens via the dt clamp (a >=CHASE_MS frame gap advances the playhead fully in one step). Known scaling limit: each commit re-tiles the entire line in the worker (O(n) every 2s) — fine at 10k+ points, chunk into multiple frozen sources if it ever shows on old phones. Per-framejumpToskips whilemap.isZooming()/isRotating()so it doesn't cancel wheel-zoom or our own eases (jumpTocallsstop()). DOM markers remain only for static/interactive points (pins, endpoints). -
Flight geometry (track line, tail, aircraft) renders ABOVE everything, including labels — user decision 2026-07-09; layers are appended with no beforeId. Historical trap that led here: layers were once inserted "before the first symbol layer", but OpenFreeMap dark places the
water_namesymbol at index 8, directly before thebuildingfill — that put the track UNDER every building and road, which read as flicker/desync near the aircraft and was a major cause of the live-map glitch reports. If flight geometry ever needs to sit below labels again, anchor at the first layer of the trailing all-symbol block, never at the first symbol. -
While following, the follow loop owns the camera COMPLETELY — including wheel zoom.
map.on("wheel")+ preventDefault intercepts scroll while following; the loop glides zoom exponentially (ZOOM_SMOOTH_MS) in the same per-frame jumpTo as center/bearing/padding. Rationale: scrollZoom's smooth ease and per-frame jumpTo fight (jumpTo calls stop(), killing the ease → slow choppy wheel zoom), and yielding to the ease instead accumulates drift-then-snap at mock compression. Single-authority also erased the long-standing one-frame wheel bob. Unpinned zoom stays native (cursor-anchored). Touch pinch is untouched: interactingRef pauses the loop and around:'center' anchors the gesture. -
Layer creation must be eventually consistent, everywhere: style.load can fire before listeners register, and isStyleLoaded() stays false until sprites/glyphs/sources finish loading AFTER style.load. LiveTrackMap now uses styledata + idle + a direct attempt with an isStyleLoaded-guarded idempotent ensure (same as the other map pages); e2e pins it with a delayed-sprite style ("track/triangle doesn't render until toggling the map" regression).
-
Verify live-map rendering with the frame-locked pixel probe, not screenshots.
scratchpad/glitch-hunter.mjspattern: hookmap.on("render"), copy the GL canvas region around the aircraft to a 2d canvas (clearRect first — a stale/clippeddrawImagesilently keeps old pixels), and pixel-check line continuity behind the arrow plus overshoot ahead of it AND a temporal smoothness metric (per-frame geographic step rate vs rolling median — geometry-only checks are blind to teleports because a snap moves arrow, line, and camera coherently), per rendered frame, across follow/zoom/track-up/unpinned phases. Test seams:__display,__tail(committed/total/tailCoords) on the map container. Static screenshots and data-side probes repeatedly passed while users saw glitches; only per-frame pixel checks caught the real artifacts. -
The
/→/flyredirect strips query params — round two:page.reload()after the redirect reloads a bare URL, silently dropping every test seam param (hold-msfell back to 1500 ms while tests held 800 ms — a "flake" that was actually deterministic truth). Kill-drill reloads in e2e mustgoto(originalUrl)instead ofreload(). Any new query-param seam will hit this; consider a proper seam registry someday. -
Live-view state (
src/ui/map/liveViewState.ts, localStorage): mapView/trackUp/follow/zoom/center persist so reload mid-flight is visually transparent (doctrine extended to UI state). Zoom saved onzoomend, center ondragend(only matters when unpinned).hold-msquery seam controls the stop-hold duration for tests. -
Tile overscan: live map renders into a container 256 px larger per side (clipped by wrapper) with camera padding compensating — tiles are always loaded one tile beyond every visible edge. Attribution control re-inset via CSS.
-
Two-layer track line: base line updated once per fix + 2-point tip segment updated per rAF — O(1) per-frame cost (full-track setData per frame at 60 fps was the laggy culprit at scale).
-
Ionic controlled overlays (IonLoading etc.) must never fast-toggle
isOpen(<~500 ms): present/dismiss overlap leaves the pagearia-hidden→ every ARIA role vanishes app-wide (found when a single-file GPX import madegetByRole("heading")return nothing). Guard: only show for real batches + enforce minimum display time. E2e pageerror watchdogs don't catch this — role-based assertions do. -
Frame-locked live map: marker, camera (per-frame
jumpTo), and the track line's tip are all driven by ONE interpolated position per rAF (renderFrame) — nothing can lag anything at any zoom. Ease-restart chasing (camera easing to stale targets on every fix) and line-ahead-of-arrow are structurally impossible now. Snap rules (stale >2.5 s / far >250 m) apply to the whole frame. -
Logbook list virtualized with virtua (Voyager's library) wired to IonContent's scroll element — 309 flights scroll smooth.
-
Flight detail = collapsible right-floating overlay card (inline-editable title/notes committed on blur, compact stat rows) — instrument tiles are for flying only, per Alex. Overlays cap at 400 px and float right on ≥768 px viewports.
- Playwright
getByText()is substring + case-insensitive: "Logbook" tab collided with the "Flight saved to logbook" toast (IonToast stays in the DOM when hidden). Use{ exact: true }or role-based locators around Ionic overlays. - The mock WAL stores only
{ armedAt, seed, compression, takeoffIndex }— the track is derived deterministically from elapsed wall-clock, so reload-recovery is exact with no serialization. The native WAL will store real fixes instead; same contract, different substrate. - Simulator determinism +
?mock-speedmakes e2e timing tractable: speed 40 gets through acquire+standing+takeoff in ~1.5 s wall; speed 2 holds the acquiring phase long enough to test Cancel. - Voyager conventions (checked ~/voyager): prettier
{tabWidth 2, trailingComma all}defaults (double quotes/semicolons), folder-by-featuresrc/features/, flat eslint config, pnpm, Playwright e2e ine2e/. Wingover follows; consider migratingsrc/toward feature folders as it grows. @types/nodeneeded forplaywright.config.ts(process.env) withtypes: ["vite/client", "node"].- GPX export in the browser ring is a plain blob download (
src/ui/download.ts); on mobile this helper becomes a Tauri share-sheet/fs call — same call site, different implementation. - Playwright warns this OS isn't officially supported (Pop!_OS → ubuntu24.04 fallback build) — works fine.
- MapTiler key facts (learned 2026-07-09): restriction types are OR-ed (origin OR user-agent — one key serves PWA via
wingover.app/localhost:5173origins AND native viaWingoverApp/1.0UA); origin entries need the explicit port for dev; dashboard changes take ~20 s to propagate (don't conclude from probes too fast); the tileset for this plan issatellite-v2(satellite-v4404s). App preflights the tile manifest and falls back to street with a console warning when the key is rejected. - MapLibre's compact AttributionControl re-opens itself whenever attribution text updates (e.g., MapTiler TileJSON arriving async after a style switch) — event-based collapsing loses that race. Solution: MutationObserver on the
openattribute that re-collapses programmatic opens after the one-time launch linger, with a summary-click listener whitelisting user taps. Also: map canvas staysopacity: 0until the maploadevent (fade-in kills the white flash from light style backgrounds painting before tiles); street style is OpenFreeMapdark(hosted;fiordalso exists) matching the app theme. - Dynamically-imported deps (maplibre-gl) must be listed in Vite
optimizeDeps.include, or dev discovers them mid-session and re-optimizes → "error loading dynamically imported module" with a stale?v=hash. - PouchDB + Vite needs the
eventsnpm package installed (Vite stubs node builtins to empty objects → "Class extends value [object Object]" at runtime, dev and prod alike). PouchDB in vitest/node needsselfandFileReadershims plus fake-indexeddb (src/test-setup.ts). Restart the dev server after installing deps — Playwright'sreuseExistingServerhappily reuses a stale one. - Walkthrough-driven bug find: the
/→/flyredirect drops the query string, so?mock-speedwas silently ignored (everything ran at the default 60×, including e2e where the speed params were placebos). Fixed by cachinglocation.searchat module load inmock.ts. Lesson: actually driving the app catches what green tests miss — worth screenshotting flows after UI changes.
The native layer is a dumb capture+buffer; JS keeps all flight semantics. Replaces the vendor-and-patch plan entirely. STEERING §Architecture layer 3 ("owns the GPS/baro pipeline and the active flight, end to end") is amended in spirit: native owns capture and durable buffering; the JS engine owns the flight. Rationale: the e2e-proven JS engine stays the only engine; the Swift surface is ~200 lines; the Kotlin port later is a foreground service feeding the same contract.
- Plugin:
src-tauri/plugins/wingover(cratetauri-plugin-wingover, in-repo path dep). CLLocationManager withallowsBackgroundLocationUpdates,pausesLocationUpdatesAutomatically=false,.airborne, best accuracy — the flags the community plugin never set. Fixes normalized at the source (CoreLocation -1 sentinels → absent keys → JS null) and buffered in memory- an append-only JSONL session file (Application Support) that survives app crash/jetsam; torn tail = couple of points, accepted budget.
- Delivery is pull, not push: JS polls
fixes_since(cursor)at 1 Hz. One code path serves live delivery AND post-reload catch-up, so the recovery path runs every second of every flight; no channels to orphan on reload (the old plugin spammed "Couldn't find callback id" after every HMR reload).stop_watch(only from engine.stop → flight finalization) stops capture and deletes the file. Page reloads never touch native state. - Engine seam:
PositionSource.watchgained{ since }— engine passes its newest WAL fix timestamp; buffering sources replay exactly the backlog. - Load-bearing discovery: WKWebView does NOT reload after iOS kills its
content process and tauri does nothing by default —
on_web_content_process_terminate → reload()is registered in lib.rs. Without it, "webview killed → rehydrate" can never happen: the app stays a dead white view. - Sim drills passed (iPhone 17 / iOS 26 sim, Freeway Drive): (1) WebContent process kill mid-recording → auto-reload → WAL rehydrate → native-queue catch-up: duration continuous, track line continuous through the dead window. (2) Full app terminate + relaunch → same, via session-file hydration. Screen-off/jetsam remain device drills.
- Also fixed on the Mac session: iOS webview was sized to the safe area
(96pt dead strip at the bottom) — tao
inner_size()reports safe-area size; setup hook resizes the WKWebView to superview bounds +contentInsetAdjustmentBehavior=.never. AndInfo.plistlocation keys now live in gen/apple/project.ymlinfo.properties(xcodegen regenerates Info.plist, wiping direct edits). - Open (question #11 below): simulator scenario fixes have NO
altitude/verticalAccuracy (verified from the session file — keys absent).
The old plugin's
-1passed the<=15vertical gate (loophole, now closed), which is the only reason sim flights ever armed. Fresh arming in the sim now sticks at "acquiring". Real devices provide valid vAcc and are unaffected.
Initial commit + GitHub repoResolved 2026-07-09:aeharding/wingovercreated by Alex, main pushed, CI live. Reminder:DEFAULT_MAPTILER_KEYinsrc/ui/map/config.tsis public by design (origin/UA-restricted).Takeoff thresholdsResolved 2026-07-10 (device testing): lowered to ≥4.5 m/s (≈10 mph — ">10 mph should trigger" per Alex) sustained 5 fixes; backdate threshold unchanged at ≥1.5 m/s. Real root cause of the ground-test no-trigger: takeoff detection demanded the strict two-axis accuracy check per fix, and accuracy degrades in motion — one bad fix per five resets the sustain forever while the armed screen shows only speed. detectTakeoff/backdate now use hAcc ≤ 35 m only (credible doppler speed; still rejects wifi junk); the strict gate remains for arming.- GPS accuracy gate: ≤10 m horizontal / ≤15 m vertical sustained 3 fixes before "waiting for takeoff". Too strict/loose? Device evidence 2026-07-10: gate passed outdoors on hardware, so plausibly right for arming; takeoff detection no longer uses it (see #2).
- Climb rate units: ft/s like PPG Flyer, or fpm? (Currently ft/s.)
- Landing auto-detection:
auto-stop vs promptDefault shipped 2026-07-09: prompt with 30 s countdown → auto-stop (steering-doc guarded-action posture). Sanity-check the thresholds: ≤1.0 m/s sustained 15 s. OK? Also: should the countdown be longer on-device? - Map tiles: OpenFreeMap as default provider OK?
- Min iOS version target?
- Flight naming: currently
Flight {locale datetime}. Good enough for v1? - To-launch tile: shows rotated arrow + distance home (steer by arrow, fuel-plan by number). PPG Flyer showed relative degrees instead — want the degrees added/back?
"Flights on a computer"Resolved 2026-07-09: PWA only — no native desktop app. Native mobile wrappers exist solely for recording reliability. Desktop = installable web app syncing via CouchDB (E2EE decryption client-side works in-browser). Static hosting for the PWA (e.g. wingover.app) keeps the zero-dynamic-backend posture.- Sim can't arm the accuracy gate (2026-07-10): simulator fixes have no vertical accuracy, so fresh arming sticks at "acquiring" on the desk (real devices unaffected). Options: a dev-only gate-relax seam (
?vacc-gate=off, consistent with existing seams) so sim-ring drills can run the full arm→takeoff flow; or accept browser-ring-only coverage for arming. Recommend the seam.
-
Attribution stays as the compact on-map control — moving it to Settings was tried and rejected by Alex 2026-07-09 (commit c1ba625, reverted). Don't propose again without new reasoning.
-
Camera pixel-snap (commit cbbb071, reverted): tried for zoomed-out shimmer; Alex still saw shimmer in the dev env afterward, so reverted pending a real-iPhone check. If shimmer reproduces on device, the snap technique + probes are in that commit's history (probe showed motion itself is uniform — any fix is rendering-side).
-
TestFlight pipeline is LIVE (first upload 2026-07-10, run 29078748225, 7 attempts). The failure ladder, so nobody re-climbs it: (1)
MATCH_GIT_BASIC_AUTHORIZATIONmust bebase64 -w0— default Linux base64 wraps at 76 chars and a wrapped PAT makes GitHub return 400 on clone; (2) gym CANNOT drive the Tauri-generated project directly — its Rust build phase (tauri ios xcode-script) SIGABRTs without the contexttauri ios buildsets up (frontend build, env); (3) the Tauri template archives with the Development identity — irrelevant now butCODE_SIGN_IDENTITY='Apple Distribution'was the fix in the gym era; (4) Tauri's own EXPORT step regenerates export options and insists on cloud signing, which needs an Admin-role ASC API key (ours is App Manager) — and it ignoresgen/apple/ExportOptions.plist; the working shape is: runtauri ios buildtolerating its export failure, assert the xcarchive exists, thenxcodebuild -exportArchivemanually withfastlane/export-options.plist(manual signing, match profile). Optional cleanup someday: an Admin API key would let cloud signing work and delete the manual-export workaround. -
TestFlight pipeline (added 2026-07-10, Voyager pattern):
.github/workflows/testflight.ymlon every main push (macos runner,environment: deploy, gracefully skips until secrets exist) → fastlaneios deploy: match (certs from the shared voyager-match repo), build_app on the Tauri-generated project (its Build Rust Code phase drives pnpm+cargo), CURRENT_PROJECT_VERSION = run number, upload_to_testflight internal-only (flipdistribute_external+ groups once an external beta group exists). One-time setup on Alex: (1) register app.wingover.wingover in App Store Connect, (2)bundle exec fastlane match appstoreonce from the Mac to mint the profile into voyager-match, (3) repo secretsAPP_STORE_CONNECT_KEY(authkey.json contents),MATCH_PASSWORD,MATCH_GIT_BASIC_AUTHORIZATION(same values as Voyager's), (4) create thedeployenvironment. Version bumped to 0.1.0 (ASC rejects 0.0.0); Settings version string is hardcoded — wire to a build define someday. -
Realtime core implemented per ARCHITECTURE.md (2026-07-10, uncommitted pending review): Rust (tauri-plugin-wingover crate) now owns the durable session fix log (
store.rs— hydration, torn-tail tolerance, ordering guard against CoreLocation relaunch redelivery), the core lifecycle (core.rs— persist-then-announce, waypoints persisted beside the session), and the waypoint announcer (announcer.rs). Swift dieted to capture/drain/permissions/speak (AVSpeechSynthesizer with.duckOthers). Wire contract to JS unchanged (fixes_sincenow answered by Rust). New: the web reimplements the plugin surface —src/engine/core.tsis core.rs's TS twin (start/stop/setWaypoints/ingest, same functions by name), the watch carries the lifecycle on BOTH platforms (webCore wraps the browser source; native = start_watch/stop_watch), engine pushes set_waypoints at watch-establish + additions only,src/flight/waypoints.tsTS twin, shared golden vectors (src/flight/golden.json) executed by BOTH vitest and cargo — cross-language conformance in CI. Waypoints are FLIGHT-SCOPED (Alex 2026-07-10):startFlight()(src/engine/session.ts, headless) copies plan pins → session waypoints (200 m radius) via engine start options, persisted in the WAL and exposed on snapshots; the plan is never read mid-flight; mid-flight additions join only that flight viaengine.addWaypoints(UI pending, see M4b). No lifecycle asymmetry between platforms (Alex): the engine runs one code path everywhere; announce lifecycle lives in core.rs and its TS twin only — no director, no boot wiring, no component effects; FlyPage has exactly ONE engine effect (all subscriptions + teardown, incl. ended-flight collection). Detection state resets per flight in both languages (fresh web tracker on start; Rust announcer resets on a fresh session). Tests: 9 cargo + 79 vitest + 16 e2e incl. pin→spoken-utterance flow with stubbed speechSynthesis. Mac must verify: Swift compile, sim drills (Rust log replaces Swift session file), background speech + ducking on device.
-
Code splitting— rejected by Alex 2026-07-09: mobile app, bundle ships locally, not a concern -
M3 replay (scrubber + graphs) — belayed by Alex 2026-07-09 in favor of the real-engine critical path
-
Native iOS recording plugin (M0 kill drills on hardware) — the
takeoff.ts+types.tscontracts are the porting reference -
Sync spike: replicate against a real CouchDB (per-user DBs,
db.replicate/db.syncis now one call away); E2EE question for the hosted tier -
App icon / branding, wingover.app landing page
-
Offline tiles, airspace, engine-hours log (steering doc parking lot)