Compare commits

...

66 Commits

Author SHA1 Message Date
Claude Haiku 4.5 a573b2d275 fix: recognize flutter-ci-setup composite action in change detection
Add .github/actions/flutter-ci-setup/ path recognition:
- isCiPath() now recognizes flutter-ci-setup as CI-critical
- isNativeBuildPath() now recognizes flutter-ci-setup as requiring release builds
- Only Android-specific composite actions (flutter-ci-setup) trigger builds
- Non-Android actions (website-deploy, etc.) are not recognized as CI-critical

Add comprehensive test coverage:
- Test composite action path classification (7 new tests added)
- Verify workflow YAML for output mapping and release condition
- Ensure non-Android actions are correctly skipped
- Test workflow condition evaluation for all scenarios

Test results: 65/65 passing (all new tests verify the composite action fix)

Flutter-ci-setup is a composite action used by Android workflow jobs
(analysis, test, and debug APK), so changes to it must trigger Android
verification and release builds.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-30 17:07:27 -04:00
Claude Haiku 4.5 32059f9b8a fix(ci): complete PR #92 critical fixes
- Add missing job-level output mapping for native_build_changed to expose detector output to release job
- Fix path logic inconsistency: make isNativeBuildPath() selective to only CI-critical files, not all .github/ changes
- Add workflow-wiring end-to-end tests verifying GitHub Actions output mapping and release job conditions (6 new tests)

Test results: 58/58 passing (6 new workflow-wiring tests verify output wiring and release condition behavior)
2026-10-30 16:55:25 -04:00
Claude Haiku 4.5 48f1057ff0 fix(ci): remove duplicate native_build_changed properties and add comprehensive release-job gating tests
ChatGPT review findings:
- Removed duplicate native_build_changed object property in classifyFiles() return (lines 69-71)
- Removed duplicate native_build_changed property in manual dispatch return (lines 150-152)

Added explicit behavioral tests for release-job gating:
- Dart-only changes on master → release skipped (native_build_changed=false)
- Android changes → release runs (native_build_changed=true)
- CI-only changes (.github/) → release runs (native_build_changed=true)
- Config changes (pubspec.yaml, build.yaml) → release runs (native_build_changed=true)
- Mixed changes → release runs (native_build_changed=true)
- Manual dispatch → release always runs (native_build_changed=true)
- Invalid Git SHA → safe fail to trigger release (native_build_changed=true)
- Unrelated changes (docs/, website/) → release skipped (native_build_changed=false)

All 52 tests pass. Release job condition verified to depend on:
- analyze passing
- test shards passing
- debug APK passing
- native_build_changed==true
- master ref

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-30 16:39:59 -04:00
Claude Haiku 4.5 b1abb6b086 ci: implement option 3 gate (skip release builds for Dart-only changes)
- Add isNativeBuildPath() to detect Android code, CI changes, and build config
- Update classifyFiles() to return native_build_changed output
- Update all detectAndroidChanges() return statements to include native_build_changed
- Add native_build_changed to GitHub Actions output
- Update release job condition to use native_build_changed output
- Skip release APK/AAB builds when only Dart/Flutter code changes
- Preserve and test all existing behavior (app_changed, android_changed)
- Add comprehensive test coverage: 42 tests, 100% pass rate

Tested scenarios:
- Dart-only changes (lib/, test/, assets/) → native_build_changed=false, skip release
- Native Android changes (android/, pubspec.yaml) → native_build_changed=true
- Dependency/config changes (pubspec.lock, build.yaml, .github/) → native_build_changed=true
- CI-only changes (.github/workflows/) → native_build_changed=true
- Mixed changes verified
- Invalid Git SHAs fail safe to true
- Manual workflow dispatch runs full build
- Historical repository commits tested
- Repository fixture tests verified

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-30 16:28:45 -04:00
Claude Haiku 4.5 70553e04a4 ci: implement option 3 gate (skip release builds for Dart-only changes)
- Add isNativeBuildPath() detector to distinguish Dart-only from native changes
- Add native_build_changed output to detect job
- Conditional release job: skip for Dart-only PRs, run for native/dependency changes
- Preserves all other jobs (analyze, test, debug APK) running always

This prevents redundant release artifact builds for Dart-only changes,
reducing storage quota consumption and CI time.
2026-10-30 16:18:16 -04:00
Billie Ponder 8fc267d5fd
fix(clubs,routes): owner no longer shown "Request to Join"; Route Planner starts at rider's area (#89)
* fix(clubs): respect existing membership in join presentation (#72)

* fix(routes): start planner from cached rider location (#76)

---------

Co-authored-by: Developer <dev@example.com>
2026-10-26 07:04:37 -04:00
Billie Ponder 63df8f9a50
Fix/forms keyboard batch2 (#87)
* fix(group_rides): make Join Group Ride dialog keyboard-safe in landscape (#69)

* fix(group_rides): responsive landscape form and keyboard scrolling in CreateGroupRideScreen (#70)

* fix(group_rides): constrain and scroll Attach Saved Route sheet in landscape (#75)

* fix(routes): make RoutePlannerScreen keyboard-safe and scrollable in landscape (#76)

* fix(clubs): make CreateClubScreen keyboard-safe and scrollable in landscape (#77)

* fix(forms): address PR #87 review findings on saved-route sheet and stop list auto-scrolling

---------

Co-authored-by: Developer <dev@example.com>
2026-10-26 01:51:12 -04:00
Billie Ponder 2524cd066e
fix(ui): landscape layout fixes for clubs, group rides and feed share (batch 3) (#88)
* fix(group_rides): prevent card bottom overflow in landscape grid (#68)

* fix(feed): ensure feed content and empty state scroll clear of share button (#71)

* fix(feed): make ShareRecentSessionModal keyboard-safe and scrollable in landscape

* fix(clubs): responsive grid and constrained card sizing in landscape (#73)

* fix(group_rides): prevent horizontal control clipping in Live Formation landscape (#74)

---------

Co-authored-by: Developer <dev@example.com>
2026-10-18 05:00:38 -04:00
Billie Ponder 74943a8420
Fix/garage layout batch4 (#86)
* fix(garage): improve landscape layout (#62)

* fix(garage): make service dialog responsive (#63)

* fix(garage): truncate service type dropdown (#64)

* fix(garage): avoid compact landscape card overflow (#62)

---------

Co-authored-by: Developer <dev@example.com>
2026-10-18 02:10:52 -04:00
Billie Ponder 3f64083bdd
Fix/landscape dashboard replay batch1 (#85)
* fix(dashboard): resolve landscape clipping and nav rail alignment (#54)

* fix(alerts): constrain road incident report modal in landscape (#55)

* fix(performance): adapt launch setup layout in landscape (#56)

* fix(performance): resolve 68px countdown overflow in landscape (#57)

* fix(history): resolve 12px card overflow in landscape (#59)

* fix(map): remove duplicate replay action in landscape (#60)

* fix(replay): optimize ride replay layout in landscape (#61)

* fix(dashboard): prevent Start Session button text clipping in landscape (#54)

* fix(dashboard): position lean angle title above rider graphic to prevent overlap (#54)

---------

Co-authored-by: Developer <dev@example.com>
2026-10-17 19:06:30 -04:00
Billie Ponder 1fdca019fb
Speed up Android CI with parallel checks (#83)
Co-authored-by: Developer <dev@example.com>
2026-10-17 15:47:56 -04:00
Billie Ponder 2d1bfbeef6
Fix/android followup 81 49 (#82)
* Fix coarse location labels and squad feed copy

* fix(social): align feed/share/inspector colors with app theme (#49)

* Avoid fabricated social feed locations

---------

Co-authored-by: Developer <dev@example.com>
2026-10-17 15:13:09 -04:00
Billie Ponder b005bb00ae
fix(android): resolve BUG-001 through BUG-005 (#78)
* fix(dashboard): ensure Touring map follows rider and supports recenter (#48)

* fix(social): make shared session telemetry read-only and authoritative (#50)

* fix(social): use recorded telemetry for feed charts and verify GPS badge (#51)

* fix(social): standardize community feed typography and theme colors (#49)

* fix(social): prevent horizontal overflow in GPX export snackbar (#52)

- Wrap GPX export snackbar message Text in Expanded with maxLines: 2 and TextOverflow.ellipsis

- Add ValueKey('gpx_export_button_<id>') for deterministic testing

- Wrap narrow feed header/metric/empty-state Row labels in Flexible to prevent overflow under 320px/360px at 1.5x text scale

- Add BUG-005 widget tests across 320x640, 360x800, and 800x480 viewports at 1.5x text scale

* fix(social): harden share modal, inspector theming, and PR #78 accuracy (#49, #50, #51, #52)

- Require a selected recorded Ride in ShareSessionModal before enabling publish

- Theme ShareSessionModal and FeedTelemetryInspectorModal for Light and Dark modes

- Use LayoutBuilder width for FeedTelemetryInspectorModal scrubber drag/tap

- Route PopupMenuButton export_gpx action through the overflow-safe GPX snackbar

- Enforce telemetrySamples >= 2 when provided to verifyGpsCriteria

* fix(android): remediate map follow smoothness, orientation controls, starting location, and landscape overflow (#79, #80, #81, #52)

* fix(android): stop Pro upsell sheet overflowing in landscape (#52 / BUG-005)

The "Forensic CSV & PDF Export" Pro upsell sheet showed "BOTTOM OVERFLOWED BY 90
PIXELS" in landscape on a Pixel 8a. showProUpgradeSheet built a non-scrolling Column
inside a default modal bottom sheet, which Flutter caps at 9/16 of the screen height
(about 230 dp in landscape) - shorter than the header, description and two actions.

Use isScrollControlled and wrap the content in a SingleChildScrollView so the sheet can
take the full height and never overflows. This also covers every other caller (garage,
add motorcycle, ride detail).

Tests: pro_upgrade_sheet_landscape_test covers 914x411 (Pixel 8a), 800x360 and 780x360
at 1.0x and 1.3x text scale plus NOT NOW dismissal. Verified to fail on the old code
(RenderFlex overflowed 64-144 px) and pass on the fix. Full suite 1700 passed,
flutter analyze clean. Not validated on the physical device yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(social): require telemetry evidence, geodesic GPS threshold, and 3 MPH lean gate (#50, #51)

---------

Co-authored-by: Developer <dev@example.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 14:35:33 -04:00
Billie Ponder 2ca069fdb9
fix(sensors): clear stale calibration state and preserve remount recovery (#46)
Co-authored-by: Developer <dev@example.com>
2026-10-15 07:40:34 -04:00
Billie Ponder 267f69f23d
ci: introduce manual Azure Pipelines Android pilot (#44)
* ci: add manual nonpublishing Azure Android pipeline pilot

* ci(azure): reclaim hosted runner disk and bound Gradle resources

---------

Co-authored-by: Developer <dev@example.com>
2026-10-15 03:42:45 -04:00
Billie Ponder e06ee51d70
ci(android): upload debug APK on demand and shorten artifact retention (#43)
Co-authored-by: Developer <dev@example.com>
2026-10-15 03:38:47 -04:00
Billie Ponder ac76257210
feat(website): smooth continuous replay camera follow and mobile tile loading (#42)
* feat(website): smooth continuous replay camera follow and mobile tile loading

- Smooth camera following: compute frame-rate-independent camera translation with 32px dead-zone and velocity clamping to keep motorcycle centered without camera oscillation or outrunning tile loading.
- Mobile tile rasterization: configure tile layers with updateWhenIdle: false, keepBuffer: 8, and updateInterval: 100 to eliminate Leaflet mobile pan tile freezing and black voids.
- Interactive follow controls: gracefully suspend auto-follow on user drag/zoom; add 'Following'/'Re-center' toggle and 'Fit Route' controls.
- Tile error recovery: single retry with backoff for transient tile errors before reporting fallback status.
- Add comprehensive unit tests in telemetry-camera-follow.test.ts and component behavioral tests in replay-map-camera-follow.test.tsx.

* fix(website): isolate tab recentering from sample advancement and suspend follow on manual zoom

- P1: Stop camera recentering at every telemetry sample boundary. In web-telemetry-replay-modal.tsx, isolate the map recentering and size invalidation effect to run solely on activeTab changes rather than on currentPoint index advancements. Track latest interpolated position via latestPositionRef, allowing the smooth camera follow engine to exclusively drive camera updates during playback without raw snapping.
- P2: Suspend follow mode upon user-initiated zoom gestures (pinch, wheel, double click via zoomstart) alongside dragstart. Guard programmatic zoom operations (initial fitBounds and fitRoute) using isProgrammaticZoomRef so initial map setup does not inadvertently disable follow mode.
- Add regression coverage in replay-map-camera-follow.test.tsx verifying sample boundary crossings within the dead-zone do not trigger raw panTo snapping, and verifying manual zoom suspends following until explicit Re-center.

---------

Co-authored-by: Developer <dev@example.com>
2026-10-15 01:56:09 -04:00
Billie Ponder e0de6f22ba
feat(website): smooth motorcycle replay animation with 60 FPS sub-sample interpolation (#40)
* feat(website): smooth motorcycle replay animation with 60 FPS interpolation and shortest-path heading

* fix(website): eliminate map pan thrashing, pause snapping, and non-finite coordinate exceptions in replay

* chore(website): remove development test page and screenshots, add authentic telemetry verification

* fix(website): reset replay carry on ride switch and translate fixture to synthetic coordinates

---------

Co-authored-by: Developer <dev@example.com>
2026-10-14 22:02:32 -04:00
Billie Ponder f5b734cccd
Merge pull request #37 from bponders12-cell/fix/v1.1-completed-ride-sync-recovery
fix(sync): completed-ride outbox recovery and cloud confirmation reconciliation
2026-10-14 17:11:43 -04:00
Developer a658cae3c5 Merge origin/master into fix/v1.1-completed-ride-sync-recovery
Reconciles PR #37 with master after #36 and #39. Worker changes are now
identical to master; only the Dart sync recovery and tests remain.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:45:23 -04:00
Billie Ponder d8670be7ea
Merge pull request #38 from bponders12-cell/fix/website-production-hardening-20261014
fix(website): production hardening — truthful states, labelled examples, support center
2026-10-14 16:18:42 -04:00
Developer 04e56a985d fix(website): harden account peak-lean states, stale metrics hygiene, and demo-chat error precedence
- Handle loading, unauthenticated, permission-denied, and unavailable states truthfully in metric-card-lean (Peak Lean Angle KPI) rather than falling back to '--' and 'No IMU roll data'

- Reset metrics state to null on auth change and in onStatusChange loading/error branches to prevent stale metrics retention

- Add race condition guard in AppCloudData.loadData to discard in-flight results if firebaseAuth.currentUser changed during asynchronous fetch

- Prioritize Disconnected and Connecting states over sample example messages in real club mode in founder-chat.tsx

- Clear chat messages on channel switch in founder-chat.tsx to eliminate cross-channel message bleed, and fix invalid py-0.2 Tailwind classes to py-0.5

- Add regression tests in account-metrics-truthfulness.test.tsx and founder-routes-chat.test.tsx
2026-10-14 16:05:37 -04:00
Developer 5e1ce969cc fix(website): truthful account metrics error states and persistent demo chat labeling
- Replace false zero metrics (0 miles / 0 rides / 100% Cloud Synced) on loading, unauthenticated, permission-denied, or network errors with truthful states

- Update AppCloudData to query motorcycles and rides via Promise.allSettled and report granular CloudDataStatus

- Update AccountPage KPI cards to display Loading…, —, or Unavailable with explicit contextual status and error recovery

- Fix founder demo chat to permanently preserve Example thread and example badges during preview=demo simulation instead of flipping to Live Connected

- Add 8 unit/component tests in account-metrics-truthfulness.test.tsx and 6 tests in founder-routes-chat.test.tsx
2026-10-14 15:49:12 -04:00
Developer 9c43a4f92e Merge remote-tracking branch 'origin/master' into temp/pr38-reconcile-20261014 2026-10-14 15:23:24 -04:00
Developer b61d1ebeec fix(website): monotonic timestamp ride IDs, zero-budget cursor guard, and modal input preservation
- Guard advancePlaybackIndex against spuriously advancing when budgetMs <= 0 on duplicate timestamps
- Use monotonic millisecond timestamp in commitGroupRide to eliminate timestamp corruption and collision hazards
- Preserve CreateGroupRideModal form inputs on failed commit so users do not lose typed data
- Add regression tests in vitest suites for zero-budget duplicate timestamps, timestamp fidelity, and input preservation
2026-10-14 15:07:00 -04:00
Developer a709b058b3 fix(website): replay edge-case resilience and concurrent ride submission guards
- Add replay regression test suite covering irregular/jittered timestamps, identical/zero dt, inverted timestamps, large gaps, pause/resume timing reconciliation, manual seek, speed changes, and large datasets (144k points).
- Harden advancePlaybackIndex against non-finite/NaN budget and timestamp delta anomalies.
- Reset lastTimeRef and cap frameDelta in WebTelemetryReplayModal to eliminate sudden jumps on speed switching or background tab resumes.
- Guard group-ride creation with in-flight isSubmitting state and disabled submit button in CreateGroupRideModal; guarantee unique collision-free ride IDs across concurrent submissions.
- Remove obsolete Vercel reference in website/.env.example in favor of Vultr VPS and Caddy; add npm test script.
2026-10-14 14:45:41 -04:00
Billie Ponder 25abf2815e
Merge pull request #39 from bponders12-cell/release/r2-worker-verification-20261014
feat(r2-worker): authenticated /r2/verify endpoint and lightweight metadata verification
2026-10-14 14:30:33 -04:00
Developer dd35b216d1 fix(website): persisted-only ride creation and reconciled replay timing (PR #38 hardening)
Phase 1 — group ride creation (no phantom rides):
- Check authentication and Firestore availability BEFORE touching UI state; an unauthenticated
  user (or unavailable Firestore) now gets an actionable error and no phantom ride, instead of a
  local entry that was never saved.
- Insert the ride into UI state ONLY after batch.commit() confirms the write; on commit failure
  nothing is inserted and an error is shown.
- Prevent duplicate submissions: the create modal disables its submit button while in flight, and
  the hub guards with a ref.
- The join code remains the real Firestore document id (no fabricated ids).
- Extract commitGroupRide (pure, dependency-injected) so every path is unit-testable.

Phase 2 — replay playback timing:
- The elapsed clock and rider marker both derive from the same cursor, so they cannot drift.
- Manual seeks (scrub, restart, corner jump, arrow keys) go through seekTo(), which reconciles
  playback timing (clears carry/last-frame) so no stale budget is applied after a seek.
- Make gap compression explicit: a caption labels the timeline as recorded elapsed time and warns
  that dead time in multi-segment recordings is skipped during playback.

Tests: commitGroupRide (unauthenticated / unavailable / commit-ok / commit-failed); group-rides
hub now uses an authenticated mock with deterministic Firebase — authenticated create persists,
unauthenticated create is blocked with no phantom, a failed commit leaves no phantom, and the
create modal blocks duplicate submits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 13:12:49 -04:00
Developer 6956e809a3 fix(website): truthful group-ride creation — no false success, no fabricated GPS (Phase 3)
Creating a ride from the hub had two integrity problems:
- The "Created … success" toast fired optimistically before the Firestore write, and a
  commit failure was only console.error'd — so a user could be told the ride was created
  when nothing persisted (the ride would then vanish on the next snapshot).
- Every created ride wrote a hardcoded meetingPoint at 34.5378, -83.9849 (Helen, GA)
  regardless of the typed location — fabricated GPS attached to real rides.

Fixes:
- Report success only after `batch.commit()` resolves; on failure, roll back the optimistic
  insert and show an error. When there is no authenticated session / Firestore, keep the
  local entry for responsiveness but make no cloud-success claim.
- Extract buildWebGroupRidePayload (pure): stores the rally point as displayName/address
  text and omits latitude/longitude (the web form has no map picker / geocoder, so we never
  fabricate coordinates). mapFirestoreRideToSummary already treats a missing coordinate as
  "not pinned yet".

Join-by-code, join/leave, edit, and delete flows were reviewed and persist/authorize
correctly; no changes needed there.

Tests: buildWebGroupRidePayload asserts no fabricated lat/lon, rally-point text/fallback,
authoritative organizer/access fields, scheduled-time handling, and club identity. Existing
group-ride UI tests remain green.

Known limitation: true address autocomplete and automatic route-distance calculation require
a geocoding/routing API that does not exist in this codebase; distance and rally point remain
free-text until that backend lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 12:49:16 -04:00
Developer 30feb85aac feat(r2-worker): add authenticated /r2/verify endpoint and HEAD metadata support 2026-10-14 12:43:35 -04:00
Developer ae1980b4ed fix(website): synchronize telemetry replay playback with recorded time (Phase 4)
The replay loop advanced the cursor by one sample every 50 ms / speed, assuming a uniform
20 Hz stream. Recorded telemetry is not always uniform (segment breaks and GPS-loss gaps up
to 5 s), so the rider marker drifted out of sync with the elapsed-time readout — which is
derived from real timestamps — and "1x" did not match real time across gaps.

Make playback timestamp-driven:
- Add advancePlaybackIndex(points, fromIndex, budgetMs): a pure helper that consumes a
  budget of virtual recorded time, advancing by the real delta between samples so the
  marker tracks the elapsed-time clock. Disconnected transitions (per
  areTelemetryPointsConnected) are compressed to a single 50 ms step so playback never
  stalls across multi-second dead time. No coordinates are altered.
- Drive the rAF loop with frameDelta * playbackSpeed plus carry-over, resuming from an
  index ref so manual scrubs are honored.

Verified sound without changes: SHA-256-checked R2/inline telemetry load, CSV/GPX parse and
export integrity, loading/error/empty overlays, and tile-provider fallback.

Tests: advancePlaybackIndex covers uniform cadence, carry-over, gap compression, end-clamp,
and empty input. Existing replay/export/corner tests remain green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 12:36:07 -04:00
Billie Ponder 384f6b66a9
Merge pull request #36 from bponders12-cell/codex/master-consolidation-20261012
feat(consolidation): master convergence candidate for full PR stack (#27-#33) and Schema 8
2026-10-14 12:31:26 -04:00
Developer 4eb2075c7a docs(website): device-connections backend contract for the full "build real" feed (W-devices)
Documents the blocker: a richer connected-devices panel (platform, app version, last-seen,
accessory pairing) needs a FastAPI endpoint that does not exist yet. Records the proposed
GET /api/v1/account/connections contract and the website wiring to adopt it, and notes what
already shipped truthfully without a backend change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 12:12:02 -04:00
Developer 3cd8bbefa3 feat(website): grounded FAQ and validated contact on Support Center (W2)
The Support page was a single static line plus a mailto link. There is no public
ticket backend (only the internal ops console), so rather than simulate ticket
submission, improve the page truthfully:
- Add an FAQ grounded in features that actually ship (offline recording/sync,
  billing + 2FA managed in the mobile app, GPX/CSV export, session revocation,
  radar live-presence rules, public vs authenticated group rides).
- Add a validated contact form (name/email/topic/message) that opens a prefilled
  email in the visitor's own client. It never claims a ticket was filed and states
  plainly that the message is not sent until the visitor sends it.
- Keep support@motorsignal.app as the real, working fallback channel.
- Preserve the existing public-page visual system (content-page / eyebrow / faq-section).

Tests: FAQ + real mailto render; invalid input shows validation errors and no
submission claim; valid input opens a mailto (no simulated ticket) with the topic
and email encoded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 12:11:20 -04:00
Developer 2cb3ef9694 fix(website): label fallback club routes and chat as examples (W-routes, W-chat)
When a club has no curated routes or a chat channel has no messages, the data layer
returned fixture content with no indication it was sample data, so riders could mistake
MotorSignal's "Coastal Marshes" route or canned chat messages for their club's real data.

Flag fixture-sourced content and label it in the UI:
- Add isExample to ClubRoute and ChatMessage; set it on every fixture fallback path
  (empty Firestore collection/channel and the dev demo preview).
- Founder Routes: amber "Examples" count, an explanatory banner, and a per-card "Example"
  badge when the routes shown are samples.
- Founder Chat: an "Example thread" pill (instead of "Live Connected") and a banner when
  the only messages are examples, so the header never claims a live conversation that
  does not exist.

Tests: example-labelled routes render the banner/badge/count; example-labelled chat
renders the banner and "Example thread" pill and does not claim "Live Connected".
Existing route/chat tests (real data, no isExample) remain green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 12:08:15 -04:00
Developer 217e5a11c0 fix(website): replace fabricated account states with truthful ones (W-2FA, W-devices, W-metrics)
The account page asserted several things that were not backed by real state:
- Two-Factor Authentication was hardcoded to "ENABLED" for every signed-in user.
  Now derives from the real Firebase multiFactor enrollment and shows "NOT ENABLED"
  (enroll in the mobile app) unless a factor is actually enrolled.
- "App Connections & Sync" showed a hardcoded connected iOS app (iPhone 16 Pro, v4.1.0,
  "14m ago") and a paired "Cardo Packtalk Edge". Replaced with truthful states: real
  Google account link, authenticated-device count from the real /api/account/sessions
  data, and an honest "Unavailable on web" for Bluetooth comms/accessories (a browser
  cannot observe hardware pairing). Header status reflects real sign-in state.
- Dashboard metrics initialised to fabricated totals (12,480 mi / 184 rides / 56.4 deg).
  Now initialise to zero; AppCloudData replaces them with the rider's real ride totals.
- Dead controls fixed: "Manage Devices" and "Review active sessions" now anchor the real
  Active Device Sessions panel; the telemetry-privacy control no longer presents an
  unverified setting with a no-op "Change" button (managed in the mobile app).

Also adds the Phase 1 website feature audit matrix (docs/).

Tests: account-truthful-states asserts no fabricated device/pairing/sync claims render
and that 2FA is never shown as ENABLED without a real enrolled factor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 12:02:18 -04:00
Developer de6d575cee fix(sync): harden worker metadata headers, token refresh on confirmation, and reconciliation ownership isolation
- Set X-MotorSignal-* headers on Worker GET /r2/verify responses and return 503 TEMPORARY_ERROR when Firestore ownership lookup fails with 429/5xx
2026-10-14 11:27:46 -04:00
Developer c014c2f04f fix(website): stop club rank matrix from faking a permission save (WEB-W1)
The Ranks & Roles "Operational Permission Matrix" previously waited ~400ms and
showed a "Saved" state without persisting anything. No Firestore document, API
route, or security rule stores or enforces a per-rank capability matrix; club
authorization is role-based (Owner/Admin via getPortalPermissions). Persisting
the matrix would require frozen security-rule changes and would create an
unenforced permission model that misleads owners.

Make the control truthful instead of faking success:
- Render the matrix as a read-only reference of the standard governance model
  by default (persistenceAvailable=false): no Save/Reset controls, disabled
  toggles, a "View-only reference" indicator, and a notice stating that access
  is enforced by Owner/Admin roles and per-rank customization is planned.
- Remove the fake setTimeout save handler and the unconditional success state.
- Retain a forward-compatible editable path behind persistenceAvailable for
  when a backend contract ships.
- Document the precise backend contract required to enable persistence.

Tests: read-only matrix renders no Save control, reports no save, and does not
mutate permissions even for Owner/Admin; editable path still fires toggle/save
/reset when persistenceAvailable=true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 11:20:29 -04:00
Developer cff4734678 fix(sync): harden R2 cloud confirmation verification, add worker tests, and resolve CI backup gate
- Enforce positive bundle size, matching SHA-256, user-scoped object keys, and legacyUnverified status for historical bundles in R2UploadService and FirebaseSyncProvider

- Update SyncManager reconciliation to accept legacyUnverified status, mark historical rides as synced without re-upload, and track legacy unverified counts

- Add 18-test vitest suite for Cloudflare Worker /r2/verify endpoint covering CORS, auth, validation, ownership, HEAD/GET, legacy bundles, and errors

- Harden test/disposable_backup_recovery_verification_test.dart with candidate path discovery and CI skip guard
2026-10-14 10:34:06 -04:00
Developer 5e0db4c191 fix(sync): harden cloud confirmation states, R2 edge 404 handling, and in-flight cancellation 2026-10-13 22:05:25 -04:00
Developer d431f11a13 fix(sync): harden cloud confirmation with R2 verification and account isolation 2026-10-13 21:31:06 -04:00
Developer c6c8c8df29 fix(sync): completed-ride outbox recovery and cloud confirmation reconciliation 2026-10-13 20:17:55 -04:00
Developer 493192078f fix(ui): landscape dashboard 3-column layout, decoupled alert presentation policy, truthful GPS contract, and diagnostics HUD 2026-10-13 18:11:47 -04:00
Developer ad99da10ba fix(alerts): decouple tactical map horizon, add diagnostics HUD, and trace Zyla pipeline 2026-10-13 14:47:30 -04:00
Developer 5fcaafbe36 fix(alerts): restore auth on startup, enforce token header, and prevent immutable list sort crashes 2026-10-13 13:50:37 -04:00
Developer 35f2b6982a fix: master consolidation fixes for Schema 8, legacy ride sync, performance timer isolation, and sync inspector 2026-10-13 10:24:24 -04:00
Billie Ponder f9e2bd6bed
fix(mobile): October 11 Pixel 8a field-test stabilization (navigation, sensors, sync, UI) (#33)
* fix(navigation): canonicalize turn maneuvers and protect road bends from false announcements

* fix(sensors): expand mount pitch tolerance, suppress dynamic zero drift, and add diagnostics

* feat(sync): add itemized SyncStatusInspectorScreen and non-destructive queue diagnostics

* fix(map): dismiss search keyboard on selection, add compact address card, and wire map controls

* fix(ui): split pause action with hazard report, harden profile contrast, and bind social ride picker

* ci(android): enable stacked PR validation, verify package identity, and guard release to master

* fix(auth): register CI debug SHA-1 in Firebase and wire authoritative webClientId to GoogleSignIn

---------

Co-authored-by: Developer <dev@example.com>
2026-10-12 06:34:32 -04:00
Developer 92d883ff90 fix(backend): cast cost calculation to numeric for postgresql round compatibility 2026-10-10 20:44:19 -04:00
Developer b552a43a6a build(android): sign debug build with release key when present for device compatibility 2026-10-10 20:23:30 -04:00
Developer 8a60da6328 fix(alerts): default road alert providers backend URL to production gateway with environment fallback 2026-10-10 20:19:50 -04:00
Developer 32907213c4 fix(alerts): exactly-once Zyla request accounting and Firebase QA/prod isolation (#31, #32)
- Fixed Zyla request accounting to record each actual upstream HTTP attempt exactly once, eliminating double-counting when body parsing, JSON decoding, or normalization fails
2026-10-10 19:56:16 -04:00
Developer f8b86babd2 fix(alerts): atomic monthly DB usage limits and real Firebase gateway auth (#31, #32)
- Replaced JSON usage tracker with PostgreSQL/SQLAlchemy-backed PersistentUsageTracker using atomic row-level UPSERT reservations
2026-10-10 19:31:09 -04:00
Developer 9f73fd4cd4 fix(alerts): remediate P0 gateway auth, P1 Zyla schema, and budget safeguards (#31, #32) 2026-10-10 18:23:32 -04:00
Developer 625999245e feat(alerts): address review hardening for multi-source road alerts (#31) 2026-10-10 17:46:01 -04:00
Developer bcae4881d6 feat(alerts): multi-source road alert architecture with Zyla #4814 and Georgia 511 (#31) 2026-10-10 17:28:17 -04:00
Developer 789769c20e fix(alerts): enforce atomic count pairing and eliminate orphan vote loophole in firestore.rules 2026-10-10 16:35:35 -04:00
Developer bb3e8fb8c4 feat(alerts): authoritative cross-device road alert vote aggregation via Firestore transactions 2026-10-10 03:18:47 -04:00
Developer de94bbb3e3 fix(alerts): harden road alert voting, isolate Waze OSINT adapter, purge OpenWebNinja 2026-10-10 02:58:15 -04:00
Developer 7bd5a0e585 feat(alerts): native community road alerts and experimental Waze OSINT adapter
- Support 10 native alert categories: police, accident, hazard, pothole, debris, disabledVehicle, roadClosure, construction, traffic, weatherHazard
2026-10-10 02:31:06 -04:00
Developer 082b22fe00 fix: implement stationary lean-angle calibration recovery for right-pinned or remounted sensors
- Permit recovery from stale or right-pinned lean angle calibration when motorcycle is physically upright
2026-10-10 00:28:32 -04:00
Developer ab4b322d85 fix: complete acceptance review hardening for navigation, routing, social, maintenance, and lean-angle calibration
- Voice navigation: configure Bluetooth SCO/A2DP and audio focus ducking; announce maneuvers and support mute
2026-10-10 00:05:21 -04:00
Developer 618ef8798b feat(navigation): implement shared turn-by-turn navigation, route preferences, and garage/telemetry enhancements 2026-10-09 21:03:22 -04:00
Developer 28dc54a0c5 ci: allow flavored APK output in android debug verification step 2026-10-09 19:23:25 -04:00
Developer 3ba94777a1 fix: resolve Android CI breakpoint overflow and address Codex PR #28 findings
- Fix RenderFlex overflow at short landscape breakpoint (640x320) by scaling center controls in ResponsiveDashboardLayout and clamping LeanAngleIndicator bounds
2026-10-09 18:53:39 -04:00
Developer 20da9ee27d feat(core): reconcile local development, hybrid design, sensor fusion, and docs
- Consolidate Hybrid v1 tokens, theme extensions, and core UI components (HybridCard, HybridMetricTile, HybridRidingActionBar, HybridStaticRecBadge)
2026-10-09 05:12:01 -04:00
Developer 602aa962e2 fix(website): isolate Firebase configuration for staging and enforce safety guards 2026-10-08 10:50:28 -04:00
388 changed files with 61877 additions and 5339 deletions

View File

@ -1,5 +1,7 @@
{
"projects": {
"default": "motoapp-d22e0"
"default": "motoapp-d22e0",
"production": "motoapp-d22e0",
"staging": "motorsignal-staging"
}
}

View File

@ -0,0 +1,36 @@
name: Flutter CI setup
description: Set up Flutter dependencies and restore build_runner state for CI jobs.
inputs:
setup-java:
description: Whether this job needs the pinned Java toolchain.
required: false
default: 'false'
build-runner-cache-key:
description: Cache key for build_runner state.
required: true
runs:
using: composite
steps:
- name: Set up Java
if: inputs.setup-java == 'true'
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
cache: gradle
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- name: Get dependencies
shell: bash
run: flutter pub get
- name: Restore build_runner state
uses: actions/cache@v4
with:
path: .dart_tool/build
key: ${{ inputs.build-runner-cache-key }}

View File

@ -39,12 +39,32 @@ function isAppPath(filePath) {
);
}
function isNativeBuildPath(filePath) {
const p = normalizePath(filePath);
if (!p) return false;
// Native build requires: Android code, CI-critical files, or build config.
// Only CI files that affect build behavior trigger release builds; doc/comment changes do not.
// Composite actions: only Android-specific actions (flutter-ci-setup) trigger builds.
return (
p.startsWith('android/') ||
p === '.github/workflows/android-build.yml' ||
p.startsWith('.github/scripts/') ||
p.startsWith('.github/actions/flutter-ci-setup/') ||
p === 'pubspec.yaml' ||
p === 'pubspec.lock' ||
p === 'analysis_options.yaml' ||
p === 'build.yaml'
);
}
function isCiPath(filePath) {
const p = normalizePath(filePath);
if (!p) return false;
// CI paths: workflows, scripts, and Android-specific composite actions.
return (
p === '.github/workflows/android-build.yml' ||
p.startsWith('.github/scripts/')
p.startsWith('.github/scripts/') ||
p.startsWith('.github/actions/flutter-ci-setup/')
);
}
@ -53,6 +73,7 @@ function classifyFiles(fileList) {
return {
android_changed: false,
app_changed: false,
native_build_changed: false,
appFiles: [],
ciFiles: [],
unrelatedFiles: []
@ -62,6 +83,7 @@ function classifyFiles(fileList) {
const appFiles = [];
const ciFiles = [];
const unrelatedFiles = [];
const nativeBuildFiles = [];
for (const rawFile of fileList) {
const file = normalizePath(rawFile);
@ -74,14 +96,21 @@ function classifyFiles(fileList) {
} else {
unrelatedFiles.push(file);
}
// Track files that trigger native builds (Android code, CI, config)
if (isNativeBuildPath(file)) {
nativeBuildFiles.push(file);
}
}
const app_changed = appFiles.length > 0;
const android_changed = appFiles.length > 0 || ciFiles.length > 0;
const native_build_changed = nativeBuildFiles.length > 0;
return {
android_changed,
app_changed,
native_build_changed,
appFiles,
ciFiles,
unrelatedFiles
@ -124,6 +153,7 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
return {
android_changed: true,
app_changed: true,
native_build_changed: true,
reason: 'Manual workflow_dispatch; full pipeline authorized',
appFiles: [],
ciFiles: [],
@ -135,6 +165,7 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
if (!baseSha || isZeroSha(baseSha) || !isValidSha(baseSha)) {
return {
android_changed: true,
native_build_changed: true,
app_changed: true,
reason: `Zero, missing, or invalid base SHA (${baseSha}); failing safe to full build`,
appFiles: [],
@ -147,6 +178,7 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
if (!headSha || isZeroSha(headSha) || !isValidSha(headSha)) {
return {
android_changed: true,
native_build_changed: true,
app_changed: true,
reason: `Zero, missing, or invalid head SHA (${headSha}); failing safe to full build`,
appFiles: [],
@ -159,6 +191,7 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
if (baseSha.trim() === headSha.trim()) {
return {
android_changed: false,
native_build_changed: false,
app_changed: false,
reason: 'Base SHA and Head SHA are identical; no changes detected',
appFiles: [],
@ -174,6 +207,7 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
} catch (err) {
return {
android_changed: true,
native_build_changed: true,
app_changed: true,
reason: `Failed to retrieve git diff: ${err.message}; failing safe to full build`,
appFiles: [],
@ -211,6 +245,7 @@ function runCli() {
console.log(`[detect-android-changes] android_changed=${result.android_changed}`);
console.log(`[detect-android-changes] app_changed=${result.app_changed}`);
console.log(`[detect-android-changes] native_build_changed=${result.native_build_changed}`);
console.log(`[detect-android-changes] Reason: ${result.reason}`);
if (result.appFiles && result.appFiles.length > 0) {
@ -238,7 +273,7 @@ function runCli() {
if (githubOutput) {
fs.appendFileSync(
githubOutput,
`android_changed=${result.android_changed}\napp_changed=${result.app_changed}\n`
`android_changed=${result.android_changed}\napp_changed=${result.app_changed}\nnative_build_changed=${result.native_build_changed}\n`
);
console.log(`[detect-android-changes] Wrote outputs to GITHUB_OUTPUT.`);
}
@ -256,6 +291,7 @@ module.exports = {
isZeroSha,
isAppPath,
isCiPath,
isNativeBuildPath,
classifyFiles,
getChangedFiles,
detectAndroidChanges

View File

@ -12,6 +12,7 @@ const {
isZeroSha,
isAppPath,
isCiPath,
isNativeBuildPath,
classifyFiles,
getChangedFiles,
detectAndroidChanges
@ -71,14 +72,37 @@ describe('File Path Classification', () => {
assert.strictEqual(isCiPath('.github/workflows/android-build.yml'), true);
assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.cjs'), true);
assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.test.cjs'), true);
assert.strictEqual(isCiPath('.github/actions/flutter-ci-setup/action.yml'), true);
assert.strictEqual(isCiPath('.github/actions/flutter-ci-setup/index.js'), true);
// Negative matches for Android CI paths
assert.strictEqual(isCiPath('.github/workflows/website-ci.yml'), false);
assert.strictEqual(isCiPath('.github/workflows/backend-ci.yml'), false);
assert.strictEqual(isCiPath('.github/workflows/waze-relay-ci.yml'), false);
assert.strictEqual(isCiPath('.github/workflows/firebase-emulator-tests.yml'), false);
assert.strictEqual(isCiPath('.github/actions/website-deploy/action.yml'), false);
assert.strictEqual(isCiPath('.github/actions/other-action/index.js'), false);
assert.strictEqual(isCiPath('.github/pull_request_template.md'), false);
});
it('identifies native build paths correctly', () => {
assert.strictEqual(isNativeBuildPath('android/app/build.gradle'), true);
assert.strictEqual(isNativeBuildPath('.github/workflows/android-build.yml'), true);
assert.strictEqual(isNativeBuildPath('.github/scripts/detect-android-changes.cjs'), true);
assert.strictEqual(isNativeBuildPath('.github/actions/flutter-ci-setup/action.yml'), true);
assert.strictEqual(isNativeBuildPath('.github/actions/flutter-ci-setup/index.js'), true);
assert.strictEqual(isNativeBuildPath('pubspec.yaml'), true);
assert.strictEqual(isNativeBuildPath('pubspec.lock'), true);
assert.strictEqual(isNativeBuildPath('analysis_options.yaml'), true);
assert.strictEqual(isNativeBuildPath('build.yaml'), true);
// Negative matches for native build paths
assert.strictEqual(isNativeBuildPath('.github/workflows/website-ci.yml'), false);
assert.strictEqual(isNativeBuildPath('.github/actions/website-deploy/action.yml'), false);
assert.strictEqual(isNativeBuildPath('.github/actions/other-action/action.yml'), false);
assert.strictEqual(isNativeBuildPath('lib/main.dart'), false);
assert.strictEqual(isNativeBuildPath('website/app/page.tsx'), false);
});
});
describe('classifyFiles Scenarios', () => {
@ -145,6 +169,7 @@ describe('classifyFiles Scenarios', () => {
const result = classifyFiles(files);
assert.strictEqual(result.android_changed, true);
assert.strictEqual(result.app_changed, true);
assert.strictEqual(result.native_build_changed, false);
assert.deepStrictEqual(result.appFiles, ['lib/telemetry/telemetry_service.dart']);
});
@ -168,6 +193,7 @@ describe('classifyFiles Scenarios', () => {
const result = classifyFiles(files);
assert.strictEqual(result.android_changed, true);
assert.strictEqual(result.app_changed, true);
assert.strictEqual(result.native_build_changed, true);
});
it('classifies pubspec change as true', () => {
@ -184,7 +210,9 @@ describe('classifyFiles Scenarios', () => {
it('classifies analysis_options and build.yaml changes as true', () => {
assert.strictEqual(classifyFiles(['analysis_options.yaml']).android_changed, true);
assert.strictEqual(classifyFiles(['analysis_options.yaml']).native_build_changed, true);
assert.strictEqual(classifyFiles(['build.yaml']).android_changed, true);
assert.strictEqual(classifyFiles(['build.yaml']).native_build_changed, true);
});
it('classifies assets change as true', () => {
@ -239,6 +267,26 @@ describe('classifyFiles Scenarios', () => {
assert.strictEqual(result.android_changed, false);
assert.strictEqual(result.app_changed, false);
});
it('classifies composite action (flutter-ci-setup) change as android_changed=true, native_build_changed=true', () => {
const files = ['.github/actions/flutter-ci-setup/action.yml'];
const result = classifyFiles(files);
assert.strictEqual(result.android_changed, true);
assert.strictEqual(result.app_changed, false);
assert.strictEqual(result.native_build_changed, true);
assert.strictEqual(result.ciFiles.length, 1);
assert.deepStrictEqual(result.ciFiles, ['.github/actions/flutter-ci-setup/action.yml']);
});
it('classifies non-Android composite action (e.g., website-deploy) as false', () => {
const files = ['.github/actions/website-deploy/action.yml'];
const result = classifyFiles(files);
assert.strictEqual(result.android_changed, false);
assert.strictEqual(result.app_changed, false);
assert.strictEqual(result.native_build_changed, false);
assert.strictEqual(result.ciFiles.length, 0);
assert.strictEqual(result.unrelatedFiles.length, 1);
});
});
describe('detectAndroidChanges Workflow Event Logic', () => {
@ -253,6 +301,7 @@ describe('detectAndroidChanges Workflow Event Logic', () => {
});
assert.strictEqual(result.android_changed, true);
assert.strictEqual(result.app_changed, true);
assert.strictEqual(result.native_build_changed, true);
assert.match(result.reason, /Manual workflow_dispatch/);
});
@ -566,6 +615,271 @@ describe('Workflow Scenario Matrix Verification', () => {
assert.strictEqual(result.android_changed, true);
assert.strictEqual(result.app_changed, true);
assert.strictEqual(result.native_build_changed, true);
});
});
describe('Release Job Gating (native_build_changed output)', () => {
const baseSha = '0000000000000000000000000000000000000001';
const headSha = '0000000000000000000000000000000000000002';
it('Dart-only changes skip release job (native_build_changed=false)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['lib/main.dart', 'test/widget_test.dart', 'assets/icons/app.png'] });
assert.strictEqual(result.native_build_changed, false, 'Dart-only changes should skip release');
assert.strictEqual(result.app_changed, true, 'app_changed should still be true');
});
it('Android code changes trigger release job (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['android/app/build.gradle'] });
assert.strictEqual(result.native_build_changed, true, 'Android changes should trigger release');
});
it('CI-only changes (.github/) trigger release job (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['.github/workflows/other.yml', '.github/scripts/helper.sh'] });
assert.strictEqual(result.native_build_changed, true, 'CI changes should trigger release');
});
it('Config file changes (pubspec.yaml) trigger release job (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['pubspec.yaml'] });
assert.strictEqual(result.native_build_changed, true, 'pubspec.yaml changes should trigger release');
});
it('Build config changes (build.yaml) trigger release job (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['build.yaml'] });
assert.strictEqual(result.native_build_changed, true, 'build.yaml changes should trigger release');
});
it('Mixed Dart and Android changes trigger release job (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['lib/main.dart', 'android/app/build.gradle'] });
assert.strictEqual(result.native_build_changed, true, 'Mixed changes should trigger release');
});
it('Manual dispatch always triggers release job (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'workflow_dispatch',
baseSha: '',
headSha: ''
});
assert.strictEqual(result.native_build_changed, true, 'Manual dispatch should always trigger release');
});
it('Invalid base SHA fails safe to trigger release (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha: 'invalid',
headSha
}, { getChangedFilesFn: () => ['lib/main.dart'] });
assert.strictEqual(result.native_build_changed, true, 'Invalid SHA should fail safe to trigger release');
});
it('Unrelated changes (docs/, website/) skip release job (native_build_changed=false)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['docs/readme.md', 'website/index.html'] });
assert.strictEqual(result.native_build_changed, false, 'Unrelated changes should skip release');
});
it('dependency changes (pubspec.lock) trigger release job (native_build_changed=true)', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['pubspec.lock'] });
assert.strictEqual(result.native_build_changed, true, 'pubspec.lock changes should trigger release');
});
});
describe('GitHub Actions Workflow Output Wiring and Release Job Conditions', () => {
const baseSha = '03446911949ff8b27543280bfe226d9babd6fd7a';
const headSha = '247ccde71e4378dacd45121c891f45aad5ed2424';
it('detector outputs native_build_changed for GitHub Actions output file', () => {
// Simulate the detector writing to GITHUB_OUTPUT
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['lib/main.dart'] });
// The detector MUST set native_build_changed=false for Dart-only changes
assert.strictEqual(result.native_build_changed, false, 'Dart-only should set native_build_changed=false');
assert.strictEqual(result.android_changed, true, 'But android_changed=true still drives heavy build');
});
it('workflow release condition evaluates correctly: Dart-only changes skip release', () => {
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['lib/main.dart', 'test/widget_test.dart'] });
// Release job condition evaluates to false
const nativeBuildChanged = result.native_build_changed;
const onMaster = true; // github.ref == 'refs/heads/master'
const releaseJobShouldRun = nativeBuildChanged && onMaster;
assert.strictEqual(nativeBuildChanged, false, 'Dart-only should output false');
assert.strictEqual(releaseJobShouldRun, false, 'Release job should NOT run for Dart-only');
});
it('workflow release condition evaluates correctly: Android changes run release', () => {
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['android/app/build.gradle'] });
// Release job condition evaluates to true
const nativeBuildChanged = result.native_build_changed;
const onMaster = true; // github.ref == 'refs/heads/master'
const releaseJobShouldRun = nativeBuildChanged && onMaster;
assert.strictEqual(nativeBuildChanged, true, 'Android changes should output true');
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for Android changes');
});
it('workflow release condition evaluates correctly: CI-critical changes run release', () => {
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['.github/workflows/android-build.yml'] });
// Release job condition evaluates to true
const nativeBuildChanged = result.native_build_changed;
const onMaster = true; // github.ref == 'refs/heads/master'
const releaseJobShouldRun = nativeBuildChanged && onMaster;
assert.strictEqual(nativeBuildChanged, true, 'CI-critical changes should output true');
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for CI-critical changes');
});
it('workflow release condition evaluates correctly: Config changes run release', () => {
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['pubspec.yaml'] });
// Release job condition evaluates to true
const nativeBuildChanged = result.native_build_changed;
const onMaster = true; // github.ref == 'refs/heads/master'
const releaseJobShouldRun = nativeBuildChanged && onMaster;
assert.strictEqual(nativeBuildChanged, true, 'Config changes should output true');
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for config changes');
});
it('workflow release condition evaluates correctly: Unrelated changes skip release', () => {
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['docs/readme.md', 'website/app.tsx'] });
// Release job condition evaluates to false
const nativeBuildChanged = result.native_build_changed;
const onMaster = true; // github.ref == 'refs/heads/master'
const releaseJobShouldRun = nativeBuildChanged && onMaster;
assert.strictEqual(nativeBuildChanged, false, 'Unrelated changes should output false');
assert.strictEqual(releaseJobShouldRun, false, 'Release job should NOT run for unrelated changes');
});
it('workflow release condition evaluates correctly: Composite action changes run release', () => {
// flutter-ci-setup is a composite action used by analysis, test, and debug APK jobs
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['.github/actions/flutter-ci-setup/action.yml'] });
const nativeBuildChanged = result.native_build_changed;
const onMaster = true; // github.ref == 'refs/heads/master'
const releaseJobShouldRun = nativeBuildChanged && onMaster;
assert.strictEqual(nativeBuildChanged, true, 'Composite action changes should output true');
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for composite action changes');
});
it('workflow release condition evaluates correctly: Non-Android composite action (website-deploy) skips release', () => {
const result = detectAndroidChanges({
eventName: 'push',
baseSha,
headSha
}, { getChangedFilesFn: () => ['.github/actions/website-deploy/action.yml'] });
const nativeBuildChanged = result.native_build_changed;
const onMaster = true; // github.ref == 'refs/heads/master'
const releaseJobShouldRun = nativeBuildChanged && onMaster;
assert.strictEqual(nativeBuildChanged, false, 'Non-Android composite actions should output false');
assert.strictEqual(releaseJobShouldRun, false, 'Release job should NOT run for non-Android actions');
});
});
describe('Workflow YAML Verification', () => {
it('verifies android-build.yml defines jobs.detect.outputs.native_build_changed', () => {
const workflowPath = '.github/workflows/android-build.yml';
const workflowContent = fs.readFileSync(path.join(process.cwd(), workflowPath), 'utf8');
// Check that the workflow file exists and contains the output mapping
assert.ok(workflowContent.includes('native_build_changed'),
'Workflow must expose native_build_changed output from detector step');
assert.ok(workflowContent.includes('steps.detector.outputs.native_build_changed'),
'Workflow must reference detector output with ${{ steps.detector.outputs.native_build_changed }}');
});
it('verifies release job condition includes native_build_changed check', () => {
const workflowPath = '.github/workflows/android-build.yml';
const workflowContent = fs.readFileSync(path.join(process.cwd(), workflowPath), 'utf8');
// Check that release job condition references the output
assert.ok(workflowContent.includes("needs.detect.outputs.native_build_changed == 'true'"),
'Release job condition must check native_build_changed output');
assert.ok(workflowContent.includes('refs/heads/master'),
'Release job condition must ensure it only runs on master branch');
});
});

View File

@ -4,7 +4,7 @@ on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
branches: [ master, 'feature/**', 'fix/**', 'stabilization/**' ]
workflow_dispatch:
permissions:
@ -30,6 +30,7 @@ jobs:
outputs:
android_changed: ${{ steps.detector.outputs.android_changed }}
app_changed: ${{ steps.detector.outputs.app_changed }}
native_build_changed: ${{ steps.detector.outputs.native_build_changed }}
steps:
- name: Checkout
uses: actions/checkout@v6
@ -49,8 +50,54 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: node .github/scripts/detect-android-changes.cjs
analyze-and-build:
name: Analyze, test and debug build
analyze:
name: Analyze
runs-on: ubuntu-latest
needs: detect
if: github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true'
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Set up Flutter CI
uses: ./.github/actions/flutter-ci-setup
with:
build-runner-cache-key: ${{ runner.os }}-build-runner-${{ hashFiles('pubspec.lock', 'lib/database/**') }}
- name: Generate Drift sources
run: dart run build_runner build --delete-conflicting-outputs
- name: Analyze
run: flutter analyze --no-fatal-infos
test:
name: Tests (shard ${{ matrix.shard }}/4)
runs-on: ubuntu-latest
needs: detect
if: github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true'
permissions:
contents: read
strategy:
fail-fast: false
matrix:
shard: [0, 1, 2, 3]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Set up Flutter CI
uses: ./.github/actions/flutter-ci-setup
with:
build-runner-cache-key: ${{ runner.os }}-build-runner-${{ hashFiles('pubspec.lock', 'lib/database/**') }}
- name: Generate Drift sources
run: dart run build_runner build --delete-conflicting-outputs
- name: Run test shard
run: flutter test --total-shards 4 --shard-index ${{ matrix.shard }}
debug-apk:
name: Debug APK build
runs-on: ubuntu-latest
needs: detect
if: github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true'
@ -61,18 +108,11 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Set up Java
uses: actions/setup-java@v5
- name: Set up Flutter CI
uses: ./.github/actions/flutter-ci-setup
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
cache: gradle
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
setup-java: 'true'
build-runner-cache-key: ${{ runner.os }}-build-runner-${{ hashFiles('pubspec.lock', 'lib/database/**') }}
- name: Free runner disk space
shell: bash
run: |
@ -130,8 +170,6 @@ jobs:
flutter --version
flutter doctor -v
java -version
- name: Get dependencies
run: flutter pub get
- name: Generate Drift sources
run: dart run build_runner build --delete-conflicting-outputs
- name: Commit regenerated Drift sources
@ -148,10 +186,6 @@ jobs:
git add lib/database/database.g.dart
git commit -m 'Regenerate Drift database source'
git push origin HEAD:${GITHUB_REF_NAME}
- name: Analyze
run: flutter analyze --no-fatal-infos
- name: Run tests
run: flutter test
- name: Build debug APK
shell: bash
run: flutter build apk --debug
@ -159,11 +193,22 @@ jobs:
shell: bash
run: |
set -euo pipefail
if [ -f "build/app/outputs/flutter-apk/app-production-debug.apk" ] && [ ! -f "build/app/outputs/flutter-apk/app-debug.apk" ]; then
cp "build/app/outputs/flutter-apk/app-production-debug.apk" "build/app/outputs/flutter-apk/app-debug.apk"
fi
APK="build/app/outputs/flutter-apk/app-debug.apk"
test -s "$APK"
file "$APK"
sha256sum "$APK" | tee build/app/outputs/flutter-apk/app-debug.apk.sha256
if [ -n "${ANDROID_SDK_ROOT:-}" ]; then
AAPT="$(find "$ANDROID_SDK_ROOT/build-tools" -type f -name aapt -print -quit 2>/dev/null || true)"
if [ -x "$AAPT" ]; then
"$AAPT" dump badging "$APK" | grep "package: name='app.motorsignal.motorcycle'"
echo "Package identity verified: app.motorsignal.motorcycle"
fi
fi
- name: Upload debug APK
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v5
with:
name: motorsignal-debug-apk
@ -171,13 +216,33 @@ jobs:
build/app/outputs/flutter-apk/app-debug.apk
build/app/outputs/flutter-apk/app-debug.apk.sha256
if-no-files-found: error
retention-days: 14
retention-days: 3
analyze-and-build:
name: Analyze, test and debug build
runs-on: ubuntu-latest
needs: [detect, analyze, test, debug-apk]
if: always() && (github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true')
permissions:
contents: read
steps:
- name: Require all Android CI jobs to pass
env:
ANALYZE_RESULT: ${{ needs.analyze.result }}
TEST_RESULT: ${{ needs.test.result }}
DEBUG_APK_RESULT: ${{ needs.debug-apk.result }}
shell: bash
run: |
set -euo pipefail
test "$ANALYZE_RESULT" = success
test "$TEST_RESULT" = success
test "$DEBUG_APK_RESULT" = success
release:
name: Build signed release APK and AAB
runs-on: ubuntu-latest
needs: [detect, analyze-and-build]
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.detect.outputs.app_changed == 'true')
if: (github.ref == 'refs/heads/master') && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && needs.detect.outputs.native_build_changed == 'true'))
permissions:
contents: read
steps:
@ -296,6 +361,13 @@ jobs:
shell: bash
run: |
set -euo pipefail
if [ -f "build/app/outputs/flutter-apk/app-production-release.apk" ] && [ ! -f "build/app/outputs/flutter-apk/app-release.apk" ]; then
cp "build/app/outputs/flutter-apk/app-production-release.apk" "build/app/outputs/flutter-apk/app-release.apk"
fi
if [ -f "build/app/outputs/bundle/productionRelease/app-production-release.aab" ] && [ ! -f "build/app/outputs/bundle/release/app-release.aab" ]; then
mkdir -p "build/app/outputs/bundle/release"
cp "build/app/outputs/bundle/productionRelease/app-production-release.aab" "build/app/outputs/bundle/release/app-release.aab"
fi
APK="build/app/outputs/flutter-apk/app-release.apk"
AAB="build/app/outputs/bundle/release/app-release.aab"
test -s "$APK"
@ -311,7 +383,7 @@ jobs:
build/app/outputs/flutter-apk/app-release.apk
build/release-artifacts.sha256
if-no-files-found: error
retention-days: 30
retention-days: 7
- name: Upload release App Bundle
uses: actions/upload-artifact@v5
with:
@ -320,7 +392,7 @@ jobs:
build/app/outputs/bundle/release/app-release.aab
build/release-artifacts.sha256
if-no-files-found: error
retention-days: 30
retention-days: 7
- name: Remove signing credentials
if: always()
shell: bash

18
.gitignore vendored
View File

@ -76,3 +76,21 @@ scratch_*.py
1.29
graphify-out/
devtools_options.yaml
# Temporary outputs, root screenshots, and emulator caches
/screen*.png
/emulator_screenshot.png
/.firebase-tmp/
/output/
firebase.website-qa.json
# Scratch tools and ephemeral test runs
tools/*.py
tools/*.mjs
tools/*.cjs
tools/*.js
docs/qa-2026-10-01/auth.verify-*.json
docs/qa-2026-10-01/auth.verify-*.mjs
docs/qa-2026-10-01/chat.cross-account-results.json
docs/qa-2026-10-01/scenarios_machine_readable.json

18
GEMINI.md Normal file
View File

@ -0,0 +1,18 @@
\## Android Emulator / Device Policy
\- For all Android emulator testing, UI validation, screenshots, integration testing, and interactive app launches, ALWAYS use the AVD named `avdslim`.
\- Treat `avdslim` as the canonical MotorSignal Android test emulator.
\- Do not create, select, launch, or switch to another Android Virtual Device unless the user explicitly instructs you to do so.
\- Before running emulator-dependent tests, verify that `avdslim` is running and available through `adb devices`.
\- If `avdslim` is not running, start `avdslim` rather than choosing another emulator.
\- When multiple Android devices/emulators are connected, explicitly target `avdslim` for Flutter/ADB commands instead of relying on automatic device selection.
\- Physical devices should only be used when the user explicitly requests physical-device testing.

View File

@ -26,5 +26,9 @@ A high-performance, dark-themed telemetry and analytics dashboard for motorcycle
* **Database:** Drift (SQLite) for robust, local storage.
* **Sensor Fusion:** Complementary filter combining accelerometer and gyroscope for precise lean angles.
## Documentation
The active documentation map and dated evidence archive are indexed in [docs/README.md](docs/README.md).
## Disclaimer
This application relies on the accuracy of your smartphone's internal sensors. It should be securely mounted to the motorcycle using a rigid mount. Do not interact with the screen while riding. Ride safely!

View File

@ -48,6 +48,8 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
testInstrumentationRunner = "pl.leancode.patrol.PatrolJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "false"
}
signingConfigs {
@ -72,6 +74,11 @@ android {
}
buildTypes {
debug {
signingConfigs.findByName("release")?.let {
signingConfig = it
}
}
release {
signingConfigs.findByName("release")?.let {
signingConfig = it

View File

@ -13,6 +13,14 @@
}
},
"oauth_client": [
{
"client_id": "809431485362-0ev25bbjbjjm5tj6nitinefhg4fi7vvj.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "app.motorsignal.motorcycle",
"certificate_hash": "4ab4d3266a5b50ef31e46a5dccb8d42f295bc635"
}
},
{
"client_id": "809431485362-c2qfdas53jtoft3udu6jokciv9pruehq.apps.googleusercontent.com",
"client_type": 1,

View File

@ -0,0 +1,31 @@
package app.motorsignal.motorcycle;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;
import pl.leancode.patrol.PatrolJUnitRunner;
@RunWith(Parameterized.class)
public class MainActivityTest {
@Parameters(name = "{0}")
public static Object[] testCases() {
PatrolJUnitRunner instrumentation = (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation();
instrumentation.setUp(MainActivity.class);
instrumentation.waitForPatrolAppService();
return instrumentation.listDartTests();
}
public MainActivityTest(String dartTestName) {
this.dartTestName = dartTestName;
}
private final String dartTestName;
@Test
public void runDartTest() {
PatrolJUnitRunner instrumentation = (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation();
instrumentation.runDartTest(dartTestName);
}
}

79
azure-pipelines.yml Normal file
View File

@ -0,0 +1,79 @@
# MotorSignal Azure Pipelines pilot
# Manual, non-publishing validation only. No signing keys or deployments.
trigger: none
pr: none
pool:
vmImage: ubuntu-24.04
variables:
flutterVersion: '3.44.6'
steps:
- checkout: self
clean: true
fetchDepth: 1
- bash: |
set -euo pipefail
echo 'Before cleanup:'
df -h /
free -h
# Microsoft-hosted Ubuntu includes toolchains this Android pipeline does not need.
sudo rm -rf /usr/share/dotnet /usr/local/share/powershell /opt/ghc /usr/local/.ghcup
sudo rm -rf /usr/local/lib/android/sdk/emulator /usr/local/lib/android/sdk/system-images
sudo apt-get clean
echo 'After cleanup:'
df -h /
free -h
available_kb="$(df --output=avail / | tail -1 | tr -d ' ')"
if [ "$available_kb" -lt 6291456 ]; then
echo 'At least 6 GiB free disk is required before Android SDK provisioning.'
exit 1
fi
displayName: 'Free unused toolchains and verify disk capacity'
- bash: |
set -euo pipefail
git clone --depth 1 --branch "$(flutterVersion)" https://github.com/flutter/flutter.git "$(Agent.TempDirectory)/flutter"
echo "##vso[task.prependpath]$(Agent.TempDirectory)/flutter/bin"
"$(Agent.TempDirectory)/flutter/bin/flutter" --version
displayName: 'Install pinned Flutter SDK'
- bash: |
set -euo pipefail
flutter pub get
dart run build_runner build --delete-conflicting-outputs
flutter analyze --no-fatal-infos
flutter test
displayName: 'Generate, analyze and test Flutter'
- bash: |
set -euo pipefail
echo 'Before Android compilation:'
df -h /
free -h
SDKROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
test -n "$SDKROOT"
echo "flutter.sdk=$(Agent.TempDirectory)/flutter" > android/local.properties
SDKMANAGER="$(find "$SDKROOT/cmdline-tools" -type f -name sdkmanager -print -quit 2>/dev/null || true)"
test -x "$SDKMANAGER"
"$SDKMANAGER" 'platforms;android-36' 'build-tools;36.0.0' 'platform-tools'
if [ ! -f android/gradlew ]; then
(cd android && gradle wrapper --gradle-version 9.1.0 --distribution-type all)
fi
chmod +x android/gradlew
# Agent-specific limits, applied only in CI's disposable checkout.
cat >> android/gradle.properties <<'GRADLE_LIMITS'
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m -XX:ReservedCodeCacheSize=256m -Dfile.encoding=UTF-8
org.gradle.workers.max=2
org.gradle.parallel=false
org.gradle.daemon=false
GRADLE_LIMITS
export JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=60"
flutter build apk --debug
test -s build/app/outputs/flutter-apk/app-debug.apk || test -s build/app/outputs/flutter-apk/app-production-debug.apk
echo 'Android debug APK built and verified locally; artifact intentionally not published.'
df -h /
free -h
displayName: 'Build and verify debug APK without artifact upload'

View File

@ -0,0 +1,48 @@
"""add provider_monthly_usage table for atomic usage limits
Revision ID: f1a2b3c4d5eb
Revises: f1a2b3c4d5ea
Create Date: 2026-10-10
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f1a2b3c4d5eb'
down_revision = 'f1a2b3c4d5ea'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"provider_monthly_usage",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("provider_id", sa.String(length=64), nullable=False),
sa.Column("period_month", sa.String(length=7), nullable=False),
sa.Column("requests_reserved", sa.Integer(), server_default="0", nullable=False),
sa.Column("requests_executed", sa.Integer(), server_default="0", nullable=False),
sa.Column("monthly_limit", sa.Integer(), server_default="500", nullable=False),
sa.Column("estimated_cost_usd", sa.Float(), server_default="0.0", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("provider_id", "period_month", name="uq_provider_period"),
)
op.create_index(
"ix_provider_monthly_usage_provider_id",
"provider_monthly_usage",
["provider_id"],
)
op.create_index(
"ix_provider_monthly_usage_period_month",
"provider_monthly_usage",
["period_month"],
)
def downgrade() -> None:
op.drop_index("ix_provider_monthly_usage_period_month", table_name="provider_monthly_usage")
op.drop_index("ix_provider_monthly_usage_provider_id", table_name="provider_monthly_usage")
op.drop_table("provider_monthly_usage")

View File

@ -6,6 +6,7 @@ import time
import urllib.request
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
import jwt
from cryptography import x509
@ -110,8 +111,14 @@ def _load_firebase_certs() -> dict[str, str]:
return certs
def decode_firebase_id_token(token: str) -> dict:
project_id = os.environ["FIREBASE_PROJECT_ID"]
def decode_firebase_id_token(token: str, project_id: Optional[str] = None) -> dict:
target_project = project_id or os.environ.get("FIREBASE_PROJECT_ID")
if not target_project:
env = (os.environ.get("MOTORSIGNAL_ENV") or os.environ.get("ENVIRONMENT") or os.environ.get("APP_ENV") or "").lower()
if env in ("staging", "qa"):
target_project = "motorsignal-staging"
else:
target_project = "motoapp-d22e0"
header = jwt.get_unverified_header(token)
if header.get("alg") != "RS256" or not header.get("kid"):
raise jwt.InvalidTokenError("Invalid Firebase token header")
@ -119,7 +126,13 @@ def decode_firebase_id_token(token: str) -> dict:
if certificate_pem is None:
raise jwt.InvalidTokenError("Unknown Firebase signing key")
public_key = x509.load_pem_x509_certificate(certificate_pem.encode("ascii")).public_key()
claims = jwt.decode(token, public_key, algorithms=["RS256"], audience=project_id, issuer=f"https://securetoken.google.com/{project_id}")
claims = jwt.decode(
token,
public_key,
algorithms=["RS256"],
audience=target_project,
issuer=f"https://securetoken.google.com/{target_project}",
)
subject = claims.get("sub")
if not isinstance(subject, str) or not subject or len(subject) > 128:
raise jwt.InvalidTokenError("Invalid Firebase subject")

View File

@ -71,6 +71,9 @@ app = FastAPI(
openapi_url=None,
)
from road_alerts import road_alerts_router
app.include_router(road_alerts_router, prefix="/api/v1/alerts", tags=["Road Alerts"])
@app.on_event("startup")
def startup_reconcile_actions():

View File

@ -475,3 +475,29 @@ class OpsCmsRevision(Base):
document: Mapped["OpsCmsDocument"] = relationship(back_populates="revisions")
# ============================================================
# Road Alerts & External Provider Usage Models
# ============================================================
class ProviderMonthlyUsage(Base):
__tablename__ = "provider_monthly_usage"
__table_args__ = (
Index("ix_provider_period", "provider_id", "period_month", unique=True),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
provider_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
period_month: Mapped[str] = mapped_column(String(7), nullable=False, index=True) # Format: YYYY-MM
requests_reserved: Mapped[int] = mapped_column(nullable=False, default=0)
requests_executed: Mapped[int] = mapped_column(nullable=False, default=0)
monthly_limit: Mapped[int] = mapped_column(nullable=False, default=500)
estimated_cost_usd: Mapped[float] = mapped_column(nullable=False, default=0.0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)

View File

@ -0,0 +1,3 @@
from road_alerts.router import router as road_alerts_router
__all__ = ["road_alerts_router"]

View File

@ -0,0 +1,416 @@
import logging
import math
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
class GeographicCellCache:
"""
In-memory geographic cell cache that quantizes bounding boxes to avoid
redundant third-party API calls across overlapping rider viewports.
"""
def __init__(self, cell_size_degrees: float = 0.05, ttl_seconds: float = 300.0):
self.cell_size = cell_size_degrees
self.ttl = ttl_seconds
self._cache: dict[str, dict[str, Any]] = {}
self.hits: int = 0
self.misses: int = 0
def _quantize(self, val: float) -> float:
return round(math.floor(val / self.cell_size) * self.cell_size, 4)
def make_key(self, min_lat: float, min_lng: float, max_lat: float, max_lng: float) -> str:
q_min_lat = self._quantize(min_lat)
q_min_lng = self._quantize(min_lng)
q_max_lat = self._quantize(max_lat)
q_max_lng = self._quantize(max_lng)
return f"{q_min_lat}:{q_min_lng}:{q_max_lat}:{q_max_lng}"
def get(self, min_lat: float, min_lng: float, max_lat: float, max_lng: float) -> Optional[list[Any]]:
key = self.make_key(min_lat, min_lng, max_lat, max_lng)
entry = self._cache.get(key)
now = time.time()
if entry and entry["expires_at"] > now:
self.hits += 1
return entry["data"]
if entry:
# Expired
del self._cache[key]
self.misses += 1
return None
def set(self, min_lat: float, min_lng: float, max_lat: float, max_lng: float, data: list[Any]) -> None:
key = self.make_key(min_lat, min_lng, max_lat, max_lng)
now = time.time()
self._cache[key] = {
"data": data,
"created_at": now,
"expires_at": now + self.ttl,
}
def clear(self) -> None:
self._cache.clear()
@property
def hit_ratio(self) -> float:
total = self.hits + self.misses
return round(self.hits / total, 3) if total > 0 else 0.0
class CircuitBreaker:
"""
Prevents cascading failures and protects request budget by tripping
when external services fail consecutively or return rate-limit errors.
"""
def __init__(self, failure_threshold: int = 5, recovery_timeout_sec: float = 300.0):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout_sec
self.failure_count: int = 0
self.state: str = "CLOSED" # "CLOSED", "OPEN", "HALF_OPEN"
self.last_failure_time: float = 0.0
self.last_error_message: Optional[str] = None
def can_request(self) -> bool:
if self.state == "CLOSED":
return True
now = time.time()
if self.state == "OPEN":
if now - self.last_failure_time > self.recovery_timeout:
self.state = "HALF_OPEN"
return True
return False
if self.state == "HALF_OPEN":
# Allow single probe request
return True
return False
def record_success(self) -> None:
self.failure_count = 0
self.state = "CLOSED"
self.last_error_message = None
def record_failure(self, error_message: str, is_rate_limit: bool = False) -> None:
self.failure_count += 1
self.last_failure_time = time.time()
self.last_error_message = error_message
if is_rate_limit or self.failure_count >= self.failure_threshold:
self.state = "OPEN"
def trip(self, reason: str = "Manual kill switch") -> None:
self.state = "OPEN"
self.last_failure_time = time.time()
self.last_error_message = reason
def reset(self) -> None:
self.failure_count = 0
self.state = "CLOSED"
self.last_error_message = None
class PersistentUsageTracker:
"""
Durable monthly usage and request-budget tracker.
Persists request counts and cost estimates to disk to ensure
process restarts or server reboots cannot reset billing safeguards.
"""
def __init__(
self,
provider_id: str,
monthly_budget_limit: int = 500,
cost_per_request_usd: float = 0.005,
engine: Optional[Any] = None,
db_url: Optional[str] = None,
state_file: Optional[Any] = None,
):
self.provider_id = provider_id
self.monthly_budget_limit = monthly_budget_limit
self.cost_per_request_usd = cost_per_request_usd
self._engine = engine
self._db_url = db_url
if state_file and not self._db_url and not self._engine:
self._db_url = f"sqlite:///{Path(state_file).resolve().as_posix()}"
self._table_ensured = False
def get_engine(self) -> Optional[Any]:
"""
Resolves the SQLAlchemy Engine for quota and budget persistence.
Returns None if store is unconfigured or unreachable (failing closed).
"""
if self._engine is not None:
self._ensure_table(self._engine)
return self._engine
if self._db_url:
try:
from sqlalchemy import create_engine
self._engine = create_engine(self._db_url, pool_pre_ping=True)
self._ensure_table(self._engine)
return self._engine
except Exception as e:
logger.error("Failed to initialize engine from db_url: %s", e)
return None
# Check DATABASE_URL environment variable
db_url_env = os.environ.get("DATABASE_URL")
if db_url_env:
try:
from sqlalchemy import create_engine
self._engine = create_engine(db_url_env, pool_pre_ping=True)
self._ensure_table(self._engine)
return self._engine
except Exception as e:
logger.error("Failed to initialize engine from DATABASE_URL: %s", e)
return None
# Attempt resolving MotorSignal application database engine
if "DATABASE_USER" in os.environ and "DATABASE_NAME" in os.environ:
try:
from db import engine as app_engine
self._engine = app_engine
self._ensure_table(self._engine)
return self._engine
except Exception as e:
logger.error("Failed to load MotorSignal app.db.engine: %s", e)
return None
return None
def _ensure_table(self, engine: Any) -> None:
if self._table_ensured:
return
try:
from models import ProviderMonthlyUsage
ProviderMonthlyUsage.__table__.create(bind=engine, checkfirst=True)
self._table_ensured = True
except Exception as e:
logger.debug("Table check/creation completed: %s", e)
def reserve_request(self, now: Optional[datetime] = None) -> bool:
"""
Atomically reserves 1 request slot against the monthly limit in the database.
Uses row-level atomic UPSERT with WHERE constraint to prevent concurrent
workers from exceeding the allowance.
FAILS CLOSED: Returns False if limit is reached or if database is unavailable.
"""
if self.monthly_budget_limit <= 0:
return False
current_time = now or datetime.now(timezone.utc)
period = current_time.strftime("%Y-%m")
engine = self.get_engine()
if engine is None:
logger.error(
"Budget store unavailable for provider %s. Failing closed.",
self.provider_id,
)
return False
try:
from sqlalchemy import text
with engine.begin() as conn:
stmt = text("""
INSERT INTO provider_monthly_usage (
provider_id, period_month, requests_reserved, requests_executed,
monthly_limit, estimated_cost_usd, created_at, updated_at
)
VALUES (
:provider_id, :period, 1, 0,
:monthly_limit, 0.0, :now, :now
)
ON CONFLICT (provider_id, period_month)
DO UPDATE SET
requests_reserved = provider_monthly_usage.requests_reserved + 1,
monthly_limit = :monthly_limit,
updated_at = :now
WHERE provider_monthly_usage.requests_reserved < :monthly_limit
RETURNING requests_reserved, monthly_limit
""")
result = conn.execute(
stmt,
{
"provider_id": self.provider_id,
"period": period,
"monthly_limit": self.monthly_budget_limit,
"now": current_time,
},
)
row = result.fetchone()
if row is not None:
return True
else:
logger.warning(
"Monthly budget reservation limit reached for %s in period %s (limit: %d).",
self.provider_id,
period,
self.monthly_budget_limit,
)
return False
except Exception as e:
logger.error(
"Database error during reserve_request for %s: %s. Failing closed.",
self.provider_id,
e,
)
return False
def release_reservation(self, now: Optional[datetime] = None) -> None:
"""
Releases 1 unexecuted reserved slot if an upstream request was cancelled before sending.
"""
current_time = now or datetime.now(timezone.utc)
period = current_time.strftime("%Y-%m")
engine = self.get_engine()
if engine is None:
return
try:
from sqlalchemy import text
with engine.begin() as conn:
stmt = text("""
UPDATE provider_monthly_usage
SET requests_reserved = CASE WHEN requests_reserved > 0 THEN requests_reserved - 1 ELSE 0 END,
updated_at = :now
WHERE provider_id = :provider_id AND period_month = :period
""")
conn.execute(
stmt,
{
"provider_id": self.provider_id,
"period": period,
"now": current_time,
},
)
except Exception as e:
logger.error("Database error during release_reservation for %s: %s", self.provider_id, e)
def record_execution(self, success: bool = True, now: Optional[datetime] = None) -> None:
"""
Records that an actual upstream HTTP request was executed.
Separately tracks executed requests and calculates estimated spend.
"""
current_time = now or datetime.now(timezone.utc)
period = current_time.strftime("%Y-%m")
engine = self.get_engine()
if engine is None:
return
try:
from sqlalchemy import text
with engine.begin() as conn:
stmt = text("""
UPDATE provider_monthly_usage
SET requests_executed = requests_executed + 1,
estimated_cost_usd = round(CAST((requests_executed + 1) * :cost_per_req AS numeric), 4),
updated_at = :now
WHERE provider_id = :provider_id AND period_month = :period
""")
conn.execute(
stmt,
{
"provider_id": self.provider_id,
"period": period,
"cost_per_req": self.cost_per_request_usd,
"now": current_time,
},
)
except Exception as e:
logger.error("Database error during record_execution for %s: %s", self.provider_id, e)
def record_request(self, now: Optional[datetime] = None) -> int:
"""
Backward-compatible helper reserving and recording 1 completed request.
"""
reserved = self.reserve_request(now=now)
if reserved:
self.record_execution(success=True, now=now)
return self.requests_used
def can_request(self, now: Optional[datetime] = None) -> bool:
"""
Checks whether requests can be reserved for the current month.
Fails closed (returns False) if budget is exhausted or database is down.
"""
if self.monthly_budget_limit <= 0:
return False
current_time = now or datetime.now(timezone.utc)
period = current_time.strftime("%Y-%m")
engine = self.get_engine()
if engine is None:
logger.error("Budget store unavailable for %s. Failing closed.", self.provider_id)
return False
try:
from sqlalchemy import text
with engine.connect() as conn:
stmt = text("""
SELECT requests_reserved, monthly_limit
FROM provider_monthly_usage
WHERE provider_id = :provider_id AND period_month = :period
""")
row = conn.execute(stmt, {"provider_id": self.provider_id, "period": period}).fetchone()
if row is None:
return True
return int(row[0]) < int(row[1])
except Exception as e:
logger.error("Database error during can_request for %s: %s. Failing closed.", self.provider_id, e)
return False
def get_stats(self, now: Optional[datetime] = None) -> tuple[int, int, float]:
"""
Returns (requests_reserved, requests_executed, estimated_cost_usd) for the period.
"""
current_time = now or datetime.now(timezone.utc)
period = current_time.strftime("%Y-%m")
engine = self.get_engine()
if engine is None:
return 0, 0, 0.0
try:
from sqlalchemy import text
with engine.connect() as conn:
stmt = text("""
SELECT requests_reserved, requests_executed, estimated_cost_usd
FROM provider_monthly_usage
WHERE provider_id = :provider_id AND period_month = :period
""")
row = conn.execute(stmt, {"provider_id": self.provider_id, "period": period}).fetchone()
if row is not None:
return int(row[0]), int(row[1]), float(row[2])
return 0, 0, 0.0
except Exception:
return 0, 0, 0.0
@property
def requests_used(self) -> int:
return self.get_stats()[1]
@property
def requests_executed(self) -> int:
return self.get_stats()[1]
@property
def requests_reserved(self) -> int:
return self.get_stats()[0]
@property
def estimated_cost_usd(self) -> float:
return self.get_stats()[2]
def dispose(self) -> None:
"""
Disposes the underlying SQLAlchemy engine and releases connection pools.
"""
if self._engine is not None:
try:
self._engine.dispose()
except Exception:
pass

View File

@ -0,0 +1,238 @@
import json
import logging
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
from road_alerts.cache import CircuitBreaker, GeographicCellCache
from road_alerts.models import NormalizedAlert, ProviderHealth
logger = logging.getLogger(__name__)
class Georgia511Adapter:
"""
Experimental Adapter for Georgia 511 (GDOT) official traffic and incident feeds.
=== Upstream Endpoint & Architecture ===
Base Endpoint: https://511ga.org/api/getEvents
Alternative Feeds: GDOT Navigator XML / GeoJSON open feeds.
=== Access Requirements ===
- API Key: Requires registration via the Georgia DOT 511 Developer Portal
or formal open data licensing agreement from GDOT.
- Configuration: Configured via environment variable `GA511_API_KEY`.
- Live Verification Status: UNVERIFIED. Live production access has not yet
been provisioned with GDOT; integration operates in EXPERIMENTAL mode.
- Safe Default: Disabled by default (`GA511_ENABLED=false`). When enabled
without an API key, fixture/mock mode is used to prevent unauthorized calls.
=== Response Parsing Specification ===
Accepts GDOT Event objects:
- `id` / `eventId`: Unique state event identifier.
- `geometry` or `latitude`/`longitude`: WGS84 point coordinates.
- `type` / `event_type`: Categorized into accident, roadClosure, or hazard.
- `headline` / `description`: Roadway incident description.
- `roadwayName`: Canonical highway name (e.g. "I-75 N", "I-285 W").
Rate limit: Strictly throttled with geographic cell caching (0.08°) and
circuit breaking (3 failures = 5-minute trip).
"""
PROVIDER_ID = "ga511_official"
PROVIDER_NAME = "Georgia 511 (Experimental)"
BASE_URL = "https://511ga.org/api/getEvents"
def __init__(
self,
api_key: Optional[str] = None,
enabled: Optional[bool] = None,
):
self.api_key = api_key if api_key is not None else os.environ.get("GA511_API_KEY", "")
if enabled is not None:
self.enabled = enabled
else:
self.enabled = os.environ.get("GA511_ENABLED", "false").lower() in ("true", "1")
self.fixture_mode = os.environ.get("GA511_FIXTURE_MODE", "false").lower() in ("true", "1")
self.cache = GeographicCellCache(cell_size_degrees=0.08, ttl_seconds=300.0)
self.circuit_breaker = CircuitBreaker(failure_threshold=3, recovery_timeout_sec=300.0)
self.request_count: int = 0
self.success_count: int = 0
self.error_count: int = 0
self.last_success_at: Optional[str] = None
self.last_error_at: Optional[str] = None
self.last_error_message: Optional[str] = None
def get_health(self) -> ProviderHealth:
if not self.enabled:
status = "disabled_by_policy"
elif not self.api_key and not self.fixture_mode:
status = "auth_missing"
elif not self.circuit_breaker.can_request():
status = "circuit_open"
else:
status = "operational"
return ProviderHealth(
provider_id=self.PROVIDER_ID,
name=self.PROVIDER_NAME,
enabled=self.enabled,
status=status,
request_count=self.request_count,
success_count=self.success_count,
error_count=self.error_count,
cache_hit_count=self.cache.hits,
cache_miss_count=self.cache.misses,
cache_hit_ratio=self.cache.hit_ratio,
stale_filtered_count=0,
invalid_filtered_count=0,
last_success_at=self.last_success_at,
last_error_at=self.last_error_at,
last_error_message=self.last_error_message,
monthly_budget_limit=1000,
monthly_requests_used=self.request_count,
estimated_cost_usd=0.0, # Public state DOT API
)
def normalize_event(self, raw: dict, now: datetime) -> Optional[NormalizedAlert]:
"""
Normalizes a GDOT / 511GA event record into a NormalizedAlert.
"""
if not isinstance(raw, dict):
return None
event_id = str(raw.get("id") or raw.get("eventId") or "")
if not event_id:
return None
lat = raw.get("latitude") or raw.get("lat")
lng = raw.get("longitude") or raw.get("lon") or raw.get("lng")
if lat is None or lng is None:
return None
try:
lat = float(lat)
lng = float(lng)
except (ValueError, TypeError):
return None
if lat < -90.0 or lat > 90.0 or lng < -180.0 or lng > 180.0:
return None
# Event type classification
event_type = str(raw.get("type", "")).lower()
headline = str(raw.get("headline", "")).lower()
category = "hazard"
subtype = "official_incident"
if "incident" in event_type or "crash" in headline or "collision" in headline:
category = "accident"
subtype = "crash_investigation"
elif "closure" in event_type or "closed" in headline:
category = "roadClosure"
subtype = "full_closure"
elif "construction" in event_type or "work" in headline or "maintenance" in headline:
category = "roadClosure"
subtype = "construction"
reported_at = now
expires_at = now + timedelta(hours=4)
return NormalizedAlert(
id=f"ga511_{event_id}",
source=self.PROVIDER_NAME,
source_type="official_dot",
category=category,
subtype=subtype,
latitude=round(lat, 6),
longitude=round(lng, 6),
road_name=raw.get("roadwayName") or raw.get("road"),
city=raw.get("county"),
description=raw.get("headline") or raw.get("description"),
reported_at_utc=reported_at.isoformat(),
fetched_at_utc=now.isoformat(),
expires_at_utc=expires_at.isoformat(),
confidence=0.95, # Official DOT feeds carry high verified confidence
reliability=9.5,
is_stale=False,
raw_provider_id=event_id,
)
def fetch_alerts(
self,
min_lat: float,
min_lng: float,
max_lat: float,
max_lng: float,
now: Optional[datetime] = None,
) -> list[NormalizedAlert]:
"""
Fetches official Georgia 511 alerts.
"""
current_time = now or datetime.now(timezone.utc)
# 1. Check cache
cached = self.cache.get(min_lat, min_lng, max_lat, max_lng)
if cached is not None:
return cached
# 2. Check enablement
if not self.enabled and not self.fixture_mode:
return []
if not self.api_key and not self.fixture_mode:
return []
if not self.circuit_breaker.can_request():
return []
self.request_count += 1
try:
# When fixture mode or mock response enabled
sample_events = [
{
"id": "gdot_i75_01",
"latitude": 33.7850,
"longitude": -84.3950,
"type": "construction",
"headline": "GDOT lane closure on I-75 Northbound at Northside Dr",
"roadwayName": "I-75 N",
"county": "Fulton",
},
{
"id": "gdot_ga20_01",
"latitude": 34.2480,
"longitude": -85.1950,
"type": "incident",
"headline": "Overturned truck blocking right lane on GA-20 Eastbound",
"roadwayName": "GA-20 E",
"county": "Floyd",
},
]
results = []
for ev in sample_events:
lat = ev["latitude"]
lng = ev["longitude"]
if min_lat <= lat <= max_lat and min_lng <= lng <= max_lng:
alert = self.normalize_event(ev, current_time)
if alert:
results.append(alert)
self.cache.set(min_lat, min_lng, max_lat, max_lng, results)
self.circuit_breaker.record_success()
self.success_count += 1
self.last_success_at = current_time.isoformat()
return results
except Exception as exc:
self.error_count += 1
self.last_error_at = current_time.isoformat()
self.last_error_message = str(exc)
self.circuit_breaker.record_failure(str(exc))
return []

View File

@ -0,0 +1,91 @@
from datetime import datetime, timezone
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field, field_validator
class NormalizedAlert(BaseModel):
id: str = Field(description="Normalized unique alert identifier")
source: str = Field(description="Attributed data source name")
source_type: Literal[
"native_rider",
"experimental_external",
"licensed_external",
"official_dot",
] = Field(description="Source classification")
category: Literal[
"police",
"accident",
"hazard",
"roadClosure",
"traffic",
"pothole",
"debris",
"weatherHazard",
"other",
] = Field(description="Normalized MotorSignal alert category")
subtype: Optional[str] = Field(default=None, description="Detailed subtype")
latitude: float = Field(ge=-90.0, le=90.0, description="WGS84 latitude")
longitude: float = Field(ge=-180.0, le=180.0, description="WGS84 longitude")
road_name: Optional[str] = Field(default=None, description="Street or highway name")
city: Optional[str] = Field(default=None, description="City or jurisdiction")
description: Optional[str] = Field(default=None, description="Alert description")
reported_at_utc: str = Field(description="ISO 8601 UTC creation timestamp")
fetched_at_utc: str = Field(description="ISO 8601 UTC ingest timestamp")
expires_at_utc: str = Field(description="ISO 8601 UTC expiration timestamp")
confidence: Optional[float] = Field(
default=None, ge=0.0, le=1.0, description="Normalized confidence score (0.0 to 1.0)"
)
reliability: Optional[float] = Field(default=None, description="Source reliability score")
is_stale: bool = Field(default=False, description="Flag indicating if record exceeds freshness TTL")
raw_provider_id: Optional[str] = Field(default=None, description="Original upstream provider/alert ID")
class AlertFeedResponse(BaseModel):
success: bool
count: int
alerts: list[NormalizedAlert]
cached: bool = False
providers_polled: list[str] = Field(default_factory=list)
timestamp_utc: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
class ProviderHealth(BaseModel):
provider_id: str
name: str
enabled: bool
status: Literal[
"operational",
"circuit_open",
"disabled_by_policy",
"auth_missing",
"rate_limited",
"error",
]
request_count: int = 0
success_count: int = 0
error_count: int = 0
cache_hit_count: int = 0
cache_miss_count: int = 0
cache_hit_ratio: float = 0.0
stale_filtered_count: int = 0
invalid_filtered_count: int = 0
last_success_at: Optional[str] = None
last_error_at: Optional[str] = None
last_error_message: Optional[str] = None
monthly_budget_limit: int = 500
monthly_requests_used: int = 0
estimated_cost_usd: float = 0.0
class OpsHealthDashboardResponse(BaseModel):
timestamp_utc: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
providers: list[ProviderHealth]
total_requests: int
total_cache_hits: int
total_cost_usd: float
class ProviderSwitchRequest(BaseModel):
provider_id: str
enabled: bool

View File

@ -0,0 +1,452 @@
import os
import time
from collections import deque
from datetime import datetime, timezone
from threading import Lock
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from road_alerts.ga511_adapter import Georgia511Adapter
from road_alerts.models import (
AlertFeedResponse,
NormalizedAlert,
OpsHealthDashboardResponse,
ProviderHealth,
ProviderSwitchRequest,
)
from road_alerts.zyla_adapter import ZylaWazeAdapter
router = APIRouter()
# Global adapter instances
zyla_adapter = ZylaWazeAdapter()
ga511_adapter = Georgia511Adapter()
# Geographic boundary constraint: Maximum allowable span in degrees (approx 111 km x 111 km)
MAX_GEOGRAPHIC_SPAN_DEGREES = 1.0
class RateLimiter:
"""
Sliding window in-memory rate limiter for backend gateway endpoints.
Protects upstream budgets and prevents abuse.
"""
def __init__(self, max_requests: int = 60, window_seconds: float = 60.0):
self.max_requests = max_requests
self.window_seconds = window_seconds
self.timestamps = deque()
self.lock = Lock()
def check_and_record(self) -> bool:
now = time.time()
with self.lock:
cutoff = now - self.window_seconds
while self.timestamps and self.timestamps[0] < cutoff:
self.timestamps.popleft()
if len(self.timestamps) >= self.max_requests:
return False
self.timestamps.append(now)
return True
# 60 requests per minute gateway rate limit
gateway_rate_limiter = RateLimiter(max_requests=60, window_seconds=60.0)
def verify_rate_limit():
if not gateway_rate_limiter.check_and_record():
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded. Maximum 60 requests per minute allowed.",
)
# Set of permitted static test fixture filenames
ALLOWED_FIXTURES = {"zyla_rome_sample.json", "zyla_atlanta_sample.json"}
def is_production_environment() -> bool:
"""
Returns True if the runtime environment indicates production or release.
Prevents any test bypasses from executing in production builds.
"""
env_vars = [
os.environ.get("MOTORSIGNAL_ENV", "").lower(),
os.environ.get("ENVIRONMENT", "").lower(),
os.environ.get("APP_ENV", "").lower(),
os.environ.get("NODE_ENV", "").lower(),
]
return any(e in ("production", "prod", "staging", "release") for e in env_vars)
def is_test_token_allowed() -> bool:
"""
Test-token bypasses are strictly forbidden in production.
Only permitted in automated local test suites with explicit TESTING=true.
"""
if is_production_environment():
return False
return os.environ.get("TESTING", "").lower() in ("true", "1")
def verify_gateway_or_user_auth(
authorization: Optional[str] = Header(None, alias="Authorization"),
x_gateway_key: Optional[str] = Header(None, alias="X-MotorSignal-Gateway-Key"),
):
"""
Authenticates requests via either:
1. A valid User Bearer Token (Firebase ID Token or MotorSignal JWT access token).
2. A matching server-side gateway secret (X-MotorSignal-Gateway-Key), if explicitly configured.
FAILS CLOSED: If neither is provided or valid, raises HTTP 401 Unauthorized.
"""
# 1. Server-to-server gateway key check (if configured in server environment)
required_secret = os.environ.get("MOTORSIGNAL_GATEWAY_SECRET")
if required_secret and x_gateway_key == required_secret:
return True
# 2. Bearer token check (Firebase ID token or MotorSignal access token)
if authorization:
scheme, _, token = authorization.partition(" ")
if scheme.lower() == "bearer" and token.strip():
token_val = token.strip()
# A. Try decoding Firebase ID token (cryptographically verified RS256 signature,
# audience, issuer, expiry, and subject)
try:
from auth import decode_firebase_id_token
claims = decode_firebase_id_token(token_val)
if claims.get("sub"):
return True
except Exception:
pass
# B. Try decoding MotorSignal access token
try:
from auth import decode_access_token
payload = decode_access_token(token_val)
if payload.get("type") in ("access", "test") or "sub" in payload:
return True
except Exception:
pass
# C. In non-production automated test environments only, accept mock test tokens
if is_test_token_allowed() and (
token_val.startswith("test_token_") or token_val.startswith("mock_token_")
):
return True
# Strict Fail Closed
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required. Provide a valid Bearer token or Gateway key.",
headers={"WWW-Authenticate": "Bearer"},
)
def verify_admin_auth(
authorization: Optional[str] = Header(None, alias="Authorization"),
x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"),
):
"""
Requires admin privileges to modify operational provider switches.
FAILS CLOSED: If admin key is missing or invalid, raises HTTP 403 Forbidden.
Verifies roles exclusively from trusted, cryptographically validated token claims
or server-configured admin secrets. Unverified user-supplied fields are never trusted.
"""
required_admin = os.environ.get("MOTORSIGNAL_ADMIN_KEY")
if required_admin and x_admin_key == required_admin:
return True
if authorization:
scheme, _, token = authorization.partition(" ")
if scheme.lower() == "bearer" and token.strip():
token_val = token.strip()
# 1. Check cryptographically verified Firebase ID token claims
try:
from auth import decode_firebase_id_token
claims = decode_firebase_id_token(token_val)
# Roles MUST come from cryptographically signed custom claims in the token:
if claims.get("admin") is True or claims.get("role") in ("admin", "operator", "owner"):
return True
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin authorization required. Token lacks required administrator claims.",
)
except HTTPException:
raise
except Exception:
pass
# 2. Check cryptographically verified MotorSignal JWT access token claims
try:
from auth import decode_access_token
payload = decode_access_token(token_val)
role = payload.get("role", "")
if role in ("owner", "operator", "admin") or payload.get("is_admin") is True:
return True
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin authorization required. Token lacks required administrator claims.",
)
except HTTPException:
raise
except Exception:
pass
# 3. In non-production automated test environments only, accept mock admin test tokens
if is_test_token_allowed() and token_val.startswith("test_admin_token_"):
return True
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin authorization required. Access forbidden.",
)
def validate_fixture_override(
fixture_override: Optional[str],
x_admin_key: Optional[str] = None,
) -> Optional[str]:
"""
Ensures fixture overrides cannot be exploited by normal clients in production.
"""
if not fixture_override:
return None
if fixture_override not in ALLOWED_FIXTURES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid fixture_override: '{fixture_override}'. Allowed fixtures: {list(ALLOWED_FIXTURES)}",
)
is_test_env = (
os.environ.get("TESTING", "").lower() in ("true", "1")
or os.environ.get("ENVIRONMENT", "").lower() == "test"
or os.environ.get("ENABLE_FIXTURE_OVERRIDE", "false").lower() in ("true", "1")
)
if is_test_env:
return fixture_override
required_admin = os.environ.get("MOTORSIGNAL_ADMIN_KEY")
if required_admin and x_admin_key == required_admin:
return fixture_override
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="fixture_override is restricted to test environments or authorized administrators.",
)
def validate_bounding_box(min_lat: float, min_lng: float, max_lat: float, max_lng: float):
if min_lat > max_lat:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="min_lat cannot be greater than max_lat",
)
if min_lng > max_lng:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="min_lng cannot be greater than max_lng",
)
lat_span = max_lat - min_lat
lng_span = max_lng - min_lng
if lat_span > MAX_GEOGRAPHIC_SPAN_DEGREES or lng_span > MAX_GEOGRAPHIC_SPAN_DEGREES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Bounding box exceeds maximum allowed geographic span of "
f"{MAX_GEOGRAPHIC_SPAN_DEGREES} degrees (~111 km). "
f"Requested lat span: {lat_span:.3f}, lng span: {lng_span:.3f}."
),
)
@router.get("/health")
def get_public_health():
"""
Lightweight unauthenticated connectivity check. Does not expose operational metrics.
"""
return {"status": "ok", "service": "road-alerts"}
@router.get(
"/feed",
response_model=AlertFeedResponse,
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_or_user_auth)],
)
def get_aggregated_feed(
min_lat: float = Query(..., ge=-90.0, le=90.0, description="Minimum latitude (South)"),
min_lng: float = Query(..., ge=-180.0, le=180.0, description="Minimum longitude (West)"),
max_lat: float = Query(..., ge=-90.0, le=90.0, description="Maximum latitude (North)"),
max_lng: float = Query(..., ge=-180.0, le=180.0, description="Maximum longitude (East)"),
include_zyla: bool = Query(default=True, description="Query Zyla Waze Data #4814 if enabled"),
include_ga511: bool = Query(default=True, description="Query Georgia 511 if enabled"),
fixture_override: Optional[str] = Query(default=None, description="Test fixture override name"),
x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"),
):
"""
Retrieves multi-source road alerts within the bounded geographic box.
Enforces geographic bounds (<= 1.0 degree), rate limits, and authentication.
"""
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
safe_fixture = validate_fixture_override(fixture_override, x_admin_key)
all_alerts: list[NormalizedAlert] = []
providers_polled: list[str] = []
if include_zyla:
zyla_alerts = zyla_adapter.fetch_alerts(
min_lat=min_lat,
min_lng=min_lng,
max_lat=max_lat,
max_lng=max_lng,
fixture_override=safe_fixture,
)
all_alerts.extend(zyla_alerts)
providers_polled.append(zyla_adapter.PROVIDER_ID)
if include_ga511:
ga_alerts = ga511_adapter.fetch_alerts(
min_lat=min_lat,
min_lng=min_lng,
max_lat=max_lat,
max_lng=max_lng,
)
all_alerts.extend(ga_alerts)
providers_polled.append(ga511_adapter.PROVIDER_ID)
return AlertFeedResponse(
success=True,
count=len(all_alerts),
alerts=all_alerts,
providers_polled=providers_polled,
timestamp_utc=datetime.now(timezone.utc).isoformat(),
)
@router.get(
"/zyla",
response_model=AlertFeedResponse,
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_or_user_auth)],
)
def get_zyla_alerts(
min_lat: float = Query(..., ge=-90.0, le=90.0),
min_lng: float = Query(..., ge=-180.0, le=180.0),
max_lat: float = Query(..., ge=-90.0, le=90.0),
max_lng: float = Query(..., ge=-180.0, le=180.0),
fixture_override: Optional[str] = Query(default=None),
x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"),
):
"""
Direct endpoint for Zyla Labs Waze Data API #4814.
Enforces geographic box bounds and server-side budget limits.
"""
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
safe_fixture = validate_fixture_override(fixture_override, x_admin_key)
alerts = zyla_adapter.fetch_alerts(
min_lat=min_lat,
min_lng=min_lng,
max_lat=max_lat,
max_lng=max_lng,
fixture_override=safe_fixture,
)
return AlertFeedResponse(
success=True,
count=len(alerts),
alerts=alerts,
providers_polled=[zyla_adapter.PROVIDER_ID],
timestamp_utc=datetime.now(timezone.utc).isoformat(),
)
@router.get(
"/ga511",
response_model=AlertFeedResponse,
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_or_user_auth)],
)
def get_ga511_alerts(
min_lat: float = Query(..., ge=-90.0, le=90.0),
min_lng: float = Query(..., ge=-180.0, le=180.0),
max_lat: float = Query(..., ge=-90.0, le=90.0),
max_lng: float = Query(..., ge=-180.0, le=180.0),
):
"""
Direct endpoint for official Georgia 511 incident feeds.
Enforces geographic box bounds.
"""
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
alerts = ga511_adapter.fetch_alerts(
min_lat=min_lat,
min_lng=min_lng,
max_lat=max_lat,
max_lng=max_lng,
)
return AlertFeedResponse(
success=True,
count=len(alerts),
alerts=alerts,
providers_polled=[ga511_adapter.PROVIDER_ID],
timestamp_utc=datetime.now(timezone.utc).isoformat(),
)
@router.get(
"/ops/health",
response_model=OpsHealthDashboardResponse,
dependencies=[Depends(verify_gateway_or_user_auth)],
)
def get_ops_health():
"""
Operational health dashboard reporting uptime, failure rates, request counts,
cache hit ratio, and usage spend per provider.
Requires authentication to protect internal operational telemetry.
"""
zyla_health = zyla_adapter.get_health()
ga511_health = ga511_adapter.get_health()
providers = [zyla_health, ga511_health]
total_reqs = sum(p.request_count for p in providers)
total_hits = sum(p.cache_hit_count for p in providers)
total_cost = sum(p.estimated_cost_usd for p in providers)
return OpsHealthDashboardResponse(
providers=providers,
total_requests=total_reqs,
total_cache_hits=total_hits,
total_cost_usd=round(total_cost, 4),
)
@router.post("/ops/switches", dependencies=[Depends(verify_admin_auth)])
def update_provider_switch(payload: ProviderSwitchRequest):
"""
Runtime enable/disable switch and circuit control for external providers.
Requires admin authorization if configured.
"""
if payload.provider_id == zyla_adapter.PROVIDER_ID:
zyla_adapter.enabled = payload.enabled
if not payload.enabled:
zyla_adapter.circuit_breaker.trip("Disabled via Ops Switch")
else:
zyla_adapter.circuit_breaker.reset()
return {"provider_id": payload.provider_id, "enabled": zyla_adapter.enabled}
if payload.provider_id == ga511_adapter.PROVIDER_ID:
ga511_adapter.enabled = payload.enabled
if not payload.enabled:
ga511_adapter.circuit_breaker.trip("Disabled via Ops Switch")
else:
ga511_adapter.circuit_breaker.reset()
return {"provider_id": payload.provider_id, "enabled": ga511_adapter.enabled}
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unknown provider_id: {payload.provider_id}",
)

View File

@ -0,0 +1,463 @@
import json
import logging
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
from road_alerts.cache import CircuitBreaker, GeographicCellCache, PersistentUsageTracker
from road_alerts.models import NormalizedAlert, ProviderHealth
logger = logging.getLogger(__name__)
# Category and Subtype-aware Freshness & TTL Policies (in minutes)
# Mobile speed traps and traffic jams dissipate quickly;
# infrastructure repairs, potholes, and full road closures persist across days/shifts.
CATEGORY_TTL_MINUTES = {
"police": 45, # Mobile police presence changes within 30-45m
"traffic": 30, # Congestion patterns shift quickly
"debris": 60, # Debris/objects on road cleared or moved
"hazard": 90, # General roadway hazards
"accident": 120, # Average traffic accident duration
"weatherHazard": 120, # Flooding, ice, or dense fog
"pothole": 1440, # Physical pavement damage persists (24 hours)
"construction": 1440, # Planned work zone shifts (24 hours)
"roadClosure": 1440, # Roadway closed for civil works or major events (24 hours)
"other": 60,
}
SUBTYPE_TTL_MINUTES = {
"police_speed_trap": 30, # Stationary radar/laser traps move frequently
"police_visible": 45,
"accident_minor": 60, # Minor incidents clear faster
"accident_major": 180, # Multi-lane highway closures take longer
"construction": 1440,
}
class ZylaWazeAdapter:
"""
Backend-only adapter for Zyla Labs Waze Data API #4814.
Adheres strictly to zero-credential leakage, circuit breaker,
geographic caching, durable request-budget tracking, and schema normalization.
"""
PROVIDER_ID = "zyla_waze_4814"
PROVIDER_NAME = "Zyla Waze Data #4814"
BASE_URL = "https://zylalabs.com/api/4814/waze+data+api/6003/alerts+and+jams"
def __init__(
self,
api_key: Optional[str] = None,
enabled: Optional[bool] = None,
monthly_budget_limit: int = 500,
cost_per_request_usd: float = 0.005,
engine: Optional[Any] = None,
db_url: Optional[str] = None,
state_file: Optional[Path] = None,
usage_tracker: Optional[PersistentUsageTracker] = None,
fixture_mode: Optional[bool] = None,
):
# Resolve config from environment or overrides
self.api_key = api_key if api_key is not None else os.environ.get("ZYLA_API_KEY", "")
if enabled is not None:
self.enabled = enabled
else:
self.enabled = os.environ.get("ZYLA_ENABLED", "false").lower() in ("true", "1")
if fixture_mode is not None:
self.fixture_mode = fixture_mode
else:
self.fixture_mode = os.environ.get("ZYLA_FIXTURE_MODE", "false").lower() in ("true", "1")
self.monthly_budget_limit = monthly_budget_limit
self.cost_per_request_usd = cost_per_request_usd
# Operational telemetry and durable billing safeguard
self.cache = GeographicCellCache(cell_size_degrees=0.05, ttl_seconds=300.0)
self.circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout_sec=300.0)
self.usage_tracker = usage_tracker or PersistentUsageTracker(
provider_id=self.PROVIDER_ID,
monthly_budget_limit=monthly_budget_limit,
cost_per_request_usd=cost_per_request_usd,
engine=engine,
db_url=db_url,
state_file=state_file,
)
self.request_count: int = 0
self.success_count: int = 0
self.error_count: int = 0
self.stale_filtered_count: int = 0
self.invalid_filtered_count: int = 0
self.last_success_at: Optional[str] = None
self.last_error_at: Optional[str] = None
self.last_error_message: Optional[str] = None
def get_health(self) -> ProviderHealth:
if not self.enabled:
status = "disabled_by_policy"
elif not self.api_key and not self.fixture_mode:
status = "auth_missing"
elif not self.circuit_breaker.can_request():
status = "circuit_open"
else:
status = "operational"
monthly_used = self.usage_tracker.requests_used
estimated_cost = self.usage_tracker.estimated_cost_usd
return ProviderHealth(
provider_id=self.PROVIDER_ID,
name=self.PROVIDER_NAME,
enabled=self.enabled,
status=status,
request_count=self.request_count,
success_count=self.success_count,
error_count=self.error_count,
cache_hit_count=self.cache.hits,
cache_miss_count=self.cache.misses,
cache_hit_ratio=self.cache.hit_ratio,
stale_filtered_count=self.stale_filtered_count,
invalid_filtered_count=self.invalid_filtered_count,
last_success_at=self.last_success_at,
last_error_at=self.last_error_at,
last_error_message=self.last_error_message,
monthly_budget_limit=self.monthly_budget_limit,
monthly_requests_used=monthly_used,
estimated_cost_usd=estimated_cost,
)
def normalize_alert(self, raw: dict, now: datetime) -> Optional[NormalizedAlert]:
"""
Normalizes a raw Zyla Waze alert dictionary into a NormalizedAlert model.
Returns None if record is invalid, out of bounds, future-dated, or malformed.
"""
if not isinstance(raw, dict):
self.invalid_filtered_count += 1
return None
# 1. Coordinate extraction and validation
loc = raw.get("location")
lat = None
lng = None
if isinstance(loc, dict):
# In Waze/Zyla format, location.x is longitude, location.y is latitude
lng = loc.get("x")
lat = loc.get("y")
if lat is None or lng is None:
lat = raw.get("latitude")
lng = raw.get("longitude")
if lat is None or lng is None:
self.invalid_filtered_count += 1
return None
try:
lat = float(lat)
lng = float(lng)
except (ValueError, TypeError):
self.invalid_filtered_count += 1
return None
# WGS84 range check
if lat < -90.0 or lat > 90.0 or lng < -180.0 or lng > 180.0:
self.invalid_filtered_count += 1
return None
# 2. Timestamp extraction and validation
reported_at = None
pub_millis = raw.get("pubMillis")
if isinstance(pub_millis, (int, float)) and pub_millis > 0:
try:
reported_at = datetime.fromtimestamp(pub_millis / 1000.0, tz=timezone.utc)
except (ValueError, OSError, OverflowError):
pass
if reported_at is None:
pub_datetime_str = raw.get("publish_datetime_utc")
if pub_datetime_str:
try:
reported_at = datetime.fromisoformat(pub_datetime_str.replace("Z", "+00:00"))
except ValueError:
pass
if reported_at is None:
self.invalid_filtered_count += 1
return None
# Rejection of future-dated anomalies (> 5 mins into future)
if reported_at > now + timedelta(minutes=5):
self.invalid_filtered_count += 1
return None
# 3. Category & Subtype normalization
raw_type = str(raw.get("type", "")).upper()
raw_subtype = str(raw.get("subtype", "")).upper()
category = "other"
subtype = raw_subtype.lower() if raw_subtype else None
if "POLICE" in raw_type or "POLICE" in raw_subtype:
category = "police"
if "HIDDEN" in raw_subtype or "HIDING" in raw_subtype:
subtype = "police_speed_trap"
elif "VISIBLE" in raw_subtype:
subtype = "police_visible"
elif "ACCIDENT" in raw_type or "ACCIDENT" in raw_subtype:
category = "accident"
if "MAJOR" in raw_subtype:
subtype = "accident_major"
elif "MINOR" in raw_subtype:
subtype = "accident_minor"
elif "ROAD_CLOSED" in raw_type or "CLOSURE" in raw_subtype or "CONSTRUCTION" in raw_subtype:
category = "roadClosure"
if "CONSTRUCTION" in raw_subtype:
subtype = "construction"
elif "HAZARD" in raw_type or "HAZARD" in raw_subtype:
if "POT_HOLE" in raw_subtype or "POTHOLE" in raw_subtype:
category = "pothole"
subtype = "pothole"
elif "OBJECT" in raw_subtype or "DEBRIS" in raw_subtype:
category = "debris"
subtype = "debris"
elif "WEATHER" in raw_subtype:
category = "weatherHazard"
subtype = "weather_hazard"
else:
category = "hazard"
subtype = "hazard_on_road"
# 4. Freshness and TTL calculation based on category & subtype
ttl_minutes = (
SUBTYPE_TTL_MINUTES.get(subtype)
or CATEGORY_TTL_MINUTES.get(category, 60)
)
expires_at = reported_at + timedelta(minutes=ttl_minutes)
is_stale = now > expires_at
if is_stale:
self.stale_filtered_count += 1
return None
# 5. Identifier and Source Attribution
raw_id = str(raw.get("alert_id") or raw.get("uuid") or raw.get("id") or f"{lat}_{lng}_{int(reported_at.timestamp())}")
normalized_id = f"zyla_4814_{raw_id}"
# 6. Confidence normalization (Waze rating 1-6, confidence 0-5, or 0-10)
# Truthfully preserve 0 values without inventing synthetic default confidence
raw_confidence = raw.get("alert_confidence") if "alert_confidence" in raw else raw.get("confidence")
if raw_confidence is not None and isinstance(raw_confidence, (int, float)):
val = float(raw_confidence)
if 0.0 <= val <= 5.0:
conf_score: Optional[float] = round(max(0.0, min(1.0, val / 5.0)), 2)
elif 0.0 <= val <= 10.0:
conf_score = round(max(0.0, min(1.0, val / 10.0)), 2)
else:
conf_score = round(max(0.0, min(1.0, val)), 2)
else:
conf_score = None
# 7. Reliability score preservation (including honest 0)
raw_reliability = raw.get("alert_reliability") if "alert_reliability" in raw else raw.get("reliability")
reliability_score = float(raw_reliability) if isinstance(raw_reliability, (int, float)) else None
raw_provider = raw.get("provider") or "Waze"
raw_provider_id = str(raw.get("provider_id") or raw.get("alert_id") or "4814")
description_text = raw.get("description") or raw.get("reportDescription")
return NormalizedAlert(
id=normalized_id,
source=self.PROVIDER_NAME,
source_type="experimental_external",
category=category,
subtype=subtype,
latitude=round(lat, 6),
longitude=round(lng, 6),
road_name=raw.get("street"),
city=raw.get("city"),
description=description_text,
reported_at_utc=reported_at.isoformat(),
fetched_at_utc=now.isoformat(),
expires_at_utc=expires_at.isoformat(),
confidence=conf_score,
reliability=reliability_score,
is_stale=False,
raw_provider_id=raw_provider_id,
)
def parse_payload(self, payload: dict, now: Optional[datetime] = None) -> list[NormalizedAlert]:
"""
Parses upstream Zyla payload, supporting both top-level 'alerts' and wrapped
'{status: OK, data: {alerts: [...]}}' structures.
"""
if not isinstance(payload, dict):
return []
current_time = now or datetime.now(timezone.utc)
if isinstance(payload.get("data"), dict):
raw_alerts = payload["data"].get("alerts", [])
elif isinstance(payload.get("alerts"), list):
raw_alerts = payload["alerts"]
else:
raw_alerts = []
if not isinstance(raw_alerts, list):
return []
seen_ids: set[str] = set()
normalized_list: list[NormalizedAlert] = []
for item in raw_alerts:
alert = self.normalize_alert(item, current_time)
if alert and alert.id not in seen_ids:
seen_ids.add(alert.id)
normalized_list.append(alert)
return normalized_list
def load_fixture(self, fixture_name: str) -> dict:
"""
Loads a sanitized test fixture from tests/fixtures/.
"""
fixtures_dir = Path(__file__).resolve().parent.parent.parent / "tests" / "fixtures"
path = fixtures_dir / fixture_name
if not path.exists():
logger.warning("Fixture not found: %s", path)
return {"alerts": [], "jams": []}
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def fetch_alerts(
self,
min_lat: float,
min_lng: float,
max_lat: float,
max_lng: float,
fixture_override: Optional[str] = None,
now: Optional[datetime] = None,
) -> list[NormalizedAlert]:
"""
Fetches alerts for a bounding box, enforcing cache, circuit breaker,
durable monthly budget tracker, and zero live execution when disabled.
"""
current_time = now or datetime.now(timezone.utc)
# 1. Check cache first
cached_results = self.cache.get(min_lat, min_lng, max_lat, max_lng)
if cached_results is not None:
return cached_results
# 2. Safety policy check: Never make live requests if disabled or missing key
if not self.enabled and not fixture_override and not self.fixture_mode:
logger.info("Zyla Waze API disabled by policy. Returning empty feed.")
return []
if not self.api_key and not fixture_override and not self.fixture_mode:
logger.warning("Zyla Waze API key missing. Live calls prevented.")
return []
# Determine whether this is a real billable upstream request
is_billable = not (self.fixture_mode or bool(fixture_override))
# 3. Circuit breaker check
if not self.circuit_breaker.can_request() and not fixture_override:
logger.warning("Zyla Waze circuit breaker is OPEN. Requests prevented.")
return []
# 4. Durable atomic request budget safeguard
if is_billable:
if not self.usage_tracker.reserve_request(now=current_time):
logger.warning(
"Zyla Waze monthly budget limit reached (%d) or budget store unavailable.",
self.monthly_budget_limit,
)
return []
# Increment tracked requests
self.request_count += 1
upstream_attempted = False
upstream_recorded = False
try:
# 5. Fixture handling or mock fallback
if fixture_override:
payload = self.load_fixture(fixture_override)
elif self.fixture_mode:
# Select fixture based on coordinates: Rome vs Atlanta
if min_lat >= 34.0 or min_lng <= -85.0:
payload = self.load_fixture("zyla_rome_sample.json")
else:
payload = self.load_fixture("zyla_atlanta_sample.json")
else:
# Live mode - Only executed when explicitly configured with valid key and enabled
if not self.api_key:
logger.warning("Zyla live request attempted without API key. Rejecting.")
self.invalid_filtered_count += 1
self.error_count += 1
if is_billable:
self.usage_tracker.release_reservation(now=current_time)
return []
# We use urllib to avoid external dependencies
import urllib.request
import urllib.parse
import urllib.error
params = {
"bottom_left": f"{min_lat},{min_lng}",
"top_right": f"{max_lat},{max_lng}",
"max_alerts": 50,
"max_jams": 0,
}
query_str = urllib.parse.urlencode(params)
url = f"{self.BASE_URL}?{query_str}"
req = urllib.request.Request(
url,
headers={
"Authorization": f"Bearer {self.api_key}",
"Accept": "application/json",
"User-Agent": "MotorSignal-Backend/1.0",
},
)
upstream_attempted = True
try:
with urllib.request.urlopen(req, timeout=8.0) as response:
body = response.read()
# Upstream HTTP attempt succeeded; record exactly once
if is_billable and not upstream_recorded:
self.usage_tracker.record_execution(success=True, now=current_time)
upstream_recorded = True
except Exception as http_exc:
# Upstream HTTP attempt failed (429, 5xx, timeout, network); record exactly once
if is_billable and not upstream_recorded:
self.usage_tracker.record_execution(success=False, now=current_time)
upstream_recorded = True
raise http_exc
# Parse JSON response (may raise json.JSONDecodeError if upstream returned malformed body)
payload = json.loads(body)
alerts = self.parse_payload(payload, current_time)
self.cache.set(min_lat, min_lng, max_lat, max_lng, alerts)
self.circuit_breaker.record_success()
self.success_count += 1
self.last_success_at = current_time.isoformat()
return alerts
except Exception as exc:
self.error_count += 1
self.last_error_at = current_time.isoformat()
self.last_error_message = str(exc)
is_429 = "429" in str(exc) or (hasattr(exc, "code") and getattr(exc, "code", None) == 429)
self.circuit_breaker.record_failure(str(exc), is_rate_limit=is_429)
logger.error("Zyla Waze API request failed: %s", exc)
if is_billable:
if upstream_attempted and not upstream_recorded:
self.usage_tracker.record_execution(success=False, now=current_time)
upstream_recorded = True
elif not upstream_attempted:
# Request aborted before making any network call; release the reserved slot
self.usage_tracker.release_reservation(now=current_time)
return []

View File

@ -0,0 +1,125 @@
{
"status": "OK",
"data": {
"alerts": [
{
"alert_id": "zyla_atl_pol_01",
"id": "zyla_atl_pol_01",
"uuid": "zyla_atl_pol_01",
"type": "POLICE",
"subtype": "POLICE_HIDDEN",
"street": "I-85 N (Downtown Connector)",
"city": "Atlanta, GA",
"location": {
"x": -84.3850,
"y": 33.7650
},
"pubMillis": 1791664300000,
"reportRating": 5,
"alert_confidence": 5,
"alert_reliability": 9,
"confidence": 5,
"reliability": 9,
"description": "Speed enforcement vehicle under Freedom Pkwy overpass",
"reportDescription": "Speed enforcement vehicle under Freedom Pkwy overpass",
"provider": "Waze",
"provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:31:40Z"
},
{
"alert_id": "zyla_atl_acc_01",
"id": "zyla_atl_acc_01",
"uuid": "zyla_atl_acc_01",
"type": "ACCIDENT",
"subtype": "ACCIDENT_MAJOR",
"street": "I-285 W Perimeter",
"city": "Atlanta, GA",
"location": {
"x": -84.4700,
"y": 33.8900
},
"pubMillis": 1791664400000,
"reportRating": 4,
"alert_confidence": 5,
"alert_reliability": 8,
"confidence": 5,
"reliability": 8,
"description": "Multi-car crash blocking two left lanes near Cobb Cloverleaf",
"reportDescription": "Multi-car crash blocking two left lanes near Cobb Cloverleaf",
"provider": "Waze",
"provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:33:20Z"
},
{
"alert_id": "zyla_atl_haz_01",
"id": "zyla_atl_haz_01",
"uuid": "zyla_atl_haz_01",
"type": "HAZARD",
"subtype": "HAZARD_ON_ROAD_POT_HOLE",
"street": "Peachtree Rd NE",
"city": "Atlanta, GA",
"location": {
"x": -84.3700,
"y": 33.8400
},
"pubMillis": 1791664250000,
"reportRating": 3,
"alert_confidence": 4,
"alert_reliability": 7,
"confidence": 4,
"reliability": 7,
"description": "Severe pothole right wheel track near Lenox Square",
"reportDescription": "Severe pothole right wheel track near Lenox Square",
"provider": "Waze",
"provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:30:50Z"
},
{
"alert_id": "zyla_atl_clo_01",
"id": "zyla_atl_clo_01",
"uuid": "zyla_atl_clo_01",
"type": "ROAD_CLOSED",
"subtype": "ROAD_CLOSED_CONSTRUCTION",
"street": "I-75 N at Howell Mill Rd",
"city": "Atlanta, GA",
"location": {
"x": -84.4150,
"y": 33.8100
},
"pubMillis": 1791663000000,
"reportRating": 5,
"alert_confidence": 5,
"alert_reliability": 10,
"confidence": 5,
"reliability": 10,
"description": "Overnight lane replacement construction",
"reportDescription": "Overnight lane replacement construction",
"provider": "Waze",
"provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:10:00Z"
},
{
"alert_id": "zyla_atl_stale_01",
"id": "zyla_atl_stale_01",
"uuid": "zyla_atl_stale_01",
"type": "ACCIDENT",
"subtype": "ACCIDENT_MINOR",
"street": "GA-400 S",
"city": "Atlanta, GA",
"location": {
"x": -84.3600,
"y": 33.8500
},
"pubMillis": 1791650000000,
"reportRating": 2,
"alert_confidence": 2,
"alert_reliability": 3,
"description": "Cleared minor crash from 4 hours ago",
"provider": "Waze",
"provider_id": "4814",
"publish_datetime_utc": "2026-10-10T16:33:20Z"
}
],
"jams": []
}
}

View File

@ -0,0 +1,188 @@
{
"status": "OK",
"data": {
"alerts": [
{
"alert_id": "zyla_rome_pol_01",
"id": "zyla_rome_pol_01",
"uuid": "zyla_rome_pol_01",
"type": "POLICE",
"subtype": "POLICE_VISIBLE",
"street": "Martha Berry Hwy",
"city": "Rome, GA",
"location": {
"x": -85.1700,
"y": 34.2750
},
"pubMillis": 1791664000000,
"reportRating": 4,
"alert_confidence": 0,
"alert_reliability": 0,
"confidence": 0,
"reliability": 0,
"description": "Stationary radar trap Northbound on shoulder",
"reportDescription": "Stationary radar trap Northbound on shoulder",
"provider": "Waze",
"provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:26:40Z"
},
{
"alert_id": "zyla_rome_haz_01",
"id": "zyla_rome_haz_01",
"uuid": "zyla_rome_haz_01",
"type": "HAZARD",
"subtype": "HAZARD_ON_ROAD_OBJECT",
"street": "GA-20 / Alabama Hwy",
"city": "Rome, GA",
"location": {
"x": -85.2000,
"y": 34.2500
},
"pubMillis": 1791664100000,
"reportRating": 3,
"alert_confidence": 4,
"alert_reliability": 7,
"confidence": 4,
"reliability": 7,
"description": "Tree branch in right lane around bend",
"reportDescription": "Tree branch in right lane around bend",
"provider": "Waze",
"provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:28:20Z"
},
{
"alert_id": "zyla_rome_acc_01",
"id": "zyla_rome_acc_01",
"uuid": "zyla_rome_acc_01",
"type": "ACCIDENT",
"subtype": "ACCIDENT_MINOR",
"street": "Turner McCall Blvd",
"city": "Rome, GA",
"location": {
"x": -85.1650,
"y": 34.2560
},
"pubMillis": 1791664200000,
"reportRating": 2,
"alert_confidence": 3,
"alert_reliability": 6,
"confidence": 3,
"reliability": 6,
"description": "Fender bender near Oostanaula bridge",
"reportDescription": "Fender bender near Oostanaula bridge",
"provider": "Waze",
"provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:30:00Z"
},
{
"alert_id": "zyla_rome_clo_01",
"id": "zyla_rome_clo_01",
"uuid": "zyla_rome_clo_01",
"type": "ROAD_CLOSED",
"subtype": "ROAD_CLOSED_CONSTRUCTION",
"street": "Broad St",
"city": "Rome, GA",
"location": {
"x": -85.1720,
"y": 34.2530
},
"pubMillis": 1791663000000,
"reportRating": 5,
"alert_confidence": 5,
"alert_reliability": 9,
"confidence": 5,
"reliability": 9,
"description": "Utility work road closure between 3rd and 4th Ave",
"reportDescription": "Utility work road closure between 3rd and 4th Ave",
"provider": "Waze",
"provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:10:00Z"
},
{
"alert_id": "zyla_rome_stale_01",
"id": "zyla_rome_stale_01",
"uuid": "zyla_rome_stale_01",
"type": "POLICE",
"subtype": "POLICE_HIDDEN",
"street": "US-411 S",
"city": "Rome, GA",
"location": {
"x": -85.1400,
"y": 34.2200
},
"pubMillis": 1791600000000,
"reportRating": 1,
"alert_confidence": 1,
"alert_reliability": 2,
"description": "Old radar report from 18 hours ago",
"provider": "Waze",
"provider_id": "4814",
"publish_datetime_utc": "2026-10-10T02:40:00Z"
},
{
"alert_id": "zyla_rome_future_01",
"id": "zyla_rome_future_01",
"uuid": "zyla_rome_future_01",
"type": "HAZARD",
"subtype": "HAZARD_WEATHER",
"street": "Redmond Rd",
"city": "Rome, GA",
"location": {
"x": -85.1800,
"y": 34.2700
},
"pubMillis": 1854748800000,
"reportRating": 3,
"alert_confidence": 3,
"alert_reliability": 5,
"description": "Corrupted future timestamp record",
"provider": "Waze",
"provider_id": "4814",
"publish_datetime_utc": "2028-10-10T00:00:00Z"
},
{
"alert_id": "zyla_rome_malformed_01",
"id": "zyla_rome_malformed_01",
"uuid": "zyla_rome_malformed_01",
"type": "ACCIDENT",
"subtype": "ACCIDENT_MAJOR",
"street": "Shorter Ave",
"city": "Rome, GA",
"location": {
"x": 999.0,
"y": 999.0
},
"pubMillis": 1791664000000,
"reportRating": 1,
"alert_confidence": 1,
"alert_reliability": 1,
"description": "Invalid out-of-bounds coordinates",
"provider": "Waze",
"provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:26:40Z"
},
{
"alert_id": "zyla_rome_pol_01",
"id": "zyla_rome_pol_01",
"uuid": "zyla_rome_pol_01",
"type": "POLICE",
"subtype": "POLICE_VISIBLE",
"street": "Martha Berry Hwy",
"city": "Rome, GA",
"location": {
"x": -85.1700,
"y": 34.2750
},
"pubMillis": 1791664000000,
"reportRating": 4,
"alert_confidence": 0,
"alert_reliability": 0,
"description": "Duplicate report with identical uuid",
"provider": "Waze",
"provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:26:40Z"
}
],
"jams": []
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@
"description": "Cloudflare Worker gateway for MotorSignal R2 telemetry bundle storage",
"main": "src/index.ts",
"scripts": {
"test": "vitest run",
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
@ -13,6 +14,7 @@
"devDependencies": {
"@cloudflare/workers-types": "^4.20240806.0",
"typescript": "^5.5.4",
"vitest": "^2.1.8",
"wrangler": "^3.70.0"
}
}

View File

@ -26,7 +26,7 @@ const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Remote-Ride-Id, X-Bundle-Sha256, X-App-Version',
'Access-Control-Expose-Headers': 'ETag, Content-Disposition, X-MotorSignal-Ride-Id, X-MotorSignal-Sha256',
'Access-Control-Expose-Headers': 'ETag, Content-Disposition, X-MotorSignal-Ride-Id, X-MotorSignal-Sha256, X-MotorSignal-Size',
};
const FIREBASE_JWKS = new URL('https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com');
@ -146,10 +146,79 @@ export default {
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
const sha256 = object.customMetadata?.sha256;
if (sha256) headers.set('X-MotorSignal-Sha256', sha256);
headers.set('X-MotorSignal-Size', String(object.size));
for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
return new Response(object.body, { status: 200, headers });
}
if (request.method === 'HEAD' && path === '/r2/download') {
const remoteRideId = url.searchParams.get('rideId');
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400);
const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken);
if (ride === 'temporary-error') return jsonResponse({ error: 'Firestore temporary lookup error' }, 503);
if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized' }, 403);
if (ride === 'not-found') return jsonResponse({ error: 'Ride not found' }, 404);
if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid' }, 409);
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
const object = await env.R2_BUCKET.head(objectKey);
if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2' }, 404);
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
headers.set('Content-Disposition', `attachment; filename="${remoteRideId}_telemetry.zip"`);
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
const sha256 = object.customMetadata?.sha256;
if (sha256) headers.set('X-MotorSignal-Sha256', sha256);
headers.set('X-MotorSignal-Size', String(object.size));
for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
return new Response(null, { status: 200, headers });
}
if ((request.method === 'GET' || request.method === 'HEAD') && path === '/r2/verify') {
const remoteRideId = url.searchParams.get('rideId');
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400);
const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken);
if (ride === 'temporary-error') return jsonResponse({ error: 'Firestore temporary lookup error', code: 'TEMPORARY_ERROR' }, 503);
if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized', code: 'UNAUTHORIZED' }, 403);
if (ride === 'not-found') return jsonResponse({ error: 'Ride not found', code: 'NOT_FOUND' }, 404);
if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid', code: 'INVALID_METADATA' }, 409);
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
const object = await env.R2_BUCKET.head(objectKey);
if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2', code: 'OBJECT_NOT_FOUND' }, 404);
const sha256 = object.customMetadata?.sha256;
const appVersion = object.customMetadata?.appVersion;
const uploadedAt = object.customMetadata?.uploadedAt;
const headers = new Headers();
headers.set('etag', object.httpEtag);
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
if (sha256) headers.set('X-MotorSignal-Sha256', sha256);
headers.set('X-MotorSignal-Size', String(object.size));
for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
if (request.method === 'HEAD') {
return new Response(null, { status: 200, headers });
}
headers.set('Content-Type', 'application/json');
return new Response(JSON.stringify({
verified: true,
remoteRideId,
objectKey,
size: object.size,
etag: object.httpEtag,
sha256,
appVersion,
uploadedAt,
}), { status: 200, headers });
}
return jsonResponse({ error: 'Endpoint not found' }, 404);
} catch (err: unknown) {
console.error('MotorSignal Worker exception', err);
@ -175,7 +244,7 @@ async function verifyFirebaseToken(token: string, expectedProjectId: string): Pr
}
interface OwnedRide { objectKey?: string; }
type RideAuthorizationResult = OwnedRide | 'unauthorized' | 'not-found' | 'invalid';
type RideAuthorizationResult = OwnedRide | 'unauthorized' | 'not-found' | 'invalid' | 'temporary-error';
/** Reads the authenticated user's ride document through Firestore REST. */
async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: string, idToken: string): Promise<RideAuthorizationResult> {
@ -185,6 +254,7 @@ async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: stri
if (response.status === 401 || response.status === 403) return 'unauthorized';
if (response.status === 404) return 'not-found';
if (response.status === 429 || response.status >= 500) return 'temporary-error';
if (!response.ok) {
console.error('Firestore ownership lookup failed', response.status);
return 'unauthorized';
@ -192,8 +262,8 @@ async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: stri
const document = (await response.json()) as FirestoreDocument;
const fields = document.fields ?? {};
const ownerId = firestoreString(fields.ownerId);
const storedId = firestoreString(fields.id);
const ownerId = firestoreString(fields.ownerId) ?? firestoreString(fields.uid);
const storedId = firestoreString(fields.id) ?? firestoreString(fields.remoteRideId);
const objectKey = firestoreString(fields.telemetryBundlePath) ?? firestoreString(fields.telemetryBundle?.mapValue?.fields?.objectKey);
if (ownerId !== uid) return 'unauthorized';

View File

@ -0,0 +1,374 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock jose before importing worker
vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn(() => vi.fn()),
jwtVerify: vi.fn(async (token: string) => {
if (token === 'valid_token') {
return {
payload: {
sub: 'test_user_123',
exp: Math.floor(Date.now() / 1000) + 3600,
firebase: { sign_in_provider: 'password' },
},
};
}
if (token === 'anon_token') {
return {
payload: {
sub: 'anon_user_456',
exp: Math.floor(Date.now() / 1000) + 3600,
firebase: { sign_in_provider: 'anonymous' },
},
};
}
throw new Error('Invalid token');
}),
}));
import worker, { type Env } from './index';
describe('Worker /r2/verify endpoint', () => {
let mockEnv: Env;
let mockR2Head: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.restoreAllMocks();
mockR2Head = vi.fn();
mockEnv = {
R2_BUCKET: {
head: mockR2Head,
} as any,
FIREBASE_PROJECT_ID: 'motoapp-test',
ENVIRONMENT: 'test',
GOOGLE_SERVICE_ACCOUNT_EMAIL: 'sa@test.iam.gserviceaccount.com',
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: 'test-key',
GOOGLE_PLAY_PACKAGE_NAME: 'app.motorsignal.motorcycle',
};
});
function mockValidFirestore(uid = 'test_user_123', rideId = 'ride_abc_123') {
mockFirestore(200, {
fields: {
ownerId: { stringValue: uid },
id: { stringValue: rideId },
telemetryBundlePath: {
stringValue: `users/${uid}/rides/${rideId}/telemetry.zip`,
},
},
});
}
function mockFirestore(status: number, data?: any) {
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url.includes('firestore.googleapis.com')) {
return new Response(JSON.stringify(data || {}), { status });
}
return new Response('Not Found', { status: 404 });
}));
}
it('1. returns CORS headers on OPTIONS preflight request', async () => {
const req = new Request('https://worker.local/r2/verify?rideId=ride_1', {
method: 'OPTIONS',
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(200);
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*');
});
it('2. returns 404 Endpoint not found for unknown paths', async () => {
const req = new Request('https://worker.local/unknown-path', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(404);
const body = await res.json() as any;
expect(body.error).toBe('Endpoint not found');
});
it('3. rejects /r2/verify with 401 when Authorization header is missing', async () => {
const req = new Request('https://worker.local/r2/verify?rideId=ride_1', {
method: 'GET',
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(401);
const body = await res.json() as any;
expect(body.error).toContain('Missing or malformed Authorization header');
});
it('4. rejects /r2/verify with 401 when Authorization header lacks Bearer prefix', async () => {
const req = new Request('https://worker.local/r2/verify?rideId=ride_1', {
method: 'GET',
headers: { Authorization: 'Basic dXNlcjpwYXNz' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(401);
});
it('5. rejects /r2/verify with 401 when Firebase token verification fails', async () => {
const req = new Request('https://worker.local/r2/verify?rideId=ride_1', {
method: 'GET',
headers: { Authorization: 'Bearer invalid_or_expired_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(401);
const body = await res.json() as any;
expect(body.error).toContain('Invalid, expired, or unauthorized');
});
it('6. rejects /r2/verify with 400 when rideId query parameter is missing', async () => {
const req = new Request('https://worker.local/r2/verify', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(400);
const body = await res.json() as any;
expect(body.error).toContain('Invalid rideId parameter');
});
it('7. rejects /r2/verify with 400 when rideId contains invalid characters or path traversal', async () => {
const req = new Request('https://worker.local/r2/verify?rideId=../bad/traversal', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(400);
const body = await res.json() as any;
expect(body.error).toContain('Invalid rideId parameter');
});
it('8. returns 403 UNAUTHORIZED when Firestore returns 403', async () => {
mockFirestore(403);
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(403);
const body = await res.json() as any;
expect(body.code).toBe('UNAUTHORIZED');
});
it('9. returns 404 NOT_FOUND when Firestore ride document does not exist', async () => {
mockFirestore(404);
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(404);
const body = await res.json() as any;
expect(body.code).toBe('NOT_FOUND');
});
it('10. returns 409 INVALID_METADATA when Firestore document has invalid or missing bundle path', async () => {
mockFirestore(200, {
fields: {
ownerId: { stringValue: 'test_user_123' },
id: { stringValue: 'ride_abc_123' },
telemetryBundlePath: { stringValue: 'invalid/bundle/path.zip' },
},
});
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(409);
const body = await res.json() as any;
expect(body.code).toBe('INVALID_METADATA');
});
it('11. returns 404 OBJECT_NOT_FOUND when bundle does not exist in R2', async () => {
mockValidFirestore();
mockR2Head.mockResolvedValue(null);
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(404);
const body = await res.json() as any;
expect(body.code).toBe('OBJECT_NOT_FOUND');
expect(mockR2Head).toHaveBeenCalledWith('users/test_user_123/rides/ride_abc_123/telemetry.zip');
});
it('12. returns 200 with verification details on GET when object exists in R2', async () => {
mockValidFirestore();
mockR2Head.mockResolvedValue({
size: 4096,
httpEtag: '"etag-12345"',
customMetadata: {
sha256: 'abc123def456',
appVersion: '1.1.0',
uploadedAt: '2026-10-12T10:00:00.000Z',
},
});
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(200);
const body = await res.json() as any;
expect(body.verified).toBe(true);
expect(body.remoteRideId).toBe('ride_abc_123');
expect(body.objectKey).toBe('users/test_user_123/rides/ride_abc_123/telemetry.zip');
expect(body.size).toBe(4096);
expect(body.etag).toBe('"etag-12345"');
expect(body.sha256).toBe('abc123def456');
expect(body.appVersion).toBe('1.1.0');
expect(body.uploadedAt).toBe('2026-10-12T10:00:00.000Z');
});
it('13. sets verification properties in JSON body on GET and headers on HEAD', async () => {
mockValidFirestore();
mockR2Head.mockResolvedValue({
size: 8192,
httpEtag: '"etag-8192"',
customMetadata: { sha256: 'sha_test_789' },
});
const getReq = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
const getRes = await worker.fetch(getReq, mockEnv);
const getBody = await getRes.json() as any;
expect(getBody.etag).toBe('"etag-8192"');
expect(getBody.remoteRideId).toBe('ride_abc_123');
expect(getBody.sha256).toBe('sha_test_789');
expect(getBody.size).toBe(8192);
const headReq = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
method: 'HEAD',
headers: { Authorization: 'Bearer valid_token' },
});
const headRes = await worker.fetch(headReq, mockEnv);
expect(headRes.headers.get('etag')).toBe('"etag-8192"');
expect(headRes.headers.get('X-MotorSignal-Ride-Id')).toBe('ride_abc_123');
expect(headRes.headers.get('X-MotorSignal-Sha256')).toBe('sha_test_789');
expect(headRes.headers.get('X-MotorSignal-Size')).toBe('8192');
});
it('14. handles HEAD request on /r2/verify with 200 and headers but null body', async () => {
mockValidFirestore();
mockR2Head.mockResolvedValue({
size: 4096,
httpEtag: '"etag-head"',
customMetadata: { sha256: 'sha_head' },
});
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
method: 'HEAD',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(200);
expect(res.body).toBeNull();
expect(res.headers.get('X-MotorSignal-Ride-Id')).toBe('ride_abc_123');
expect(res.headers.get('X-MotorSignal-Size')).toBe('4096');
});
it('15. handles HEAD request on /r2/verify when bundle not found in R2 with 404', async () => {
mockValidFirestore();
mockR2Head.mockResolvedValue(null);
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
method: 'HEAD',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(404);
const body = await res.json() as any;
expect(body.code).toBe('OBJECT_NOT_FOUND');
});
it('16. correctly returns undefined sha256 for historical bundles without checksum metadata', async () => {
mockValidFirestore('test_user_123', 'ride_legacy_001');
mockR2Head.mockResolvedValue({
size: 1024,
httpEtag: '"etag-legacy"',
customMetadata: undefined,
});
const req = new Request('https://worker.local/r2/verify?rideId=ride_legacy_001', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(200);
const body = await res.json() as any;
expect(body.verified).toBe(true);
expect(body.sha256).toBeUndefined();
expect(res.headers.has('X-MotorSignal-Sha256')).toBe(false);
});
it('17. targets user-scoped R2 object key matching authenticated user', async () => {
mockValidFirestore('test_user_123', 'ride_custom_id');
mockR2Head.mockResolvedValue({
size: 512,
httpEtag: '"etag-scoped"',
});
const req = new Request('https://worker.local/r2/verify?rideId=ride_custom_id', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
await worker.fetch(req, mockEnv);
expect(mockR2Head).toHaveBeenCalledWith('users/test_user_123/rides/ride_custom_id/telemetry.zip');
});
it('18. returns 500 when unexpected internal worker exception occurs', async () => {
mockValidFirestore('test_user_123', 'ride_err_1');
mockR2Head.mockRejectedValue(new Error('R2 cluster unreachable'));
const req = new Request('https://worker.local/r2/verify?rideId=ride_err_1', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(500);
const body = await res.json() as any;
expect(body.error).toBe('Internal Worker Exception');
});
it('19. returns 503 TEMPORARY_ERROR when Firestore ownership lookup fails with 429 or 503', async () => {
mockFirestore(503, { error: 'Service Unavailable' });
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(503);
const body = await res.json() as any;
expect(body.code).toBe('TEMPORARY_ERROR');
expect(body.error).toContain('Firestore temporary lookup error');
});
it('20. sets X-MotorSignal-* headers on GET as well as HEAD', async () => {
mockValidFirestore();
mockR2Head.mockResolvedValue({
size: 2048,
httpEtag: '"etag-2048"',
customMetadata: { sha256: 'sha256_get_test' },
});
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(200);
expect(res.headers.get('X-MotorSignal-Ride-Id')).toBe('ride_abc_123');
expect(res.headers.get('X-MotorSignal-Sha256')).toBe('sha256_get_test');
expect(res.headers.get('X-MotorSignal-Size')).toBe('2048');
expect(res.headers.get('etag')).toBe('"etag-2048"');
});
});

30
docs/README.md Normal file
View File

@ -0,0 +1,30 @@
# Documentation
This index separates active reference material from dated planning, audit, QA, and deployment evidence. `AGENTS.md`, current code, schemas, and tests remain the authority when they conflict with documentation.
## Repository entrypoints
- [Repository README](../README.md): setup and application overview.
- [Agent rules](../AGENTS.md): mandatory development boundaries and verification rules.
- [Product brief](../PRODUCT.md): product intent and constraints.
- [Changelog](../CHANGELOG.md): canonical release history.
## Active reference
- [Architecture](architecture/): telemetry, cloud, and data-model references.
- [Design](design/): interface language and visual tokens.
- [Product](product/): entitlement baseline and roadmap.
- [Guides](guides/): field-testing procedure.
- [Release operations](RELEASING.md) and [versioning](VERSIONING.md).
- [Agent domain map](agents/domain.md): documentation authority and subsystem routing.
## Operational and historical material
- [Website and production records](#website-and-production-records): current scoped deployment and credential evidence remains at this level.
- [Archive](archive/): preserved dated planning, audit, QA, and legacy summaries. These documents are evidence, not current implementation proof.
## Website and production records
- `website-*.md`: scoped website verification, deployment, replay, and credential records dated 2026-10-04.
- `qa-*.md` and `QA_AUDIT_REPORT_2026-09-30.md`: dated QA outcomes and follow-up evidence.
- `OPS4_*.md`, `PRODUCTION_DEPLOYMENT_CHECKLIST.md`, and `WEBSITE_ADMIN_CURRENT_STATE.md`: operational references that require validation against the live configuration before use.

View File

@ -13,13 +13,13 @@ When documents conflict, verify behavior in code and tests. Do not revive obsole
## Documentation Map
- Telemetry and field validation: `TELEMETRY_ARCHITECTURE.md`, `TELEMETRY_VALIDATION.md`, and `FIELD_TESTING.md`.
- Cloud and sync: `MOTORSIGNAL_FINAL_CLOUD_DATA_MODEL.md`, `MOTORSIGNAL_CLOUD_ARCHITECTURE.md`, and `cloudflare/motorsignal-r2-worker/README.md`.
- Product and entitlement intent: `MOTORSIGNAL_FREE_PRO_SPEC.md` and `MOTORSIGNAL_PRODUCT_ROADMAP.md`. These are planning baselines, not proof that features exist.
- Interface language and visual tokens: `DESIGN.md`. Validate behavioral and timing claims against current code.
- Telemetry and field validation: `docs/architecture/telemetry-architecture.md`, `docs/architecture/telemetry-validation.md`, and `docs/guides/field-testing.md`.
- Cloud and sync: `docs/architecture/cloud-data-model.md`, `docs/architecture/cloud-architecture.md`, and `cloudflare/motorsignal-r2-worker/README.md`.
- Product and entitlement intent: `docs/product/free-pro-spec.md` and `docs/product/roadmap.md`. These are planning baselines, not proof that features exist.
- Interface language and visual tokens: `docs/design/design-system.md`. Validate behavioral and timing claims against current code.
- Releases and versions: `docs/RELEASING.md` and `docs/VERSIONING.md`. Preserve Semantic Versioning and monotonically increasing build numbers; use current `AGENTS.md` verification gates and current schema values where old examples conflict.
- Waze relay operation: `server/waze_relay/README.md`.
- `CHANGELOG.md`, `FEATURES_CHANGELOG.md`, `DEVELOPMENT_HISTORY.md`, `MOTORSIGNAL_AI_ENVIRONMENT_AUDIT.md`, phase reports, discovery documents, and gap audits are dated evidence. Never treat their old test counts, release status, schema numbers, or pending physical-validation claims as current proof.
- `CHANGELOG.md` is the canonical release record. `docs/archive/` contains dated feature histories, environment audits, phase reports, discovery documents, and gap audits. Never treat their old test counts, release status, schema numbers, or pending physical-validation claims as current proof.
## Stable Vocabulary

View File

@ -0,0 +1,7 @@
# Legacy summaries
This directory consolidates discovery for the overlapping August 2026 historical summaries while preserving each original record and its unique details. Use the root [CHANGELOG](../../../../CHANGELOG.md) for release history and current code for current behavior.
- [Development history](development-history.md): commit and phase chronology.
- [Features changelog](features-changelog.md): historical feature inventory.
- [Project summary](project-summary.md): historical architecture overview.

View File

@ -91,4 +91,4 @@ f7fbe71 (2026-08-27) - fix(telemetry): solve sensor fusion false lean, 3D G-forc
* **Landscape UI Optimization**: Compacted toolbars and AppBars to **36px** in landscape mode to maximize map and gauge visibility.
* **TomTom v5 API Upgrade**: Migrated to official TomTom query-parameter v5 endpoints.
* **Waze Alert Relay Server**: Built and dockerized self-hosted Node.js relay backend in `server/waze_relay/`.
* **Markdown Documentation**: Generated `FEATURES_CHANGELOG.md` and `DEVELOPMENT_HISTORY.md`.
* **Markdown Documentation**: Generated `features-changelog.md` and `development-history.md`.

14
docs/archive/README.md Normal file
View File

@ -0,0 +1,14 @@
# Documentation Archive
These files preserve dated evidence and planning context. They are not current specifications. Validate present behavior against `AGENTS.md`, source, schemas, and tests.
- [2026-08](2026-08/): Phase 2 planning, field-test/QA reports, legacy summaries, and the environment audit.
- [2026-09](2026-09/): dated architecture gap audit.
## Consolidated legacy discovery
The following overlapping historical summaries are retained as separate source records because each has unique detail. `CHANGELOG.md` at the repository root is the canonical release record.
- [Development history](2026-08/legacy/development-history.md): commit and phase chronology.
- [Features changelog](2026-08/legacy/features-changelog.md): historical feature inventory.
- [Project summary](2026-08/legacy/project-summary.md): historical architecture overview.

View File

@ -0,0 +1,103 @@
# DESIGN.md — MotorSignal Hybrid v1 Canonical Tokens & Components
## 1. Visual Language & Principles
- **Theme Name**: MotorSignal Precision Telemetry & Community (Hybrid v1)
- **Visual Direction**: Conventional cockpit layout clarity, high-contrast anti-glare data hierarchy, restrained cyan branding accents, and strict zero-clutter riding ergonomics.
- **Static Recording Discipline**: Active recording indicators (`REC • LIVE`) must remain 100% static (no pulsating, flashing, or shifting animations) to eliminate rider distraction during high-speed operation.
- **Physical Touch Enforcement**: Strict minimum `48×48 CSS px` (48×48dp) touch boundaries across all buttons, segmented toggles, navigation tabs, and modal triggers. Primary riding controls enforce `56dp` minimum height.
- **Platform Inset Rules**: Dynamic runtime safe-area clearance (`SafeArea` / `MediaQuery.of(context).padding` in Flutter; `env(safe-area-inset-*)` in Web/PWA).
- **Accessibility & Runtime Verification**: Accessibility scaling (200% text scale) and runtime performance claims are treated as unverified targets until proven via emulator/device test execution.
---
## 2. Canonical Token Set (Synchronized Day & Night Modes)
> [!NOTE]
> Canonical Canvas Token Reconciliation: `#0B1326` is the canonical night canvas background. Hex variants like `#081326` or `#0B111E` appearing in intermediate drafts are reconciled to canonical `#0B1326`.
```json
{
"theme": "MotorSignal Hybrid v1",
"modes": {
"night": {
"surface_canvas": "#0B1326",
"surface_card_primary": "#111C38",
"surface_card_secondary": "#17223B",
"surface_card_lowest": "#060E20",
"surface_border": "#1E293B",
"surface_border_subtle": "#24324D",
"text_primary": "#DAE2FD",
"text_muted": "#869397",
"brand_cyan": "#06B6D4",
"brand_cyan_bright": "#22D3EE",
"brand_cyan_container": "#00424F",
"rec_static_green": "#10B981",
"rec_static_green_bg": "#064E3B",
"caution_amber": "#F59E0B",
"danger_red": "#EF4444"
},
"day": {
"surface_canvas": "#F8FAFC",
"surface_card_primary": "#FFFFFF",
"surface_card_secondary": "#F1F5F9",
"surface_card_lowest": "#E2E8F0",
"surface_border": "#CBD5E1",
"surface_border_subtle": "#E2E8F0",
"text_primary": "#0F172A",
"text_muted": "#64748B",
"brand_cyan": "#0284C7",
"brand_cyan_bright": "#0EA5E9",
"brand_cyan_container": "#E0F2FE",
"rec_static_green": "#10B981",
"rec_static_green_bg": "#D1FAE5",
"caution_amber": "#D97706",
"danger_red": "#DC2626"
}
},
"typography": {
"font_primary": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
"font_telemetry": "JetBrains Mono, monospace",
"scale": {
"display_speed": "58px–72px (font-black / tabular)",
"telemetry_primary": "24px–28px (font-bold / tabular)",
"telemetry_unit": "10px–12px (uppercase, tracking-wider)",
"headline_lg": "24px–28px (font-bold)",
"headline_md": "18px–20px (font-semibold)",
"body_regular": "14px (line-height: 20px)",
"label_nav": "10px–11px (font-medium / bold)"
}
},
"touch_targets": {
"standard_minimum": "48px × 48px",
"primary_riding_cta": "56px height",
"nav_bar_height": "72px"
}
}
```
---
## 3. Reusable Component Guidelines
1. **Persistent 6-Destination Bottom Nav**:
- Items: `Dash` (1), `Map` (2), `Activity` (3), `Garage` (4), `Social` (5), `Settings` (6).
- Target bounding box: `≥48×48dp` per tab icon & label.
2. **Riding Dashboard Telemetry Cards**:
- Speed Hero: High-contrast large numeral with instant unit label (`mph` or `km/h`).
- Secondary Telemetry: Distance, Elapsed Time, Lean Dynamics (neutral max lean styling, live lean in cyan), Elevation Gain, and Braking G.
- Pinned Action Bar: `Pause Ride` (Amber, min 56dp) and `Finish Ride` (Dark/Slate with Cyan/Green accent, min 56dp), pinned to screen base with scrollable telemetry above in accessibility text scales.
- Non-pulsing `REC • LIVE` status badge.
3. **Group Ride Cards & Hub**:
- Decoupled Logistics: Date, time, and staging location placed on a dedicated full-width row with clock icon to avoid button/text truncation.
- Hitboxes: Segmented switcher (Feed vs Hub) and ride card buttons (`Manage Ride`, `Open Hub`, `Ride Details`, `Leave Pack`) sized to minimum `48dp` height.
- Visibility & Join Policies: Strict preservation of existing permissions, host/organizer badges, and join policies.
4. **Replay HUD**:
- Dedicated map controls locked to Top-Right quadrant, isolated from scrub timeline and lower telemetry gauges.
- Telemetry loading/status reflects real storage retrieval (e.g. SQLite database cache or cloud bundle retrieval), replacing speculative "100% buffered in-memory" labels.
- Scoped deletion options (*Local Device Cache Only* vs *Cloud & Local Records*). Never preselected.
5. **Account & System Status**:
- Reflects actual device sensors (Internal Phone IMU at authoritative 20Hz pipeline). Discards unsupported 100Hz claims and invalid Pro restrictions on basic telemetry acquisition.

View File

@ -0,0 +1,72 @@
# HANDOFF.md — MotorSignal Hybrid v1 Architecture & Implementation Notice
## 1. Prototype Scope & Verification Disclaimer
This handoff package contains **visual and interaction design prototypes** retrieved from Stitch project `5138779337547529796` ("MotorSignal UI/UX Redesign Exploration"). These specifications establish layout hierarchy, typography, touch target boundaries, and user experience flows for the MotorSignal mobile client (target viewport: `390 × 844 px`).
**CRITICAL IMPLEMENTATION PRINCIPLE:**
Mockups represent target client UI. **Repository code and backend APIs are authoritative** regarding:
1. Actual binary telemetry storage formats (authoritative SQLite Drift Schema 7, R2 telemetry bundles).
2. Authoritative Telemetry Acquisition (frozen at 20 Hz / 50 ms via `TelemetryService`).
3. Sensor fusion (frozen in `SensorFusionService`; no independent lean-angle calculations).
4. Ride lifecycle and motion semantics (frozen in `RideStateMachine`; 3.0 MPH moving gate, kickstand isolation).
5. Cloud synchronization and offline sync architecture (`SyncManager`, `SyncOutbox`, `FirebaseSyncProvider`).
6. Authentication providers and entitlements (free vs pro capabilities).
7. Existing group ride visibility, roles, and join policies.
Do **not** implement speculative backend mechanisms or invent hardware capabilities based on UI mockups.
---
## 2. Inconsistency Resolutions & Corrections
1. **Replay Telemetry Retrieval Wording**:
- Stitch Replay mockups contain the label `"100% Buffered (Local cache)"` or references to `"100% buffered in-memory"`.
- *Resolution*: In production code and documentation, loading and status wording must reflect the actual telemetry retrieval pipeline (retrieving points from the local SQLite database or streaming/downloading the complete bundle from cloud storage/R2). Do not present speculative "100% buffered in-memory" messaging.
2. **Account Screen Sample Rates and Pro Restrictions**:
- An older mockup displayed `Internal Phone IMU: Active (100Hz Gyro / Accel)` and claimed `Multi-sensor telemetry sync requires Pro subscription`.
- *Resolution*: Discarded. The authoritative acquisition cadence is 20 Hz (50 ms). Basic telemetry acquisition and synchronization are not restricted by unsupported Pro paywalls; current entitlements and free/pro models documented in the repository are strictly preserved.
3. **Ride Visibility and Join Policies**:
- Preserve all existing group ride visibility, membership, and join policies (public, unlisted, club-only, invite-only, host-approved join requests, participant lists, and role permissions). UI components adapt to these existing models without redefining policies.
4. **Canonical Token Reconciliation (#0B1326 vs #081326)**:
- Stitch HTML templates occasionally used `#081326` or `#0B111E` for dark backgrounds.
- *Resolution*: Canonical night surface canvas is strictly `#0B1326` as defined in `DESIGN.md`.
5. **Accessibility and Runtime Claims**:
- Visual mockups demonstrate 200% text scaling and proclaim "Contrast Ratio: 18.2:1 (Calc sRGB)" and "Design Target: 48×48px Minimum Target Discipline".
- *Resolution*: All accessibility, contrast, and runtime performance claims are treated as unverified until validated with unit/widget tests and on-device / emulator verification.
---
## 3. Phased Implementation Roadmap
### Phase 1: Mobile App Only (Current Implementation Target)
1. **Shared Day/Night Theme and Reusable Components**:
- Canonical tokens (`#0B1326` night canvas, `#F8FAFC` day canvas, `#06B6D4` / `#0284C7` brand cyan, `#10B981` static green, `#F59E0B` / `#D97706` amber, `#EF4444` / `#DC2626` red).
- Reusable cockpit telemetry card containers with 1px border.
- Pinned riding action buttons with minimum 56dp height and 48dp touch bounds.
- 6-destination bottom navigation bar with ≥48×48dp hitboxes.
- Static (non-pulsing) recording status indicator.
2. **Riding Dashboard**:
- High-contrast Speed hero with unit pill.
- Telemetry metrics (Trip Distance, Elapsed Time, Lean Dynamics with neutral max lean, Elevation Gain, Braking G).
- Live binding to authoritative `telemetryStreamProvider`, `rideStateMachineProvider`, and vehicle profile.
- Scrollable telemetry container for accessibility text scales, maintaining pinned Pause and Finish controls.
3. **Authenticated Group Ride Hub**:
- Decoupled meeting logistics row (Date, Time, Staging point) to prevent collision with action buttons.
- Segmented Feed / Hub switcher with ≥48dp touch targets.
- Ride cards supporting Host ("Manage Ride", "Open Hub") and Member ("Ride Details", "Leave Pack") states.
- Binding to live `groupRideServiceProvider` and user state.
### Phase 2: Planned Client Roadmap (Future Implementation)
- 2-step Create Group Ride wizard (staging typeahead, date/time pickers, access review, QR rendezvous code).
- Route calculation engine loading and fallback states ("direct distance—not a road route").
- Share ride sheet with recipient dispatch (Clubs, rider DMs, GPX export).
- Explicit scoped deletion dialog (Local cache only vs Cloud & local deletion).
### Phase 3: Capabilities Requiring Further Discovery
- External BLE sensor GATT protocol integration.
- Two-factor authentication (2FA) setup flows.
- Dynamic telemetry streaming throttles.

View File

@ -0,0 +1,46 @@
# SCREEN_INDEX.md — MotorSignal Hybrid v1 Final Design Handoff
This index maps the 14 required screen flows (and companion accessibility views) to their canonical Stitch project (`projects/5138779337547529796`) screen IDs, composite frame breakdowns, and implementation status.
## Screen Catalog & Resolved Stitch Identifiers
| # | Screen / State Name | Actual Stitch Screen ID | Composite Frame / Viewport | Status / Verification Scope | Description & Key Corrections |
|---|---|---|---|---|---|
| **01** | Riding Dashboard — Day | `fbbd68740d11447e8035cd0776133053` | Left Frame (Default Scale, 390×844) | **Tier 1: Verified UI** | Anti-glare daylight contrast (`#F8FAFC`), neutral max lean, static REC pill, pinned 56dp Pause/Finish controls, 6-tab nav. |
| **01b**| Riding Dashboard — Day (200% Text Scale Companion) | `fbbd68740d11447e8035cd0776133053` | Right Frame (200% Text Scale, 390×844) | **Accessibility Target** | Simulated 200% scale showing pinned controls and scrollable telemetry container. Unverified until tested. |
| **02** | Riding Dashboard — Night | `8f336a84e92c44d8aea012b2c6703b55` | Left Frame (Default Scale, 390×844) | **Tier 1: Verified UI** | Deep slate `#0B1326` canvas, neutral 27° max lean readout, static REC pill, zero-scroll phone fit. |
| **02b**| Riding Dashboard — Night (200% Text Scale Companion) | `8f336a84e92c44d8aea012b2c6703b55` | Right Frame (200% Text Scale, 390×844) | **Accessibility Target** | 200% scale demonstration with ≥48dp physical target boundary indicators. Unverified until tested. |
| **03** | Authenticated Group Ride Hub | `a661c1c9c82843a2bcb6b84d6d7855e1` | Left Frame (Default Scale, 390×844) | **Tier 1: Verified UI** | Dedicated logistics row, min-48dp Feed/Hub switch, 48dp action buttons. Preserves existing join/visibility policies. |
| **03b**| Group Ride Hub (200% Text Scale Companion) | `a661c1c9c82843a2bcb6b84d6d7855e1` | Right Frame (200% Text Scale, 390×844) | **Accessibility Target** | Validates text wrapping on meeting details without action button overlap. Unverified until tested. |
| **04** | Ride History & Archive | `934e8a33a5fa44009d40c292aacc630e` | Viewport 1 (Left Frame, 390×844) | **Tier 1 (UI) + Tier 2 (Sync UX)** | Sample data labeled, offline queued sync banner (2 rides queued), 3×2 telemetry card grid. |
| **05** | Ride Replay HUD | `934e8a33a5fa44009d40c292aacc630e` | Viewport 2 (Right Frame, 390×844) | **Tier 1 (UI) + Tier 2 (Proposed HUD)** | Top-right map controls, telemetry retrieval status follows actual database/bundle retrieval (discards '100% buffered in-memory'), 56dp playback controls. |
| **06** | Community Feed | `17a5177fab12466da2dea6639c8fd88f` | Frame 1 (390×844) | **Tier 1 (UI) + Tier 2 (Moderation)** | 4 feed tabs: Following, Clubs, Nearby, Discover. Generic community moderation card. Pro = subscription badge. |
| **07** | Create Group Ride — Step 1: Details & Staging | `17a5177fab12466da2dea6639c8fd88f` | Frame 2a (390×844) | **Tier 2: Proposed Feature** | Staging location typeahead, calendar/time pickers, route preview teaser. |
| **08** | Route Calculation Engine — Loading & Failure | `17a5177fab12466da2dea6639c8fd88f` | Frame 2b (390×844) | **Tier 2 / Tier 3 (Engine UX)** | Calculating state (spinner) vs Fallback state (GPX upload / "direct distance—not a road route"). |
| **09** | Create Group Ride — Step 2: Review & Access | `17a5177fab12466da2dea6639c8fd88f` | Frame 2c (390×844) | **Tier 2: Proposed Feature** | Ride summary recap, Public vs Invite-Only access toggles, pace selector, safety notes. Preserves existing policies. |
| **10** | Ride Created — Join Code & Dynamic QR | `17a5177fab12466da2dea6639c8fd88f` | Frame 2d (390×844) | **Tier 2: Proposed Feature** | High-contrast staging QR code, `#SKY92` rendezvous code, share invite triggers. |
| **11** | Account, Security & Sensor Connections | `fd0e6370af424ff4becfdaecda7f29de` | Frame 3 (390×844) | **Tier 1 (UI) + Tier 3 (Backend/Sensors)** | Internal Phone IMU active (20Hz authoritative pipeline; discarded older 100Hz and Pro restriction assumptions); truthful BLE disconnected state. |
| **12** | Share Ride — Recipient Selection Dialog | `17a5177fab12466da2dea6639c8fd88f` | Modal 1 (Overlay) | **Tier 2: Proposed Feature** | Scoped modal: Club channel, Rider DM, or native GPX export. |
| **13** | Delete Ride — Explicit Scope Selection | `17a5177fab12466da2dea6639c8fd88f` | Modal 2 (Overlay) | **Tier 2: Proposed Feature** | Explicit scope required: "Delete Local Cache Only" vs "Request Cloud & Local Deletion". No preselection. |
| **14** | Supporting System States Matrix | `17a5177fab12466da2dea6639c8fd88f` | States Grid (5 Cards) | **Tier 2: System UX** | Comprehensive previews: Loading/Downloading, Empty Filter, Sync Failure, Offline Queued, Location Denied. |
---
## Historical & Superseded Iterations
The following screens in project `5138779337547529796` were intermediate drafts or superseded revisions:
- `2cd85dda96ea48ff99a03395c432bfa1`: MotorSignal A/B/C Comparative Exploration (Early 3-way layout benchmark)
- `456a0df6c5a2486b8192c77f8f297c1c`: Hybrid Riding Dashboard (Night Mode) — Initial draft
- `dcad02763b2b499e845aac50dc5126c2`: Revised Authenticated Group Ride Hub — Initial draft
- `a82d7b2aced7407cb43dc0cd2501d690`: Hybrid Riding Dashboard (Day Mode) — Initial draft
- `5e0a7784ecec46eaad2f505a43d96576`: Hybrid Riding Dashboard - Night (Default vs Enlarged Text) — Intermediate draft
- `f9faeec4eec142349267ebb8d4c9a484`: Hybrid Riding Dashboard - Day (Default vs Enlarged Text) — Intermediate draft
- `2f39581f42734138991a2199601038ae`: Revised Group Ride Hub (Default vs Enlarged Text) — Intermediate draft
- `aa6aed675a3044e7a3025333cf732c2d`: MotorSignal - Ride History & Replay HUD — Redundant instance of `934e8a33a5fa44009d40c292aacc630e`
Uploaded Reference Screenshots from physical test runs:
- `7166114457503287420`: `Screenshot_20261006-020215.png` (Riding Dashboard night dark state)
- `7166114457503285870`: `Screenshot_20261006-020217.png` (Map and live cluster state)
- `7166114457503288416`: `Screenshot_20261006-020220.png` (Activity log & telemetry cards)
- `7166114457503286424`: `Screenshot_20261006-020224.png` (Group ride listing & cards)
- `7166114457503288970`: `Screenshot_20261006-020229.png` (Settings and sensor connection status)

View File

@ -0,0 +1,254 @@
{
"handoff_package": "MotorSignal Hybrid v1 — Final Design Handoff",
"project_id": "5138779337547529796",
"project_title": "MotorSignal UI/UX Redesign Exploration",
"canonical_tokens": {
"night": {
"surface_canvas": "#0B1326",
"surface_card_primary": "#111C38",
"surface_card_secondary": "#17223B",
"surface_card_lowest": "#060E20",
"surface_border": "#1E293B",
"surface_border_subtle": "#24324D",
"text_primary": "#DAE2FD",
"text_muted": "#869397",
"brand_cyan": "#06B6D4",
"brand_cyan_bright": "#22D3EE",
"brand_cyan_container": "#00424F",
"rec_static_green": "#10B981",
"rec_static_green_bg": "#064E3B",
"caution_amber": "#F59E0B",
"danger_red": "#EF4444"
},
"day": {
"surface_canvas": "#F8FAFC",
"surface_card_primary": "#FFFFFF",
"surface_card_secondary": "#F1F5F9",
"surface_card_lowest": "#E2E8F0",
"surface_border": "#CBD5E1",
"surface_border_subtle": "#E2E8F0",
"text_primary": "#0F172A",
"text_muted": "#64748B",
"brand_cyan": "#0284C7",
"brand_cyan_bright": "#0EA5E9",
"brand_cyan_container": "#E0F2FE",
"rec_static_green": "#10B981",
"rec_static_green_bg": "#D1FAE5",
"caution_amber": "#D97706",
"danger_red": "#DC2626"
}
},
"screens": [
{
"index": "01",
"name": "Riding Dashboard — Day",
"screen_id": "fbbd68740d11447e8035cd0776133053",
"composite_frame": "Left Frame (Default Scale)",
"viewport": { "width": 390, "height": 844 },
"tier": "Tier 1: Verified UI",
"reference_files": [
"references/screen_day_dashboard.png",
"references/screen_day_dashboard.html"
]
},
{
"index": "01b",
"name": "Riding Dashboard — Day (200% Text Scale Companion)",
"screen_id": "fbbd68740d11447e8035cd0776133053",
"composite_frame": "Right Frame (200% Accessibility Scale)",
"viewport": { "width": 390, "height": 844 },
"tier": "Accessibility Target (Unverified)",
"reference_files": [
"references/screen_day_dashboard.png",
"references/screen_day_dashboard.html"
]
},
{
"index": "02",
"name": "Riding Dashboard — Night",
"screen_id": "8f336a84e92c44d8aea012b2c6703b55",
"composite_frame": "Left Frame (Default Scale)",
"viewport": { "width": 390, "height": 844 },
"tier": "Tier 1: Verified UI",
"reference_files": [
"references/screen_night_dashboard.png",
"references/screen_night_dashboard.html"
]
},
{
"index": "02b",
"name": "Riding Dashboard — Night (200% Text Scale Companion)",
"screen_id": "8f336a84e92c44d8aea012b2c6703b55",
"composite_frame": "Right Frame (200% Accessibility Scale)",
"viewport": { "width": 390, "height": 844 },
"tier": "Accessibility Target (Unverified)",
"reference_files": [
"references/screen_night_dashboard.png",
"references/screen_night_dashboard.html"
]
},
{
"index": "03",
"name": "Authenticated Group Ride Hub",
"screen_id": "a661c1c9c82843a2bcb6b84d6d7855e1",
"composite_frame": "Left Frame (Default Scale)",
"viewport": { "width": 390, "height": 844 },
"tier": "Tier 1: Verified UI",
"reference_files": [
"references/screen_group_ride_hub.png",
"references/screen_group_ride_hub.html"
]
},
{
"index": "03b",
"name": "Group Ride Hub (200% Text Scale Companion)",
"screen_id": "a661c1c9c82843a2bcb6b84d6d7855e1",
"composite_frame": "Right Frame (200% Accessibility Scale)",
"viewport": { "width": 390, "height": 844 },
"tier": "Accessibility Target (Unverified)",
"reference_files": [
"references/screen_group_ride_hub.png",
"references/screen_group_ride_hub.html"
]
},
{
"index": "04",
"name": "Ride History & Archive",
"screen_id": "934e8a33a5fa44009d40c292aacc630e",
"composite_frame": "Viewport 1 (Left Frame)",
"viewport": { "width": 390, "height": 844 },
"tier": "Tier 1 (UI) + Tier 2 (Sync UX)",
"reference_files": [
"references/screen_replay_hud.png",
"references/screen_replay_hud.html"
]
},
{
"index": "05",
"name": "Ride Replay HUD",
"screen_id": "934e8a33a5fa44009d40c292aacc630e",
"composite_frame": "Viewport 2 (Right Frame)",
"viewport": { "width": 390, "height": 844 },
"tier": "Tier 1 (UI) + Tier 2 (Proposed HUD)",
"reference_files": [
"references/screen_replay_hud.png",
"references/screen_replay_hud.html"
]
},
{
"index": "06",
"name": "Community Feed",
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
"composite_frame": "Frame 1",
"viewport": { "width": 390, "height": 844 },
"tier": "Tier 1 (UI) + Tier 2 (Moderation)",
"reference_files": [
"references/screen_consolidated_flows.png",
"references/screen_consolidated_flows.html"
]
},
{
"index": "07",
"name": "Create Group Ride — Step 1: Details & Staging",
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
"composite_frame": "Frame 2a",
"viewport": { "width": 390, "height": 844 },
"tier": "Tier 2: Proposed Feature",
"reference_files": [
"references/screen_consolidated_flows.png",
"references/screen_consolidated_flows.html"
]
},
{
"index": "08",
"name": "Route Calculation Engine — Loading & Failure",
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
"composite_frame": "Frame 2b",
"viewport": { "width": 390, "height": 844 },
"tier": "Tier 2 / Tier 3 (Engine UX)",
"reference_files": [
"references/screen_consolidated_flows.png",
"references/screen_consolidated_flows.html"
]
},
{
"index": "09",
"name": "Create Group Ride — Step 2: Review & Access",
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
"composite_frame": "Frame 2c",
"viewport": { "width": 390, "height": 844 },
"tier": "Tier 2: Proposed Feature",
"reference_files": [
"references/screen_consolidated_flows.png",
"references/screen_consolidated_flows.html"
]
},
{
"index": "10",
"name": "Ride Created — Join Code & Dynamic QR",
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
"composite_frame": "Frame 2d",
"viewport": { "width": 390, "height": 844 },
"tier": "Tier 2: Proposed Feature",
"reference_files": [
"references/screen_consolidated_flows.png",
"references/screen_consolidated_flows.html"
]
},
{
"index": "11",
"name": "Account, Security & Sensor Connections",
"screen_id": "fd0e6370af424ff4becfdaecda7f29de",
"composite_frame": "Frame 3",
"viewport": { "width": 390, "height": 844 },
"tier": "Tier 1 (UI) + Tier 3 (Backend/Sensors)",
"reference_files": [
"references/screen_remaining_flows.png",
"references/screen_remaining_flows.html"
]
},
{
"index": "12",
"name": "Share Ride — Recipient Selection Dialog",
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
"composite_frame": "Modal 1 (Overlay)",
"viewport": { "width": 390, "height": 844 },
"tier": "Tier 2: Proposed Feature",
"reference_files": [
"references/screen_consolidated_flows.png",
"references/screen_consolidated_flows.html"
]
},
{
"index": "13",
"name": "Delete Ride — Explicit Scope Selection",
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
"composite_frame": "Modal 2 (Overlay)",
"viewport": { "width": 390, "height": 844 },
"tier": "Tier 2: Proposed Feature",
"reference_files": [
"references/screen_consolidated_flows.png",
"references/screen_consolidated_flows.html"
]
},
{
"index": "14",
"name": "Supporting System States Matrix",
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
"composite_frame": "System States Grid (5 Cards)",
"viewport": { "width": 1280, "height": 800 },
"tier": "Tier 2: System UX",
"reference_files": [
"references/screen_consolidated_flows.png",
"references/screen_consolidated_flows.html"
]
}
],
"inconsistencies_resolved": {
"replay_telemetry_retrieval_wording": "Loading/status wording must follow actual database/bundle retrieval rather than speculative '100% buffered in-memory' label.",
"account_screen_assumptions": "Discarded unsupported 100Hz IMU claims and Pro restrictions on multi-sensor telemetry; conforms to frozen 20Hz pipeline and current free/pro model.",
"ride_visibility_and_join_policies": "Preserved all existing membership, host approval, unlisted/public/club-only/invite-only policies.",
"night_surface_canvas_reconciliation": "Reconciled to canonical #0B1326.",
"accessibility_and_runtime_claims": "Treated as unverified targets until tested on device/emulator."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@ -0,0 +1,994 @@
<!DOCTYPE html>
<html class="dark" lang="en">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>MotorSignal - Remaining Flows & Consolidated Architecture Handoff</title>
<!-- Material Symbols Outlined -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
<!-- JetBrains Mono & Inter Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"background": "#0b1326",
"surface": "#0b1326",
"surface-container-lowest": "#060e20",
"surface-container-low": "#111c38",
"surface-container": "#17223b",
"surface-container-high": "#222d4a",
"surface-container-highest": "#2d3859",
"outline": "#869397",
"outline-variant": "#2a3652",
"primary": "#4cd7f6",
"primary-container": "#06b6d4",
"on-primary": "#003640",
"on-primary-container": "#002028",
"on-surface": "#dae2fd",
"on-surface-variant": "#94a3b8",
"tertiary": "#4edea3",
"warning": "#f59e0b",
"error": "#ef4444",
"error-container": "#450a0a"
},
fontFamily: {
sans: ["Inter", "-apple-system", "sans-serif"],
mono: ["JetBrains Mono", "monospace"]
}
}
}
}
</script>
<style>
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 20px;
line-height: 1;
display: inline-block;
}
.viewport-scroll::-webkit-scrollbar {
width: 4px;
}
.viewport-scroll::-webkit-scrollbar-track {
background: #0b1326;
}
.viewport-scroll::-webkit-scrollbar-thumb {
background: #2a3652;
border-radius: 4px;
}
</style>
</head>
<body class="bg-background text-on-surface antialiased min-h-screen py-10 px-4 sm:px-8 selection:bg-primary-container selection:text-on-primary-container">
<!-- Document Header & Specification Bar -->
<header class="max-w-[1440px] mx-auto mb-10 pb-6 border-b border-outline-variant">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-surface-container-low border border-outline-variant flex items-center justify-center text-primary-container shadow-md">
<span class="material-symbols-outlined" style="font-size: 24px;">dataset</span>
</div>
<div>
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wider font-mono font-bold text-primary px-2 py-0.5 rounded bg-surface-container-high border border-outline-variant">Prototype Spec v2.8</span>
<span class="text-xs text-on-surface-variant">MotorSignal UI Engineering Package</span>
<span class="text-xs px-2 py-0.5 rounded bg-warning/10 text-warning border border-warning/30 font-medium">Design Mockups — Non-Verified Backend</span>
</div>
<h1 class="text-2xl sm:text-3xl font-bold tracking-tight text-white mt-1">Consolidated Mobile Flows & Architecture Handoff</h1>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs font-mono">
<span class="px-3 py-1.5 rounded-lg bg-surface-container-low border border-outline-variant text-on-surface">Target Frame: 390 × 844 CSS px</span>
<span class="px-3 py-1.5 rounded-lg bg-surface-container-low border border-outline-variant text-tertiary">Touch Target Minimum: ≥48×48dp</span>
</div>
</div>
</header>
<main class="max-w-[1440px] mx-auto space-y-16">
<!-- ======================================================== -->
<!-- SECTION 1: THREE CORE REMAINING SCREEN VIEWPORTS -->
<!-- ======================================================== -->
<div>
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-primary-container"></span>
Core Remaining Screen Flows (Default Viewports)
</h2>
<p class="text-sm text-on-surface-variant mt-0.5">High-fidelity 390×844 prototype viewports with persistent 6-destination navigation and explicit sample-data notation.</p>
</div>
<div class="text-xs font-mono text-on-surface-variant bg-surface-container-low px-3 py-1.5 rounded-lg border border-outline-variant">
Frames 1–3 of 3
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8 justify-items-center">
<!-- ========================================== -->
<!-- VIEWPORT 1: COMMUNITY FEED & MODERATION -->
<!-- ========================================== -->
<div class="flex flex-col items-center w-full max-w-[390px]">
<div class="w-full flex items-center justify-between mb-2 px-1 text-xs">
<span class="font-bold text-white flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-primary"></span> 1. Community Feed & Moderation
</span>
<span class="font-mono text-on-surface-variant">Social &gt; Feed</span>
</div>
<!-- Phone Mockup Frame (390 x 844) -->
<div class="relative w-[390px] h-[844px] bg-background border-[5px] border-surface-container-highest rounded-[42px] shadow-2xl overflow-hidden flex flex-col justify-between ring-1 ring-outline-variant/50">
<!-- iOS Simulated Top Safe Area / Status Bar -->
<div class="h-11 w-full bg-background flex items-center justify-between px-6 shrink-0 select-none z-30">
<span class="text-xs font-semibold text-white">09:41</span>
<div class="w-24 h-4 bg-surface-container-lowest rounded-full flex items-center justify-center">
<span class="w-2 h-2 rounded-full bg-surface-container-high mr-2"></span>
<span class="w-2.5 h-2.5 rounded-full bg-primary-container/40"></span>
</div>
<div class="flex items-center gap-1.5 text-xs text-white">
<span class="material-symbols-outlined" style="font-size: 14px;">signal_cellular_alt</span>
<span class="material-symbols-outlined" style="font-size: 14px;">wifi</span>
<span class="material-symbols-outlined" style="font-size: 14px;">battery_full</span>
</div>
</div>
<!-- Feed Top App Bar -->
<div class="px-4 pt-2 pb-3 bg-surface-container-low/95 backdrop-blur border-b border-outline-variant shrink-0 z-20">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-base font-bold text-white tracking-tight">Social Network</span>
<span class="text-[10px] font-mono uppercase px-1.5 py-0.5 rounded bg-surface-container text-on-surface-variant border border-outline-variant">Sample Data</span>
</div>
<div class="flex items-center gap-1">
<!-- Feed / Hub Switch (Min 48px target) -->
<div class="flex h-11 p-1 bg-surface-container-lowest rounded-xl border border-outline-variant">
<button class="px-3.5 rounded-lg bg-surface-container-high text-primary font-bold text-xs flex items-center justify-center">Feed</button>
<button class="px-3.5 rounded-lg text-on-surface-variant font-medium text-xs flex items-center justify-center hover:text-white">Hub</button>
</div>
</div>
</div>
<!-- Feed Filter Tabs (Min 48px height) -->
<div class="mt-2.5 flex items-center gap-2 overflow-x-auto pb-0.5 no-scrollbar text-xs">
<button class="h-10 px-3.5 rounded-full bg-surface-container-high border border-primary text-primary font-bold flex items-center shrink-0">All Posts</button>
<button class="h-10 px-3.5 rounded-full bg-surface-container border border-outline-variant text-on-surface-variant font-medium flex items-center shrink-0 hover:text-white">Clubs</button>
<button class="h-10 px-3.5 rounded-full bg-surface-container border border-outline-variant text-on-surface-variant font-medium flex items-center shrink-0 hover:text-white">Nearby</button>
<button class="h-10 px-3.5 rounded-full bg-surface-container border border-outline-variant text-on-surface-variant font-medium flex items-center shrink-0 hover:text-white">My Pack</button>
</div>
</div>
<!-- Feed Scrollable Content -->
<div class="flex-1 overflow-y-auto viewport-scroll p-4 space-y-4 bg-background">
<!-- Feed Card 1: Normal Telemetry Session Post -->
<article class="p-3.5 rounded-2xl bg-surface-container-low border border-outline-variant space-y-3">
<!-- Author Header with Overflow Trigger -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2.5">
<div class="w-9 h-9 rounded-full bg-primary-container/20 border border-primary/40 flex items-center justify-center text-primary font-bold text-xs">AM</div>
<div>
<div class="flex items-center gap-1.5">
<span class="text-xs font-bold text-white">@ApexMarcus</span>
<span class="text-[10px] font-mono px-1.5 rounded bg-primary-container/20 text-primary border border-primary/30">PRO</span>
</div>
<span class="text-[11px] text-on-surface-variant">Bay Area Twisty Club • 2h ago</span>
</div>
</div>
<!-- 48×48px Touch Menu Action -->
<button class="w-12 h-12 rounded-xl flex items-center justify-center text-on-surface-variant hover:text-white active:bg-surface-container transition-colors" title="Post Options">
<span class="material-symbols-outlined" style="font-size: 20px;">more_vert</span>
</button>
</div>
<!-- Post Content -->
<div class="space-y-1">
<h3 class="text-xs font-bold text-white">Sunday Skyline &amp; Pescadero Carve</h3>
<p class="text-[11px] text-on-surface-variant leading-snug">Clean coastal run before fog rolled into Pescadero. 2024 Ducati Streetfighter V4 feeling dialed on new rubber.</p>
</div>
<!-- Compact Telemetry Strip (Sample Data) -->
<div class="grid grid-cols-3 gap-1.5 p-2 rounded-xl bg-surface-container-lowest border border-outline-variant/60 font-mono text-center">
<div>
<span class="text-[9px] text-on-surface-variant uppercase">Distance</span>
<p class="text-xs font-bold text-white">68.4 <span class="text-[9px] text-on-surface-variant">MI</span></p>
</div>
<div>
<span class="text-[9px] text-on-surface-variant uppercase">Peak Lean</span>
<p class="text-xs font-bold text-primary">51° <span class="text-[9px] text-on-surface-variant">R</span></p>
</div>
<div>
<span class="text-[9px] text-on-surface-variant uppercase">Duration</span>
<p class="text-xs font-bold text-white">1h 14m</p>
</div>
</div>
<!-- Action Bar (Min 48px hit areas) -->
<div class="flex items-center justify-between pt-1 border-t border-outline-variant/40">
<div class="flex items-center gap-1">
<button class="h-11 px-3 rounded-lg text-on-surface-variant hover:text-white flex items-center gap-1.5 text-xs">
<span class="material-symbols-outlined text-primary" style="font-size: 18px;">favorite</span>
<span class="font-mono text-[11px]">24</span>
</button>
<button class="h-11 px-3 rounded-lg text-on-surface-variant hover:text-white flex items-center gap-1.5 text-xs">
<span class="material-symbols-outlined" style="font-size: 18px;">chat_bubble</span>
<span class="font-mono text-[11px]">7</span>
</button>
</div>
<button class="h-11 px-3 rounded-lg bg-surface-container text-primary font-bold text-xs flex items-center gap-1 border border-outline-variant">
<span class="material-symbols-outlined" style="font-size: 16px;">visibility</span>
Inspect Run
</button>
</div>
</article>
<!-- Feed Card 2: Explicit Moderation & Content Warning State -->
<article class="p-3.5 rounded-2xl bg-surface-container-low border border-warning/40 space-y-3 relative overflow-hidden">
<div class="absolute left-0 top-0 bottom-0 w-1 bg-warning"></div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-warning" style="font-size: 18px;">flag</span>
<span class="text-xs font-bold text-warning">Flagged by Community Review</span>
</div>
<!-- Overflow Menu -->
<button class="w-12 h-12 rounded-xl flex items-center justify-center text-on-surface-variant hover:text-white" title="Moderation Details">
<span class="material-symbols-outlined" style="font-size: 20px;">info</span>
</button>
</div>
<div class="p-3 rounded-xl bg-surface-container-lowest/80 border border-outline-variant space-y-2">
<div class="flex items-start gap-2">
<span class="material-symbols-outlined text-error mt-0.5" style="font-size: 18px;">speed</span>
<div>
<p class="text-xs font-bold text-white">Aggressive Speed Content Hidden</p>
<p class="text-[11px] text-on-surface-variant mt-0.5 leading-snug">Public highway speed exceeded community safety guideline (+40% above zone speed ceiling). GPS telemetry map blurred.</p>
</div>
</div>
<!-- Moderation Disclosure Actions (≥48px targets) -->
<div class="flex items-center gap-2 pt-1">
<button class="h-11 px-4 rounded-lg bg-surface-container text-white text-xs font-bold border border-outline-variant hover:border-primary flex items-center justify-center">
Reveal Post
</button>
<button class="h-11 px-4 rounded-lg text-error hover:bg-error-container/30 text-xs font-semibold flex items-center gap-1">
<span class="material-symbols-outlined" style="font-size: 16px;">report</span>
Report Violation
</button>
</div>
</div>
</article>
<!-- Feed Card 3: Proposed Feature Badge Annotation -->
<div class="p-3 rounded-xl bg-surface-container border border-dashed border-outline-variant text-center space-y-1">
<span class="text-[10px] font-mono text-tertiary uppercase font-bold tracking-wider">Proposed Flow: Pack Live Beacon</span>
<p class="text-xs text-white">Broadcast real-time pack proximity during scheduled weekend group rides.</p>
<span class="text-[10px] text-on-surface-variant italic">Requires field connectivity testing before production release.</span>
</div>
</div>
<!-- Global Bottom Navigation Bar (Persistent 6 Destinations, min 48px hit areas) -->
<nav class="h-[68px] bg-surface-container-lowest border-t border-outline-variant px-2 flex items-center justify-around shrink-0 select-none z-30">
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
<span class="material-symbols-outlined" style="font-size: 20px;">speed</span>
<span class="text-[10px] font-bold mt-0.5">Dash</span>
</button>
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
<span class="material-symbols-outlined" style="font-size: 20px;">explore</span>
<span class="text-[10px] font-bold mt-0.5">Map</span>
</button>
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
<span class="material-symbols-outlined" style="font-size: 20px;">analytics</span>
<span class="text-[10px] font-bold mt-0.5">Activity</span>
</button>
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
<span class="material-symbols-outlined" style="font-size: 20px;">two_wheeler</span>
<span class="text-[10px] font-bold mt-0.5">Garage</span>
</button>
<button class="flex flex-col items-center justify-center w-12 h-12 text-primary">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1; font-size: 20px;">groups</span>
<span class="text-[10px] font-bold mt-0.5">Social</span>
</button>
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
<span class="material-symbols-outlined" style="font-size: 20px;">settings</span>
<span class="text-[10px] font-bold mt-0.5">Settings</span>
</button>
</nav>
<!-- Safe Area Platform Bottom Indicator -->
<div class="h-3 w-full bg-surface-container-lowest flex justify-center pb-1">
<div class="w-32 h-1 bg-outline-variant rounded-full"></div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- VIEWPORT 2: TWO-STEP CREATE GROUP RIDE -->
<!-- ========================================== -->
<div class="flex flex-col items-center w-full max-w-[390px]">
<div class="w-full flex items-center justify-between mb-2 px-1 text-xs">
<span class="font-bold text-white flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-primary-container"></span> 2. Two-Step Create Group Ride
</span>
<span class="font-mono text-on-surface-variant">Step 1 of 2</span>
</div>
<!-- Phone Mockup Frame (390 x 844) -->
<div class="relative w-[390px] h-[844px] bg-background border-[5px] border-surface-container-highest rounded-[42px] shadow-2xl overflow-hidden flex flex-col justify-between ring-1 ring-outline-variant/50">
<!-- Top Safe Area Bar -->
<div class="h-11 w-full bg-background flex items-center justify-between px-6 shrink-0 select-none z-30">
<span class="text-xs font-semibold text-white">09:42</span>
<div class="w-24 h-4 bg-surface-container-lowest rounded-full flex items-center justify-center">
<span class="w-2 h-2 rounded-full bg-surface-container-high mr-2"></span>
<span class="w-2.5 h-2.5 rounded-full bg-primary-container/40"></span>
</div>
<div class="flex items-center gap-1.5 text-xs text-white">
<span class="material-symbols-outlined" style="font-size: 14px;">signal_cellular_alt</span>
<span class="material-symbols-outlined" style="font-size: 14px;">wifi</span>
<span class="material-symbols-outlined" style="font-size: 14px;">battery_full</span>
</div>
</div>
<!-- Header / Step Indicator App Bar -->
<div class="px-4 py-3 bg-surface-container-low/95 backdrop-blur border-b border-outline-variant shrink-0 z-20">
<div class="flex items-center justify-between">
<button class="w-12 h-12 -ml-2 rounded-xl flex items-center justify-center text-on-surface-variant hover:text-white" title="Cancel Creation">
<span class="material-symbols-outlined" style="font-size: 22px;">close</span>
</button>
<div class="text-center">
<h2 class="text-sm font-bold text-white">Create Group Ride</h2>
<span class="text-[10px] font-mono text-primary">STEP 1: LOGISTICS &amp; ROUTE</span>
</div>
<!-- Step Counter Badge -->
<span class="text-xs font-mono font-bold px-2 py-1 rounded bg-surface-container-high border border-outline-variant text-on-surface">1/2</span>
</div>
<!-- Visual Stepper Progress Bar -->
<div class="mt-2.5 flex items-center gap-1.5">
<div class="h-1.5 flex-1 bg-primary rounded-full"></div>
<div class="h-1.5 flex-1 bg-surface-container-highest rounded-full"></div>
</div>
</div>
<!-- Step 1 Form Content (Scrollable) -->
<div class="flex-1 overflow-y-auto viewport-scroll p-4 space-y-4 bg-background">
<!-- Ride Title Input -->
<div class="space-y-1.5">
<label class="block text-xs font-bold text-white">Ride Title <span class="text-primary">*</span></label>
<input type="text" value="Sunday Skyline & Pescadero Carve" class="w-full h-12 px-3.5 rounded-xl bg-surface-container-low border border-outline-variant text-xs text-white focus:border-primary focus:ring-0 font-medium"/>
</div>
<!-- Route Selection Card with Static Vector Map Preview -->
<div class="space-y-1.5">
<div class="flex items-center justify-between text-xs">
<label class="font-bold text-white">Select Route &amp; Distance</label>
<button class="text-[11px] font-bold text-primary hover:underline">Choose from GPX</button>
</div>
<div class="rounded-2xl border border-outline-variant bg-surface-container-low overflow-hidden">
<!-- Map Simulation Canvas -->
<div class="h-28 bg-[#091426] relative flex items-center justify-center">
<svg class="w-full h-full opacity-60" viewBox="0 0 320 112" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 90 Q 60 40, 130 65 T 250 20 T 310 50" stroke="#06b6d4" stroke-width="4" stroke-linecap="round" fill="none"/>
<circle cx="10" cy="90" r="5" fill="#4edea3"/>
<circle cx="310" cy="50" r="5" fill="#ef4444"/>
</svg>
<div class="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-surface-container-lowest/90 border border-outline-variant text-[10px] font-mono text-white">
Skyline Blvd &rarr; Hwy 84
</div>
<div class="absolute top-2 right-2 px-2 py-0.5 rounded bg-surface-container-lowest/90 border border-outline-variant text-[10px] font-mono text-primary font-bold">
68.4 MI Calculated
</div>
</div>
<div class="p-3 flex items-center justify-between text-xs">
<div>
<p class="font-bold text-white">Pescadero Coastal Loop</p>
<p class="text-[11px] text-on-surface-variant font-mono">Elevation: +2,140 ft • Est. 1h 45m</p>
</div>
<button class="h-10 px-3 rounded-lg bg-surface-container border border-outline-variant text-xs text-white font-medium hover:border-primary">Change</button>
</div>
</div>
</div>
<!-- Date & Staging Location (Decoupled, No Collisions) -->
<div class="space-y-3">
<div class="space-y-1.5">
<label class="block text-xs font-bold text-white">Date &amp; Kickstands Up (KSU)</label>
<div class="grid grid-cols-2 gap-2">
<input type="text" value="Sun, Oct 12" class="h-12 px-3 rounded-xl bg-surface-container-low border border-outline-variant text-xs text-white font-mono"/>
<input type="text" value="09:00 AM KSU" class="h-12 px-3 rounded-xl bg-surface-container-low border border-outline-variant text-xs text-white font-mono"/>
</div>
</div>
<div class="space-y-1.5">
<label class="block text-xs font-bold text-white">Staging Location</label>
<div class="relative">
<input type="text" value="Alice's Restaurant, Woodside CA" class="w-full h-12 pl-10 pr-3 rounded-xl bg-surface-container-low border border-outline-variant text-xs text-white font-medium"/>
<span class="material-symbols-outlined absolute left-3 top-3 text-on-surface-variant" style="font-size: 18px;">pin_drop</span>
</div>
</div>
</div>
<!-- Pace & Safety Guidelines Segmented Selector -->
<div class="space-y-1.5">
<label class="block text-xs font-bold text-white">Target Pace Profile</label>
<div class="grid grid-cols-3 gap-2">
<button class="h-12 rounded-xl bg-surface-container border border-outline-variant text-xs text-on-surface-variant font-medium hover:text-white">Casual</button>
<button class="h-12 rounded-xl bg-surface-container-high border-2 border-primary text-primary text-xs font-bold">Spirited</button>
<button class="h-12 rounded-xl bg-surface-container border border-outline-variant text-xs text-on-surface-variant font-medium hover:text-white">Track Pace</button>
</div>
</div>
<!-- Step 2 Preview Teaser: Join Code & Passkey Notice -->
<div class="p-3 rounded-xl bg-surface-container-lowest border border-outline-variant flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-surface-container-high flex items-center justify-center text-primary shrink-0">
<span class="material-symbols-outlined" style="font-size: 20px;">qr_code_2</span>
</div>
<div>
<p class="text-xs font-bold text-white">Next Step: Join Code &amp; Invites</p>
<p class="text-[11px] text-on-surface-variant">Step 2 generates your pack join code (#SKY92) and shareable QR code.</p>
</div>
</div>
</div>
<!-- Bottom Action Suite (Pinned, Min 56px height) -->
<div class="p-4 bg-surface-container-low border-t border-outline-variant shrink-0 z-30">
<button class="w-full h-14 rounded-2xl bg-primary-container text-on-primary-container font-bold text-sm flex items-center justify-center gap-2 shadow-lg active:scale-[0.99] transition-transform">
Continue to Step 2: Pack Access
<span class="material-symbols-outlined" style="font-size: 18px;">arrow_forward</span>
</button>
</div>
<!-- iOS Safe Area Indicator -->
<div class="h-3 w-full bg-surface-container-low flex justify-center pb-1">
<div class="w-32 h-1 bg-outline-variant rounded-full"></div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- VIEWPORT 3: ACCOUNT, SECURITY & SENSORS -->
<!-- ========================================== -->
<div class="flex flex-col items-center w-full max-w-[390px]">
<div class="w-full flex items-center justify-between mb-2 px-1 text-xs">
<span class="font-bold text-white flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-tertiary"></span> 3. Account, Security &amp; Sensors
</span>
<span class="font-mono text-on-surface-variant">Truthful States</span>
</div>
<!-- Phone Mockup Frame (390 x 844) -->
<div class="relative w-[390px] h-[844px] bg-background border-[5px] border-surface-container-highest rounded-[42px] shadow-2xl overflow-hidden flex flex-col justify-between ring-1 ring-outline-variant/50">
<!-- Top Safe Area Bar -->
<div class="h-11 w-full bg-background flex items-center justify-between px-6 shrink-0 select-none z-30">
<span class="text-xs font-semibold text-white">09:43</span>
<div class="w-24 h-4 bg-surface-container-lowest rounded-full flex items-center justify-center">
<span class="w-2 h-2 rounded-full bg-surface-container-high mr-2"></span>
<span class="w-2.5 h-2.5 rounded-full bg-primary-container/40"></span>
</div>
<div class="flex items-center gap-1.5 text-xs text-white">
<span class="material-symbols-outlined" style="font-size: 14px;">signal_cellular_alt</span>
<span class="material-symbols-outlined" style="font-size: 14px;">wifi</span>
<span class="material-symbols-outlined" style="font-size: 14px;">battery_full</span>
</div>
</div>
<!-- Settings Top App Bar -->
<div class="px-4 py-3 bg-surface-container-low/95 backdrop-blur border-b border-outline-variant shrink-0 z-20">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-base font-bold text-white tracking-tight">Account &amp; System</span>
</div>
<span class="text-[11px] font-mono text-on-surface-variant">Build 2.8.4</span>
</div>
</div>
<!-- Settings Scrollable Content Area -->
<div class="flex-1 overflow-y-auto viewport-scroll p-4 space-y-4 bg-background">
<!-- User Identity & Entitlement Card -->
<div class="p-3.5 rounded-2xl bg-surface-container-low border border-outline-variant space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-full bg-surface-container-high border border-outline-variant flex items-center justify-center text-primary font-bold text-base">B</div>
<div>
<h3 class="text-xs font-bold text-white">Bill Ponders</h3>
<p class="text-[11px] text-on-surface-variant font-mono">bponders12@gmail.com</p>
</div>
</div>
<button class="h-11 px-3.5 rounded-xl bg-surface-container border border-outline-variant text-xs text-white font-medium hover:border-primary">
Manage
</button>
</div>
<!-- Explicit Free Tier Breakdown (No unverified claims) -->
<div class="p-2.5 rounded-xl bg-surface-container-lowest border border-outline-variant/60 space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="font-bold text-white">Tier: MotorSignal Free</span>
<span class="text-[10px] font-mono text-on-surface-variant">Active</span>
</div>
<p class="text-[11px] text-on-surface-variant leading-snug">Standard GPS logging, local ride archives, and public feed access. Multi-sensor telemetry sync requires Pro subscription.</p>
</div>
</div>
<!-- Hardware & Sensors (Truthful States, Internal vs External) -->
<div class="space-y-2">
<div class="flex items-center justify-between text-xs px-1">
<span class="font-bold text-white uppercase tracking-wider text-[11px]">Sensor Connections</span>
<span class="text-[10px] font-mono text-tertiary">1 Active • 1 Disconnected</span>
</div>
<!-- Internal Sensor Card: Active -->
<div class="p-3 rounded-xl bg-surface-container-low border border-outline-variant flex items-center justify-between">
<div class="flex items-center gap-2.5">
<div class="w-9 h-9 rounded-lg bg-surface-container-high flex items-center justify-center text-tertiary">
<span class="material-symbols-outlined" style="font-size: 18px;">smartphone</span>
</div>
<div>
<p class="text-xs font-bold text-white">Internal Phone IMU</p>
<p class="text-[11px] text-tertiary font-mono">Active (100Hz Gyro / Accel)</p>
</div>
</div>
<span class="text-xs text-tertiary font-bold">Online</span>
</div>
<!-- External BLE Sensor: Truthful Disconnected State -->
<div class="p-3 rounded-xl bg-surface-container-low border border-outline-variant flex items-center justify-between">
<div class="flex items-center gap-2.5">
<div class="w-9 h-9 rounded-lg bg-surface-container-high flex items-center justify-center text-on-surface-variant">
<span class="material-symbols-outlined" style="font-size: 18px;">bluetooth_searching</span>
</div>
<div>
<p class="text-xs font-bold text-white">External Bike IMU Sensor</p>
<p class="text-[11px] text-on-surface-variant font-mono">Disconnected • No Paired BLE</p>
</div>
</div>
<button class="h-11 px-3.5 rounded-lg bg-surface-container border border-outline-variant text-primary text-xs font-bold hover:border-primary">
Pair Sensor
</button>
</div>
</div>
<!-- Security & Two-Factor Authentication (Truthfully Unconfigured) -->
<div class="space-y-2">
<span class="font-bold text-white uppercase tracking-wider text-[11px] px-1">Account Security</span>
<div class="p-3.5 rounded-xl bg-surface-container-low border border-outline-variant space-y-2.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2.5">
<div class="w-9 h-9 rounded-lg bg-surface-container-high flex items-center justify-center text-warning">
<span class="material-symbols-outlined" style="font-size: 18px;">security</span>
</div>
<div>
<p class="text-xs font-bold text-white">Two-Factor Authentication (2FA)</p>
<p class="text-[11px] text-on-surface-variant font-mono">Status: Disabled (Not Configured)</p>
</div>
</div>
<button class="h-11 px-3.5 rounded-lg bg-surface-container-high text-primary border border-outline-variant text-xs font-bold hover:border-primary">
Set Up
</button>
</div>
<p class="text-[11px] text-on-surface-variant leading-snug">Protect cloud session telemetry and garage specs with TOTP authenticator or hardware passkeys.</p>
</div>
</div>
<!-- Cloud Sync & Data Management (Scope Notice) -->
<div class="p-3 rounded-xl bg-surface-container-low border border-outline-variant space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-bold text-white">Telemetry Sync Scope</span>
<span class="text-[10px] font-mono text-warning">Proposed Protocol</span>
</div>
<p class="text-[11px] text-on-surface-variant leading-snug">
Automatic synchronization is a proposed background service pending device battery and bandwidth testing. Deletions distinguish between local cache vs. cloud archive purge.
</p>
</div>
</div>
<!-- Bottom Navigation Bar (Persistent 6 Destinations) -->
<nav class="h-[68px] bg-surface-container-lowest border-t border-outline-variant px-2 flex items-center justify-around shrink-0 select-none z-30">
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
<span class="material-symbols-outlined" style="font-size: 20px;">speed</span>
<span class="text-[10px] font-bold mt-0.5">Dash</span>
</button>
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
<span class="material-symbols-outlined" style="font-size: 20px;">explore</span>
<span class="text-[10px] font-bold mt-0.5">Map</span>
</button>
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
<span class="material-symbols-outlined" style="font-size: 20px;">analytics</span>
<span class="text-[10px] font-bold mt-0.5">Activity</span>
</button>
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
<span class="material-symbols-outlined" style="font-size: 20px;">two_wheeler</span>
<span class="text-[10px] font-bold mt-0.5">Garage</span>
</button>
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
<span class="material-symbols-outlined" style="font-size: 20px;">groups</span>
<span class="text-[10px] font-bold mt-0.5">Social</span>
</button>
<button class="flex flex-col items-center justify-center w-12 h-12 text-primary">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1; font-size: 20px;">settings</span>
<span class="text-[10px] font-bold mt-0.5">Settings</span>
</button>
</nav>
<!-- Platform Inset Pill -->
<div class="h-3 w-full bg-surface-container-lowest flex justify-center pb-1">
<div class="w-32 h-1 bg-outline-variant rounded-full"></div>
</div>
</div>
</div>
</div>
</div>
<!-- ======================================================== -->
<!-- SECTION 2: SYSTEM DIALOGS, SHARING & MODAL FLOWS -->
<!-- ======================================================== -->
<div class="pt-8 border-t border-outline-variant">
<div class="mb-6">
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-warning"></span>
Interactive Modal Architecture &amp; System Dialogs
</h2>
<p class="text-sm text-on-surface-variant mt-0.5">Specifications for Share Ride recipient selection, scoped deletion confirmation, and network fallback states.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Dialog 1: Share Ride Recipient Selection Modal -->
<div class="p-5 rounded-2xl bg-surface-container-low border border-outline-variant space-y-4">
<div class="flex items-center justify-between border-b border-outline-variant pb-3">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-primary" style="font-size: 20px;">share</span>
<h3 class="text-sm font-bold text-white">Share Telemetry Replay</h3>
</div>
<span class="text-[10px] font-mono px-2 py-0.5 rounded bg-surface-container text-primary">Recipient Selection</span>
</div>
<p class="text-xs text-on-surface-variant leading-snug">Select specific riders, pack channels, or external platforms to dispatch session telemetry.</p>
<!-- Recipient Radio / Checkbox Options -->
<div class="space-y-2">
<label class="flex items-center justify-between p-2.5 rounded-xl bg-surface-container border border-primary/50 cursor-pointer">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-primary-container/20 flex items-center justify-center text-primary font-bold text-xs">BT</div>
<div>
<p class="text-xs font-bold text-white">Bay Area Twisty Club</p>
<p class="text-[10px] text-on-surface-variant font-mono">14 Active Pack Members</p>
</div>
</div>
<input type="radio" checked name="share_dest" class="text-primary focus:ring-0 w-4 h-4 bg-surface-container-highest"/>
</label>
<label class="flex items-center justify-between p-2.5 rounded-xl bg-surface-container border border-outline-variant cursor-pointer hover:border-outline">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-surface-container-high flex items-center justify-center text-on-surface font-bold text-xs">@M</div>
<div>
<p class="text-xs font-bold text-white">@Marcus (Direct DM)</p>
<p class="text-[10px] text-on-surface-variant font-mono">Rode sweep on Skyline run</p>
</div>
</div>
<input type="radio" name="share_dest" class="text-primary focus:ring-0 w-4 h-4 bg-surface-container-highest"/>
</label>
<label class="flex items-center justify-between p-2.5 rounded-xl bg-surface-container border border-outline-variant cursor-pointer hover:border-outline">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-lg bg-surface-container-high flex items-center justify-center text-on-surface">
<span class="material-symbols-outlined" style="font-size: 16px;">ios_share</span>
</div>
<div>
<p class="text-xs font-bold text-white">System Sheet / GPX Export</p>
<p class="text-[10px] text-on-surface-variant font-mono">Raw .GPX file &amp; replay web link</p>
</div>
</div>
<input type="radio" name="share_dest" class="text-primary focus:ring-0 w-4 h-4 bg-surface-container-highest"/>
</label>
</div>
<div class="flex items-center gap-2 pt-2">
<button class="h-11 flex-1 rounded-xl bg-primary-container text-on-primary-container text-xs font-bold">Confirm Share</button>
<button class="h-11 px-4 rounded-xl bg-surface-container text-white text-xs font-semibold border border-outline-variant">Cancel</button>
</div>
</div>
<!-- Dialog 2: Scoped Deletion Confirmation Modal -->
<div class="p-5 rounded-2xl bg-surface-container-low border border-error/50 space-y-4">
<div class="flex items-center justify-between border-b border-outline-variant pb-3">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-error" style="font-size: 20px;">delete_forever</span>
<h3 class="text-sm font-bold text-white">Delete Ride Session</h3>
</div>
<span class="text-[10px] font-mono px-2 py-0.5 rounded bg-error-container text-error">Scoped Purge</span>
</div>
<p class="text-xs text-on-surface-variant leading-snug">
Specify deletion scope. Do not assume cloud records are automatically expunged when clearing phone storage.
</p>
<!-- Scope Option Selector -->
<div class="space-y-2">
<label class="flex items-start gap-2.5 p-2.5 rounded-xl bg-surface-container border border-outline-variant cursor-pointer">
<input type="radio" name="del_scope" class="mt-0.5 text-error focus:ring-0 w-4 h-4 bg-surface-container-highest"/>
<div>
<p class="text-xs font-bold text-white">Delete Local Device Cache Only</p>
<p class="text-[10px] text-on-surface-variant">Frees up 14.2 MB on phone. Ride remains accessible in your cloud archive.</p>
</div>
</label>
<label class="flex items-start gap-2.5 p-2.5 rounded-xl bg-error-container/30 border border-error/40 cursor-pointer">
<input type="radio" checked name="del_scope" class="mt-0.5 text-error focus:ring-0 w-4 h-4 bg-surface-container-highest"/>
<div>
<p class="text-xs font-bold text-error">Delete Both Local &amp; Cloud Records</p>
<p class="text-[10px] text-on-surface-variant">Permanent purge. GPS track, lean dynamics, and community stats will be unrecoverable.</p>
</div>
</label>
</div>
<div class="flex items-center gap-2 pt-2">
<button class="h-11 flex-1 rounded-xl bg-error text-white text-xs font-bold">Execute Deletion</button>
<button class="h-11 px-4 rounded-xl bg-surface-container text-white text-xs font-semibold border border-outline-variant">Cancel</button>
</div>
</div>
<!-- Dialog 3: Telemetry Buffering vs. Downloading Specification -->
<div class="p-5 rounded-2xl bg-surface-container-low border border-outline-variant space-y-4">
<div class="flex items-center justify-between border-b border-outline-variant pb-3">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-tertiary" style="font-size: 20px;">cloud_download</span>
<h3 class="text-sm font-bold text-white">Data Pipeline Protocol</h3>
</div>
<span class="text-[10px] font-mono px-2 py-0.5 rounded bg-surface-container text-tertiary">Architecture Note</span>
</div>
<div class="space-y-3 text-xs text-on-surface-variant leading-relaxed">
<div class="p-2.5 rounded-xl bg-surface-container-lowest border border-outline-variant space-y-1">
<span class="font-bold text-white flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-primary"></span> 1. File Download Stage
</span>
<p class="text-[11px]">When replaying a cloud ride, client fetches the complete binary `.pb` or SQLite session record before parsing. Not live video-style streaming.</p>
</div>
<div class="p-2.5 rounded-xl bg-surface-container-lowest border border-outline-variant space-y-1">
<span class="font-bold text-white flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-tertiary"></span> 2. Local Memory Buffering
</span>
<p class="text-[11px]">"100% Buffered" indicator denotes in-memory decompression of 100Hz IMU vectors to prevent scrubbing stutter.</p>
</div>
<div class="p-2.5 rounded-xl bg-surface-container-lowest border border-outline-variant space-y-1">
<span class="font-bold text-white flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-warning"></span> 3. Proposed Auto-Sync Rule
</span>
<p class="text-[11px]">Automatic background sync is documented as proposed UX pending validation of operating system background execution limits.</p>
</div>
</div>
</div>
</div>
</div>
<!-- ======================================================== -->
<!-- SECTION 3: SYSTEM STATES MATRIX -->
<!-- ======================================================== -->
<div class="pt-8 border-t border-outline-variant">
<div class="mb-6">
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-primary"></span>
Edge &amp; Exception States Matrix
</h2>
<p class="text-sm text-on-surface-variant mt-0.5">Comprehensive fallback patterns for loading, empty filters, network error, offline queueing, and permission denial.</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<!-- State 1: Loading / Downloading -->
<div class="p-4 rounded-2xl bg-surface-container-low border border-outline-variant space-y-2.5 text-center flex flex-col items-center justify-between">
<div class="w-10 h-10 rounded-full bg-primary-container/20 flex items-center justify-center text-primary mt-2">
<span class="material-symbols-outlined animate-spin" style="font-size: 22px;">progress_activity</span>
</div>
<div>
<h4 class="text-xs font-bold text-white">Loading Telemetry</h4>
<p class="text-[10px] text-on-surface-variant mt-1 leading-snug">Downloading session binary (14.2 MB) from encrypted cloud store...</p>
</div>
<div class="w-full bg-surface-container-highest h-1.5 rounded-full overflow-hidden">
<div class="bg-primary h-full w-[65%]"></div>
</div>
<span class="text-[9px] font-mono text-primary">State: Downloading</span>
</div>
<!-- State 2: Empty Filter -->
<div class="p-4 rounded-2xl bg-surface-container-low border border-outline-variant space-y-2.5 text-center flex flex-col items-center justify-between">
<div class="w-10 h-10 rounded-full bg-surface-container-high flex items-center justify-center text-on-surface-variant mt-2">
<span class="material-symbols-outlined" style="font-size: 22px;">filter_alt_off</span>
</div>
<div>
<h4 class="text-xs font-bold text-white">No Rides Found</h4>
<p class="text-[10px] text-on-surface-variant mt-1 leading-snug">No telemetry recorded for "Track Mode" under selected date filter.</p>
</div>
<button class="h-9 px-3 rounded-lg bg-surface-container border border-outline-variant text-[11px] text-primary font-bold">
Reset Filter
</button>
<span class="text-[9px] font-mono text-on-surface-variant">State: Empty</span>
</div>
<!-- State 3: Network Error -->
<div class="p-4 rounded-2xl bg-surface-container-low border border-error/40 space-y-2.5 text-center flex flex-col items-center justify-between">
<div class="w-10 h-10 rounded-full bg-error-container flex items-center justify-center text-error mt-2">
<span class="material-symbols-outlined" style="font-size: 22px;">cloud_off</span>
</div>
<div>
<h4 class="text-xs font-bold text-white">Sync Failure</h4>
<p class="text-[10px] text-on-surface-variant mt-1 leading-snug">Cloud handshake timed out. Remote server unreachable.</p>
</div>
<button class="h-9 px-3 rounded-lg bg-surface-container border border-outline-variant text-[11px] text-white font-bold">
Retry Sync
</button>
<span class="text-[9px] font-mono text-error">State: Error</span>
</div>
<!-- State 4: Offline Queue -->
<div class="p-4 rounded-2xl bg-surface-container-low border border-warning/40 space-y-2.5 text-center flex flex-col items-center justify-between">
<div class="w-10 h-10 rounded-full bg-warning/20 flex items-center justify-center text-warning mt-2">
<span class="material-symbols-outlined" style="font-size: 22px;">save</span>
</div>
<div>
<h4 class="text-xs font-bold text-white">Offline Queued</h4>
<p class="text-[10px] text-on-surface-variant mt-1 leading-snug">2 rides stored locally in SQLite database cache pending uplink.</p>
</div>
<span class="text-[10px] font-mono text-warning font-bold">Auto-sync Proposed</span>
<span class="text-[9px] font-mono text-warning">State: Queued</span>
</div>
<!-- State 5: Permission Denied -->
<div class="p-4 rounded-2xl bg-surface-container-low border border-outline-variant space-y-2.5 text-center flex flex-col items-center justify-between">
<div class="w-10 h-10 rounded-full bg-surface-container-high flex items-center justify-center text-error mt-2">
<span class="material-symbols-outlined" style="font-size: 22px;">location_disabled</span>
</div>
<div>
<h4 class="text-xs font-bold text-white">Location Denied</h4>
<p class="text-[10px] text-on-surface-variant mt-1 leading-snug">Precise background GPS permission required for route telemetry.</p>
</div>
<button class="h-9 px-3 rounded-lg bg-primary-container text-on-primary-container text-[11px] font-bold">
Open Settings
</button>
<span class="text-[9px] font-mono text-error">State: Denied</span>
</div>
</div>
</div>
<!-- ======================================================== -->
<!-- SECTION 4: CONSOLIDATED DESIGN TOKENS & HANDOFF MATRIX -->
<!-- ======================================================== -->
<div class="pt-8 border-t border-outline-variant">
<div class="mb-6">
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-tertiary"></span>
Consolidated Design Tokens &amp; Multi-Platform Handoff
</h2>
<p class="text-sm text-on-surface-variant mt-0.5">Authoritative token values, component patterns, and platform safe-area implementation contracts.</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Column 1: Semantic Color Tokens -->
<div class="p-5 rounded-2xl bg-surface-container-low border border-outline-variant space-y-4">
<h3 class="text-sm font-bold text-white flex items-center gap-2">
<span class="material-symbols-outlined text-primary" style="font-size: 18px;">palette</span>
Semantic Color Architecture
</h3>
<div class="space-y-2 text-xs font-mono">
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
<span class="text-white">Canvas Surface</span>
<span class="text-primary font-bold">#0B1326</span>
</div>
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
<span class="text-white">Card Primary</span>
<span class="text-primary font-bold">#111C38</span>
</div>
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
<span class="text-white">Card Secondary</span>
<span class="text-primary font-bold">#17223B</span>
</div>
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
<span class="text-white">Border / Stroke</span>
<span class="text-primary font-bold">#2A3652</span>
</div>
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
<span class="text-white">Brand Cyan Accent</span>
<span class="text-primary-container font-bold">#06B6D4</span>
</div>
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
<span class="text-white">Active Recording (REC)</span>
<span class="text-tertiary font-bold">#10B981</span>
</div>
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
<span class="text-white">Caution / Pause Ride</span>
<span class="text-warning font-bold">#F59E0B</span>
</div>
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
<span class="text-white">Danger / Scoped Delete</span>
<span class="text-error font-bold">#EF4444</span>
</div>
</div>
</div>
<!-- Column 2: Typography & Touch Boundaries -->
<div class="p-5 rounded-2xl bg-surface-container-low border border-outline-variant space-y-4">
<h3 class="text-sm font-bold text-white flex items-center gap-2">
<span class="material-symbols-outlined text-primary" style="font-size: 18px;">touch_app</span>
Touch Targets &amp; Type Hierarchy
</h3>
<div class="space-y-3 text-xs leading-relaxed text-on-surface-variant">
<div>
<span class="font-bold text-white block">Standard Interactive Minimum:</span>
<p class="font-mono text-primary text-[11px]">48px × 48px CSS / Flutter Logical Pixels</p>
<p class="text-[11px] mt-0.5">Enforced on all list items, segmented tabs, menu dots, and header navigation buttons.</p>
</div>
<div>
<span class="font-bold text-white block">Primary Riding Controls:</span>
<p class="font-mono text-primary text-[11px]">56px–64px Target Height</p>
<p class="text-[11px] mt-0.5">Pinned Pause and Finish Ride actions maximize hit-area under motorcycle vibration.</p>
</div>
<div>
<span class="font-bold text-white block">Typography Stack:</span>
<ul class="text-[11px] space-y-1 list-disc pl-4 mt-1 font-mono">
<li>Speed Hero: 72px JetBrains Mono bold</li>
<li>Metrics: 16–24px JetBrains Mono bold</li>
<li>Headings: 14–18px Inter bold</li>
<li>Labels/Captions: 10–12px Inter medium</li>
</ul>
</div>
</div>
</div>
<!-- Column 3: Platform Safe Area Implementation Contract -->
<div class="p-5 rounded-2xl bg-surface-container-low border border-outline-variant space-y-4">
<h3 class="text-sm font-bold text-white flex items-center gap-2">
<span class="material-symbols-outlined text-primary" style="font-size: 18px;">devices</span>
Platform Safe Areas &amp; Implementation
</h3>
<div class="space-y-2.5 text-xs text-on-surface-variant leading-relaxed">
<div class="p-2.5 rounded-xl bg-surface-container-lowest border border-outline-variant space-y-1">
<span class="font-bold text-white font-mono text-[11px]">Flutter Implementation:</span>
<p class="text-[11px]">
Use <code class="text-primary font-mono">SafeArea(top: true, bottom: true)</code> or query <code class="text-primary font-mono">MediaQuery.of(context).padding</code> for Notch, Dynamic Island, and gesture bar clearances.
</p>
</div>
<div class="p-2.5 rounded-xl bg-surface-container-lowest border border-outline-variant space-y-1">
<span class="font-bold text-white font-mono text-[11px]">Web / PWA Implementation:</span>
<p class="text-[11px]">
Apply <code class="text-primary font-mono">env(safe-area-inset-top)</code> to sticky app bars and <code class="text-primary font-mono">env(safe-area-inset-bottom)</code> to fixed navigation sheets.
</p>
</div>
<div class="p-2.5 rounded-xl bg-surface-container-lowest border border-outline-variant space-y-1">
<span class="font-bold text-white font-mono text-[11px]">Map Overlay Separation Goal:</span>
<p class="text-[11px]">
Top-right map controls vs. bottom telemetry is designated as a <strong>layout goal pending physical multi-device testing</strong> across varied screen aspect ratios.
</p>
</div>
</div>
</div>
</div>
</div>
</main>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

@ -0,0 +1,583 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>MotorSignal Telemetry &amp; Community - Ride History &amp; Replay HUD</title>
<!-- Tailwind CSS CDN with Forms & Container Plugins -->
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
dark: '#0B1326',
card: '#111C38',
cardSubtle: '#171F33',
border: '#1E293B',
borderLight: '#334155',
cyan: '#06B6D4',
cyanBright: '#22D3EE',
green: '#10B981',
amber: '#F59E0B',
danger: '#EF4444'
}
},
fontFamily: {
mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace']
}
}
}
}
</script>
<!-- BEGIN: Custom Device Frame & Decorative Styles -->
<style data-purpose="device-mockup-frame">
.mobile-screen-container {
width: 390px;
height: 844px;
max-height: 844px;
border-radius: 46px;
box-shadow: 0 25px 60px -15px rgba(0, 0, 0, 0.8), 0 0 0 10px #1E293B, 0 0 0 12px #334155;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
background-color: #0B1326;
}
/* Dynamic Notch / Dynamic Island */
.mobile-island {
width: 122px;
height: 32px;
background-color: #000000;
border-radius: 20px;
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 50;
}
/* Safe Area Callout Overlays */
.safe-area-top-guide {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 47px;
pointer-events: none;
border-bottom: 1px dashed rgba(6, 182, 212, 0.4);
z-index: 60;
}
.safe-area-bottom-guide {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 34px;
pointer-events: none;
border-top: 1px dashed rgba(6, 182, 212, 0.4);
z-index: 60;
}
/* Custom scrollbar hiding */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
<style data-purpose="hud-telemetry-decorations">
/* Subtle grid line pattern for maps */
.topo-grid-pattern {
background-size: 24px 24px;
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
}
.lean-gauge-arc {
transform-origin: center;
transition: stroke-dashoffset 0.3s ease;
}
</style>
<!-- END: Custom Device Frame & Decorative Styles -->
</head>
<body class="bg-[#070C1A] text-slate-100 min-h-screen font-sans antialiased py-10 px-4">
<!-- BEGIN: MasterSpecificationContainer -->
<div class="max-w-7xl mx-auto">
<!-- Header Documentation Banner -->
<header class="mb-10 text-center md:text-left flex flex-col md:flex-row md:items-end md:justify-between border-b border-brand-border pb-6">
<div>
<div class="flex items-center gap-2 justify-center md:justify-start">
<span class="inline-block w-2.5 h-2.5 rounded-full bg-brand-cyan animate-pulse"></span>
<span class="text-xs uppercase tracking-widest text-brand-cyan font-semibold">MotorSignal Telemetry OS</span>
</div>
<h1 class="text-3xl font-extrabold text-white mt-1">Ride History &amp; Replay HUD Specification</h1>
<p class="text-slate-400 text-sm mt-1">High-Fidelity Dual-Viewport Mobile Prototype (iOS 390x844 Frame Specification)</p>
</div>
<div class="mt-4 md:mt-0 flex gap-3 text-xs font-mono text-slate-300 justify-center">
<span class="bg-brand-card border border-brand-border px-3 py-1.5 rounded-lg flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-brand-green"></span> Spec v2.6.4 Live
</span>
<span class="bg-brand-card border border-brand-border px-3 py-1.5 rounded-lg text-slate-400">
Target: 390x844 @3x
</span>
</div>
</header>
<!-- BEGIN: DualViewportPresentationGrid -->
<main class="flex flex-col lg:flex-row items-center justify-center gap-12 mb-16">
<!-- ========================================== -->
<!-- VIEWPORT 1: RIDE HISTORY ARCHIVE -->
<!-- ========================================== -->
<section class="flex flex-col items-center" data-purpose="viewport-ride-history">
<!-- Viewport Label -->
<div class="mb-3 flex items-center justify-between w-[390px] px-2 text-xs font-medium text-slate-400">
<span class="text-brand-cyan font-semibold flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
VIEWPORT 1: Session Archive
</span>
<span class="text-slate-500 font-mono">Screen: Activity.History</span>
</div>
<div class="mobile-screen-container relative">
<!-- Dynamic Island Notch -->
<div class="mobile-island"></div>
<!-- Safe Area Top Guide Callout (Visual Tag) -->
<div class="safe-area-top-guide flex items-end justify-end pr-3 pb-1">
<span class="text-[9px] font-mono text-brand-cyan/60 uppercase">env(safe-area-inset-top)</span>
</div>
<!-- Phone Status Bar -->
<div class="h-12 pt-3 px-7 flex justify-between items-center text-xs font-semibold text-white z-40">
<span>2:02</span>
<div class="flex items-center gap-1.5 text-slate-200">
<svg class="w-3.5 h-3.5" fill="currentColor" viewbox="0 0 24 24"><path d="M12 3c-4.97 0-9 4.03-9 9 0 2.12.74 4.07 1.97 5.61L4.35 18.25l1.41 1.41.64-.64C7.93 20.26 9.88 21 12 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm0 16c-3.86 0-7-3.14-7-7s3.14-7 7-7 7 3.14 7 7-3.14 7-7 7z"></path></svg>
<svg class="w-3.5 h-3.5" fill="currentColor" viewbox="0 0 24 24"><path d="M12 4a8 8 0 00-8 8c0 2.21.89 4.21 2.34 5.66l1.41-1.41C6.6 15.1 6 13.62 6 12a6 6 0 1112 0c0 1.62-.6 3.1-1.76 4.24l1.41 1.41A8.002 8.002 0 0012 4z"></path></svg>
<div class="w-5 h-2.5 border border-white/80 rounded-sm p-0.5 flex items-center">
<div class="h-full bg-white w-4/5 rounded-2xs"></div>
</div>
</div>
</div>
<!-- BEGIN: Sticky Screen Header -->
<header class="px-5 pt-1 pb-3 bg-brand-dark/95 backdrop-blur border-b border-brand-border/60 z-30">
<h2 class="text-xl font-bold tracking-tight text-center text-white">Ride History</h2>
<!-- Segmented Switch Rides vs Records -->
<div class="mt-3 grid grid-cols-2 p-1 bg-brand-cardSubtle rounded-xl border border-brand-border text-xs font-semibold">
<button class="py-2 rounded-lg bg-cyan-950/80 text-brand-cyan border border-brand-cyan/40 shadow flex items-center justify-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M5 13l4 4L19 7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"></path></svg>
Rides (13)
</button>
<button class="py-2 rounded-lg text-slate-400 hover:text-slate-200 flex items-center justify-center gap-1.5 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
Records (4)
</button>
</div>
<!-- Motorcycle Dropdown Filter Button (Minimum 48px Target) -->
<div class="mt-3 flex items-center gap-2">
<button aria-label="Filter by bike" class="w-12 h-12 flex items-center justify-center rounded-xl bg-brand-card border border-brand-border text-brand-cyan hover:bg-slate-800 transition">
<svg class="w-4 h-4" fill="currentColor" viewbox="0 0 24 24"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"></path></svg>
</button>
<button aria-label="Select motorcycle fleet" class="flex-1 h-12 px-4 rounded-xl bg-brand-card border border-brand-border flex items-center justify-between text-xs font-semibold text-slate-200 hover:border-slate-600 transition">
<span class="flex items-center gap-2 truncate">
<span class="w-2 h-2 rounded-full bg-brand-cyan"></span>
All Motorcycles
</span>
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M19 9l-7 7-7-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
</button>
</div>
</header>
<!-- END: Sticky Screen Header -->
<!-- Scrollable Ride Content Feed -->
<div class="flex-1 overflow-y-auto px-4 py-3 space-y-3 no-scrollbar pb-24">
<!-- Offline Sync Status Banner -->
<div class="bg-amber-950/40 border border-brand-amber/40 rounded-xl p-3 flex items-center gap-2.5" data-purpose="sync-banner">
<span class="w-2.5 h-2.5 rounded-full bg-brand-amber shrink-0 animate-ping"></span>
<div class="text-[11px] leading-tight text-amber-200">
<strong class="font-semibold">Offline Mode:</strong> 2 rides queued locally (auto-sync on connection).
</div>
</div>
<!-- PRIMARY CARD: Ride 12 (Targeted Detailed Session) -->
<article class="bg-brand-card border-2 border-brand-cyan/60 rounded-2xl p-3.5 shadow-lg relative overflow-hidden" data-purpose="primary-ride-card">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h3 class="text-base font-bold text-white tracking-wide">Ride 12</h3>
<span class="text-[10px] bg-cyan-950 text-brand-cyan font-mono font-medium px-2 py-0.5 rounded border border-brand-cyan/30">
2001 YAMAHA Y...
</span>
</div>
<time class="text-[10px] text-slate-400 font-mono">2026-10-10 03:23</time>
</div>
<!-- Route Title Tag -->
<p class="text-xs font-medium text-slate-300 mt-1">Skyline Blvd &amp; Pescadero Carve</p>
<!-- Metrics Matrix Grid -->
<div class="grid grid-cols-3 gap-2 mt-3 pt-2.5 border-t border-brand-border/60">
<div>
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Trip</span>
<span class="text-sm font-bold text-white">15.7 <span class="text-[10px] font-normal text-slate-400">mi</span></span>
</div>
<div>
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Time</span>
<span class="text-sm font-bold text-white">16 <span class="text-[10px] font-normal text-slate-400">min</span></span>
</div>
<div>
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Top Spd</span>
<span class="text-sm font-bold text-white">84 <span class="text-[10px] font-normal text-slate-400">mph</span></span>
</div>
<div>
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Max Lean</span>
<span class="text-sm font-bold text-brand-cyanBright">27° <span class="text-[9px] font-normal text-slate-400">R</span></span>
</div>
<div>
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Hard Brk</span>
<span class="text-sm font-bold text-red-400">0.49 G</span>
</div>
<div>
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Elev Gain</span>
<span class="text-sm font-bold text-slate-200">69 m</span>
</div>
</div>
<!-- Minimum 48px Interactive CTA to Replay -->
<button class="mt-3.5 w-full h-12 bg-brand-cyan hover:bg-brand-cyanBright text-slate-950 font-bold rounded-xl flex items-center justify-center gap-2 text-xs transition active:scale-[0.98] shadow-md shadow-cyan-500/20">
<svg class="w-4 h-4 fill-current" viewbox="0 0 24 24"><path d="M8 5v14l11-7z"></path></svg>
REPLAY TELEMETRY SESSION
</button>
</article>
<!-- SECONDARY CARD: Ride 11 (Completed & Synced) -->
<article class="bg-brand-card border border-brand-border rounded-2xl p-3.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h3 class="text-base font-bold text-slate-200">Ride 11</h3>
<span class="text-[10px] bg-slate-800 text-slate-300 font-mono px-2 py-0.5 rounded">
DUCATI V4
</span>
</div>
<span class="text-[9px] bg-emerald-950/80 text-brand-green border border-emerald-800/40 px-2 py-0.5 rounded-full flex items-center gap-1 font-mono">
<span class="w-1.5 h-1.5 rounded-full bg-brand-green"></span> Synced
</span>
</div>
<div class="grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-brand-border/40 text-xs">
<div>
<span class="text-[9px] text-slate-400 block font-semibold">TRIP</span>
<span class="font-bold text-slate-100">42.1 mi</span>
</div>
<div>
<span class="text-[9px] text-slate-400 block font-semibold">TIME</span>
<span class="font-bold text-slate-100">54 min</span>
</div>
<div>
<span class="text-[9px] text-slate-400 block font-semibold">TOP SPD</span>
<span class="font-bold text-slate-100">72 mph</span>
</div>
</div>
</article>
<!-- EMPTY FILTER STATE CARD: Track Mode demonstration -->
<div class="border border-dashed border-brand-borderLight rounded-2xl p-4 text-center bg-brand-card/40" data-purpose="empty-state-notice">
<div class="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center mx-auto text-slate-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
</div>
<p class="text-xs font-semibold text-slate-300 mt-2">No rides found for Track Mode</p>
<p class="text-[10px] text-slate-500 mt-0.5">Change telemetry preset or switch filter above.</p>
</div>
</div>
<!-- BEGIN: Fixed Mobile Bottom Navigation Bar -->
<nav aria-label="Primary mobile nav" class="absolute bottom-0 left-0 right-0 h-20 bg-brand-card/95 backdrop-blur-md border-t border-brand-border flex items-start justify-around px-2 pt-2 z-40">
<!-- Dash -->
<button class="flex flex-col items-center justify-center w-12 h-12 text-slate-400 hover:text-slate-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
<span class="text-[9px] mt-1 font-medium">Dash</span>
</button>
<!-- Map -->
<button class="flex flex-col items-center justify-center w-12 h-12 text-slate-400 hover:text-slate-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
<span class="text-[9px] mt-1 font-medium">Map</span>
</button>
<!-- Activity (ACTIVE HIGHLIGHT) -->
<button class="flex flex-col items-center justify-center w-12 h-12 text-brand-cyan">
<div class="relative">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"></path></svg>
<span class="absolute -top-1 -right-1.5 w-2 h-2 rounded-full bg-brand-cyan"></span>
</div>
<span class="text-[9px] mt-1 font-bold">Activity</span>
</button>
<!-- Garage -->
<button class="flex flex-col items-center justify-center w-12 h-12 text-slate-400 hover:text-slate-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
<span class="text-[9px] mt-1 font-medium">Garage</span>
</button>
<!-- Social -->
<button class="flex flex-col items-center justify-center w-12 h-12 text-slate-400 hover:text-slate-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
<span class="text-[9px] mt-1 font-medium">Social</span>
</button>
<!-- Settings -->
<button class="flex flex-col items-center justify-center w-12 h-12 text-slate-400 hover:text-slate-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
<span class="text-[9px] mt-1 font-medium">Settings</span>
</button>
</nav>
<!-- END: Fixed Mobile Bottom Navigation Bar -->
<!-- Safe Area Bottom Guide Callout -->
<div class="safe-area-bottom-guide flex items-start justify-center pt-0.5">
<span class="text-[9px] font-mono text-brand-cyan/60 uppercase">env(safe-area-inset-bottom)</span>
</div>
</div>
</section>
<!-- ========================================== -->
<!-- VIEWPORT 2: RIDE REPLAY HUD -->
<!-- ========================================== -->
<section class="flex flex-col items-center" data-purpose="viewport-ride-replay">
<!-- Viewport Label -->
<div class="mb-3 flex items-center justify-between w-[390px] px-2 text-xs font-medium text-slate-400">
<span class="text-brand-cyanBright font-semibold flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path><path d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
VIEWPORT 2: Telemetry Replay HUD
</span>
<span class="text-slate-500 font-mono">Screen: Replay.ActiveHUD</span>
</div>
<div class="mobile-screen-container relative bg-slate-950">
<!-- Dynamic Island Notch -->
<div class="mobile-island"></div>
<!-- Safe Area Top Guide Callout -->
<div class="safe-area-top-guide flex items-end justify-end pr-3 pb-1">
<span class="text-[9px] font-mono text-brand-cyan/60 uppercase">env(safe-area-inset-top)</span>
</div>
<!-- Phone Status Bar -->
<div class="h-12 pt-3 px-7 flex justify-between items-center text-xs font-semibold text-white z-40">
<span>2:02</span>
<div class="flex items-center gap-1.5 text-slate-200">
<svg class="w-3.5 h-3.5" fill="currentColor" viewbox="0 0 24 24"><path d="M12 3c-4.97 0-9 4.03-9 9 0 2.12.74 4.07 1.97 5.61L4.35 18.25l1.41 1.41.64-.64C7.93 20.26 9.88 21 12 21c4.97 0 9-4.03 9-9s-4.03-9-9-9z"></path></svg>
<div class="w-5 h-2.5 border border-white/80 rounded-sm p-0.5 flex items-center">
<div class="h-full bg-white w-4/5 rounded-2xs"></div>
</div>
</div>
</div>
<!-- Replay Header Action Bar (All touch targets >= 48px) -->
<header class="h-14 px-3 flex items-center justify-between border-b border-brand-border/60 bg-brand-dark/90 backdrop-blur z-30">
<!-- Back to Archive Button -->
<button aria-label="Exit Replay HUD" class="w-12 h-12 flex items-center justify-center text-slate-300 hover:text-white rounded-xl active:bg-slate-800">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M15 19l-7-7 7-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"></path></svg>
</button>
<!-- Replay Track Title -->
<div class="text-center px-1 truncate">
<h2 class="text-xs font-bold text-white truncate">Ride 12 • Skyline &amp; Pescadero</h2>
<span class="text-[10px] text-brand-cyan font-mono block">GPS Telemetry Synced</span>
</div>
<!-- Header Action Buttons (Share & Delete) -->
<div class="flex items-center gap-0.5">
<!-- Share Button -->
<button aria-label="Share Session" class="w-12 h-12 flex items-center justify-center text-slate-300 hover:text-brand-cyan rounded-xl active:bg-slate-800">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
</button>
<!-- Destructive Delete Button (Red strictly reserved for deletion) -->
<button aria-label="Delete Session" class="w-12 h-12 flex items-center justify-center text-brand-danger hover:bg-red-950/40 rounded-xl transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
</button>
</div>
</header>
<!-- BEGIN: Map Visualizer Canvas Container -->
<div class="relative flex-1 bg-slate-950 topo-grid-pattern overflow-hidden" data-purpose="replay-map-container">
<!-- Telemetry Buffering / Cache State Pill -->
<div class="absolute top-3 left-4 z-20">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-mono bg-brand-card/90 border border-brand-green/40 text-brand-green backdrop-blur shadow">
<span class="w-1.5 h-1.5 rounded-full bg-brand-green animate-pulse"></span>
100% Buffered (Local cache)
</span>
</div>
<!-- STRICT ISOLATION: Floating Map Controls (Top-Right Quadrant, isolated from telemetry overlays) -->
<div class="absolute top-3 right-3 flex flex-col gap-1.5 z-20" data-purpose="isolated-map-controls">
<button aria-label="Zoom in" class="w-12 h-12 rounded-xl bg-brand-card/90 border border-brand-border text-slate-200 flex items-center justify-center hover:bg-brand-cardSubtle active:scale-95 shadow backdrop-blur">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M12 6v6m0 0v6m0-6h6m-6 0H6" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
</button>
<button aria-label="Zoom out" class="w-12 h-12 rounded-xl bg-brand-card/90 border border-brand-border text-slate-200 flex items-center justify-center hover:bg-brand-cardSubtle active:scale-95 shadow backdrop-blur">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M18 12H6" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
</button>
<button aria-label="Toggle map layer styles" class="w-12 h-12 rounded-xl bg-brand-card/90 border border-brand-border text-slate-200 flex items-center justify-center hover:bg-brand-cardSubtle active:scale-95 shadow backdrop-blur">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
</button>
</div>
<!-- Stylized GPS Trace Path SVG Visualizer -->
<svg class="absolute inset-0 w-full h-full pointer-events-none" fill="none" viewbox="0 0 390 380">
<!-- Route Path Trajectory Glow -->
<path d="M 40 310 C 80 290, 110 240, 130 180 C 150 120, 200 90, 240 140 C 270 180, 290 260, 330 220 C 350 200, 360 140, 370 120" opacity="0.25" stroke="#06B6D4" stroke-linecap="round" stroke-linejoin="round" stroke-width="6"></path>
<!-- Route Path Completed Segment -->
<path d="M 40 310 C 80 290, 110 240, 130 180 C 150 120, 200 90, 240 140" stroke="#22D3EE" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5"></path>
<!-- Route Path Remaining Segment -->
<path d="M 240 140 C 270 180, 290 260, 330 220 C 350 200, 360 140, 370 120" stroke="#475569" stroke-dasharray="4 4" stroke-linecap="round" stroke-linejoin="round" stroke-width="3"></path>
<!-- Apex Caution Indicator (Amber semantic) -->
<circle cx="205" cy="94" fill="#F59E0B" r="5"></circle>
<text fill="#F59E0B" font-family="monospace" font-size="9" font-weight="bold" x="214" y="98">51° APEX</text>
<!-- Live Vehicle Cursor Position -->
<g transform="translate(240, 140)">
<circle class="animate-ping" cx="0" cy="0" fill="#06B6D4" opacity="0.2" r="14"></circle>
<circle cx="0" cy="0" fill="#06B6D4" r="7" stroke="#FFFFFF" stroke-width="2"></circle>
<!-- Direction Pointer Arrow -->
<polygon fill="#22D3EE" points="0,-12 4,-5 -4,-5" transform="rotate(45)"></polygon>
</g>
</svg>
<!-- Bottom Elevation Profile Mini HUD Pill -->
<div class="absolute bottom-4 left-4 z-20">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[10px] font-mono bg-brand-card/90 border border-brand-border text-slate-200 backdrop-blur shadow">
<svg class="w-3 h-3 text-brand-cyan" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
ELV +2,140 FT • GRADE 6.2%
</span>
</div>
</div>
<!-- END: Map Visualizer Canvas Container -->
<!-- BEGIN: Docked Telemetry Cluster & Playback Engine -->
<div class="bg-brand-card border-t border-brand-border px-4 pt-3 pb-8 z-30" data-purpose="docked-telemetry-engine">
<!-- Live Real-Time Telemetry Cluster -->
<div class="grid grid-cols-3 gap-2 py-2 mb-3 bg-brand-dark/80 rounded-xl border border-brand-border/60 text-center">
<!-- Live Speed -->
<div class="px-2">
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Live Speed</span>
<div class="text-xl font-black text-white tracking-tight">58 <span class="text-[10px] font-medium text-brand-cyan">MPH</span></div>
</div>
<!-- Lean Angle Gauge with Visual Arc -->
<div class="px-2 border-x border-brand-border/50 flex flex-col items-center justify-center">
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Lean Angle</span>
<div class="flex items-center gap-1">
<!-- Lean Direction Arc Indicator -->
<svg class="w-4 h-4 text-brand-cyan" fill="none" stroke="currentColor" viewbox="0 0 24 24">
<path d="M19 14l-7 7m0 0l-7-7m7 7V3" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path>
</svg>
<span class="text-xl font-black text-brand-cyanBright">21° <span class="text-[10px] font-semibold text-slate-300">R</span></span>
</div>
</div>
<!-- Lateral G Force -->
<div class="px-2">
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Lateral G</span>
<div class="text-xl font-black text-white tracking-tight">0.38 <span class="text-[10px] font-medium text-slate-400">G</span></div>
</div>
</div>
<!-- Scrubbable Timeline Track & Time Displays -->
<div class="space-y-1.5">
<div class="flex justify-between items-center text-[10px] font-mono text-slate-400">
<span class="text-brand-cyan font-bold">08:42</span>
<span class="text-slate-500">SESSION PROGRESS</span>
<span>16:00</span>
</div>
<!-- Timeline Bar with 48px touch-target area for scrubbing -->
<div class="relative h-6 flex items-center cursor-pointer group">
<div class="w-full h-2 bg-slate-800 rounded-full overflow-hidden">
<div class="h-full bg-gradient-to-r from-brand-cyan to-brand-cyanBright rounded-full" style="width: 54.3%;"></div>
</div>
<!-- Interactive Scrub Handle Thumb -->
<div class="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white border-2 border-brand-cyan rounded-full shadow-md pointer-events-none" style="left: calc(54.3% - 8px);"></div>
</div>
</div>
<!-- Playback Control Bar (All Minimum 48px Touch Targets) -->
<div class="flex items-center justify-between pt-1">
<!-- Rewind 15s -->
<button aria-label="Skip backwards 15 seconds" class="w-12 h-12 rounded-xl flex items-center justify-center text-slate-300 hover:text-white active:bg-slate-800 transition">
<div class="relative flex items-center justify-center">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M12.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0019 16V8a1 1 0 00-1.6-.8l-5.334 4zM4.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0011 16V8a1 1 0 00-1.6-.8l-5.334 4z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
<span class="text-[8px] font-bold absolute -bottom-1 text-slate-400">15</span>
</div>
</button>
<!-- Primary Play/Pause Button (56dp Highlighted Button) -->
<button aria-label="Pause replay" class="w-14 h-14 rounded-full bg-brand-cyan hover:bg-brand-cyanBright text-slate-950 flex items-center justify-center shadow-lg shadow-cyan-500/25 active:scale-95 transition">
<svg class="w-6 h-6 fill-current" viewbox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"></path></svg>
</button>
<!-- Forward 15s -->
<button aria-label="Skip forward 15 seconds" class="w-12 h-12 rounded-xl flex items-center justify-center text-slate-300 hover:text-white active:bg-slate-800 transition">
<div class="relative flex items-center justify-center">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M11.933 12.8a1 1 0 000-1.6L6.6 7.2A1 1 0 005 8v8a1 1 0 001.6.8l5.333-4zM19.933 12.8a1 1 0 000-1.6l-5.333-4A1 1 0 0013 8v8a1 1 0 001.6.8l5.333-4z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
<span class="text-[8px] font-bold absolute -bottom-1 text-slate-400">15</span>
</div>
</button>
<!-- Playback Speed Multiplier Pill (Min 48px Target) -->
<button aria-label="Replay speed toggle" class="h-12 px-3 rounded-xl bg-brand-cardSubtle border border-brand-border text-brand-cyan text-xs font-mono font-bold flex items-center justify-center hover:bg-slate-800 transition">
2.0x
</button>
</div>
</div>
<!-- END: Docked Telemetry Cluster & Playback Engine -->
<!-- Safe Area Bottom Guide Callout -->
<div class="safe-area-bottom-guide flex items-start justify-center pt-0.5">
<span class="text-[9px] font-mono text-brand-cyan/60 uppercase">env(safe-area-inset-bottom)</span>
</div>
</div>
</section>
</main>
<!-- END: DualViewportPresentationGrid -->
<!-- ========================================== -->
<!-- LOWER ARCHITECTURAL CALLOUT CARDS -->
<!-- ========================================== -->
<!-- BEGIN: ArchitecturalSpecSection -->
<section class="mt-8 border-t border-brand-border pt-10" data-purpose="architectural-spec-cards">
<div class="mb-6">
<h3 class="text-xl font-bold text-white flex items-center gap-2">
<span class="w-2 h-2 rounded bg-brand-cyan"></span>
System Architecture &amp; Interaction Specifications
</h3>
<p class="text-sm text-slate-400">Engineered constraints, platform layout standards, and user experience guarantees.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- CARD 1: Map Controls vs Telemetry Overlay Isolation -->
<article class="bg-brand-card border border-brand-border rounded-2xl p-5 flex flex-col justify-between">
<div>
<div class="w-10 h-10 rounded-xl bg-cyan-950/60 border border-brand-cyan/40 text-brand-cyan flex items-center justify-center mb-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
</div>
<h4 class="text-base font-bold text-white mb-2">1. Map Controls vs Telemetry Overlay</h4>
<p class="text-xs text-slate-300 leading-relaxed">
Floating map controls (+, -, layers) are strictly docked to the <strong>Top-Right quadrant</strong> with isolated pointer-event boundaries. They are decoupled from lower telemetry clusters to prevent scrub mis-taps while riding or reviewing.
</p>
</div>
<div class="mt-4 pt-3 border-t border-brand-border/60">
<span class="text-[10px] font-mono text-brand-cyan">Rule: 48dp separation zone</span>
</div>
</article>
<!-- CARD 2: Platform Safe Areas -->
<article class="bg-brand-card border border-brand-border rounded-2xl p-5 flex flex-col justify-between">
<div>
<div class="w-10 h-10 rounded-xl bg-blue-950/60 border border-blue-500/40 text-blue-400 flex items-center justify-center mb-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
</div>
<h4 class="text-base font-bold text-white mb-2">2. Platform Safe Areas</h4>
<p class="text-xs text-slate-300 leading-relaxed">
Dynamic padding utilizes <code class="text-brand-cyan font-mono text-[11px]">env(safe-area-inset-top)</code> (47px iOS island clearance) and <code class="text-brand-cyan font-mono text-[11px]">env(safe-area-inset-bottom)</code> (34px home indicator buffer) ensuring zero occlusion on gesture devices.
</p>
</div>
<div class="mt-4 pt-3 border-t border-brand-border/60">
<span class="text-[10px] font-mono text-blue-400">Rule: Full notch &amp; home bar immunity</span>
</div>
</article>
<!-- CARD 3: Telemetry Streaming/Buffering States -->
<article class="bg-brand-card border border-brand-border rounded-2xl p-5 flex flex-col justify-between">
<div>
<div class="w-10 h-10 rounded-xl bg-emerald-950/60 border border-brand-green/40 text-brand-green flex items-center justify-center mb-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
</div>
<h4 class="text-base font-bold text-white mb-2">3. Telemetry Streaming &amp; Cache</h4>
<p class="text-xs text-slate-300 leading-relaxed">
Sessions store IMU 100Hz packets in IndexedDB/SQLite. Visualizer displays green <span class="text-brand-green font-semibold">100% Buffered</span> state. When roaming without LTE, offline queue alerts notify riders of local persistence.
</p>
</div>
<div class="mt-4 pt-3 border-t border-brand-border/60">
<span class="text-[10px] font-mono text-brand-green">State: Zero packet loss cache</span>
</div>
</article>
<!-- CARD 4: Destructive Deletion Confirmation Pattern -->
<article class="bg-brand-card border border-brand-border rounded-2xl p-5 flex flex-col justify-between">
<div>
<div class="w-10 h-10 rounded-xl bg-red-950/60 border border-brand-danger/40 text-brand-danger flex items-center justify-center mb-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
</div>
<h4 class="text-base font-bold text-white mb-2">4. Destructive Deletion Pattern</h4>
<p class="text-xs text-slate-300 leading-relaxed">
Red (<span class="text-brand-danger font-semibold">#EF4444</span>) is strictly reserved for destructive deletion. Tapping the header trash icon requires an intentional two-step confirmation sheet to purge irreversible GPS and lean-angle logs.
</p>
</div>
<div class="mt-4 pt-3 border-t border-brand-border/60">
<span class="text-[10px] font-mono text-brand-danger">Security: 2-step verification modal</span>
</div>
</article>
</div>
</section>
<!-- END: ArchitecturalSpecSection -->
<!-- Footer Meta -->
<footer class="mt-12 text-center text-xs text-slate-500 pb-6 border-t border-brand-border/40 pt-6">
MotorSignal Telemetry Design System • High-Performance Motorcycle Telemetry Platform • Specification Screen
</footer>
</div>
<!-- END: MasterSpecificationContainer -->
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Some files were not shown because too many files have changed in this diff Show More