summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-04-23 23:10:11 +0000
committerClaude <noreply@anthropic.com>2026-04-23 23:10:11 +0000
commite1db9200e9d44c10450361cc8984a45b2eda87b7 (patch)
tree21bdfca02491e6ff4cde7a22673c821c0a03e108 /docs
parent55509f579b4d074f01237dd90791b6d25aaec3cd (diff)
parenteb78d317c722234a7ef2c501c68c9aa730ec2758 (diff)
Merge origin/main — unite all work onto one branch
Brings in dev log (NavLogger), UnitPrefs, MapLayerManager, HUD views, conditions throttling, track save/load pipeline, improved ParticleWindView (antimeridian-aware, dynamic particle count), Snackbar error surfacing, and all other main-branch work from the prior session. Combined with master's hardware source flags, vessel registry, crew management, thermal alarm, CPA collision alerts, and track stats. Also documents primary branch policy in CLAUDE.md and .agent/config.md: all work merges to main, never master. https://claude.ai/code/session_011h2dXbgXg3PesQMmQUNTCW
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-04-03-map-interaction.md256
-rw-r--r--docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md1407
-rw-r--r--docs/superpowers/specs/2026-04-03-map-interaction-design.md59
-rw-r--r--docs/superpowers/specs/2026-04-04-instrument-sheet-visual-redesign.md156
4 files changed, 1878 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-04-03-map-interaction.md b/docs/superpowers/plans/2026-04-03-map-interaction.md
new file mode 100644
index 0000000..9f8fa13
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-03-map-interaction.md
@@ -0,0 +1,256 @@
+# Map Interaction Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add manual pan/zoom with auto-follow GPS centering, a Recenter button that appears when the user pans away, and full UI fade-out in manual mode.
+
+**Architecture:** `MapHandler` owns an `isFollowing: StateFlow<Boolean>` and registers a `MapLibreMap.OnCameraMoveStartedListener` to detect gesture-driven camera moves. `MainActivity` collects the flow and animates the bottom sheet, nav bar, FABs, and recenter button in/out.
+
+**Tech Stack:** MapLibre Android SDK (`MapLibreMap.OnCameraMoveStartedListener`, `REASON_API_GESTURE`), Kotlin `StateFlow`, Android `View.animate()`.
+
+---
+
+## File Map
+
+| File | What changes |
+|------|-------------|
+| `android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt` | Add `isFollowing` StateFlow, `lastLat`/`lastLon`, gesture listener, `recenter()`, guard in `centerOnLocation()` |
+| `android-app/app/src/main/res/layout/activity_main.xml` | Add `fab_recenter` pill button inside the map ConstraintLayout |
+| `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt` | Observe `mapHandler.isFollowing`, animate UI in/out, wire `fab_recenter` click |
+
+---
+
+### Task 1: Extend MapHandler with follow state and gesture detection
+
+**Files:**
+- Modify: `android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt`
+
+- [ ] **Step 1: Add imports and new fields**
+
+Open `MapHandler.kt`. Add these imports at the top (after existing imports):
+
+```kotlin
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.maplibre.android.maps.MapLibreMap
+```
+
+Add these fields inside the `MapHandler` class, before `setupLayers`:
+
+```kotlin
+private val _isFollowing = MutableStateFlow(true)
+val isFollowing: StateFlow<Boolean> = _isFollowing.asStateFlow()
+
+private var lastLat: Double = 0.0
+private var lastLon: Double = 0.0
+```
+
+- [ ] **Step 2: Register the gesture listener in the constructor**
+
+Replace the class declaration line:
+```kotlin
+class MapHandler(private val maplibreMap: MapLibreMap) {
+```
+with:
+```kotlin
+class MapHandler(private val maplibreMap: MapLibreMap) {
+
+ init {
+ maplibreMap.addOnCameraMoveStartedListener { reason ->
+ if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) {
+ _isFollowing.value = false
+ }
+ }
+ }
+```
+
+- [ ] **Step 3: Guard centerOnLocation and store last position**
+
+Replace the existing `centerOnLocation` method:
+```kotlin
+fun centerOnLocation(lat: Double, lon: Double, zoom: Double = 14.0) {
+ lastLat = lat
+ lastLon = lon
+ if (!_isFollowing.value) return
+ val position = CameraPosition.Builder()
+ .target(LatLng(lat, lon))
+ .zoom(zoom)
+ .build()
+ maplibreMap.animateCamera(CameraUpdateFactory.newCameraPosition(position), 1000)
+}
+```
+
+- [ ] **Step 4: Add recenter()**
+
+Add this method after `centerOnLocation`:
+```kotlin
+fun recenter() {
+ _isFollowing.value = true
+ centerOnLocation(lastLat, lastLon)
+}
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
+git commit -m "feat(map): add isFollowing state and gesture-driven manual mode to MapHandler"
+```
+
+---
+
+### Task 2: Add the Recenter button to the layout
+
+**Files:**
+- Modify: `android-app/app/src/main/res/layout/activity_main.xml`
+
+- [ ] **Step 1: Add fab_recenter inside the ConstraintLayout**
+
+In `activity_main.xml`, find the ConstraintLayout that wraps `mapView` (around line 10). It currently contains `mapView` and `fragment_container`. Add the recenter button as a third child, before the closing `</androidx.constraintlayout.widget.ConstraintLayout>` tag:
+
+```xml
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/fab_recenter"
+ android:layout_width="wrap_content"
+ android:layout_height="40dp"
+ android:text="⊙ Recenter"
+ android:textSize="13sp"
+ android:paddingStart="20dp"
+ android:paddingEnd="20dp"
+ android:visibility="gone"
+ app:cornerRadius="20dp"
+ app:elevation="20dp"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ android:layout_marginBottom="24dp" />
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add android-app/app/src/main/res/layout/activity_main.xml
+git commit -m "feat(ui): add fab_recenter pill button to map layout"
+```
+
+---
+
+### Task 3: Wire MainActivity to animate UI on follow state changes
+
+**Files:**
+- Modify: `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt`
+
+- [ ] **Step 1: Add imports**
+
+Add these imports to `MainActivity.kt` (with existing imports):
+```kotlin
+import androidx.cardview.widget.CardView
+import com.google.android.material.button.MaterialButton
+```
+
+- [ ] **Step 2: Add view fields**
+
+In the `MainActivity` class body, alongside the existing `fabRecordTrack` declaration, add:
+```kotlin
+private lateinit var fabMob: FloatingActionButton
+private lateinit var fabRecenter: MaterialButton
+private lateinit var bottomSheet: CardView
+private lateinit var bottomNav: BottomNavigationView
+```
+
+- [ ] **Step 3: Add the fade helpers**
+
+Add these two private methods to `MainActivity` (before `onStart`):
+
+```kotlin
+private fun fadeOut(vararg views: View, gone: Boolean = false) {
+ views.forEach { v ->
+ v.animate().alpha(0f).setDuration(150).withEndAction {
+ v.visibility = if (gone) View.GONE else View.INVISIBLE
+ }.start()
+ }
+}
+
+private fun fadeIn(vararg views: View) {
+ views.forEach { v ->
+ v.alpha = 0f
+ v.visibility = View.VISIBLE
+ v.animate().alpha(1f).setDuration(150).start()
+ }
+}
+```
+
+- [ ] **Step 4: Wire up views and observe isFollowing in initializeUI**
+
+In `initializeUI()`, after the existing `fabRecordTrack` setup, add:
+
+```kotlin
+fabMob = findViewById(R.id.fab_mob)
+fabRecenter = findViewById(R.id.fab_recenter)
+bottomSheet = findViewById(R.id.instrument_bottom_sheet)
+bottomNav = findViewById(R.id.bottom_navigation)
+
+fabRecenter.setOnClickListener {
+ mapHandler?.recenter()
+}
+```
+
+- [ ] **Step 5: Observe isFollowing after mapHandler is created**
+
+In `setupMap()`, inside the `getMapAsync` lambda, after `mapHandler = MapHandler(maplibreMap)`, add:
+
+```kotlin
+lifecycleScope.launch {
+ mapHandler!!.isFollowing.collect { following ->
+ if (following) {
+ fadeOut(fabRecenter, gone = true)
+ fadeIn(bottomSheet, bottomNav, fabMob, fabRecordTrack)
+ } else {
+ fadeOut(bottomSheet, bottomNav, fabMob, fabRecordTrack, gone = true)
+ fadeIn(fabRecenter)
+ }
+ }
+}
+```
+
+- [ ] **Step 6: Manual smoke test**
+
+Build and install. Verify:
+1. App opens — bottom sheet, nav, FABs visible; no Recenter button
+2. Pan the map — all UI fades out, Recenter button fades in
+3. Tap Recenter — UI fades back in, map animates to GPS position, Recenter gone
+4. GPS updates while in manual mode — map does NOT jump back to GPS position
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+git commit -m "feat(map): wire UI fade-out and recenter button to MapHandler.isFollowing"
+```
+
+---
+
+### Task 4: Request Gemini review, fix issues, loop until clean, merge
+
+- [ ] **Step 1: Push branch and open PR**
+```bash
+git push local main
+git push github main
+```
+Then open a PR (or request review inline if working on main).
+
+- [ ] **Step 2: Request code review using code-review:code-review skill**
+
+Invoke `code-review:code-review` skill against the PR. Address all issues with confidence ≥ 80.
+
+- [ ] **Step 3: Loop until review returns clean**
+
+Re-request review after each fix. Stop when no issues ≥ 80 are found.
+
+- [ ] **Step 4: Merge and push to both remotes**
+```bash
+gh pr merge <number> --repo thepeterstone/nav --squash --delete-branch
+git checkout main && git pull github main
+git push local main
+```
diff --git a/docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md b/docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md
new file mode 100644
index 0000000..e1a086b
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md
@@ -0,0 +1,1407 @@
+# Instrument Sheet Visual Redesign — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Redesign the instrument bottom sheet with an animated wave divider, direction arrows, imperial units, updated typography, and touch-event containment.
+
+**Architecture:** Two new custom Views (`DirectionArrowView`, `WaveView`) are wired through an expanded `InstrumentHandler`. The layout is fully restructured; `MainActivity` is updated to pass numeric bearings and feet-converted heights. No new dependencies required.
+
+**Tech Stack:** Kotlin, Android View system, `Canvas`/`Paint`, `ValueAnimator`, Material3, ConstraintLayout, GridLayout
+
+---
+
+## File Map
+
+| File | Action | Responsibility |
+|---|---|---|
+| `layout_instruments_sheet.xml` | Rewrite | Cell structure, WaveView, forecast layout, touch containment |
+| `DirectionArrowView.kt` | Create | Draws rotating chevron arrow in a circle |
+| `WaveView.kt` | Create | Animated swell + wind wave canvas |
+| `InstrumentHandler.kt` | Rewrite | Drives all text, arrows, and wave view |
+| `MainActivity.kt` | Modify | Remove report buttons, convert units, wire new views |
+| `themes.xml` | Modify | Typography styles: weight 300, unit label, forecast styles |
+| `dimens.xml` | Modify | Updated font sizes |
+| `InstrumentHandlerTest.kt` | Create | Unit tests for formatting/conversion helpers |
+
+---
+
+### Task 1: Remove Report Buttons and Fix Touch-Through
+
+**Files:**
+- Modify: `android-app/app/src/main/res/layout/layout_instruments_sheet.xml`
+- Modify: `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt`
+
+- [ ] **Step 1.1: Remove the three report views from the layout**
+
+In `layout_instruments_sheet.xml`, delete the entire `reports_divider` View, `label_reports` TextView, and `reports_row` LinearLayout (lines 199–248). Also add `android:clickable="true"` and `android:focusable="true"` to the root `ConstraintLayout` to prevent touch-through to the map.
+
+The root element should look like:
+```xml
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:padding="16dp"
+ android:background="?attr/colorSurface"
+ android:clickable="true"
+ android:focusable="true">
+```
+
+- [ ] **Step 1.2: Remove button click listeners from MainActivity**
+
+In `MainActivity.kt`, delete lines 95–101:
+```kotlin
+// DELETE THESE:
+findViewById<MaterialButton>(R.id.btn_nav_pretrip).setOnClickListener {
+ showReport(org.terst.nav.tripreport.PreTripReportFragment())
+}
+
+findViewById<MaterialButton>(R.id.btn_nav_tripreport).setOnClickListener {
+ showReport(org.terst.nav.tripreport.TripReportFragment())
+}
+```
+
+Also remove the `MaterialButton` import if it becomes unused after this change.
+
+- [ ] **Step 1.3: Build and verify no compile errors**
+
+```bash
+cd android-app && ./gradlew assembleDebug 2>&1 | tail -20
+```
+Expected: `BUILD SUCCESSFUL`
+
+- [ ] **Step 1.4: Commit**
+
+```bash
+git add android-app/app/src/main/res/layout/layout_instruments_sheet.xml \
+ android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+git commit -m "feat(ui): remove report section from instrument sheet, fix touch-through"
+```
+
+---
+
+### Task 2: Update Typography Styles
+
+**Files:**
+- Modify: `android-app/app/src/main/res/values/themes.xml`
+- Modify: `android-app/app/src/main/res/values/dimens.xml`
+
+- [ ] **Step 2.1: Update dimens.xml**
+
+Replace the contents of `dimens.xml` with:
+```xml
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <dimen name="text_size_instrument_primary">26sp</dimen>
+ <dimen name="text_size_instrument_label">10sp</dimen>
+ <dimen name="text_size_instrument_unit">10sp</dimen>
+ <dimen name="text_size_forecast_value">22sp</dimen>
+ <dimen name="text_size_forecast_label">10sp</dimen>
+ <dimen name="text_size_forecast_bearing">12sp</dimen>
+ <dimen name="instrument_margin">8dp</dimen>
+ <dimen name="instrument_padding">4dp</dimen>
+</resources>
+```
+
+- [ ] **Step 2.2: Update and add styles in themes.xml**
+
+Replace the instrument styles block (lines 41–70) with:
+```xml
+<!-- Instrument Display Styles -->
+<style name="InstrumentLabel" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">@color/instrument_text_secondary</item>
+ <item name="android:textSize">@dimen/text_size_instrument_label</item>
+ <item name="android:textAllCaps">true</item>
+ <item name="android:letterSpacing">0.12</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
+</style>
+
+<style name="InstrumentPrimaryValue" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">@color/instrument_text_normal</item>
+ <item name="android:textSize">@dimen/text_size_instrument_primary</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
+ <item name="android:includeFontPadding">false</item>
+</style>
+
+<style name="InstrumentUnit" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#79747E</item>
+ <item name="android:textSize">@dimen/text_size_instrument_unit</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">2dp</item>
+ <item name="android:layout_marginBottom">3dp</item>
+</style>
+
+<style name="ForecastLabel" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#5B9EC2</item>
+ <item name="android:textSize">@dimen/text_size_forecast_label</item>
+ <item name="android:textAllCaps">true</item>
+ <item name="android:letterSpacing">0.12</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
+</style>
+
+<style name="ForecastValue" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#CAE8FF</item>
+ <item name="android:textSize">@dimen/text_size_forecast_value</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
+ <item name="android:includeFontPadding">false</item>
+</style>
+
+<style name="ForecastUnit" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#3B6785</item>
+ <item name="android:textSize">@dimen/text_size_forecast_label</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">2dp</item>
+ <item name="android:layout_marginBottom">2dp</item>
+</style>
+
+<style name="ForecastBearing" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#6FC3E8</item>
+ <item name="android:textSize">@dimen/text_size_forecast_bearing</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">4dp</item>
+</style>
+
+<style name="ForecastPeriod" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#4A7FA0</item>
+ <item name="android:textSize">@dimen/text_size_forecast_bearing</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">4dp</item>
+</style>
+
+<style name="InstrumentCard" parent="Widget.Material3.CardView.Elevated">
+ <item name="cardBackgroundColor">@color/instrument_card_background</item>
+ <item name="cardCornerRadius">12dp</item>
+ <item name="cardElevation">2dp</item>
+</style>
+```
+
+- [ ] **Step 2.3: Build**
+
+```bash
+cd android-app && ./gradlew assembleDebug 2>&1 | tail -20
+```
+Expected: `BUILD SUCCESSFUL`
+
+- [ ] **Step 2.4: Commit**
+
+```bash
+git add android-app/app/src/main/res/values/themes.xml \
+ android-app/app/src/main/res/values/dimens.xml
+git commit -m "feat(ui): update instrument sheet typography — weight 300, unit labels, forecast styles"
+```
+
+---
+
+### Task 3: Create DirectionArrowView
+
+**Files:**
+- Create: `android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt`
+- Create: `android-app/app/src/test/kotlin/org/terst/nav/ui/DirectionArrowViewTest.kt`
+
+- [ ] **Step 3.1: Write the failing test**
+
+Create `android-app/app/src/test/kotlin/org/terst/nav/ui/DirectionArrowViewTest.kt`:
+```kotlin
+package org.terst.nav.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class DirectionArrowViewTest {
+
+ @Test
+ fun `bearing is normalised — values over 360 wrap`() {
+ assertEquals(10f, normalizeBearing(370f), 0.001f)
+ }
+
+ @Test
+ fun `bearing is normalised — negative values wrap`() {
+ assertEquals(350f, normalizeBearing(-10f), 0.001f)
+ }
+
+ @Test
+ fun `bearing is normalised — exactly 360 becomes 0`() {
+ assertEquals(0f, normalizeBearing(360f), 0.001f)
+ }
+}
+
+// Top-level helper extracted from DirectionArrowView for testability
+fun normalizeBearing(deg: Float): Float = ((deg % 360f) + 360f) % 360f
+```
+
+- [ ] **Step 3.2: Run test to verify it fails**
+
+```bash
+cd android-app && ./gradlew test --tests "org.terst.nav.ui.DirectionArrowViewTest" 2>&1 | tail -20
+```
+Expected: FAIL — `normalizeBearing` does not exist yet.
+
+- [ ] **Step 3.3: Create DirectionArrowView.kt**
+
+Create `android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt`:
+```kotlin
+package org.terst.nav.ui
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Path
+import android.util.AttributeSet
+import android.view.View
+
+/** Normalises a bearing in degrees to [0, 360). */
+fun normalizeBearing(deg: Float): Float = ((deg % 360f) + 360f) % 360f
+
+/**
+ * A small circular direction indicator. Draws a notched chevron arrow
+ * pointing in [bearing] degrees (0 = north/up, clockwise).
+ *
+ * Two pre-defined palettes:
+ * - [ArrowStyle.SKY] — grey, for the instrument section
+ * - [ArrowStyle.OCEAN] — blue, for the forecast section
+ */
+class DirectionArrowView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+
+ enum class ArrowStyle { SKY, OCEAN }
+
+ var bearing: Float = 0f
+ set(value) { field = normalizeBearing(value); invalidate() }
+
+ var arrowStyle: ArrowStyle = ArrowStyle.SKY
+ set(value) {
+ field = value
+ circlePaint.color = circleColor()
+ arrowPaint.color = arrowColor()
+ invalidate()
+ }
+
+ private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = 1.5f
+ color = circleColor()
+ }
+
+ private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.FILL
+ color = arrowColor()
+ }
+
+ private val arrowPath = Path()
+
+ private fun circleColor() = when (arrowStyle) {
+ ArrowStyle.SKY -> Color.parseColor("#3A3640")
+ ArrowStyle.OCEAN -> Color.parseColor("#1E4A6E")
+ }
+
+ private fun arrowColor() = when (arrowStyle) {
+ ArrowStyle.SKY -> Color.parseColor("#9A94A0")
+ ArrowStyle.OCEAN -> Color.parseColor("#6FC3E8")
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ val cx = width / 2f
+ val cy = height / 2f
+ val r = (minOf(width, height) / 2f) - circlePaint.strokeWidth
+
+ // Circle outline
+ canvas.drawCircle(cx, cy, r, circlePaint)
+
+ // Notched chevron: tip at top in unrotated space
+ val tipY = cy - r * 0.72f
+ val baseY = cy + r * 0.50f
+ val notchY = cy + r * 0.22f
+ val halfW = r * 0.42f
+
+ arrowPath.reset()
+ arrowPath.moveTo(cx, tipY)
+ arrowPath.lineTo(cx - halfW, baseY)
+ arrowPath.lineTo(cx, notchY)
+ arrowPath.lineTo(cx + halfW, baseY)
+ arrowPath.close()
+
+ canvas.save()
+ canvas.rotate(bearing, cx, cy)
+ canvas.drawPath(arrowPath, arrowPaint)
+ canvas.restore()
+ }
+}
+```
+
+- [ ] **Step 3.4: Run test to verify it passes**
+
+```bash
+cd android-app && ./gradlew test --tests "org.terst.nav.ui.DirectionArrowViewTest" 2>&1 | tail -20
+```
+Expected: `BUILD SUCCESSFUL` and 3 tests passing.
+
+- [ ] **Step 3.5: Commit**
+
+```bash
+git add android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt \
+ android-app/app/src/test/kotlin/org/terst/nav/ui/DirectionArrowViewTest.kt
+git commit -m "feat(ui): add DirectionArrowView — rotating chevron compass indicator"
+```
+
+---
+
+### Task 4: Create WaveView
+
+**Files:**
+- Create: `android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt`
+
+WaveView is a canvas-drawn View; its correctness is verified visually. No pure-logic unit tests apply here.
+
+- [ ] **Step 4.1: Create WaveView.kt**
+
+Create `android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt`:
+```kotlin
+package org.terst.nav.ui
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.LinearGradient
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.Shader
+import android.os.SystemClock
+import android.util.AttributeSet
+import android.view.View
+import kotlin.math.sin
+
+/**
+ * Draws an animated ocean-horizon scene used as the divider between the
+ * instrument section and the forecast section.
+ *
+ * The primary wave is driven by swell height and period; a secondary
+ * high-frequency wave represents wind chop. Whitecaps appear at wind-wave
+ * crests. The view self-animates via [postInvalidateOnAnimation].
+ *
+ * Set [swellHeightFt], [swellPeriodSec], and [windWaveHeightFt] to update
+ * the wave state when new forecast data arrives.
+ */
+class WaveView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+
+ var swellHeightFt: Float = 3f
+ set(value) { field = value.coerceAtLeast(0f); invalidate() }
+
+ var swellPeriodSec: Float = 10f
+ set(value) { field = value.coerceAtLeast(1f); invalidate() }
+
+ var windWaveHeightFt: Float = 1.5f
+ set(value) { field = value.coerceAtLeast(0f); invalidate() }
+
+ private var startTimeMs = -1L
+
+ private val wavePath = Path()
+
+ private val skyPaint = Paint()
+ private val seaPaint = Paint()
+
+ private val shimmerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = 1.5f
+ color = Color.argb(77, 111, 195, 232)
+ }
+
+ private val whitecapPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = 1.5f
+ strokeCap = Paint.Cap.ROUND
+ color = Color.argb(128, 255, 255, 255)
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ startTimeMs = SystemClock.elapsedRealtime()
+ postInvalidateOnAnimation()
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ skyPaint.shader = LinearGradient(
+ 0f, 0f, 0f, h * 0.6f,
+ Color.parseColor("#1C1B1F"),
+ Color.parseColor("#162433"),
+ Shader.TileMode.CLAMP
+ )
+ seaPaint.shader = LinearGradient(
+ 0f, h * 0.4f, 0f, h.toFloat(),
+ Color.parseColor("#0B3050"),
+ Color.parseColor("#0D2137"),
+ Shader.TileMode.CLAMP
+ )
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ if (startTimeMs < 0) startTimeMs = SystemClock.elapsedRealtime()
+ val t = (SystemClock.elapsedRealtime() - startTimeMs) / 1000.0 // seconds
+
+ val w = width.toFloat()
+ val h = height.toFloat()
+
+ // Amplitudes scale with wave data, capped relative to view height
+ val swellAmp = (h * (swellHeightFt / 28f)).coerceIn(h * 0.04f, h * 0.22f)
+ val windAmp = (h * (windWaveHeightFt / 28f)).coerceIn(h * 0.02f, h * 0.10f)
+ val swellWlen = w * (swellPeriodSec / 14f)
+ val windWlen = swellWlen * 0.35f
+ val midY = h * 0.52f
+
+ fun waveY(x: Float): Float =
+ (midY
+ + sin((x / swellWlen) * TWO_PI - t * 0.8) * swellAmp
+ + sin((x / windWlen) * TWO_PI - t * 1.8 + 1.2) * windAmp).toFloat()
+
+ // ── Sky fill ──────────────────────────────────────────────────
+ wavePath.reset()
+ wavePath.moveTo(0f, 0f)
+ wavePath.lineTo(0f, waveY(0f))
+ var x = 2f
+ while (x <= w) { wavePath.lineTo(x, waveY(x)); x += 2f }
+ wavePath.lineTo(w, 0f)
+ wavePath.close()
+ canvas.drawPath(wavePath, skyPaint)
+
+ // ── Sea fill ──────────────────────────────────────────────────
+ wavePath.reset()
+ wavePath.moveTo(0f, h)
+ wavePath.lineTo(0f, waveY(0f))
+ x = 2f
+ while (x <= w) { wavePath.lineTo(x, waveY(x)); x += 2f }
+ wavePath.lineTo(w, h)
+ wavePath.close()
+ canvas.drawPath(wavePath, seaPaint)
+
+ // ── Shimmer line ──────────────────────────────────────────────
+ wavePath.reset()
+ wavePath.moveTo(0f, waveY(0f))
+ x = 2f
+ while (x <= w) { wavePath.lineTo(x, waveY(x)); x += 2f }
+ canvas.drawPath(wavePath, shimmerPaint)
+
+ // ── Whitecaps at wind-wave crests ─────────────────────────────
+ val capPath = Path()
+ x = windWlen * 0.5f
+ while (x <= w) {
+ val wv = sin((x / windWlen) * TWO_PI - t * 1.8 + 1.2) * windAmp
+ val wn = sin(((x + 3) / windWlen) * TWO_PI - t * 1.8 + 1.2) * windAmp
+ if (wv > windAmp * 0.55 && wv >= wn) {
+ val y = waveY(x)
+ capPath.reset()
+ capPath.moveTo(x - 7f, y + 1f)
+ capPath.quadTo(x, y - 2.5f, x + 8f, y + 1f)
+ canvas.drawPath(capPath, whitecapPaint)
+ }
+ x += windWlen * 0.9f
+ }
+
+ postInvalidateOnAnimation()
+ }
+
+ companion object {
+ private const val TWO_PI = Math.PI * 2.0
+ }
+}
+```
+
+- [ ] **Step 4.2: Build**
+
+```bash
+cd android-app && ./gradlew assembleDebug 2>&1 | tail -20
+```
+Expected: `BUILD SUCCESSFUL`
+
+- [ ] **Step 4.3: Commit**
+
+```bash
+git add android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt
+git commit -m "feat(ui): add WaveView — animated swell/wind-wave canvas divider"
+```
+
+---
+
+### Task 5: Restructure layout_instruments_sheet.xml
+
+**Files:**
+- Rewrite: `android-app/app/src/main/res/layout/layout_instruments_sheet.xml`
+
+This is a full rewrite. The outer ConstraintLayout chains remain; the inner cell structure changes to use `DirectionArrowView` inline, and the forecast section is restructured.
+
+- [ ] **Step 5.1: Replace the layout file**
+
+Write the complete new `layout_instruments_sheet.xml`:
+```xml
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingStart="16dp"
+ android:paddingEnd="16dp"
+ android:paddingBottom="16dp"
+ android:background="?attr/colorSurface"
+ android:clickable="true"
+ android:focusable="true">
+
+ <!-- Drag handle -->
+ <View
+ android:id="@+id/drag_handle"
+ android:layout_width="36dp"
+ android:layout_height="4dp"
+ android:layout_marginTop="12dp"
+ android:background="@color/md_theme_outline"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+ <!--
+ 3×2 grid: AWS/HDG/BSP top row, TWS/COG/SOG bottom row.
+ Cells with direction: AWS, TWS (kts + unit + arrow inline)
+ HDG, COG (°-in-value + arrow inline)
+ Cells without: BSP, SOG (kts + unit, no arrow)
+ -->
+ <androidx.gridlayout.widget.GridLayout
+ android:id="@+id/instrument_grid"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="12dp"
+ app:columnCount="3"
+ app:rowCount="2"
+ app:layout_constraintTop_toBottomOf="@id/drag_handle"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent">
+
+ <!-- AWS -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ app:layout_columnWeight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="AWS" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_aws"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="18.2" />
+ <TextView style="@style/InstrumentUnit" android:text="kts" />
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_aws"
+ android:layout_width="14dp"
+ android:layout_height="14dp"
+ android:layout_marginStart="3dp"
+ android:layout_marginBottom="3dp"
+ android:layout_gravity="bottom" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- HDG: ° is part of the value string -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ app:layout_columnWeight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="HDG" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_hdg"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="247°" />
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_hdg"
+ android:layout_width="14dp"
+ android:layout_height="14dp"
+ android:layout_marginStart="3dp"
+ android:layout_marginBottom="3dp"
+ android:layout_gravity="bottom" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- BSP: no direction -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ app:layout_columnWeight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="BSP" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="baseline">
+ <TextView
+ android:id="@+id/value_bsp"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="6.8" />
+ <TextView style="@style/InstrumentUnit" android:text="kts" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- TWS -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ app:layout_columnWeight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="TWS" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_tws"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="15.5" />
+ <TextView style="@style/InstrumentUnit" android:text="kts" />
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_tws"
+ android:layout_width="14dp"
+ android:layout_height="14dp"
+ android:layout_marginStart="3dp"
+ android:layout_marginBottom="3dp"
+ android:layout_gravity="bottom" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- COG: ° is part of the value string -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ app:layout_columnWeight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="COG" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_cog"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="253°" />
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_cog"
+ android:layout_width="14dp"
+ android:layout_height="14dp"
+ android:layout_marginStart="3dp"
+ android:layout_marginBottom="3dp"
+ android:layout_gravity="bottom" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- SOG: no direction -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ app:layout_columnWeight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="SOG" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="baseline">
+ <TextView
+ android:id="@+id/value_sog"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="7.1" />
+ <TextView style="@style/InstrumentUnit" android:text="kts" />
+ </LinearLayout>
+ </LinearLayout>
+
+ </androidx.gridlayout.widget.GridLayout>
+
+ <!-- Depth + Baro side by side -->
+ <LinearLayout
+ android:id="@+id/expanded_instruments"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginTop="4dp"
+ app:layout_constraintTop_toBottomOf="@id/instrument_grid"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="Depth" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="baseline">
+ <TextView
+ android:id="@+id/value_depth"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="42.0" />
+ <TextView style="@style/InstrumentUnit" android:text="ft" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <View
+ android:layout_width="1dp"
+ android:layout_height="match_parent"
+ android:background="#2B2930" />
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="Baro" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="baseline">
+ <TextView
+ android:id="@+id/value_baro"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="1013" />
+ <TextView style="@style/InstrumentUnit" android:text="hPa" />
+ </LinearLayout>
+ </LinearLayout>
+
+ </LinearLayout>
+
+ <!-- Animated wave divider -->
+ <org.terst.nav.ui.WaveView
+ android:id="@+id/wave_divider"
+ android:layout_width="match_parent"
+ android:layout_height="72dp"
+ android:layout_marginStart="-16dp"
+ android:layout_marginEnd="-16dp"
+ app:layout_constraintTop_toBottomOf="@id/expanded_instruments"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+ <!-- Forecast section (ocean) -->
+ <LinearLayout
+ android:id="@+id/forecast_row"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:background="#0D2137"
+ android:paddingTop="14dp"
+ android:paddingBottom="20dp"
+ android:layout_marginStart="-16dp"
+ android:layout_marginEnd="-16dp"
+ app:layout_constraintTop_toBottomOf="@id/wave_divider"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent">
+
+ <!-- Current -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="4dp">
+ <TextView style="@style/ForecastLabel" android:text="Current" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="baseline">
+ <TextView
+ android:id="@+id/value_curr_spd"
+ style="@style/ForecastValue"
+ tools:text="0.8" />
+ <TextView style="@style/ForecastUnit" android:text="kts" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="4dp">
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_curr"
+ android:layout_width="14dp"
+ android:layout_height="14dp" />
+ <TextView
+ android:id="@+id/bearing_curr"
+ style="@style/ForecastBearing"
+ tools:text="185°" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- Waves -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="4dp">
+ <TextView style="@style/ForecastLabel" android:text="Waves" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="baseline">
+ <TextView
+ android:id="@+id/value_wave_ht"
+ style="@style/ForecastValue"
+ tools:text="3.5" />
+ <TextView style="@style/ForecastUnit" android:text="ft" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="4dp">
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_waves"
+ android:layout_width="14dp"
+ android:layout_height="14dp" />
+ <TextView
+ android:id="@+id/bearing_waves"
+ style="@style/ForecastBearing"
+ tools:text="275°" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- Swell -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="4dp">
+ <TextView style="@style/ForecastLabel" android:text="Swell" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="baseline">
+ <TextView
+ android:id="@+id/value_swell_ht"
+ style="@style/ForecastValue"
+ tools:text="5.2" />
+ <TextView style="@style/ForecastUnit" android:text="ft" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="4dp">
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_swell"
+ android:layout_width="14dp"
+ android:layout_height="14dp" />
+ <TextView
+ android:id="@+id/bearing_swell"
+ style="@style/ForecastBearing"
+ tools:text="260°" />
+ <TextView
+ android:id="@+id/value_swell_per"
+ style="@style/ForecastPeriod"
+ tools:text="· 14s" />
+ </LinearLayout>
+ </LinearLayout>
+
+ </LinearLayout>
+
+</androidx.constraintlayout.widget.ConstraintLayout>
+```
+
+- [ ] **Step 5.2: Build**
+
+```bash
+cd android-app && ./gradlew assembleDebug 2>&1 | tail -30
+```
+Expected: `BUILD SUCCESSFUL`. Expect resource ID errors for the old IDs that `InstrumentHandler` still references (`value_curr_dir`, `value_wave_dir`) — those are fixed in Task 6.
+
+- [ ] **Step 5.3: Commit**
+
+```bash
+git add android-app/app/src/main/res/layout/layout_instruments_sheet.xml
+git commit -m "feat(ui): restructure instrument sheet layout — inline arrows, WaveView, ocean forecast section"
+```
+
+---
+
+### Task 6: Refactor InstrumentHandler
+
+**Files:**
+- Rewrite: `android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt`
+- Create: `android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt`
+
+- [ ] **Step 6.1: Write failing tests for formatting helpers**
+
+Create `android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt`:
+```kotlin
+package org.terst.nav.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.util.Locale
+
+class InstrumentHandlerTest {
+
+ @Test
+ fun `metresToFeet converts correctly`() {
+ assertEquals(3.28f, metresToFeet(1.0).toFloat(), 0.01f)
+ }
+
+ @Test
+ fun `metresToFeet zero returns zero`() {
+ assertEquals(0f, metresToFeet(0.0).toFloat(), 0.001f)
+ }
+
+ @Test
+ fun `formatFt formats to one decimal`() {
+ val result = formatFt(3.28084, Locale.US)
+ assertEquals("3.3", result)
+ }
+
+ @Test
+ fun `formatBearing appends degree symbol`() {
+ assertEquals("275°", formatBearing(275.0, Locale.US))
+ }
+
+ @Test
+ fun `formatBearing rounds to zero decimals`() {
+ assertEquals("123°", formatBearing(123.4, Locale.US))
+ }
+
+ @Test
+ fun `formatPeriod appends s`() {
+ assertEquals("· 14s", formatPeriod(14.0, Locale.US))
+ }
+}
+```
+
+- [ ] **Step 6.2: Run tests to verify they fail**
+
+```bash
+cd android-app && ./gradlew test --tests "org.terst.nav.ui.InstrumentHandlerTest" 2>&1 | tail -20
+```
+Expected: FAIL — helpers not defined yet.
+
+- [ ] **Step 6.3: Rewrite InstrumentHandler.kt**
+
+Replace the entire contents of `InstrumentHandler.kt`:
+```kotlin
+package org.terst.nav.ui
+
+import android.widget.TextView
+import java.util.Locale
+
+// ── Pure formatting helpers (top-level for testability) ──────────────────────
+
+private const val M_TO_FT = 3.28084
+
+/** Converts metres to feet. */
+fun metresToFeet(metres: Double): Double = metres * M_TO_FT
+
+/** Formats a feet value to one decimal place. */
+fun formatFt(ft: Double, locale: Locale = Locale.getDefault()): String =
+ "%.1f".format(locale, ft)
+
+/** Formats a bearing to zero decimal places with a degree symbol. */
+fun formatBearing(deg: Double, locale: Locale = Locale.getDefault()): String =
+ "%.0f°".format(locale, deg)
+
+/** Formats a swell period with leading dot separator. */
+fun formatPeriod(sec: Double, locale: Locale = Locale.getDefault()): String =
+ "· %.0fs".format(locale, sec)
+
+// ── InstrumentHandler ────────────────────────────────────────────────────────
+
+/**
+ * Drives all text fields, direction arrows, and the wave view in the
+ * instrument bottom sheet.
+ *
+ * All [DirectionArrowView] instances in the forecast section are initialised
+ * with [DirectionArrowView.ArrowStyle.OCEAN] style automatically.
+ *
+ * Units contract:
+ * - Speed values: formatted strings in knots, already formatted by caller
+ * - Height values: caller passes raw metres; this class converts to feet
+ * - Bearing values: raw degrees as [Float], rotated into arrows and formatted
+ * into bearing TextViews by this class
+ */
+class InstrumentHandler(
+ // ── Instrument section TextViews ─────────────────────────────────
+ private val valueAws: TextView,
+ private val valueTws: TextView,
+ private val valueHdg: TextView,
+ private val valueCog: TextView,
+ private val valueBsp: TextView,
+ private val valueSog: TextView,
+ private val valueDepth: TextView,
+ private val valueBaro: TextView,
+ // ── Instrument section DirectionArrowViews ───────────────────────
+ private val arrowAws: DirectionArrowView,
+ private val arrowTws: DirectionArrowView,
+ private val arrowHdg: DirectionArrowView,
+ private val arrowCog: DirectionArrowView,
+ // ── Forecast section TextViews ───────────────────────────────────
+ private val valueCurrSpd: TextView,
+ private val valueWaveHt: TextView,
+ private val valueSwellHt: TextView,
+ private val valueSwellPer: TextView,
+ // ── Forecast section DirectionArrowViews ─────────────────────────
+ private val arrowCurr: DirectionArrowView,
+ private val arrowWaves: DirectionArrowView,
+ private val arrowSwell: DirectionArrowView,
+ // ── Forecast section bearing TextViews ───────────────────────────
+ private val bearingCurr: TextView,
+ private val bearingWaves: TextView,
+ private val bearingSwell: TextView,
+ // ── Wave view ────────────────────────────────────────────────────
+ private val waveView: WaveView
+) {
+ init {
+ arrowCurr.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN
+ arrowWaves.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN
+ arrowSwell.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN
+ }
+
+ /**
+ * Updates instrument-section text and arrows.
+ * Null arguments leave the current value unchanged.
+ *
+ * Heights should already be formatted strings (caller's responsibility
+ * for baro/depth). Bearings are raw degrees; pass null to leave arrow
+ * position unchanged.
+ *
+ * [depthM] is raw metres — converted to feet internally.
+ */
+ fun updateDisplay(
+ aws: String? = null, awsBearingDeg: Float? = null,
+ tws: String? = null, twsBearingDeg: Float? = null,
+ hdg: String? = null, hdgBearingDeg: Float? = null,
+ cog: String? = null, cogBearingDeg: Float? = null,
+ bsp: String? = null,
+ sog: String? = null,
+ depthM: Double? = null,
+ baro: String? = null
+ ) {
+ aws?.let { valueAws.text = it }
+ tws?.let { valueTws.text = it }
+ hdg?.let { valueHdg.text = it }
+ cog?.let { valueCog.text = it }
+ bsp?.let { valueBsp.text = it }
+ sog?.let { valueSog.text = it }
+ baro?.let { valueBaro.text = it }
+ depthM?.let { valueDepth.text = formatFt(metresToFeet(it)) }
+
+ awsBearingDeg?.let { arrowAws.bearing = it }
+ twsBearingDeg?.let { arrowTws.bearing = it }
+ hdgBearingDeg?.let { arrowHdg.bearing = it }
+ cogBearingDeg?.let { arrowCog.bearing = it }
+ }
+
+ /**
+ * Updates the forecast section.
+ *
+ * [waveHeightM] and [swellHeightM] are raw metres — converted to feet
+ * internally. Speed strings are pre-formatted by the caller. Bearings
+ * are raw degrees.
+ */
+ fun updateConditions(
+ currSpd: String? = null,
+ currDirDeg: Float? = null,
+ waveHeightM: Double? = null,
+ waveDirDeg: Float? = null,
+ swellHeightM: Double? = null,
+ swellDirDeg: Float? = null,
+ swellPeriodS: Double? = null
+ ) {
+ currSpd?.let { valueCurrSpd.text = it }
+ currDirDeg?.let {
+ arrowCurr.bearing = it
+ bearingCurr.text = formatBearing(it.toDouble())
+ }
+ waveHeightM?.let { valueWaveHt.text = formatFt(metresToFeet(it)) }
+ waveDirDeg?.let {
+ arrowWaves.bearing = it
+ bearingWaves.text = formatBearing(it.toDouble())
+ }
+ swellHeightM?.let { valueSwellHt.text = formatFt(metresToFeet(it)) }
+ swellDirDeg?.let {
+ arrowSwell.bearing = it
+ bearingSwell.text = formatBearing(it.toDouble())
+ }
+ swellPeriodS?.let { valueSwellPer.text = formatPeriod(it) }
+ }
+
+ /**
+ * Updates the WaveView with current sea state data.
+ * All inputs in feet. Call whenever new marine forecast data arrives.
+ */
+ fun updateWaveState(
+ swellHeightFt: Float,
+ swellPeriodSec: Float,
+ windWaveHeightFt: Float
+ ) {
+ waveView.swellHeightFt = swellHeightFt
+ waveView.swellPeriodSec = swellPeriodSec
+ waveView.windWaveHeightFt = windWaveHeightFt
+ }
+}
+```
+
+- [ ] **Step 6.4: Run tests to verify they pass**
+
+```bash
+cd android-app && ./gradlew test --tests "org.terst.nav.ui.InstrumentHandlerTest" 2>&1 | tail -20
+```
+Expected: `BUILD SUCCESSFUL`, 6 tests passing.
+
+- [ ] **Step 6.5: Build (will fail until MainActivity is updated)**
+
+```bash
+cd android-app && ./gradlew assembleDebug 2>&1 | grep -E "error:|BUILD" | head -20
+```
+Expected: compile errors in `MainActivity.kt` referencing old `InstrumentHandler` constructor and old `updateConditions` signature. That is expected — fixed in Task 7.
+
+- [ ] **Step 6.6: Commit**
+
+```bash
+git add android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt \
+ android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt
+git commit -m "feat(ui): refactor InstrumentHandler — direction arrows, WaveView, feet conversion"
+```
+
+---
+
+### Task 7: Update MainActivity — Wire New Views and Fix Units
+
+**Files:**
+- Modify: `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt`
+
+- [ ] **Step 7.1: Update setupHandlers() to wire all new view references**
+
+Replace the `setupHandlers()` function in `MainActivity.kt`:
+```kotlin
+private fun setupHandlers() {
+ instrumentHandler = InstrumentHandler(
+ // Instrument TextViews
+ valueAws = findViewById(R.id.value_aws),
+ valueTws = findViewById(R.id.value_tws),
+ valueHdg = findViewById(R.id.value_hdg),
+ valueCog = findViewById(R.id.value_cog),
+ valueBsp = findViewById(R.id.value_bsp),
+ valueSog = findViewById(R.id.value_sog),
+ valueDepth = findViewById(R.id.value_depth),
+ valueBaro = findViewById(R.id.value_baro),
+ // Instrument arrows
+ arrowAws = findViewById(R.id.arrow_aws),
+ arrowTws = findViewById(R.id.arrow_tws),
+ arrowHdg = findViewById(R.id.arrow_hdg),
+ arrowCog = findViewById(R.id.arrow_cog),
+ // Forecast TextViews
+ valueCurrSpd = findViewById(R.id.value_curr_spd),
+ valueWaveHt = findViewById(R.id.value_wave_ht),
+ valueSwellHt = findViewById(R.id.value_swell_ht),
+ valueSwellPer = findViewById(R.id.value_swell_per),
+ // Forecast arrows
+ arrowCurr = findViewById(R.id.arrow_curr),
+ arrowWaves = findViewById(R.id.arrow_waves),
+ arrowSwell = findViewById(R.id.arrow_swell),
+ // Forecast bearings
+ bearingCurr = findViewById(R.id.bearing_curr),
+ bearingWaves = findViewById(R.id.bearing_waves),
+ bearingSwell = findViewById(R.id.bearing_swell),
+ // Wave view
+ waveView = findViewById(R.id.wave_divider)
+ )
+ instrumentHandler?.updateDisplay(
+ aws = "—", tws = "—", hdg = "—",
+ cog = "—", bsp = "—", sog = "—",
+ baro = "—"
+ )
+ instrumentHandler?.updateConditions(
+ currSpd = "—"
+ )
+}
+```
+
+- [ ] **Step 7.2: Update observeDataSources() — GPS collector**
+
+In `observeDataSources()`, update the GPS collector to pass bearings as floats and format COG with `°` embedded in the value. Replace the `locationFlow` collector block:
+```kotlin
+lifecycleScope.launch {
+ LocationService.locationFlow.collect { gpsData ->
+ mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude)
+ mapHandler?.updateUserPosition(gpsData.latitude, gpsData.longitude, gpsData.cog.toFloat())
+ viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, gpsData.sog, gpsData.cog)
+ instrumentHandler?.updateDisplay(
+ sog = "%.1f".format(Locale.getDefault(), gpsData.sog),
+ cog = "%.0f°".format(Locale.getDefault(), gpsData.cog),
+ cogBearingDeg = gpsData.cog.toFloat()
+ )
+ if (!conditionsLoaded) {
+ conditionsLoaded = true
+ viewModel.loadConditions(gpsData.latitude, gpsData.longitude)
+ }
+ }
+}
+```
+
+- [ ] **Step 7.3: Update observeDataSources() — marine conditions collector**
+
+Replace the `viewModel.marineConditions` collector block:
+```kotlin
+lifecycleScope.launch {
+ viewModel.marineConditions.collect { c ->
+ if (c == null) return@collect
+ instrumentHandler?.updateDisplay(
+ tws = c.windSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) },
+ twsBearingDeg = c.windDirDeg?.toFloat()
+ )
+ instrumentHandler?.updateConditions(
+ currSpd = c.currentSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) } ?: "—",
+ currDirDeg = c.currentDirDeg?.toFloat(),
+ waveHeightM = c.waveHeightM,
+ waveDirDeg = c.waveDirDeg?.toFloat(),
+ swellHeightM = c.swellHeightM,
+ swellDirDeg = c.swellDirDeg?.toFloat(),
+ swellPeriodS = c.swellPeriodS
+ )
+ // Drive wave animation with forecast data (convert M→ft here)
+ val swellHtFt = c.swellHeightM?.let { (it * 3.28084f).toFloat() } ?: 3f
+ val windHtFt = c.waveHeightM?.let { (it * 3.28084f).toFloat() } ?: 1.5f
+ val swellPeriod = c.swellPeriodS?.toFloat() ?: 10f
+ instrumentHandler?.updateWaveState(swellHtFt, swellPeriod, windHtFt)
+ }
+}
+```
+
+- [ ] **Step 7.4: Wire depth display from NMEA flow**
+
+Add a new collector inside `observeDataSources()` (after the barometer collector):
+```kotlin
+lifecycleScope.launch {
+ LocationService.nmeaDepthDataFlow.collect { depthData ->
+ instrumentHandler?.updateDisplay(depthM = depthData.depthMeters)
+ }
+}
+```
+
+- [ ] **Step 7.5: Build — expect clean compile**
+
+```bash
+cd android-app && ./gradlew assembleDebug 2>&1 | tail -20
+```
+Expected: `BUILD SUCCESSFUL` with no errors.
+
+- [ ] **Step 7.6: Run all unit tests**
+
+```bash
+cd android-app && ./gradlew test 2>&1 | tail -30
+```
+Expected: All tests pass. Verify `InstrumentHandlerTest` (6 tests) and `DirectionArrowViewTest` (3 tests) are listed.
+
+- [ ] **Step 7.7: Commit**
+
+```bash
+git add android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+git commit -m "feat(ui): wire redesigned instrument sheet — direction arrows, wave state, depth display, feet units"
+```
+
+---
+
+## Self-Review Checklist
+
+| Spec requirement | Task |
+|---|---|
+| Wave divider — swell sine + wind noise + whitecaps | Task 4 (WaveView) |
+| Wave divider — sky gradient above, ocean below | Task 4 (WaveView.onSizeChanged) |
+| Wave divider data-driven (height, period) | Task 6 updateWaveState + Task 7 |
+| Direction arrows inline with numeric values | Task 5 layout + Task 6 InstrumentHandler |
+| Mini arrows in forecast section, ocean-tinted | Task 6 InstrumentHandler.init |
+| `°` as part of HDG/COG value, not unit label | Task 5 layout (no unit TextView for these), Task 7 COG format |
+| AWS/TWS direction arrows driven from forecast | Task 7 (twsBearingDeg from windDirDeg) |
+| Forecast cell layout: value / [arrow] bearing | Task 5 layout |
+| Swell period on direction line | Task 5 layout (value_swell_per in dir row) |
+| Depth in feet | Task 6 (metresToFeet in updateDisplay) |
+| Wave/swell heights in feet | Task 6 (metresToFeet in updateConditions) |
+| Remove reports section | Task 1 |
+| Prevent map touch-through | Task 1 (clickable/focusable on root) |
+| Depth now actually displayed (was unconnected) | Task 7 Step 7.4 |
+| "Waves" label (not "Wave") | Task 5 layout |
+| "Current" label (not "Curr") | Task 5 layout |
+| No "FORECAST" section header | Task 5 layout (absent) |
diff --git a/docs/superpowers/specs/2026-04-03-map-interaction-design.md b/docs/superpowers/specs/2026-04-03-map-interaction-design.md
new file mode 100644
index 0000000..02e38e1
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-03-map-interaction-design.md
@@ -0,0 +1,59 @@
+# Map Interaction Design
+_2026-04-03_
+
+## Overview
+
+Enable free map pan/zoom while keeping GPS auto-follow as the default. When the user gestures on the map, the UI hides and a Recenter button appears. Tapping Recenter restores auto-follow and the full UI.
+
+## State
+
+A single `isFollowing: Boolean` flag owned by `MapHandler`. Starts `true`. This is the only piece of new state.
+
+## Entering Manual Mode
+
+`MapLibreMap.addOnCameraMoveStartedListener` fires with `REASON_API_GESTURE` when the user initiates a pan, pinch-zoom, or rotate. On that event:
+
+- `MapHandler.isFollowing` → `false`
+- `MapHandler` emits via a new `StateFlow<Boolean> isFollowing` exposed to MainActivity
+- `MapHandler.centerOnLocation()` becomes a no-op while `!isFollowing` — GPS updates still arrive, last position is stored
+
+## Returning to Auto-Follow
+
+`fab_recenter` click handler:
+
+- Calls `MapHandler.recenter()` — sets `isFollowing = true`, calls `centerOnLocation(lastLat, lastLon)`
+- `isFollowing` StateFlow emits `true` → MainActivity restores UI
+
+## UI Changes
+
+### New element: `fab_recenter`
+
+- Pill-shaped button (`wrap_content` width, 40dp height, 20dp corner radius)
+- Label: "⊙ Recenter"
+- Position: centered horizontally, `24dp` above the bottom of the map `ConstraintLayout`
+- `android:visibility="gone"` by default
+- Elevation: 20dp (above the instrument sheet)
+
+### Visibility toggling (MainActivity)
+
+When `isFollowing` → `false`:
+- Fade out (alpha 0, 150ms): `instrument_bottom_sheet`, `bottom_navigation`, `fab_mob`, `fab_record_track`
+- Show `fab_recenter` (visibility VISIBLE, fade in 150ms)
+
+When `isFollowing` → `true`:
+- Hide `fab_recenter` (fade out 150ms, then GONE)
+- Fade in (alpha 1, 150ms): `instrument_bottom_sheet`, `bottom_navigation`, `fab_mob`, `fab_record_track`
+
+## Files to Change
+
+| File | Change |
+|------|--------|
+| `MapHandler.kt` | Add `isFollowing` StateFlow, `lastLat`/`lastLon` storage, `recenter()`, guard in `centerOnLocation()`, register `OnCameraMoveStartedListener` |
+| `activity_main.xml` | Add `fab_recenter` pill button |
+| `MainActivity.kt` | Observe `mapHandler.isFollowing`, animate UI in/out, wire `fab_recenter` click |
+
+## Out of Scope
+
+- Tapping the map (without gesture) to restore UI — not requested
+- Timeout to auto-restore UI — not requested
+- Zoom-level persistence across recenter — not requested
diff --git a/docs/superpowers/specs/2026-04-04-instrument-sheet-visual-redesign.md b/docs/superpowers/specs/2026-04-04-instrument-sheet-visual-redesign.md
new file mode 100644
index 0000000..4e4e0c4
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-04-instrument-sheet-visual-redesign.md
@@ -0,0 +1,156 @@
+# Instrument Sheet Visual Redesign
+
+**Date:** 2026-04-04
+**Status:** Approved for implementation
+
+## Overview
+
+Redesign `layout_instruments_sheet.xml` and supporting code to replace the plain divider between the instrument grid and forecast section with an animated water-state canvas, enhance typography, add direction indicators, use imperial units, and prevent touch-through to the map.
+
+---
+
+## 1. Animated Wave Divider Canvas
+
+Replace the static `View` divider (and `FORECAST` section header) with a `WaveView` — a custom `View` subclass that draws an animated ocean scene.
+
+**Visual behaviour:**
+- Height: ~72dp
+- Top half gradient: surface background colour (`#1C1B1F`) → dark transitional blue (`#162433`)
+- Bottom half gradient: ocean blue (`#0B3050`) → ocean section background (`#0D2137`)
+- Primary sine wave: amplitude driven by **swell height (ft)**, wavelength proportional to **swell period (s)**
+- Secondary high-frequency sine: amplitude driven by **wind wave height (ft)**, wavelength ~35% of swell wavelength
+- Whitecaps: short curved strokes at wind-wave crests when amplitude exceeds threshold
+- Surface shimmer: thin semi-transparent cyan stroke along the wave waterline
+- Animated via `ValueAnimator` / `postInvalidateOnAnimation()` loop; paused when view is not visible
+
+**Data inputs** (passed from `InstrumentHandler`):
+- `swellHeightFt: Float`
+- `swellPeriodSec: Float`
+- `windWaveHeightFt: Float`
+
+**File:** `android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt`
+
+---
+
+## 2. Typography Enhancements
+
+**Instrument values (grid + depth/baro):**
+- Value: `26sp`, weight `300` (light), colour `#E6E1E5`
+- Unit label: `10sp`, weight `400`, colour `#79747E` — baseline-aligned, small gap after value
+
+**Heading/COG special case:**
+- The `°` symbol is part of the value string, not a separate unit label (e.g. `"247°"` rendered in the value style)
+
+**Forecast values (ocean section):**
+- Value: `22sp`, weight `300`, colour `#CAE8FF`
+- Unit: `10sp`, colour `#3B6785`
+
+**Forecast labels:**
+- `10sp`, weight `500`, letter-spacing `1.2sp`, colour `#5B9EC2`, uppercase
+
+---
+
+## 3. Direction Indicators
+
+A single reusable SVG-style drawable approach: a `DirectionArrowView` (custom `View`, ~14dp × 14dp) that draws:
+- Thin circle outline (stroke only, no fill)
+- Chevron arrow (notched polygon) pointing in the given bearing
+- Rotated via `bearing` property setter
+
+**Instrument section style:** outline `#3A3640`, arrow fill `#9A94A0`
+**Forecast section style:** outline `#1E4A6E`, arrow fill `#6FC3E8`
+
+Applied to:
+- AWS, TWS (wind speed cells) — bearing = apparent/true wind direction
+- HDG, COG (heading/course cells) — bearing = heading value itself
+- Current, Waves, Swell (forecast cells) — bearing from forecast data
+
+Cells without a direction (BSP, SOG, Depth, Baro) have no arrow.
+
+**File:** `android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt`
+
+---
+
+## 4. Forecast Cell Layout
+
+Each forecast cell (Current, Waves, Swell) is two lines:
+
+```
+CURRENT ← forecast-label style
+0.8 kts ← value + unit baseline-aligned
+[arrow] 185° ← DirectionArrowView + bearing text
+```
+
+Swell adds period to the direction line:
+```
+[arrow] 260° · 14s
+```
+
+`"Wave"` → `"Waves"`, `"Curr"` → `"Current"`.
+No section header ("FORECAST" label removed).
+
+---
+
+## 5. Units: Feet Instead of Metres
+
+All heights from the data layer are in metres and must be converted before display (`* 3.28084`):
+
+- `DepthData.depthMeters` — converted in `InstrumentHandler.updateDisplay()`
+- `MarineConditions.waveHeightM` — converted in `InstrumentHandler.updateConditions()`
+- `MarineConditions.swellHeightM` — converted in `InstrumentHandler.updateConditions()`
+
+The `WaveView` data inputs (`swellHeightFt`, `windWaveHeightFt`) therefore receive already-converted values.
+
+---
+
+## 6. Remove Reports Section
+
+Remove from `layout_instruments_sheet.xml`:
+- `reports_divider` View
+- `label_reports` TextView
+- `reports_row` LinearLayout (containing `btn_nav_pretrip` and `btn_nav_tripreport`)
+
+Remove corresponding wiring from `MainActivity` / wherever the button click listeners are set up.
+
+---
+
+## 7. Prevent Map Touch-Through
+
+The bottom sheet currently sits inside a `CoordinatorLayout`. Touch events on the sheet's non-interactive areas fall through to the `MapFragment` below.
+
+**Fix:** Set `android:clickable="true"` and `android:focusable="true"` on the root `ConstraintLayout` of `layout_instruments_sheet.xml`. This consumes all touch events on the sheet, preventing drag gestures from reaching the map.
+
+---
+
+## 8. Colour Palette Summary
+
+| Token | Value | Use |
+|---|---|---|
+| Surface | `#1C1B1F` | Instrument section background |
+| Outline dim | `#2B2930` | Subtle dividers within instrument grid |
+| Value | `#E6E1E5` | Instrument values |
+| Label | `#CAC4D0` | Instrument labels |
+| Unit | `#79747E` | Instrument unit labels |
+| Arrow outline (sky) | `#3A3640` | Arrow circle border in instrument section |
+| Arrow fill (sky) | `#9A94A0` | Arrow chevron in instrument section |
+| Ocean bg | `#0D2137` | Forecast section background |
+| Ocean value | `#CAE8FF` | Forecast values |
+| Ocean label | `#5B9EC2` | Forecast labels |
+| Ocean unit | `#3B6785` | Forecast unit labels |
+| Ocean arrow outline | `#1E4A6E` | Arrow circle border in forecast section |
+| Ocean arrow fill | `#6FC3E8` | Arrow chevron in forecast section |
+| Bearing text | `#6FC3E8` | Bearing degrees in forecast section |
+| Period text | `#4A7FA0` | Swell period in forecast section |
+
+---
+
+## Files Changed
+
+| File | Change |
+|---|---|
+| `layout_instruments_sheet.xml` | Major restructure: wave canvas, cell layout changes, remove reports |
+| `WaveView.kt` | New custom View |
+| `DirectionArrowView.kt` | New custom View |
+| `InstrumentHandler.kt` | Add `updateWave()`, `updateDirections()`, feet conversion for depth |
+| `MainActivity.kt` | Remove report button wiring |
+| `styles.xml` / `themes.xml` | Add/update `InstrumentLabel`, `InstrumentPrimaryValue` styles if needed |