# Handoff: LocalSky Radar — macOS Widget App

## Overview

LocalSky Radar is a macOS app whose primary surface **is its widgets** — there is no traditional main window. Users install the app to gain access to a suite of WidgetKit widgets that display a live ADS-B radar scope centered on their location (or a chosen airport), with nearby aircraft listed and selectable. The app container handles location services, data fetching, and widget configuration; the widgets ARE the product.

**Data source:** [airplanes.live](https://airplanes.live) ADS-B/MLAT feed  
**Platform:** macOS 14 Sonoma+ (interactive widgets), iOS 17+ if ported  
**Design fidelity:** High-fidelity. The HTML prototypes use exact colors, type sizes, spacing, and interaction logic. Recreate pixel-accurately in SwiftUI WidgetKit.

---

## Visual Mockups

### Extra Large Widget (720 × 382 pt)

**Muted theme — Circle scope (default)**
![XL Muted Circle](mockups/xl-muted-circle.png)

**Green theme — Circle scope**
![XL Green Circle](mockups/xl-green-circle.png)

**Gray theme — Circle scope**
![XL Gray Circle](mockups/xl-gray-circle.png)

**Muted theme — Square scope**
![XL Muted Square](mockups/xl-muted-square.png)

**Green theme — Square scope**
![XL Green Square](mockups/xl-green-square.png)

**Gray theme — Square scope**
![XL Gray Square](mockups/xl-gray-square.png)

---

### Medium Widget (338 × 158 pt)

**Muted theme — Circle scope (default)**
![Medium Muted Circle](mockups/med-muted-circle.png)

**Green theme — Circle scope**
![Medium Green Circle](mockups/med-green-circle.png)

**Gray theme — Circle scope**
![Medium Gray Circle](mockups/med-gray-circle.png)

---

## About the Design Files

The files in this bundle are **HTML design prototypes** — they show the intended look and behavior with full fidelity, but are not production code. The task is to recreate these designs in SwiftUI using WidgetKit, CoreLocation, and the airplanes.live API. Reference the HTML files in a browser to inspect visual details; use this README for measurements and token values.

---

## App Architecture

```
LocalSky Radar.app
├── Main app target          — handles location, data fetch, widget reload, App Intents
│   ├── LocationManager      — CLLocationManager, resolves user location → nearest airport
│   ├── RadarDataService     — polls airplanes.live API every 60s, stores in AppStorage
│   └── IntentHandler        — App Intent for aircraft row selection (see Interactions)
└── Widget Extension target
    ├── LocalSkyWidgetBundle — registers all widget configurations
    ├── ExtraLargeWidget     — 720×382 pt layout
    ├── MediumWidget         — 338×158 pt layout
    └── LargeWidget          — 360×382 pt (Tower / Approach / Instrument sub-layouts)
```

The main app does not need a visible UI beyond an onboarding/settings screen. A `Settings.app` pane or a minimal menu-bar presence for configuration is sufficient.

---

## Widget Sizes & Layouts

All sizes are in SwiftUI points (= CSS px at 1× scale).

| Widget family  | Size (pt)   | Corner radius | Layout name                        |
|---------------|-------------|---------------|------------------------------------|
| Extra Large   | 720 × 382   | 22            | `xl` — radar + hero detail + 6-row list |
| Medium        | 338 × 158   | 22            | `medium` — radar + 3-row list      |
| Large         | 360 × 382   | 22            | `tower` / `approach` / `instrument` |

> **Note:** Large variants (Tower / Approach / Instrument) are designed but lower priority. Focus first on Extra Large and Medium.

---

## Extra Large Layout (720 × 382)

### Overall structure
```
┌─────────────────────────────────────────────────────────────────────────┐
│  LEFT PANEL (radar)     │  RIGHT PANEL (detail + list)                  │
│  Circle: 366 pt wide    │  Width: 354 pt (circle) / 338 pt (square)     │
│  Square: 382 pt wide    │  Padding: 18 pt all sides                     │
│  overflow: hidden       │                                                │
└─────────────────────────────────────────────────────────────────────────┘
```

### Left panel
- Background: `frameBg` color (theme-dependent)
- Right edge: 1 pt border in `panelLine` color
- `overflow: hidden` (clips square scope corners into the widget's rounded frame)

**Overlaid labels (absolute positioned, always present):**
- Top-left: App icon (17×17 pt SVG ring) + "LocalSky Radar" label — font: SF Pro 12/600, color: `text`
- Bottom-left chip: `{N} · {rangeLabel}` — font: SF Mono 8.5pt/400, color: `chipText`, background: `chip`, border: `panelLine`, border-radius: 7, padding: 5 8

**Radar face:** centered in panel (see Radar Face Component section)

### Right panel
**Header row** (flex, space-between):
- Left: status dot (7×7 pt circle, border `warn` color) + status text — SF Pro 11pt/400, `textDim`
- Right: "UPD 1m · Airplanes.live" — SF Mono 9.5pt, `textFaint`

**Hero aircraft card** (rounded panel):
- Background: `panel`, border: `panelLine`, border-radius: 13, padding: 14 16
- Top row: callsign (SF Mono 24pt/700) | distance + bearing (SF Mono 27pt/700, `accent`)
- Subtext: type · reg · src — SF Mono 10pt, `textFaint`
- Stats grid (3 cols): ALTITUDE / GROUND SPEED / TRACK — label SF Mono 8pt `textFaint`, value SF Mono 13pt

**Nearby aircraft table:**
- Header: "NEARBY AIRCRAFT" (left) + "TAP TO TRACK ›" (right, `accent` color) — SF Mono 8pt `textFaint`
- Columns: `1fr  40pt  56pt  36pt  44pt  46pt` — CALLSIGN · TYPE · ALT · GS · TRK · DIST
- Row height: ~24 pt (6 pt vertical padding), border-bottom `panelLine`
- **Selected row:** background `chip`, left inset border 2pt `accent`, callsign in `accent`
- Row count: 6 rows max (aircraft within active range, sorted by distance)

---

## Medium Layout (338 × 158)

### Structure
```
┌────────────────────────────────────────┐
│  LEFT (radar)  │  RIGHT (list)         │
│  Circle: 152pt │  Width: 186 pt        │
│  Square: 158pt │  Padding: 13 14 11    │
└────────────────────────────────────────┘
```

**Left panel:** overflow hidden, border-right `panelLine`, bottom-left chip: `{N} · {rangeLabel}`

**Right panel:**
- Header: app icon (15×15) + "LocalSky Radar" (SF Pro 11.5/600) | "UPD 1m" (SF Mono 9pt)
- Status row: dot (6×6, `warn`) + location label — SF Mono 9pt `textDim`
- Rows (3 max): `1fr  52pt  42pt` — CALLSIGN + TYPE | ALT | DIST
  - **Selected row:** background `chip`, left inset 2pt `accent`, callsign in `accent`

---

## Widget Configuration (Edit Sheet)

The widget exposes 4 user-configurable settings via `WidgetConfigurationIntent`:

### 1. Scope Shape
| Value    | Behavior |
|----------|----------|
| `circle` | **Default.** Radar scope is circular (classic). Map clipped to disc. |
| `square` | Scope fills the full left panel. Map bleeds into corners. Range rings stay circular. |

### 2. Range
| Value      | Behavior |
|------------|----------|
| `adaptive` | **Default.** Computes the smallest ring step [25, 50, 75, 100 NM] showing 2–4 aircraft. |
| `50`       | Fixed 50 NM outer ring. |
| `75`       | Fixed 75 NM outer ring. |
| `100`      | Fixed 100 NM outer ring. |

**Adaptive range algorithm:**
```swift
func adaptiveRange(aircraft: [Aircraft]) -> Int {
    let dists = aircraft.map(\.distNM).sorted()
    for r in [25, 50, 75, 100] {
        let count = dists.filter { $0 <= Double(r) }.count
        if count >= 2 && count <= 4 { return r }
    }
    return dists.filter { $0 <= 100 }.count < 2 ? 100 : 50
}
```

The range chip always shows the resolved value: `"ADAPTIVE · 25 NM"` or `"75 NM"`.

### 3. Location
| Value     | Behavior |
|-----------|----------|
| `current` | **Default.** Uses device GPS. Status shows "Using GPS location". |
| `airport` | User enters a 4-character ICAO code (e.g. `KOAK`). Status shows "Fallback airport · KOAK". |

### 4. Theme
| Value   | Look |
|---------|------|
| `muted` | **Default.** Modern dark, cyan accent. |
| `green` | Classic phosphor green. CRT glow on aircraft markers. |
| `gray`  | True grayscale neutral. Emphasis via value & weight only. |

---

## Radar Face Component

The radar scope is a square SVG rendered in the left panel. Key parameters:

| Param | Circle | Square |
|-------|--------|--------|
| Clip | Circular clip at radius R | Full rect (no clip) |
| R (outer ring radius) | `cx × 0.89` | `cx × 0.88` |
| SVG border-radius | 50% | 0 (widget frame clips corners) |
| Vignette | Radial gradient, circle R | Radial gradient, full rect |

**Projection:** equirectangular, centered on the user's airport.
```
nmN = (lat − centerLat) × 60
nmE = (lon − centerLon) × 60 × cos(centerLat)
x = cx + nmE / rangeNM × R
y = cy − nmN / rangeNM × R
```

### Basemap layers (drawn in order, all clipped to scope shape)
1. **Land fill** — `land` color
2. **Ocean** — `ocean` color (Pacific, west of coast polyline)
3. **Bay** — `bay` color (SF Bay polygon)
4. **Graticule** — faint crosshair lines through center, `grid` color
5. **Coastline** — `coast` color, 1.1pt stroke, rounded joins
6. **City dots** — tier-1 cities always; tier-2 only in `dense` mode. `cityDot` fill, r=1.5pt
7. **Airport diamonds** — `◇` shape; major airports labeled in `airportLabel` (SF Mono 7.5pt)

### Range rings
- One ring every 25 NM up to the active range
- Inner rings: `ring` color, 1pt stroke
- Outer ring: `ringStrong` color, 1.3pt stroke
- Distance labels along NE diagonal: SF Mono 8pt, `ringLabel`
- "NM" unit label just inside outer ring

### Bearing ticks & cardinals
- 30° tick marks at outer ring edge; cardinal (N/E/S/W) ticks longer
- N/E/S/W letters: SF Mono 8.5pt/600, `textFaint`

### Sweep beam
- Static wedge, ~28° wide, bearing ~36–64°
- Fill: `sweep` color (theme-dependent transparent tone)
- Clipped to scope shape

### Center marker
- Ring (r=4pt) + dot (r=1.3pt) + crosshair lines, `center` color
- Airport code label below: SF Mono 8.5pt/700, `center` color

### Aircraft markers
- **Shape:** filled chevron pointing in track direction
- **Size scale:** `s = diameter / 300`
- **Tiers:**
  - Highlighted (selected or nearest if none selected): `acHi` color, 1.18× size, highlight ring (r=9pt), callsign + distance label offset radially
  - High altitude (≥18,000 ft): `acDim` color
  - Normal: `ac` color
- **Motion trail:** line from marker backward along track, length ∝ ground speed, `trail` color
- **Green theme only:** drop-shadow glow filter on markers
- **Hit area:** transparent circle r=11pt centered on marker for tap targeting

### Vignette
- Radial gradient, transparent at 58%, 55% opaque black at 100%
- Applied over the entire scope (circle or rect) with `pointerEvents: none`

---

## Interactions & Behavior

### Aircraft selection (interactive widget)

**Production implementation:** iOS 17+ / macOS Sonoma+ interactive widgets via `AppIntent`.

```swift
struct SelectAircraftIntent: AppIntent {
    static var title: LocalizedStringResource = "Select Aircraft"
    
    @Parameter(title: "Aircraft ID")
    var aircraftId: String
    
    func perform() async throws -> some IntentResult {
        // Store selected ID in shared AppStorage/UserDefaults suite
        UserDefaults(suiteName: "group.com.yourapp.localsky")?
            .set(aircraftId, forKey: "selectedAircraftId")
        return .result()
    }
}
```

**Two tap surfaces:**
1. **Aircraft rows** — each row is a `Button(intent: SelectAircraftIntent(aircraftId: id))`
2. **Map markers** — each aircraft marker shape is a `Button(intent: SelectAircraftIntent(aircraftId: id))` with a generous tap target (≥44pt)

**Selection behavior:**
- Tapping an unselected aircraft → selects it → becomes hero in detail card, highlighted on map with ring + label
- Tapping the currently selected aircraft → deselects → hero reverts to nearest in-range aircraft
- Selection persists via `@AppStorage` shared group suite
- If selected aircraft moves out of range, hero falls back to nearest in-range

### Whole-widget tap
```swift
.widgetURL(URL(string: "localsky://open?airport=\(centerCode)"))
```
Opens the main app to a full track view of the currently selected/nearest aircraft.

### Timeline updates
- Refresh interval: 60 seconds (`TimelineReloadPolicy.after(Date().addingTimeInterval(60))`)
- On selection intent: trigger immediate reload via `WidgetCenter.shared.reloadAllTimelines()`

---

## State & Data Model

### Widget timeline entry
```swift
struct RadarEntry: TimelineEntry {
    let date: Date
    let center: Airport          // user's location/chosen airport
    let aircraft: [Aircraft]     // all within 100 NM, sorted by distance
    let rangeNM: Int             // resolved range (after adaptive)
    let rangeMode: RangeMode     // .adaptive / .nm50 / .nm75 / .nm100
    let selectedAircraftId: String?
    let locationMode: LocationMode // .currentLocation / .airport
    let theme: Theme
    let scopeShape: ScopeShape   // .circle / .square
}
```

### Aircraft model
```swift
struct Aircraft: Identifiable, Codable {
    let id: String           // ICAO hex (e.g. "a0c1d2")
    let callsign: String     // flight number or N-number
    let type: String         // ICAO type code (e.g. "B739")
    let registration: String
    let lat: Double
    let lon: Double
    let altitudeFt: Int
    let groundSpeedKt: Int
    let trackDeg: Double     // 0–360, true heading
    let trend: AltitudeTrend // .climb / .descend / .level
    let source: DataSource   // .adsb / .mlat
    // Computed from center:
    let distNM: Double
    let bearingDeg: Double
}
```

### Airport model
```swift
struct Airport: Codable {
    let icao: String         // e.g. "KSFO"
    let name: String
    let lat: Double
    let lon: Double
}
```

---

## Data Service

**API:** `https://airplanes.live/` — see their documentation for the lat/lon/radius endpoint.

**Recommended approach:**
```swift
// Fetch aircraft within 100 NM of center
// URL: GET https://api.airplanes.live/v2/point/{lat}/{lon}/100
// Response: { ac: [ { hex, flight, t, r, alt_baro, gs, track, ... } ] }
```

**Coordinate math (equirectangular, sufficient for ≤100 NM):**
```swift
let cosLat = cos(center.lat * .pi / 180)
let nmN = (ac.lat - center.lat) * 60
let nmE = (ac.lon - center.lon) * 60 * cosLat
let dist = sqrt(nmN*nmN + nmE*nmE)
var brg = atan2(nmE, nmN) * 180 / .pi
if brg < 0 { brg += 360 }
```

---

## Design Tokens

### Typography

| Use | Font | Size | Weight | Extra |
|-----|------|------|--------|-------|
| Widget title / callsign | SF Pro Text | 12–13pt | 600 | |
| Hero callsign (XL) | SF Mono | 24pt | 700 | letter-spacing: 0.4 |
| Hero distance (XL) | SF Mono | 27pt | 700 | `accent` color |
| Stat values | SF Mono | 13pt | 400 | |
| Stat labels | SF Mono | 8pt | 400 | letter-spacing: 0.6, uppercase |
| Row callsign | SF Mono | 11–12.5pt | 700 | |
| Row secondary | SF Mono | 9.5–10pt | 400 | `textDim` |
| Chips / labels | SF Mono | 8–9.5pt | 400 | letter-spacing: 0.5–0.6 |
| Map labels | SF Mono | 7.5–9pt | 400–700 | scaled with scope |

### Colors — Theme: Muted (default dark/cyan)

| Token | Value | Use |
|-------|-------|-----|
| `frameBg` | `#0c1117` | Widget background |
| `frameBorder` | `rgba(150,180,210,0.10)` | Widget border |
| `frameShadow` | `0 22px 50px -18px rgba(0,0,0,0.65)` | Widget shadow |
| `scopeBg` | `#0a0e13` | Scope background |
| `ocean` | `#080b10` | Pacific Ocean fill |
| `land` | `#111923` | Land fill |
| `bay` | `#080b10` | SF Bay fill |
| `coast` | `#2d4256` | Coastline stroke |
| `ring` | `#1f2c3a` | Range ring (inner) |
| `ringStrong` | `#33485e` | Range ring (outer) |
| `ringLabel` | `#4f6b7c` | Ring distance labels |
| `tick` | `#283745` | Bearing ticks |
| `center` | `#5fd0ff` | Center crosshair + KSFO label |
| `grid` | `#16202b` | Graticule lines |
| `ac` | `#5fd0ff` | Normal aircraft marker |
| `acDim` | `#6f8a9e` | High-altitude aircraft (≥18,000 ft) |
| `acHi` | `#ffb454` | Selected / nearest aircraft |
| `trail` | `rgba(95,208,255,0.28)` | Motion trail |
| `text` | `#e7eef5` | Primary text |
| `textDim` | `#8aa0b2` | Secondary text |
| `textFaint` | `#5f7384` | Tertiary / labels |
| `accent` | `#5fd0ff` | Accent (distance, selected callsign) |
| `warn` | `#ffb454` | Warning dot (fallback location) |
| `panel` | `#0e151d` | Hero card background |
| `panelLine` | `rgba(150,180,210,0.09)` | Dividers, borders |
| `chip` | `rgba(95,208,255,0.10)` | Chip background / selected row bg |
| `chipText` | `#8fd9ff` | Chip text |
| `pos` | `#5fd0ff` | Climbing trend (↑) |
| `neg` | `#ff9d6b` | Descending trend (↓) |
| `sweep` | `rgba(95,208,255,0.07)` | Sweep beam fill |
| `glow` | `none` | Aircraft glow filter |

### Colors — Theme: Green (phosphor)

| Token | Value |
|-------|-------|
| `frameBg` | `#04120c` |
| `scopeBg` | `#03110b` |
| `land` | `#062017` |
| `coast` | `#1f7a4d` |
| `ring` | `#0e4d31` |
| `ringStrong` | `#1c6e46` |
| `center` | `#9bffc4` |
| `ac` | `#5dff9c` |
| `acDim` | `#37b06b` |
| `acHi` | `#d8ffae` |
| `accent` | `#5dff9c` |
| `pos` | `#5dff9c` |
| `neg` | `#d8ffae` |
| `glow` | `drop-shadow(0 0 3px rgba(93,255,156,0.55))` |
| `panelLine` | `rgba(93,255,156,0.12)` |
| `chip` | `rgba(93,255,156,0.10)` |

### Colors — Theme: Gray (grayscale)

| Token | Value |
|-------|-------|
| `frameBg` | `#0d0e10` |
| `scopeBg` | `#0b0c0d` |
| `land` | `#16181b` |
| `coast` | `#3d4146` |
| `ring` | `#23262a` |
| `ringStrong` | `#3a3e43` |
| `center` | `#f4f6f8` |
| `ac` | `#dfe3e7` |
| `acDim` | `#7e848a` |
| `acHi` | `#ffffff` |
| `accent` | `#e9ecef` |
| `pos` | `#e9ecef` |
| `neg` | `#b6bbc0` |
| `glow` | `none` |
| `panelLine` | `rgba(255,255,255,0.08)` |

### Spacing & Radius

| Token | Value |
|-------|-------|
| Widget corner radius | 22 pt |
| Hero card radius | 13 pt |
| Chip radius | 7 pt |
| XL right panel padding | 18 pt |
| Medium right panel padding | 13 14 11 pt |
| Left panel border | 1 pt `panelLine` |
| Row vertical padding (XL) | 6 pt |
| Row vertical padding (Medium) | 5 pt |
| Selected row inset accent | 2 pt |

---

## Geographic Reference Data (SF Bay Area basemap)

The basemap is a simplified polyline approximation — not survey-accurate. In production, use MapKit or a tile-based approach for the basemap. The current polylines (in `radar-data.js`) are suitable for the radar overlay style shown.

**Center (default):** KSFO — 37.6189°N, 122.3750°W

**Primary airports shown on scope:**
- KSFO (center), KOAK, KSJC (major — always labeled)
- KHWD, KPAO, KSQL, KNUQ, KCCR, KAPC, KLVK, KRHV, KSCK (minor)

**Cities (tier-1, always shown):** San Francisco, Oakland, San Jose  
**Cities (tier-2, shown in dense/XL mode):** Santa Rosa, Napa, Santa Cruz, Monterey, Stockton, Sacramento

---

## Altitude Trend Display

| Trend | Glyph | Color token |
|-------|-------|-------------|
| Climbing | ↑ | `pos` |
| Descending | ↓ | `neg` |
| Level | – | `textDim` |

---

## Location Resolution Logic

```
1. If locationMode == .currentLocation:
   - Use CLLocation to get user coords
   - Resolve nearest major airport (within 200 NM) as the "center"
   - Status: "Using GPS location"
2. If locationMode == .airport:
   - Fetch airport from ICAO code (local DB or API)
   - Use that airport's coords as center
   - Status: "Fallback airport · {ICAO}"
3. If location unavailable:
   - Fall back to last known center
   - Status: "Using fallback airport"
```

---

## Files in This Package

| File | Description |
|------|-------------|
| `README.md` | This document — complete implementation spec |
| `LocalSky Radar Widgets.dc.html` | **Primary reference** — open in browser. Shows all widget variants, live interactive settings demo, and circle vs. square comparison |
| `Widget.dc.html` | Widget component — all layout logic, settings model, selection state |
| `RadarFace.dc.html` | Radar scope SVG component — all drawing logic, square/circle modes |
| `radar-data.js` | Sample data, theme tokens, geographic polylines, projection math |

Open `LocalSky Radar Widgets.dc.html` in a full-width browser (1280px+) for the best view. The interactive demo section lets you toggle all 4 settings live and tap aircraft to select them.

---

## Implementation Checklist

- [ ] WidgetKit extension with `ExtraLargeWidget` and `MediumWidget` configurations
- [ ] `AppIntentConfiguration` with `WidgetConfigurationIntent` (shape, range, location, theme)
- [ ] `SelectAircraftIntent: AppIntent` for row/map taps
- [ ] Shared `AppGroup` UserDefaults for selected aircraft ID
- [ ] `CLLocationManager` in main app → resolves nearest airport
- [ ] `RadarDataService`: polls airplanes.live, decodes aircraft, computes dist/brg from center
- [ ] `adaptiveRange()` function matching the algorithm above
- [ ] MapKit or custom SVG basemap with coastline, bay, airports, cities
- [ ] Three theme token sets matching the color tables above
- [ ] Aircraft chevron markers with motion trails (scale with zoom)
- [ ] Range rings every 25 NM with labels
- [ ] Circle vs. square scope shape (SwiftUI `.clipShape(Circle())` vs. no clip)
- [ ] 60-second timeline refresh + immediate reload on intent
- [ ] `widgetURL` deep-link into main app
