From d14dab8736cc7b754dc50e66847600785cf0c0ba Mon Sep 17 00:00:00 2001 From: Reese Norris Date: Mon, 27 Jul 2026 17:59:01 -0400 Subject: [PATCH] web: add site-wide dark mode with shared theme tokens Bootstrap data-bs-theme + localStorage toggle in layout chrome; map console CSS (users/sweatbox/airport editor) onto shared --ofs-console-* tokens; swap OSM basemap for dark maps. Include PE and webjs coverage, and the approved sweatbox TWRTrainer P2 command-parity design doc. --- docs/design/sweatbox-twr-parity-p2.md | 1171 +++++++++++++++++ internal/web/pe_test.go | 45 + .../web/static/css/openfsd/airport-editor.css | 88 +- .../static/css/openfsd/sweatbox-manual.css | 21 +- internal/web/static/css/openfsd/sweatbox.css | 49 +- internal/web/static/css/openfsd/theme.css | 124 ++ internal/web/static/css/openfsd/users.css | 48 +- .../js/openfsd/airport-editor/map-layers.js | 144 +- .../js/openfsd/airport-editor/ui-toolbar.js | 2 +- internal/web/static/js/openfsd/dashboard.js | 46 +- internal/web/static/js/openfsd/theme.js | 326 +++++ internal/web/templates/airport_editor.html | 16 +- internal/web/templates/landing.html | 4 +- internal/web/templates/layout.html | 33 +- internal/web/templates/login.html | 4 +- internal/web/templates/sweatbox.html | 2 +- internal/web/templates/usereditor.html | 1 - scripts/check-webjs.sh | 5 +- webjs/airport-editor/map-layers.test.js | 31 + webjs/package.json | 4 +- webjs/theme.test.js | 262 ++++ 21 files changed, 2286 insertions(+), 140 deletions(-) create mode 100644 docs/design/sweatbox-twr-parity-p2.md create mode 100644 internal/web/static/css/openfsd/theme.css create mode 100644 internal/web/static/js/openfsd/theme.js create mode 100644 webjs/theme.test.js diff --git a/docs/design/sweatbox-twr-parity-p2.md b/docs/design/sweatbox-twr-parity-p2.md new file mode 100644 index 0000000..64ca2a5 --- /dev/null +++ b/docs/design/sweatbox-twr-parity-p2.md @@ -0,0 +1,1171 @@ +# openfsd Sweatbox — TWRTrainer Command Parity (P2) + +| Field | Value | +|-------|--------| +| **Document** | Sweatbox TWRTrainer command parity (P2) | +| **Author** | _(design author / implementer)_ | +| **Date** | 2026-07-27 | +| **Status** | **Approved** (rev 3 — design review consensus; ready for implementation) | +| **Intended permanent home** | `docs/design/sweatbox-twr-parity-p2.md` | +| **Parent design** | `docs/design/sweatbox-integrated-simulator.md` (Status: Implemented, P0+P1) | +| **Target package** | `internal/sweatbox` (pure engine); minimal host/UI if any | +| **Reference (UX + sim only)** | `/Users/rnorris/scratch/openfsd-twrtrainer/` — findings 03/06, official `docs/website/docs/twrtrainer_command_ref.html`, `reconstructed/python_ref/commands.py` | + +--- + +## Overview + +openfsd already ships an in-process sweatbox simulator covering spawn, ground taxi/hold/CTO, air vectors, flight plans, and closed-traffic pattern flying (P0+P1). Instructors still lack a set of **pattern spacing**, **runway exit**, **helicopter present-position**, and **misc/debug** commands that TWRTrainer instructors rely on for realistic tower training density. + +This design completes **command + motion parity** for the gap inventory against TWRTrainer’s catalog (`reconstructed/python_ref/commands.py`), entirely inside `internal/sweatbox` where possible. Control plane remains `POST /sweatbox/command` text (same as today). No multi-TCP pilot model, no radio-frequency command bridge, no protocol wire changes. + +```text +Gap classes (source tags: official = command_ref.html; findings = binary/findings/python_ref): + A Pattern spacing: 360 / 270 / S-turns / LAHSO [official] + B Dead verbs: ctopp, land, hs (registered, no case) [official] + C Runway exit: er/el + auto-exit after FS landing [official] + D Misc/debug: appmode, setairline, getcoords, moveto [findings-only] + E Polish: status/instruction, cancel, coverage ≥95% +``` + +**Catalog completeness:** every verb in `python_ref/commands.py` is either already implemented (P0+P1) or planned here. There is no separate `stop` command; `DeleteArrivalsWhenParked` is an engine setting, not an instructor verb. Official debug HTML also documents `fhn` / `sln` (immediate heading / speed); openfsd already has `fhn`, and maps `sln`→`spd` (gradual)—changing `sln` to immediate is **out of P2 scope**. + +--- + +## Background & Motivation + +### Current state (code) + +| Area | Location | Behavior today | +|------|----------|----------------| +| Verb registry | `command.go` `isAircraftVerb` / `normalizeVerb` | Lists `ctopp`, `land`, `hs`; **missing** P2 verbs (`ml3`, `er`, …) | +| Dispatch | `dispatch.go` `Engine.Command` | No cases for `ctopp`/`land`/`hs` → `"Invalid command: …"` | +| Tick switch | `motion.go` `tickAircraftLocked` | Explicit status cases; unknown → `default` → `tickAirborneLocked` (vector steering only) | +| Pattern motion | `pattern.go`, `motion.go` `tickPatternLocked` | Rectangular circuit; TG/SG/LA/FS at threshold; **no** 360/270/S-turns | +| Full-stop landing | `handlePatternThresholdLocked` FS branch | Caps speed at **40** kt; decelerates in place; **no** taxiway exit | +| Turnoff geometry | `pkg/twrfiles.Surface.TurnoffLeft` | Parsed/formatted; **unused** by sim | +| Taxi holds | `taxi.go` + `tickTaxiLocked` | Hold-shorts by waypoint index — reuse model for LAHSO release | +| Helicopter | `EngineHelicopter`, low rotate speed | CTO still runway-oriented; no present-position TO/land | +| Snapshots / UI | `snapshot.go`, `serviceapi.SweatboxAircraftJSON` | Generic `Status` + `Instruction` already flow to instructor table | +| Host wire | `internal/server/sweatbox_*.go` | Tick updates positions; no per-command host special cases needed for pure engine | + +### Pain points + +1. **Spacing tools missing** — instructors cannot create pattern separation (360, 270-to-next-leg, S-turns on final) without manual `fh`/`ext` juggling that loses pattern state. +2. **Landing roll dead-end** — full-stop arrivals freeze on the runway; ground scenarios require manual `taxi` from an unnatural “on runway mid-field” state. +3. **Dead verbs** — `ctopp`/`land`/`hs` appear in the aircraft-verb list (prefix targeting works) but always fail, which is worse than unknown commands. +4. **Helicopter training incomplete** — present-position TO/land is a core helo flow in TWRTrainer. +5. **Parent design already named P2** — `sweatbox-integrated-simulator.md` phased catalog: “P2 | Edge cases (`lahso`, s-turns, …)”. + +### Authoritative sources (priority) + +When findings conflict with the official HTML, **prefer the official command reference**: + +1. `openfsd-twrtrainer/docs/website/docs/twrtrainer_command_ref.html` (**official**) +2. `docs/findings/03-COMMANDS.md`, `06-SIMULATION.md` (**findings**) +3. Status string table in `docs/findings/02-ARCHITECTURE.md` +4. openfsd parent design + existing `internal/sweatbox` patterns + +**Important correction vs findings 03:** findings list `er`/`el` under pattern as “Extend right/left”. The official ref defines them as **exit runway right/left** overrides. This design implements **exit**, not leg-extend (extend remains `ext`). + +**Standalone `hs`:** official ref: *“Cancels a previously issued position-and-hold instruction.”* Taxi sub-token `hs` in `taxi … hs …` already works via `parseTaxiArgs`. + +**D-class source:** `appmode`, `setairline`, `getcoords`, `moveto` appear in findings 03 / python_ref / debug form paths but **not** in the official HTML command ref (debug section only has `fhn` / `sln`). They remain in scope for **findings-complete catalog parity** as instructor/debug tools (KD-9). + +--- + +## Goals & Non-Goals + +### Goals + +1. Implement all gap commands tagged above (official A–C/B + findings-only D) with TWR-themed soft errors and status/instruction strings. +2. Integrate spacing maneuvers with existing pattern tick without breaking TG/SG/LA/FS, `ga`, `ext`, `tc`/`td`/`tb`, `msa`/`mna`. +3. Auto-exit after full-stop landing using runway `turnoff=` and `er`/`el` override. +4. LAHSO stop before a **crossing** runway intersection on the landing roll / post-touchdown path. +5. Helicopter `ctopp` / `land` with sensible motion. +6. Keep `internal/sweatbox` pure (stdlib + `internal/geo` + `pkg/twrfiles` only). +7. Maintain hard coverage floor **≥95%** on `internal/sweatbox`; race-clean tests. +8. Prefer **no host/protocol changes**; Status/Instruction already reach the UI. + +### Non-Goals + +- Multi-TCP pilot connections +- Radio-frequency instructor command bridge over `#TM` +- Full aero / weather / wake +- Multi-airport concurrent scenarios +- Voice simulation; recording/playback +- SPA instructor UI +- Changing `pkg/protocol` wire bytes +- Perfect binary-identical kinematics vs TWRTrainer (semantics + training usefulness) +- New service-HTTP routes (extend messages on existing `POST /sweatbox/command` only) +- Client-side command authority or new PE frameworks +- Changing `sln` from gradual (`spd` alias) to official immediate-speed debug semantics +- New instructor verbs beyond the python_ref catalog (e.g. no `stop` verb) + +--- + +## Key Decisions + +| # | Decision | Rationale | +|---|----------|-----------| +| **KD-1** | **Overlay maneuver state on `SimAircraft` fields**, temporarily set `Status` to TWR vocabulary (`Making 360`, `Making 270`, `Making s-turns`) while storing `ResumeStatus` for return after 360 / 270 | Matches instructor UI strings; preserves leg to resume; avoids inventing parallel UI columns | +| **KD-2** | **360 is heading-accumulation turn**, not a path orbit: integrate `turnRateDegPerSec` (existing 3°/s) L/R until accumulated ≥ 360°, then restore `ResumeHeading` + pattern/air targets | Simple, deterministic; training-visible duration ~120 s at standard rate | +| **KD-3** | **270 turns the long way onto the next-leg heading using the opposite of `patternTurnDir`.** On consume: `DesiredHeading = legHeading(next)`, `TurnDir = opposite(patternTurnDir)`, `Status = Making 270`. Rely on existing forced-direction `turnToward` long arc (optionally track `TurnAccumDeg` for UI/tests). End heading **equals** next leg heading without a corrective snap. | Same-direction 270° ends **180° off** the next leg (math below). Opposite-dir long arc is the training-useful interpretation of “270 instead of 90” that still rejoins the circuit. Official ref does not specify direction. | +| **KD-4** | **S-turns only on Final or OnApproach**; optional complete-turn count; cancel on threshold, `ga`, `fh` family, pattern re-entry | Official: “s-turns on final” | +| **KD-5** | **Runway exit is pure-engine post-FS behavior** using graph **waypoint-snap** intersections (~100 ft, same as taxi—not continuous line-line) + `Surface.TurnoffLeft` (reciprocal inverted); `er`/`el` set `ExitDir` | Turnoff already in `pkg/twrfiles`; reuses taxi path walker | +| **KD-6** | **LAHSO reuses hold-short kinematics** after FS: stop at snap intersection with named crossing runway; status `Holding Short` | Same `StatusHoldingShort` + `HoldShortOf`; `res`/`cross` release | +| **KD-7** | **Standalone `hs` cancels LUAW** (`pos`), not free-form hold-short | Official command ref | +| **KD-8** | **`ctopp`/`land` require `EngineHelicopter`** | Official ref | +| **KD-9** | **Misc commands are findings-only, still in P2**: `appmode` on Engine; `setairline` overrides callsign generator; `getcoords`/`moveto` Message / domain reposition | Catalog parity; cheap; no FSD side effects beyond next tick for `moveto` | +| **KD-10** | **No host/wire DTO expansion required** for instructor table | `serviceapi.SweatboxAircraftJSON` already carries Status/Instruction | +| **KD-11** | **Incremental PRs**; **PR 1 ships tick integration stubs** (`tickAircraftLocked` early maneuver branch + clear helpers), not fields-only | Prevents PR 2/3/4 each reinventing dispatch; 270 does not hard-depend on 360 semantics | +| **KD-12** | **Authoritative doc for er/el is official HTML (exit), not findings “extend”** | Avoid shipping wrong feature | +| **KD-13** | **S-turns final-only** (reject other legs) | Decided product default; no arm-on-downwind | +| **KD-14** | **On short-approach cut / `msa`: do not run a 270 turn; clear `Pending270`** | Short approach is not a square-pattern corner; leaving the flag latent would fire a 270 on a later circuit (e.g. after TG rejoin)—surprising UX. Clear is predictable. | +| **KD-15** | **Exit preferred-side miss → fallback other side** with Instruction noting actual exit; if none → stop on runway | Decided; removes open question | +| **KD-16** | **`appmode` is instructor context only in v1** (ops/snapshot + optional add default FS in approach mode); no student-visible radio change | Closes product ambiguity for implementers | +| **KD-17** | **Single shared `landingRollTargetKt = 45`** used by FS threshold cap and landed-roll target (replaces hard-coded 40 in FS branch) | One magic number aligned with findings 06 | +| **KD-18** | **`inPatternContext` + anchors independent of Status** | Maneuver statuses are not leg names; geometry keys off `LandingRunway` + traffic + `InPattern` | + +### KD-3 math (why same-direction 270 fails) + +For start heading \(H\) and traffic-side sign \(s \in \{+1,-1\}\) (left traffic \(s=-1\) if positive is right): + +| Turn | End heading | +|------|-------------| +| Normal 90° traffic-side | \(H + 90s\) = next leg | +| Same-direction 270° | \(H + 270s = H - 90s\) = **opposite** of next leg (180° error) | +| Opposite-direction 270° | \(H - 270s = H + 90s\) = **next leg** ✓ | + +Example: left traffic, upwind \(H=0\), next (crosswind) \(=270\): + +- Left 90° → 270 (correct) +- Left 270° → 90 (wrong) +- **Right 270°** → 270 (correct long way) + +Unit table (required tests): + +| Traffic | startHdg | nextHdg | TurnDir for 270 | expected arc ≥ | +|---------|----------|---------|-----------------|----------------| +| L | 0 | 270 | TurnRight | 250° | +| L | 270 | 180 | TurnRight | 250° | +| R | 0 | 90 | TurnLeft | 250° | +| R | 90 | 180 | TurnLeft | 250° | + +--- + +## Proposed Design + +### Architecture + +```mermaid +flowchart TB + subgraph cmd["Command layer"] + C[command.go verbs + aliases] + D[dispatch.go cases] + C --> D + end + + subgraph state["SimAircraft fields"] + M[Maneuver / Pending270 / STurn*] + X[ExitDir / LahsoOf / Resume*] + G[existing taxi + pattern fields] + H[HeloLand target] + end + + subgraph tick["tickAircraftLocked"] + M360[tickManeuver360Locked] + M270[tickManeuver270Locked] + MST[tickSTurnLocked] + HL[tickHeloLandLocked] + TP[tickPatternLocked] + TL[tickLandedRollLocked] + TX[tickTaxiLocked] + TA[tickAirborneLocked] + end + + D --> state + state --> tick + tick --> SN[snapshot Status + Instruction] + SN --> UI[Instructor table via host] +``` + +### Tick control flow (`tickAircraftLocked`) — mandatory + +Today’s switch has no cases for new maneuver statuses; `default` only runs `tickAirborneLocked`, which steers to `DesiredHeading` and **does not** implement free 360 accumulation or S-turn phases. Implementers must **not** rely on `default`. + +Exact order (after existing ClearedTakeoff→Takeoff promotion, before or integrated with the main switch): + +```text +func tickAircraftLocked(ac, dtSec): + // 0. Existing: ClearedTakeoff ground→Takeoff promotion + + // 1. Active air maneuvers — preempt pattern/airborne leg guidance + if ac.Maneuver == "360L" || ac.Maneuver == "360R" || ac.Status == StatusMaking360: + return tickManeuver360Locked(ac, dtSec) + + if ac.Maneuver == "270" || ac.Status == StatusMaking270: + return tickManeuver270Locked(ac, dtSec) + + if ac.Maneuver == "STURN" || ac.Status == StatusMakingSTurns: + return tickSTurnLocked(ac, dtSec) + + if ac.HeloLand: // flag set by land command + return tickHeloLandLocked(ac, dtSec) + + // 2. Existing status switch — ADD explicit cases for new statuses + // (defense in depth if Maneuver cleared but Status lag): + switch ac.Status { + case StatusParked: ... + case StatusHolding, StatusHoldingShort, StatusHoldingInPosition: ... + case StatusTaxiing: tickTaxiLocked + case StatusTakeoff: tickTakeoffLocked + case StatusDeparting, StatusAirborne, StatusOnApproach: tickAirborneLocked + case StatusUpwind, StatusCrosswind, StatusDownwind, StatusBase, StatusFinal: + tickPatternLocked + case StatusLanded: + // SG wait | hasTaxiPath taxi | else tickLandedRollLocked (NEW) + case StatusMaking360, StatusMaking270, StatusMakingSTurns: + // Should have been handled above; re-enter maneuver tick or clear + return tickManeuver* / clearAirManeuverFields fallback + default: + tickAirborneLocked if moving/vectors + } +``` + +**PR 1 requirement:** ship this early-branch skeleton with no-op or “clear unknown Maneuver” stubs so later PRs only fill tick bodies. + +Pattern mermaid for airborne 360 eligibility: 360 tick runs for **any** status once `Maneuver` is set (pattern legs, Airborne, Departing, OnApproach, Takeoff airborne)—not only inside `tickPatternLocked`. + +### Status vocabulary additions + +```go +const ( + StatusMaking360 = "Making 360" + StatusMaking270 = "Making 270" + StatusMakingSTurns = "Making s-turns" + // Existing StatusLanded / StatusHoldingShort used for exit + LAHSO + // Helo land uses StatusOnApproach (no new status string) +) +``` + +Instruction examples: + +| Situation | Instruction | +|-----------|-------------| +| 360 L | `Making left 360` | +| 270 executing | `Making 270` | +| S-turns | `Making s-turns` | +| LAHSO armed airborne | pattern/final instruction + `, LAHSO ` | +| LAHSO stopped | `Holding short of (LAHSO)` | +| Landing roll exit | `Exiting runway via ` | +| Clear of runway | `Clear of runway ` | +| Exit fallback side | `Exiting runway via (fallback)` | +| No exit found | `Landed runway (no exit)` | +| ctopp | `Cleared for takeoff present position` (+ heading) | +| land PP | `Landing present position` | +| land @P | `Landing @P` | + +### SimAircraft field additions + +```go +// Pattern spacing maneuvers (P2) +ResumeStatus string // pattern leg (or airborne) to restore after 360; next leg after 270 +ResumeHeading float64 // heading to resume after 360 +Maneuver string // "", "360L", "360R", "270", "STURN" +TurnAccumDeg float64 // degrees turned in current maneuver segment (tests/UI) +Pending270 bool // m2 set; consumed at next normal pattern corner / tc-family + +// S-turns +STurnDir int // TurnLeft / TurnRight initial +STurnRemain int // complete s-turns remaining; -1 = until final ends +STurnPhase int // 0 = first half, 1 = second half of a complete S +STurnBaseHdg float64 // final/landing heading under the S + +// Runway exit / LAHSO +ExitDir string // "", "L", "R" — instructor override; empty → apt turnoff +LahsoOf string // crossing runway name to hold short of after landing +ExitPlanned bool // true once auto-exit path installed this landing + +// Helicopter land (P2) +HeloLand bool // true while executing land command +HeloLandPark string // parking name without @, or "" for present position +``` + +### Helpers (required) + +```go +// inPatternLeg: unchanged — five circuit leg names only. + +// inPatternContext: eligibility that includes active pattern work + maneuvers. +func inPatternContext(ac *SimAircraft) bool { + if ac == nil { return false } + if ac.InPattern || ac.inPatternLeg() { return true } + switch ac.Status { + case StatusMaking360, StatusMaking270, StatusMakingSTurns: + return true + } + return false +} + +// anchorsForAircraftLocked MUST remain independent of Status +// (uses LandingRunway / DepRunway + PatternTraffic + size only). +// Document and test: anchors work while Status is Making 360. + +func oppositeTurnDir(dir int) int { + if dir == TurnLeft { return TurnRight } + if dir == TurnRight { return TurnLeft } + return TurnShortest +} + +func clearAirManeuverFields(ac *SimAircraft) { + // Clears: Maneuver, TurnAccumDeg, ResumeStatus, ResumeHeading, + // Pending270, STurn*, and if Status is Making* restore? caller sets Status. + // Does NOT clear ExitDir, LahsoOf, ExitPlanned, HeloLand*. +} + +func clearHeloLandFields(ac *SimAircraft) { HeloLand=false; HeloLandPark="" } +``` + +**Mandatory call sites for `clearAirManeuverFields`:** + +| Handler / path | Also clear | +|----------------|------------| +| `cmdGoAroundLocked` | yes — first lines after requireAircraft | +| `cmdEnterPatternLocked` / `placeOnPatternLegLocked` | yes | +| `cmdFlyHeadingLocked` / `fph` (fh/fhn/tr/tl) | yes — Pending270 included (vector leaves pattern guidance) | +| Threshold TG/SG/LA/FS entry | yes (airborne maneuvers) | +| Starting a new 360 / S-turn | clear prior air maneuver (not ExitDir) | +| `del` | whole aircraft gone | + +Clearing rules summary: + +| Event | Clears air maneuvers | Clears ExitDir | Clears LahsoOf | Clears HeloLand | +|-------|----------------------|----------------|----------------|-----------------| +| `ml3`/`mr3` | prior 360/S-turn/270-active; not Pending270 unless policy: **keep Pending270** | no | no | yes if any | +| `m2` | active 360/S-turn/270 | no | no | — | +| `no270` | Pending270 only | no | no | — | +| `ga` | all air + Pending270 | no | **yes** | yes | +| pattern enter | all air + Pending270 | no | yes | yes | +| `fh` family | all air + Pending270 | no | no | yes | +| New `taxi` | — | no | no | — | +| `fs` / landing type | no | no | no | — | + +### A. Pattern spacing maneuvers + +#### A1. `ml3` / `mr3` (aliases `ml360` / `mr360`) — **official** + +**Parse:** no args. + +**Eligibility:** airborne-ish — pattern legs, `StatusOnApproach`, `StatusAirborne`, `StatusDeparting`, `StatusTakeoff` with `Alt > field+eps`. Reject ground: `"Not airborne."` + +**Dispatch:** + +```text +ResumeStatus = current Status if inPatternLeg or air family else StatusAirborne +ResumeHeading = Heading +Maneuver = "360L" | "360R" +TurnAccumDeg = 0 +Status = StatusMaking360 +InPattern = keep prior InPattern (true if was pattern) +Instruction = "Making left 360" | "Making right 360" +// Keep speed/alt targets; do not clear LandingType / PatternTraffic +``` + +**Tick (`tickManeuver360Locked`)** — entered via early branch for any status: + +1. `TurnDir` = Left/Right from Maneuver +2. Each tick: free-turn rotate heading by `turnRateDegPerSec * dt` in that direction (do **not** chase DesiredHeading for completion); `TurnAccumDeg += step` +3. Advance position along current heading; honor DesiredAlt/DesiredSpeed if set (or hold alt) +4. When `TurnAccumDeg >= 360 - hdgEqualEpsDeg`: + - Snap heading to `ResumeHeading` + - `Status = ResumeStatus` (empty → `StatusAirborne`) + - Re-seed `DesiredHeading = ResumeHeading`, pattern TurnDir if still InPattern + - Clear maneuver fields; refresh Instruction + +**Interactions:** second ml3/mr3 restarts; `ga` clears + go-around; `ext`/`tc` fail (not on leg status)—OK; pause freezes. + +**Tests:** accumulation timing; mid-downwind resume InPattern; ground reject; tick works when Status was Airborne (not only pattern). + +#### A2. `m2` / `m270` and `no270` — **official** + +**Parse:** no args. Aliases: `m270`→`m2`. + +**Eligibility:** `m2` requires `inPatternContext` or pattern leg (not merely Airborne without pattern): `"Not in the pattern."` +`no270`: idempotent clear if aircraft exists. + +**Dispatch:** + +```text +m2: Pending270 = true + // Status stays on current leg; Instruction may stay formatPatternInstruction + // Optional: append nothing until consume (latent flag) +no270: Pending270 = false +``` + +##### Pending270 state machine (precise) + +**Latent phase:** `Pending270==true`, `Maneuver==""`, `Status` = current leg name. +`tickPatternLocked` behaves normally until a **consume event**. + +**Consume events** (must all call the same helper `begin270TurnLocked(ac, nextLeg)`): + +| Event | Where | Behavior | +|-------|-------|----------| +| Auto corner `d <= patternCornerEpsM` | `tickPatternLocked` | **Consume** → `begin270TurnLocked` instead of `advancePatternLegLocked` | +| `tc` / `td` / `tb` | `cmdTurnPatternLegLocked` | **Consume** → branch **before** immediate Status=next | +| Short-approach cut downwind→final | `tickPatternLocked` ShortApproach branch | **No 270 turn** (KD-14); **`Pending270 = false`** then normal advance to Final | +| `msa` command | `cmdShortApproachLocked` | **`Pending270 = false`** when arming short approach (same KD-14 predictability) | + +**`begin270TurnLocked(ac, next string)`** — do **not** call unmodified `advancePatternLegLocked`: + +```text +a := anchorsForAircraftLocked(ac) +targetHdg := a.legHeading(next) +trafficDir := patternTurnDir(a.Traffic) + +ac.Pending270 = false // consumed +ac.Maneuver = "270" +ac.TurnAccumDeg = 0 +ac.ResumeStatus = next // leg to enter when turn completes +ac.ResumeHeading = targetHdg // = DesiredHeading +ac.DesiredHeading = targetHdg +ac.HasDesiredHeading = true +ac.TurnDir = oppositeTurnDir(trafficDir) // KD-3 +ac.ImmediateHeading = false +ac.Status = StatusMaking270 +ac.InPattern = true +ac.ExtendLeg = false +ac.Instruction = "Making 270" +// Alt: set DesiredAlt for next leg (pattern alt, or final glideslope if next==Final) +``` + +**`tickManeuver270Locked`:** + +```text +// Must NOT run tickPatternLocked leg-target rewrite (early branch guarantees this). +// Turn via existing turnToward(ac, DesiredHeading, TurnDir, dt) +// Accumulate |heading change| into TurnAccumDeg for tests +// Position along Heading; alt/speed as airborne/pattern rates +// When |headingDelta(Heading, DesiredHeading)| <= hdgEqualEpsDeg +// AND TurnAccumDeg >= 250 (guards against accidental short arc): +// Status = ResumeStatus (next leg) +// clear Maneuver / TurnAccum / Resume* +// TurnDir = patternTurnDir(traffic) for subsequent leg flying +// if next == Final: seed final alt as advancePatternLegLocked would +// Instruction = formatPatternInstruction(ac) +// MidfieldReported reset if next == Downwind +``` + +**`cmdTurnPatternLegLocked` patch:** + +```text +want := turnCommandTargetLeg(verb) +needCur := prevLeg(want) +if ac.Status != needCur { error } +if ac.Pending270 { + return begin270TurnLocked(ac, want) // soft OK +} +// else existing immediate advance to want +``` + +**`tickPatternLocked` corner / short-approach patch:** + +```text +// Short approach cut (before or instead of normal corner): +if ac.ShortApproach && leg == StatusDownwind && !ac.ExtendLeg && pastMidfield { + ac.Pending270 = false // KD-14: clear latent flag — do not fire later + e.advancePatternLegLocked(ac, a) // forces Final via existing ShortApproach branch + return +} + +if !ac.ExtendLeg && d <= patternCornerEpsM { + next := nextLeg(leg) + if ac.Pending270 { + begin270TurnLocked(ac, next) + } else { + advancePatternLegLocked(ac, a) + } +} +``` + +**`cmdShortApproachLocked` (msa true):** also set `Pending270 = false` when enabling short approach. + +**Tests:** unit table from KD-3; no270 before corner → normal 90°; m2 then fh clears Pending270; short approach does **not** start Making 270 **and** leaves `Pending270==false`; mid-270 pattern tick does not overwrite TurnDir. + +#### A3. `mls` / `mrs` [n] (aliases `sturn`, `sturns` → `mls`) — **official** + +**Eligibility:** **Final or OnApproach only** (KD-13). Else `"Not on final."` + +**Parse:** optional positive int; invalid → example `"mls 3"`. + +**Dispatch:** + +```text +Maneuver = "STURN" +STurnDir = Left (mls) / Right (mrs) +STurnRemain = n or -1 +STurnPhase = 0 +STurnBaseHdg = anchors.LandingHdg (or current final heading) +Status = StatusMakingSTurns +InPattern = true if was +Instruction = "Making s-turns" +``` + +**Tick (`tickSTurnLocked`)** — early branch preempts `tickPatternLocked`, so **threshold detection must live here** (or a shared helper). Otherwise aircraft never land while `Maneuver=="STURN"` (including unlimited `STurnRemain==-1`). + +```text +func tickSTurnLocked(ac, dtSec): + a, err := anchorsForAircraftLocked(ac) + if err != "" { + // fall back to free airborne vectors + return tickAirborneLocked(ac, dtSec) + } + + // --- 1. Threshold / overshoot FIRST (shared with Final) --- + // Same geometry as tickPatternLocked Final block: + dThr := distToPoint(ac, a.Threshold) + brgToThr := initialBearingDeg(ac.Lat, ac.Lon, a.Threshold.Lat, a.Threshold.Lon) + past := abs(headingDelta(brgToThr, a.LandingHdg)) > 90 && dThr < a.SizeNM*metersPerNM + near := dThr <= patternThreshEpsM + if near || past { + // Cancel S-turn, restore Final context for landing-type handler, then land. + clearAirManeuverFields(ac) // Maneuver, STurn*, TurnAccum; not ExitDir/LahsoOf + ac.Status = StatusFinal + ac.InPattern = true // keep circuit context for TG/SG/LA defaulting + // DesiredHeading may be off-final after S; threshold handler snaps placement + return handlePatternThresholdLocked(ac, a, dtSec) + } + + // --- 2. S-turn phases (only when still short of threshold) --- + offset = sTurnOffsetDeg (30) + phase 0: DesiredHeading = Base + dir*offset + phase 1: DesiredHeading = Base - dir*offset + TurnDir = toward phase target (forced) + turnToward + integrate position on Heading + Alt: continue approachAltitude(field, distNM) toward threshold (Final glideslope) + on phase complete: flip phase; if phase wrapped 1→0: STurnRemain-- + if STurnRemain == 0: + clear STURN maneuver fields only + Status = StatusFinal // or OnApproach if was approach + DesiredHeading = STurnBaseHdg / LandingHdg + Instruction = formatPatternInstruction + // subsequent ticks enter tickPatternLocked Final path (early branch off) + // STurnRemain < 0 (unlimited): keep phases until threshold step 1 fires +``` + +**Shared helper (recommended):** extract `finalThresholdArrival(ac, a) (nearOrPast bool)` used by both `tickPatternLocked` Final and `tickSTurnLocked` so geometry cannot drift. + +**Important:** Counted S-turns that complete **before** threshold return to Final and land on a later tick via normal Final path. Unlimited S-turns land **only** via step 1 above (threshold cancels S-turn mid-maneuver). Neither path requires finishing the S-turn count first. + +**Tests:** +- unlimited `mls` still lands (FS/TG/…) when near/past threshold; Status becomes Landed/Takeoff/etc., not stuck Making s-turns +- mid-S-turn at threshold cancels phases and runs TG/SG/LA/FS per LandingType +- `mls 1` completes one S then resumes Final track and can still land +- mrs initial right; ga clears; not on final rejects + +#### A4. `lahso` runway — **official** + +**Parse:** `lahso ` required. + +**Soft errors:** missing rwy; unknown surface; same as landing runway; **no waypoint-snap intersection** between landing runway surface and LAHSO surface → `"Runways do not intersect."` + +**Intersection model:** graph waypoint-snap (~100 ft / `DefaultIntersectionTolM`), **same as taxi**—not continuous segment-segment intersection. Apt files must have near-colocated vertices at crossings (TWR apt convention; KBTV `19/1`↔`33/15` covered in `airport_test.go`). + +**Dispatch:** set `LahsoOf`; optionally leave LandingType; Instruction append `, LAHSO `. + +**Motion after FS:** + +1. Touchdown as C3 (shared `landingRollTargetKt`). +2. Hold point = `FindIntersection(landingSurface, LahsoOf)`; choose pair on landing surface. +3. Along-track distance from aircraft to hold point using landing heading unit vector. If along-track < `−lahsoBehindEpsM` (already past) → stop ASAP, `StatusHoldingShort`, Instruction notes past intersection. +4. Else roll toward hold; stop when **along-track** remaining ≤ `lahsoArriveEpsM` (**25 m**, named constant—not `taxiArriveEpsM` 3 m). +5. `StatusHoldingShort`, `HoldShortOf = LahsoOf`, Instruction `"Holding short of (LAHSO)"`. +6. `res` / `cross` reuse ground commands; then auto-exit may run if no taxi plan. + +**Constants:** + +```go +const ( + lahsoArriveEpsM = 25.0 // along-track; larger than taxiArriveEpsM (3 m) + lahsoBehindEpsM = 15.0 // treat as past if along-track < -this + landingRollTargetKt = 45.0 // FS cap + roll target (KD-17) +) +``` + +**Tests:** synthetic crossing runways; non-intersect error; overshoot past intersection; after hold `cross` works. + +--- + +### B. Dead verbs with dispatch — **official** + +#### B1. `ctopp` [hdg] + +Helicopter only; groundOK statuses; present-position takeoff without DepRunway alignment. + +```text +ClearedTakeoff = true; PositionHold = false; HoldShortOf = "" +optional DepHeading; PatternTraffic cleared +Status = StatusTakeoff; Instruction = "Cleared for takeoff present position" [...] +// tickTakeoffLocked existing path → Departing +``` + +Reject fixed-wing: `"ctopp is for helicopters only."` + +#### B2. `land` [@parking] — full tick/status contract + +**Eligibility:** `EngineHelicopter` only; must be airborne or at least `Alt > field` or Status in air family. Reject: `"land is for helicopters only."` / `"Not airborne."` as appropriate. + +**No new Status string.** Reuse `StatusOnApproach` + `HeloLand` flag. + +**Dispatch (`cmdLandLocked`):** + +```text +clearAirManeuverFields(ac) +clear taxi path / pattern: + InPattern = false + LandingType = "" + PatternTraffic = "" // optional keep + ExtendLeg / ShortApproach / SG* = false + LandingRunway may clear or keep — clear for PP land + +HeloLand = true +if args has @P or parking name: + resolve parking surface; HeloLandPark = name + Instruction = "Landing @" + name +else: + HeloLandPark = "" + Instruction = "Landing present position" + +Status = StatusOnApproach +DesiredAlt = fieldElevFeet(airport) +HasDesiredAlt = true +DesiredSpeed = 0 +HasDesiredSpeed = true +// optional: DesiredHeading = present or toward parking bearing +HasDesiredHeading = true if parking set (bearing to park), else false / fph +TurnDir = TurnShortest +ImmediateHeading = false +ClearedTakeoff = false +``` + +**Tick (`tickHeloLandLocked`)** — early branch when `HeloLand`: + +```text +field := fieldElevFeet(apt) +// Speed toward 0 (or min 10 kt until near park then 0) +speedToward(ac, max(0, DesiredSpeed), dt) + +if HeloLandPark != "": + // steer toward parking coords + park := graph.Surface(HeloLandPark).Points[0] + brg := initialBearingDeg(ac, park) + DesiredHeading = brg; turnToward(...) + // move along heading + if dist(ac, park) <= heloLandArriveM (15 m) && Alt <= field+altEqualEpsFt && Speed <= spdEqualEpsKt: + snap to park; finishHeloLand → Parked +else: + // present position: no lateral target + // still move if Speed > 0 along heading while decelerating + if Alt <= field+altEqualEpsFt && Speed <= spdEqualEpsKt: + finishHeloLand → Parked at current lat/lon + +// Altitude: climbTowardRate toward field (or gentle helo descent rate) +// finishHeloLand: +// Status = StatusParked +// Alt = field; Speed = 0 +// Parking / CurrentSurface = HeloLandPark or "" +// Instruction = "Parked" / "Parked "+name +// clearHeloLandFields; clear air Desired* optional keep +``` + +**Cancel (in-air only — `ctopp` is ground-only and is NOT a land abort):** + +| Command | Effect while HeloLand | +|---------|----------------------| +| `fh` / `fhn` / `tr` / `tl` / `fph` | `clearHeloLandFields`; clear air maneuver; `Status=StatusAirborne`; apply vector as usual | +| `cm` / `spd` | `clearHeloLandFields`; stay/set `StatusAirborne` with alt/speed targets (leave lateral free or present heading) | +| `del` | remove aircraft | +| Second `land` | re-issue: restart land to new/@same target (refresh HeloLand fields) | +| `ctopp` | **soft-fail** (eligibility = groundOK); does not cancel airborne land | + +**Tests:** airborne helo land PP → Parked same coords; land @parking moves; non-helo reject; mid-land fh cancels HeloLand → Airborne; ctopp while landing fails with ground eligibility error. + +#### B3. `hs` (standalone) — **official** + +**Official:** *Cancels a previously issued position-and-hold instruction.* + +**Not** a free-form hold-short of an arbitrary surface (taxi sub-token `hs` remains only inside `taxi … hs …` via `parseTaxiArgs`). + +**Dispatch (`cmdHSLocked`)** — inverse of `cmdPosLocked` ground recovery (not identical to `cmdCTOCLocked`): + +```text +ac, err := requireAircraftLocked(target) +if Status != StatusHoldingInPosition && !PositionHold: + return OK=false, Message: "Not holding in position." + +// Cancel LUAW +PositionHold = false +ClearedTakeoff = false // line-up cancel implies re-issue cto later (same as pos clearing cto on re-pos) +// Do not clear DepRunway / taxi plan / PatternTraffic + +if hasTaxiPath(): + Status = StatusTaxiing + Instruction = formatTaxiInstruction(from ac.TaxiSteps/Holds/Parking) + // HoldShortOf already "" or leave; taxi tick resumes path +else if DepRunway != "": + Status = StatusHoldingShort + HoldShortOf = DepRunway + Instruction = "Holding short of " + DepRunway +else: + Status = StatusHolding + HoldShortOf = "" + Instruction = "Hold position" + +return OK=true +``` + +**Contrast with `ctoc`:** `ctoc` requires `ClearedTakeoff` and may return from `StatusTakeoff` roll to hold-short. `hs` requires holding in position / PositionHold and never applies mid-takeoff-roll. + +**Tests:** +- `pos` then `hs` → HoldingShort of DepRunway when DepRunway set and no taxi path +- `taxi …` then `pos` then `hs` → StatusTaxiing with path restored +- `hs` without pos → `"Not holding in position."` +- airborne / pattern → same not-holding error + +--- + +### C. Runway exit after landing (`er` / `el` + auto-exit) — **official** + +#### C1. Commands + +`er` → `ExitDir="R"`; `el` → `ExitDir="L"`. Always OK if aircraft exists. + +#### C2. Default turnoff + +```go +func exitSideForLanding(s *Surface, landingEnd string) string { + leftForA := s.TurnoffLeft // true → left for RwyA + end := normalize landing end to RwyA or RwyB + if end == s.RwyA { + if leftForA { return "L" } + return "R" + } + // RwyB: opposite of A + if leftForA { return "R" } + return "L" +} +// Instructor ExitDir overrides when non-empty. +``` + +#### C3. Full-stop + landed roll + +**FS threshold** uses `landingRollTargetKt` (45) instead of hard-coded 40: + +```text +ac.Speed = min(ac.Speed, landingRollTargetKt) +StatusLanded; InPattern=false; arr++; Instruction = "Landed runway X" +CurrentSurface = landing runway; clear air maneuvers +``` + +**`tickLandedRollLocked`** (when Landed, not SGWaiting, no taxi path yet): + +```text +if LahsoOf != "" && not yet held → roll/stop for LAHSO +else if !ExitPlanned → planRunwayExitLocked(ac) +else if hasTaxiPath → should not be here (outer switch uses taxi) +else decelerate to 0 on runway +``` + +#### C4. `planRunwayExitLocked` — point-order contract + +```go +// planRunwayExitLocked installs a short taxi path off the landing runway. +// Returns false if no candidate (caller leaves aircraft stopping on runway). +func (e *Engine) planRunwayExitLocked(ac *SimAircraft) bool +``` + +**Algorithm:** + +1. **Resolve** landing surface + end via `resolveRunwaySurfaceLocked(ac.LandingRunway)` (same as pattern). If fail → return false. +2. **Side** = `ExitDir` if set, else `exitSideForLanding(s, end)`. +3. **Walk direction:** runway `Points` are ordered RwyA→RwyB (apt convention). + - Landing RwyA: walk indices `i = 0 .. n-1` (increasing). + - Landing RwyB: walk indices `i = n-1 .. 0` (decreasing). + Along-track positive direction = landing heading from `runwayThreshold`. +4. **Aircraft index:** `ClosestWaypoint` on runway; start search at that index going forward along walk direction. +5. **Candidates:** for each runway vertex at/after start, for each `Neighbors(runwayName)`: + - Skip self, skip pure HOLD surfaces as exit destinations (HOLD may mark hold lines; exit target should be TAXIWAY or other RUNWAY only if needed—**prefer SurfaceTaxiway**). + - `FindIntersection(runway, neighbor)` must succeed (waypoint-snap). + - **Along-track** from aircraft to intersection point: require `along >= -exitBehindEpsM` (default 10 m)—slightly behind still allowed (tol), not far behind. + - **Side classification:** + \(\vec{v}\) = unit vector along landing heading; \(\vec{w}\) = aircraft (or runway center) → exit point projected horizontally; + `cross = v_e*w_n - v_n*w_e` (east/north components); + left if cross > 0 (northern-hemisphere screen math: document using local ENU: east = sin(hdg), north = cos(hdg); left = positive cross of heading × to-point). +6. **Select:** nearest ahead (min along-track ≥ −ε) on **preferred side**. If none: nearest ahead **any side**, set `usedFallback=true` (KD-15). If none at all: Instruction `"Landed runway X (no exit)"`; `ExitPlanned=true` to avoid replan loop; return false. +7. **Path:** waypoints = `[ac position or snap, intersection on runway, stub point]`. Stub = from intersection along taxiway polyline ~`exitStubM` (60 m) away from runway (next vertex direction that increases distance to runway centerline). +8. Install `TaxiWaypoints`, `TaxiWPIndex=0`, `Status=StatusTaxiing`, `ExitPlanned=true`, Instruction `"Exiting runway X via Y"` or with `(fallback)`. + +**Instructor taxi during roll:** if `hasTaxiPath()` already, never call planner (outer Landed branch uses taxi tick). + +**Clear of runway:** when taxi path finishes or distance to runway > `clearRunwayM` (40 m): Instruction `"Clear of runway X"`; Speed 0; Status Holding unless more path. Honor `NoStop` if further taxi queued. + +**Fixture requirements for tests:** + +- Synthetic runway ≥4 points, taxiway left-only and right-only neighbors at distinct vertices. +- Landing A and B both tested for walk direction. +- KBTV smoke optional (geometry denser; synthetic is source of truth for side logic). + +--- + +### D. Misc / debug — **findings-only** + +#### D1. `appmode` approach|tower + +Global verb. Engine `appMode` default `"tower"`. Invalid arg → usage error. + +**v1 behavior (KD-16):** store flag; include in `ops` message and optional `EngineSnapshot.AppMode`. In `approach` mode only: new `add` on approach form defaults `LandingType=FS` if empty. No student radio change. + +#### D2. `setairline` [prefix] + +Global. Engine `airlineOverride`; empty arg clears. Affects `generateCallsignLocked` jet/turbo prefix list (override single prefix). + +#### D3. `getcoords` + +Aircraft-scoped. `Message: ": "` at 6 decimal places. No state change. + +#### D4. `moveto` lat lon [alt] [hdg] + +Aircraft-scoped debug. Validate finite ranges. Clear taxi path + `clearAirManeuverFields` + `clearHeloLandFields`. If alt > field+50 → `StatusAirborne`, `InPattern=false`; else keep ground-compatible status, update coords. Host needs no special API (next Tick positions). + +--- + +### E. Behavioral polish matrix + +| Existing cmd | vs 360 | vs 270 pending/active | vs S-turn | vs Exit/LAHSO | vs HeloLand | +|--------------|--------|------------------------|-----------|---------------|-------------| +| `ga` | clearAirManeuver + go around | clear | clear | clear LahsoOf | clear | +| `fh`/`tr`/`tl`/`fph` | clearAirManeuver | clear Pending270 + active | clear | keep exit/lahso | clear → Airborne vectors | +| `ext` | fail (not on leg) | keep latent Pending270 | fail | — | — | +| `tc`/`td`/`tb` | fail if not on leg | **begin270** if pending | fail | — | — | +| `msa` / short-approach cut | — | **clear Pending270** (no 270 turn) | — | — | — | +| threshold (Final or STURN tick) | clear then land | — | **clear STURN then `handlePatternThresholdLocked`** | LAHSO if FS | — | +| `tg`/`sg`/`la`/`fs` | keep until threshold | keep | keep | LAHSO if FS | — | +| pattern enter | clearAirManeuver | clear | clear | clear lahso | clear | +| `res`/`cross` | — | — | — | release LAHSO hold | — | +| `pause` | freeze | freeze | freeze | freeze | freeze | +| `ctopp` | — | — | — | — | **no** (ground-only; soft-fail in air) | + +--- + +## API / Interface Changes + +### Instructor text API (unchanged transport) + +```text +POST /sweatbox/command { "callsign": "…", "command": "ml3" } +→ { "ok": true, "message": "" } +→ { "ok": true, "message": "N12345: 44.471234 -73.150000" } // getcoords +→ { "ok": false, "message": "…" } +``` + +### Verb registration deltas (`command.go`) + +```go +// isAircraftVerb additions: +"ml3", "mr3", "m2", "no270", "mls", "mrs", +"lahso", "er", "el", +// already present: "ctopp", "land", "hs" +"getcoords", "moveto", + +// isGlobalVerb additions: +"appmode", "setairline", + +// normalizeVerb: +"ml360" → "ml3" +"mr360" → "mr3" +"m270" → "m2" +"sturn", "sturns" → "mls" +``` + +### Snapshot + +UI does not require new JSON fields. Optional internal AircraftSnapshot fields for tests: `Pending270`, `ExitDir`, `LahsoOf`, `HeloLand`. Optional `EngineSnapshot.AppMode`. + +### Host / serviceapi + +**No required changes.** + +--- + +## Data Model Changes + +### Runtime only + +No SQLite migrations. No `.apt` / `.air` format changes. + +### Constants + +```go +const ( + sTurnOffsetDeg = 30.0 + landingRollTargetKt = 45.0 // KD-17: FS threshold + roll + exitStubM = 60.0 + clearRunwayM = 40.0 + exitBehindEpsM = 10.0 + lahsoArriveEpsM = 25.0 + lahsoBehindEpsM = 15.0 + heloLandArriveM = 15.0 +) +``` + +--- + +## Alternatives Considered + +### 1. Status stays on pattern leg; maneuver only in Instruction + +- **Pros:** simpler resume; `inPatternLeg()` unchanged +- **Cons:** Status column never shows TWR `Making 360` vocabulary +- **Rejected** — KD-1 with ResumeStatus + +### 2. Same-direction 270° then snap heading to next leg + +- **Pros:** “true” 270° in traffic direction +- **Cons:** requires artificial snap; mid-turn heading does not point at next leg; more complex tests +- **Rejected** in favor of KD-3 opposite-dir long arc onto next heading (no snap) + +### 3. Multi-TCP / external TWRTrainer for missing commands + +- **Rejected** — parent design forbids multi-TCP + +### 4. Host-side scripting of 360 via repeated `fh` + +- **Rejected** — fragile, not pause-safe + +### 5. Full curved taxi geometry for exits + +- **Rejected** — polyline stub sufficient + +### 6. Defer findings-only misc (D-class) + +- **Rejected as exclusion** — still ship in PR 8; tagged findings-only for product awareness + +--- + +## Security & Privacy Considerations + +| Threat | Mitigation | +|--------|------------| +| Instructor command abuse | Existing admin-only service JWT + web CSRF; no new routes | +| `moveto` teleport abuse | Admin-only control plane | +| Resource exhaustion | MaxAircraft + O(1) maneuver state per AC; exit planner finite vertices | +| Import graph violation | All code in `internal/sweatbox` | + +No new PII. + +--- + +## Observability + +| Signal | Approach | +|--------|----------| +| Soft command errors | `CommandResult.Message` → UI flash | +| Geometry failures | Soft instructor messages; **no panic**; engine silent (no slog required) | +| Ops | FS increments arr once at threshold; unpaused elapsed unchanged | +| Debug | `getcoords`; Status column for maneuvers | + +--- + +## Rollout Plan + +1. Land pure-engine PRs (sweatbox already gated by `SWEATBOX_ENABLED`). +2. Each PR: `go test -race ./internal/sweatbox/...`, coverage floors, hygiene, import graph. +3. PR 5 geometry may need extra synthetic fixtures to hold ≥95%—budget time; do not weaken floor. +4. Optional e2e smoke in PR 9. +5. Rollback: revert PR(s); no DB migration. + +--- + +## Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Exit side ENU cross sign inverted | Med | Synthetic left/right fixtures with known headings; visual KBTV smoke | +| LAHSO overshoot at 45 kt / 1 Hz | Med | `lahsoArriveEpsM=25` + along-track stop; overshoot test | +| Coverage dip on exit planner | Med | Table-driven synthetic apts; note PR 5 size in plan | +| `hs` vs taxi `hs` confusion | Low | Document; different parse path | +| Helo land vs pattern Final | Low | land clears InPattern / HeloLand flag | +| Implementer uses default tick for Making* | **High if ignored** | PR 1 mandatory early branch (Issue 2) | + +--- + +## Open Questions + +Only residual product questions (decided items promoted to KDs 13–17): + +1. **Should `/sweatbox` MPA gain a static collapsible command cheatsheet for P2?** Optional UX; non-blocking for engine. Default: skip unless product asks in PR 9. +2. **Should `setairline` accept multi-prefix CSV or single token only?** Default: **single token** (one airline code); clear with bare `setairline`. + +--- + +## Feature specifications (implementation checklist) + +### Shared prerequisites (PR 1) + +- [ ] Status constants + fields on `SimAircraft` + HeloLand* +- [ ] `clearAirManeuverFields` / `clearHeloLandFields` / `inPatternContext` / `oppositeTurnDir` +- [ ] `tickAircraftLocked` early maneuver + HeloLand branches (stubs OK) +- [ ] Explicit switch cases for Making* statuses +- [ ] `landingRollTargetKt` constant; FS branch uses it (even before exit PR) +- [ ] Snapshot copy if fields exported +- [ ] Verb registration may wait until feature PRs (or register unused—prefer register with feature) + +### Per-command + +| Command | Source | Parse/aliases | Fields | Tick path | Tests | Host | +|---------|--------|---------------|--------|-----------|-------|------| +| ml3/mr3 | official | ml360/mr360 | Maneuver 360* | tickManeuver360Locked | yes | no | +| m2/no270 | official | m270 | Pending270 / 270 | begin270 + tickManeuver270 | yes | no | +| mls/mrs | official | sturn(s) | STurn* | tickSTurnLocked (threshold-first → handlePatternThreshold) | yes | no | +| lahso | official | rwy | LahsoOf | landed roll | yes | no | +| er/el | official | — | ExitDir | plan on FS | yes | no | +| auto-exit | official semantics | — | ExitPlanned | planRunwayExitLocked | yes | no | +| ctopp | official | [hdg] | CTO flags | tickTakeoffLocked | yes | no | +| land | official | [@pk] | HeloLand* | tickHeloLandLocked | yes | no | +| hs | official | — | clear pos | none | yes | no | +| appmode | findings | approach\|tower | engine.appMode | none | yes | no | +| setairline | findings | prefix | airlineOverride | none | yes | no | +| getcoords | findings | — | Message | none | yes | no | +| moveto | findings | lat lon… | pos | none | yes | no | + +--- + +## References + +- Parent: `docs/design/sweatbox-integrated-simulator.md` +- Engine: `internal/sweatbox/{command,dispatch,motion,pattern,aircraft,taxi,airport,engine,snapshot}.go` +- Tick switch today: `motion.go` `tickAircraftLocked` (explicit cases + default airborne) +- Apt turnoff: `pkg/twrfiles.Surface.TurnoffLeft` +- TWR command ref (official): ctopp, land, hs, er, el, lahso, ml3, mr3, m2, no270, mls, mrs +- TWR findings: 02 status strings; 03 catalog; 06 landing ~45 kt + turnoff exit +- Intersections: `Graph.FindIntersection` waypoint-snap ~100 ft (not line-line) +- Agents.md: sweatbox pure; coverage ≥95% + +--- + +## PR Plan + +Each PR: `go test -race ./internal/sweatbox/...` green; coverage floor ≥95%. PR 5 may be large—synthetic fixtures first. + +--- + +### PR 1 — Fields, helpers, tick integration points (not fields-only) + +- **Title:** `sweatbox: P2 fields, clear helpers, and tickAircraftLocked maneuver hooks` +- **Files:** `aircraft.go`, `snapshot.go`, `motion.go` (early branch + Making* cases + FS uses `landingRollTargetKt`), new `maneuver.go` stubs (`tickManeuver360Locked` etc. return false / no-op until filled), helpers tests +- **Dependencies:** none +- **Description:** Ship SimAircraft fields, status constants, `clearAirManeuverFields`, `inPatternContext`, `oppositeTurnDir`, **mandatory** early `tickAircraftLocked` routing so PR 2–4/7 only fill bodies. Align FS speed cap to 45 kt constant. No new instructor commands required in this PR (optional). + +--- + +### PR 2 — 360 maneuvers (`ml3` / `mr3`) + +- **Title:** `sweatbox: pattern spacing 360 left/right (ml3/mr3)` +- **Files:** `command.go`, dispatch, `maneuver.go` 360 body, tests +- **Dependencies:** PR 1 +- **Description:** Aliases, eligibility, free-turn accumulation, resume, clear on ga/fh via shared helper. + +--- + +### PR 3 — 270 pattern turns (`m2` / `m270` / `no270`) + +- **Title:** `sweatbox: pending 270-to-next-leg opposite long arc (m2/no270)` +- **Files:** dispatch, `pattern.go` / `cmdTurnPatternLegLocked`, `tickPatternLocked` corner, `tickManeuver270Locked`, KD-3 unit table +- **Dependencies:** **PR 1 hard** (tick hook + oppositeTurnDir). Does **not** require PR 2 (360) semantics. +- **Description:** Latent Pending270; `begin270TurnLocked`; opposite patternTurnDir; short-approach / `msa` **clears** Pending270 without 270 turn (KD-14); never call unmodified `advancePatternLegLocked` while pending. + +--- + +### PR 4 — S-turns on final (`mls` / `mrs`) + +- **Title:** `sweatbox: S-turns on final (mls/mrs)` +- **Files:** command aliases, dispatch, `tickSTurnLocked` (**with threshold-first** + shared `finalThresholdArrival` helper), tests +- **Dependencies:** **PR 1 hard**. PR 2 not required (shares tick hook only). +- **Description:** Final/OnApproach only; optional count; ±30° phases. **Must** detect near/past threshold inside `tickSTurnLocked` and call `handlePatternThresholdLocked` so unlimited S-turns still land. + +--- + +### PR 5 — Runway exit after landing (`er`/`el` + auto-exit) + +- **Title:** `sweatbox: landing roll runway exit (er/el + turnoff)` +- **Files:** new `exit.go` (`planRunwayExitLocked` full contract), `motion.go` landed roll, command er/el, synthetic two-side fixtures, tests +- **Dependencies:** PR 1 (Landed roll split + speed constant) +- **Description:** Point-order walk by landing end; side via ENU cross; fallback other side; clear-of-runway; instructor taxi wins. Coverage-heavy—budget fixtures. Intersections = waypoint-snap only. + +--- + +### PR 6 — LAHSO + +- **Title:** `sweatbox: land and hold short (lahso)` +- **Files:** dispatch, landed-roll LAHSO branch, `lahsoArriveEpsM` along-track, tests +- **Dependencies:** PR 5 (shared landing-roll pipeline) +- **Description:** Crossing runway snap intersection; Holding Short; res/cross release. + +--- + +### PR 7 — Helicopter `ctopp`/`land` + standalone `hs` + +- **Title:** `sweatbox: helicopter ctopp/land and cancel LUAW (hs)` +- **Files:** dispatch, `tickHeloLandLocked` (PR 1 stub filled), command, tests +- **Dependencies:** PR 1 +- **Description:** Full B2 land contract (cancel = fh/cm/del/re-land only, not ctopp); ctopp present-pos TO (groundOK); full B3 `hs` state machine (taxi resume / hold-short DepRunway / Holding). Soft-merge with PR 8 OK if bandwidth constrained. + +--- + +### PR 8 — Misc `appmode` / `setairline` / `getcoords` / `moveto` + +- **Title:** `sweatbox: findings-only appmode, setairline, getcoords, moveto` +- **Files:** command global verbs, engine generateCallsign override, dispatch, tests +- **Dependencies:** PR 1 +- **Description:** Tagged findings-only in comments; getcoords Message format stable. + +--- + +### PR 9 — E2E smoke + docs closeout + +- **Title:** `sweatbox: P2 parity closeout (e2e smoke + design status)` +- **Files:** optional `e2e_sweatbox_test.go`, parent design P2 status note, this design Status → Implemented; optional non-blocking cheatsheet +- **Dependencies:** PR 2–8 (all feature PRs) +- **Description:** Light service-HTTP smoke (e.g. ml3 → state Status Making 360). No protocol changes. + +--- + +### Dependency graph + +```mermaid +flowchart LR + P1[PR1 fields+tick hooks] + P2[PR2 360] + P3[PR3 270] + P4[PR4 S-turns] + P5[PR5 exit] + P6[PR6 LAHSO] + P7[PR7 helo/hs] + P8[PR8 misc] + P9[PR9 closeout] + P1 --> P2 + P1 --> P3 + P1 --> P4 + P1 --> P5 --> P6 + P1 --> P7 + P1 --> P8 + P2 --> P9 + P3 --> P9 + P4 --> P9 + P6 --> P9 + P7 --> P9 + P8 --> P9 +``` + +PR 2, 3, 4, 5, 7, 8 are parallel after PR 1. PR 6 after 5. PR 9 last. diff --git a/internal/web/pe_test.go b/internal/web/pe_test.go index 931b49f..9157540 100644 --- a/internal/web/pe_test.go +++ b/internal/web/pe_test.go @@ -322,6 +322,51 @@ func TestAuthedDashboardRendersUser(t *testing.T) { } } +// TestThemeChromeInLayout ensures every layout-based page ships the shared +// dark-mode assets and toggle (no per-page theme wiring). +func TestThemeChromeInLayout(t *testing.T) { + ts := newTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/login", nil) + w := httptest.NewRecorder() + ts.engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET /login status %d", w.Code) + } + body := w.Body.String() + for _, want := range []string{ + `/static/js/openfsd/theme.js`, + `/static/css/openfsd/theme.css`, + `data-js="theme-toggle"`, + `ofs-theme-toggle`, + // Theme-aware nav/control classes (not btn-outline-dark) + `btn-outline-secondary`, + } { + if !strings.Contains(body, want) { + t.Fatalf("login HTML missing %q; snippet: %s", want, clip(body, 500)) + } + } + if strings.Contains(body, "btn-outline-dark") { + t.Fatal("login HTML still uses btn-outline-dark (poor dark-mode contrast)") + } + + // Static assets must be served (embed). + for _, path := range []string{ + "/static/js/openfsd/theme.js", + "/static/css/openfsd/theme.css", + } { + req = httptest.NewRequest(http.MethodGet, path, nil) + w = httptest.NewRecorder() + ts.engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET %s status %d", path, w.Code) + } + if w.Body.Len() < 32 { + t.Fatalf("GET %s body too small", path) + } + } +} + func TestAPICookieAuthRequiresCSRF(t *testing.T) { ts := newTestServer(t) user := createTestUser(t, ts, "pw", int(protocol.NetworkRatingSupervisor)) diff --git a/internal/web/static/css/openfsd/airport-editor.css b/internal/web/static/css/openfsd/airport-editor.css index 6599a92..65b41f3 100644 --- a/internal/web/static/css/openfsd/airport-editor.css +++ b/internal/web/static/css/openfsd/airport-editor.css @@ -6,21 +6,21 @@ * height (required for correct OSM tile coverage). */ -/* --- tokens (mirrors sweatbox density; --apted-* namespace) --- */ +/* --- tokens (map to shared theme.css --ofs-console-*) --- */ .apted { --apted-gap: 0.5rem; --apted-pad: 0.5rem 0.75rem; - --apted-border: #c5c9ce; - --apted-border-strong: #8b9198; - --apted-bg: #f4f5f6; - --apted-panel: #fff; - --apted-muted: #5c636a; - --apted-accent: #1a1a1a; - --apted-sel: #dce8f5; - --apted-sel-border: #4a7ab0; - --apted-danger: #842029; - --apted-ok: #0f5132; - --apted-warn-bg: #fff3cd; + --apted-border: var(--ofs-console-border); + --apted-border-strong: var(--ofs-console-border-strong); + --apted-bg: var(--ofs-console-bg); + --apted-panel: var(--ofs-console-panel); + --apted-muted: var(--ofs-console-muted); + --apted-accent: var(--ofs-console-accent); + --apted-sel: var(--ofs-console-sel); + --apted-sel-border: var(--ofs-console-sel-border); + --apted-danger: var(--ofs-console-danger); + --apted-ok: var(--ofs-console-ok); + --apted-warn-bg: var(--ofs-console-warn-bg); --apted-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; --apted-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --apted-fs: 0.8125rem; @@ -114,27 +114,27 @@ } .apted-chip.is-dirty { - border-color: #c9a227; + border-color: var(--ofs-console-warn-border); background: var(--apted-warn-bg); - color: #664d03; + color: var(--ofs-console-warn-fg); } .apted-chip-issues.is-ok { - border-color: #a3cfbb; - background: #d1e7dd; + border-color: var(--ofs-console-ok-border); + background: var(--ofs-console-ok-bg); color: var(--apted-ok); } .apted-chip-issues.is-err { - border-color: #f1aeb5; - background: #f8d7da; + border-color: var(--ofs-console-err-border); + background: var(--ofs-console-err-bg); color: var(--apted-danger); } .apted-chip-issues.is-warn { - border-color: #c9a227; + border-color: var(--ofs-console-warn-border); background: var(--apted-warn-bg); - color: #664d03; + color: var(--ofs-console-warn-fg); } .apted-chip-issues.is-empty { @@ -152,23 +152,23 @@ font-weight: 700; line-height: 1.35; vertical-align: middle; - background: #e9ecef; + background: var(--ofs-console-chrome); color: var(--apted-muted); } .apted-tab-badge.is-ok { - background: #d1e7dd; + background: var(--ofs-console-ok-bg); color: var(--apted-ok); } .apted-tab-badge.is-err { - background: #f8d7da; + background: var(--ofs-console-err-bg); color: var(--apted-danger); } .apted-tab-badge.is-warn { background: var(--apted-warn-bg); - color: #664d03; + color: var(--ofs-console-warn-fg); } .apted-flash { @@ -199,15 +199,15 @@ } .apted-flash-ok { - background: #d1e7dd; + background: var(--ofs-console-ok-bg); color: var(--apted-ok); - border-color: #a3cfbb; + border-color: var(--ofs-console-ok-border); } .apted-flash-err { - background: #f8d7da; + background: var(--ofs-console-err-bg); color: var(--apted-danger); - border-color: #f1aeb5; + border-color: var(--ofs-console-err-border); } /* --- toolbar --- */ @@ -319,7 +319,8 @@ padding: 0.2rem 0.35rem; width: 100%; min-width: 0; - background: #fff; + background: var(--ofs-console-input); + color: var(--apted-accent); } .apted-field-inline > span { @@ -343,6 +344,8 @@ border-radius: 2px; padding: 0.2rem 0.35rem; max-width: 8rem; + background: var(--ofs-console-input); + color: var(--apted-accent); } .apted-icon-btn { @@ -412,7 +415,7 @@ /* Map full-bleed (Leaflet owns the box when enhanced) */ .apted-map { min-height: 16rem; - background: #dfe6ea; + background: var(--ofs-console-map); border: 1px solid var(--apted-border-strong); border-radius: 2px; display: flex; @@ -467,7 +470,7 @@ flex-wrap: wrap; gap: 0; border-bottom: 1px solid var(--apted-border); - background: #e9ecef; + background: var(--ofs-console-chrome); flex-shrink: 0; } @@ -489,7 +492,7 @@ .apted-tab:hover { color: var(--apted-accent); - background: #dde1e5; + background: var(--ofs-console-tab-hover); } .apted-tab.is-active, @@ -535,7 +538,7 @@ gap: 0.25rem 0.5rem; padding: 0.3rem 0.5rem; border-bottom: 1px solid var(--apted-border); - background: #f0f2f4; + background: var(--ofs-console-chrome-soft); } .apted-panel-hd h2 { @@ -622,7 +625,7 @@ } .apted-list-item:hover { - background: #f0f4f8; + background: var(--ofs-console-hover); } .apted-list-item.is-selected, @@ -640,15 +643,15 @@ } .apted-err-list-warn { - color: #664d03; + color: var(--ofs-console-warn-fg); background: var(--apted-warn-bg); - border: 1px solid #e6d28a; + border: 1px solid var(--ofs-console-warn-border); border-radius: 2px; padding: 0.35rem 0.5rem 0.35rem 1.25rem; } .apted-soft-hd { - color: #664d03; + color: var(--ofs-console-warn-fg); } .apted-soft-note { @@ -677,9 +680,9 @@ } .apted-server-stale { - color: #664d03; + color: var(--ofs-console-warn-fg); background: var(--apted-warn-bg); - border: 1px solid #e6d28a; + border: 1px solid var(--ofs-console-warn-border); border-radius: 2px; padding: 0.25rem 0.4rem; } @@ -710,7 +713,7 @@ } .apted-mini-table th { - background: #e9ecef; + background: var(--ofs-console-chrome); font-weight: 600; text-align: center; } @@ -724,7 +727,8 @@ width: 100%; resize: vertical; min-height: 5rem; - background: #f8f9fa; + background: var(--ofs-console-help); + color: var(--apted-accent); } /* Field blocks (inspector / raw panels) */ @@ -751,6 +755,8 @@ padding: 0.35rem 0.45rem; width: 100%; max-width: 100%; + background: var(--ofs-console-input); + color: var(--apted-accent); } .apted-field textarea { diff --git a/internal/web/static/css/openfsd/sweatbox-manual.css b/internal/web/static/css/openfsd/sweatbox-manual.css index 9bdd4ce..31999e2 100644 --- a/internal/web/static/css/openfsd/sweatbox-manual.css +++ b/internal/web/static/css/openfsd/sweatbox-manual.css @@ -1,17 +1,18 @@ /* * Sweatbox instructor manual — readable document layout. * Separate from the dense operator console (sweatbox.css). + * Colors from shared theme.css tokens. */ .sbxm { --sbxm-max: 52rem; --sbxm-toc-w: 14rem; --sbxm-gap: 1.5rem; - --sbxm-border: #d0d4d9; - --sbxm-muted: #5c636a; - --sbxm-bg: #f8f9fa; - --sbxm-panel: #fff; - --sbxm-link: #0b57d0; + --sbxm-border: var(--ofs-console-border); + --sbxm-muted: var(--ofs-console-muted); + --sbxm-bg: var(--ofs-console-bg); + --sbxm-panel: var(--ofs-console-panel); + --sbxm-link: var(--ofs-console-link); --sbxm-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; --sbxm-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; @@ -19,7 +20,7 @@ font-family: var(--sbxm-font); font-size: 0.9375rem; line-height: 1.55; - color: #1a1a1a; + color: var(--ofs-console-accent); background: var(--sbxm-bg); max-width: none; margin: 0; @@ -175,7 +176,7 @@ .sbxm-doc code { font-family: var(--sbxm-mono); font-size: 0.88em; - background: #f0f2f4; + background: var(--ofs-console-code-bg); padding: 0.08em 0.28em; border-radius: 0.2rem; } @@ -205,7 +206,7 @@ } .sbxm-table th { - background: #eef1f4; + background: var(--ofs-console-chrome); font-weight: 650; } @@ -227,7 +228,7 @@ .sbxm-dl dd { margin: 0.15rem 0 0.35rem 0; - color: #2a2a2a; + color: var(--ofs-console-accent); } .sbxm-note { @@ -257,6 +258,7 @@ @media print { .sbxm { background: #fff; + color: #000; padding: 0; } @@ -267,6 +269,7 @@ .sbxm-doc { border: none; padding: 0; + background: #fff; } .sbxm-kicker { diff --git a/internal/web/static/css/openfsd/sweatbox.css b/internal/web/static/css/openfsd/sweatbox.css index aeb1324..79ad3a0 100644 --- a/internal/web/static/css/openfsd/sweatbox.css +++ b/internal/web/static/css/openfsd/sweatbox.css @@ -8,21 +8,21 @@ * the control surface fits one viewport rather than page-scrolling. */ -/* --- tokens --- */ +/* --- tokens (map to shared theme.css --ofs-console-*) --- */ .sbx { --sbx-gap: 0.5rem; --sbx-pad: 0.5rem 0.75rem; - --sbx-border: #c5c9ce; - --sbx-border-strong: #8b9198; - --sbx-bg: #f4f5f6; - --sbx-panel: #fff; - --sbx-muted: #5c636a; - --sbx-accent: #1a1a1a; - --sbx-sel: #dce8f5; - --sbx-sel-border: #4a7ab0; - --sbx-danger: #842029; - --sbx-ok: #0f5132; - --sbx-warn-bg: #fff3cd; + --sbx-border: var(--ofs-console-border); + --sbx-border-strong: var(--ofs-console-border-strong); + --sbx-bg: var(--ofs-console-bg); + --sbx-panel: var(--ofs-console-panel); + --sbx-muted: var(--ofs-console-muted); + --sbx-accent: var(--ofs-console-accent); + --sbx-sel: var(--ofs-console-sel); + --sbx-sel-border: var(--ofs-console-sel-border); + --sbx-danger: var(--ofs-console-danger); + --sbx-ok: var(--ofs-console-ok); + --sbx-warn-bg: var(--ofs-console-warn-bg); --sbx-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; --sbx-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --sbx-fs: 0.8125rem; @@ -84,21 +84,21 @@ } .sbx-flash-ok { - background: #d1e7dd; + background: var(--ofs-console-ok-bg); color: var(--sbx-ok); - border-color: #a3cfbb; + border-color: var(--ofs-console-ok-border); } .sbx-flash-err { - background: #f8d7da; + background: var(--ofs-console-err-bg); color: var(--sbx-danger); - border-color: #f1aeb5; + border-color: var(--ofs-console-err-border); } .sbx-flash-warn { background: var(--sbx-warn-bg); - color: #664d03; - border-color: #ffecb5; + color: var(--ofs-console-warn-fg); + border-color: var(--ofs-console-warn-border); } /* --- status strip --- */ @@ -202,7 +202,7 @@ gap: 0.25rem 0.5rem; padding: 0.3rem 0.5rem; border-bottom: 1px solid var(--sbx-border); - background: #e9ecef; + background: var(--ofs-console-chrome); } .sbx-panel-hd h2 { @@ -249,7 +249,7 @@ .sbx-table th, .sbx-table td { padding: 0.2rem 0.35rem; - border-bottom: 1px solid #e2e4e7; + border-bottom: 1px solid var(--ofs-console-row-border); vertical-align: middle; white-space: nowrap; } @@ -258,7 +258,7 @@ position: sticky; top: 0; z-index: 1; - background: #e9ecef; + background: var(--ofs-console-chrome); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; @@ -272,7 +272,7 @@ } .sbx-table tbody tr:hover { - background: #f0f4f8; + background: var(--ofs-console-hover); } .sbx-table tbody tr.sbx-row-selected { @@ -356,7 +356,8 @@ padding: 0.25rem 0.4rem; border: 1px solid var(--sbx-border-strong); border-radius: 2px; - background: #fff; + background: var(--ofs-console-input); + color: var(--sbx-accent); width: 100%; } @@ -394,7 +395,7 @@ .sbx-help { margin: 0; padding: 0.3rem 0.4rem; - background: #f8f9fa; + background: var(--ofs-console-help); border: 1px solid var(--sbx-border); border-radius: 2px; font-size: 0.68rem; diff --git a/internal/web/static/css/openfsd/theme.css b/internal/web/static/css/openfsd/theme.css new file mode 100644 index 0000000..1311f91 --- /dev/null +++ b/internal/web/static/css/openfsd/theme.css @@ -0,0 +1,124 @@ +/* + * openfsd shared theme tokens. + * + * Bootstrap 5.3 handles data-bs-theme for its own components. + * This file owns site chrome + console design tokens so Users / Sweatbox / + * Airport Editor / Manual dark modes stay in one place (no per-page copy). + */ + +/* --- Bootstrap root: native form controls follow theme --- */ +:root, +[data-bs-theme="light"] { + color-scheme: light; +} + +[data-bs-theme="dark"] { + color-scheme: dark; +} + +/* --- Shared console tokens (light defaults) --- */ +:root, +[data-bs-theme="light"] { + --ofs-console-bg: #f4f5f6; + --ofs-console-panel: #fff; + --ofs-console-chrome: #e9ecef; + --ofs-console-chrome-soft: #f0f2f4; + --ofs-console-hover: #f0f4f8; + --ofs-console-input: #fff; + --ofs-console-help: #f8f9fa; + --ofs-console-map: #dfe6ea; + --ofs-console-border: #c5c9ce; + --ofs-console-border-strong: #8b9198; + --ofs-console-row-border: #e2e4e7; + --ofs-console-muted: #5c636a; + --ofs-console-accent: #1a1a1a; + --ofs-console-sel: #dce8f5; + --ofs-console-sel-border: #4a7ab0; + --ofs-console-danger: #842029; + --ofs-console-ok: #0f5132; + --ofs-console-warn-fg: #664d03; + --ofs-console-warn-bg: #fff3cd; + --ofs-console-warn-border: #ffecb5; + --ofs-console-ok-bg: #d1e7dd; + --ofs-console-ok-border: #a3cfbb; + --ofs-console-err-bg: #f8d7da; + --ofs-console-err-border: #f1aeb5; + --ofs-console-link: #0b57d0; + --ofs-console-code-bg: #f0f2f4; + --ofs-console-tab-hover: #dde1e5; +} + +/* --- Shared console tokens (dark) --- */ +[data-bs-theme="dark"] { + --ofs-console-bg: #121417; + --ofs-console-panel: #1c1f24; + --ofs-console-chrome: #252a31; + --ofs-console-chrome-soft: #22262c; + --ofs-console-hover: #2a3140; + --ofs-console-input: #161a1f; + --ofs-console-help: #161a1f; + --ofs-console-map: #1a2228; + --ofs-console-border: #3a424c; + --ofs-console-border-strong: #5c6670; + --ofs-console-row-border: #2e343c; + --ofs-console-muted: #a8b0b8; + --ofs-console-accent: #e8eaed; + --ofs-console-sel: #1e3a5f; + --ofs-console-sel-border: #6ea8e0; + --ofs-console-danger: #f1aeb5; + --ofs-console-ok: #75b798; + --ofs-console-warn-fg: #ffda6a; + --ofs-console-warn-bg: #332701; + --ofs-console-warn-border: #664d03; + --ofs-console-ok-bg: #051b11; + --ofs-console-ok-border: #0f5132; + --ofs-console-err-bg: #2c0b0e; + --ofs-console-err-border: #842029; + --ofs-console-link: #6ea8fe; + --ofs-console-code-bg: #252a31; + --ofs-console-tab-hover: #2a3038; +} + +/* --- Site header chrome --- */ +.ofs-site-header { + background-color: var(--bs-body-bg); + border-bottom-color: var(--bs-border-color) !important; +} + +.ofs-brand-link { + color: var(--bs-body-color); + font-weight: 600; +} + +.ofs-brand-link:hover, +.ofs-brand-link:focus-visible { + color: var(--bs-body-color); +} + +/* Logo mark: invert the black outer plate in dark mode; keep white inset + black plane */ +[data-bs-theme="dark"] .ofs-brand-mark { + filter: invert(1); +} + +/* Theme toggle */ +.ofs-theme-toggle { + min-width: 2.25rem; +} + +.ofs-theme-toggle .ofs-theme-icon-dark { + display: none; +} + +[data-bs-theme="dark"] .ofs-theme-toggle .ofs-theme-icon-light { + display: none; +} + +[data-bs-theme="dark"] .ofs-theme-toggle .ofs-theme-icon-dark { + display: inline; +} + +/* Login card uses theme border/surface (no hardcoded dark-subtle) */ +.ofs-login-card { + background-color: var(--bs-body-bg); + border-color: var(--bs-border-color) !important; +} diff --git a/internal/web/static/css/openfsd/users.css b/internal/web/static/css/openfsd/users.css index 56b2233..854f734 100644 --- a/internal/web/static/css/openfsd/users.css +++ b/internal/web/static/css/openfsd/users.css @@ -1,17 +1,19 @@ -/* users.css — dense utilitarian Users console (self-contained) */ +/* users.css — dense utilitarian Users console (self-contained) + * Colors come from shared tokens in theme.css (--ofs-console-*). */ + .usr { --usr-gap: 0.5rem; --usr-pad: 0.5rem 0.75rem; - --usr-border: #c5c9ce; - --usr-border-strong: #8b9198; - --usr-bg: #f4f5f6; - --usr-panel: #fff; - --usr-muted: #5c636a; - --usr-accent: #1a1a1a; - --usr-sel: #dce8f5; - --usr-sel-border: #4a7ab0; - --usr-danger: #842029; - --usr-ok: #0f5132; + --usr-border: var(--ofs-console-border); + --usr-border-strong: var(--ofs-console-border-strong); + --usr-bg: var(--ofs-console-bg); + --usr-panel: var(--ofs-console-panel); + --usr-muted: var(--ofs-console-muted); + --usr-accent: var(--ofs-console-accent); + --usr-sel: var(--ofs-console-sel); + --usr-sel-border: var(--ofs-console-sel-border); + --usr-danger: var(--ofs-console-danger); + --usr-ok: var(--ofs-console-ok); --usr-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; --usr-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --usr-fs: 0.8125rem; @@ -72,19 +74,19 @@ font-size: var(--usr-fs-sm); } .usr-flash-ok { - background: #d1e7dd; - border-color: #a3cfbb; + background: var(--ofs-console-ok-bg); + border-color: var(--ofs-console-ok-border); color: var(--usr-ok); } .usr-flash-err { - background: #f8d7da; - border-color: #f1aeb5; + background: var(--ofs-console-err-bg); + border-color: var(--ofs-console-err-border); color: var(--usr-danger); } .usr-flash-warn { - background: #fff3cd; - border-color: #ffecb5; - color: #664d03; + background: var(--ofs-console-warn-bg); + border-color: var(--ofs-console-warn-border); + color: var(--ofs-console-warn-fg); } .usr-filters { @@ -119,7 +121,7 @@ padding: 0.2rem 0.4rem; border: 1px solid var(--usr-border); border-radius: 2px; - background: #fff; + background: var(--ofs-console-input); color: var(--usr-accent); max-width: 100%; } @@ -165,7 +167,7 @@ gap: 0.25rem 0.5rem; padding: 0.3rem 0.5rem; border-bottom: 1px solid var(--usr-border); - background: #e9ecef; + background: var(--ofs-console-chrome); } .usr-panel-hd h2 { margin: 0; @@ -207,7 +209,7 @@ .usr-table th, .usr-table td { padding: 0.2rem 0.35rem; - border-bottom: 1px solid #e2e4e7; + border-bottom: 1px solid var(--ofs-console-row-border); vertical-align: middle; white-space: nowrap; } @@ -215,7 +217,7 @@ position: sticky; top: 0; z-index: 1; - background: #e9ecef; + background: var(--ofs-console-chrome); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; @@ -237,7 +239,7 @@ outline: 1px solid var(--usr-sel-border); outline-offset: -1px; } -.usr-table tbody tr:hover { background: #f0f4f8; } +.usr-table tbody tr:hover { background: var(--ofs-console-hover); } .usr-table tbody tr.usr-row-selected:hover { background: var(--usr-sel); } .usr-table .usr-cid { font-family: var(--usr-mono); diff --git a/internal/web/static/js/openfsd/airport-editor/map-layers.js b/internal/web/static/js/openfsd/airport-editor/map-layers.js index ca0578c..96a40fc 100644 --- a/internal/web/static/js/openfsd/airport-editor/map-layers.js +++ b/internal/web/static/js/openfsd/airport-editor/map-layers.js @@ -12,10 +12,19 @@ import { SurfaceHold, } from './model.js'; -/** OSM Standard tiles (default). Attribution required — do not strip. */ +/** + * OSM basemap URLs — keep in sync with OpenFSDTheme.osmBasemap (theme.js). + * Light: OSM Standard. Dark: CARTO Dark Matter (OSM data + CARTO style). + * Attribution required — do not strip. + */ export const OSM_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; export const OSM_ATTRIBUTION = '© OpenStreetMap'; +export const OSM_DARK_TILE_URL = + 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; +export const OSM_DARK_ATTRIBUTION = + '© OpenStreetMap ' + + '© CARTO'; /** Esri World Imagery (optional basemap). Attribution required. */ export const ESRI_TILE_URL = @@ -27,6 +36,49 @@ export const ESRI_ATTRIBUTION = export const BLANK_TILE_DATA_URI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; +/** + * Effective UI theme for basemap selection. + * Prefers OpenFSDTheme (layout); falls back to data-bs-theme / light. + * @param {Document|null|undefined} [doc] + * @returns {"light"|"dark"} + */ +export function currentUiTheme(doc) { + const d = doc || (typeof globalThis !== 'undefined' ? globalThis.document : null); + const T = typeof globalThis !== 'undefined' ? globalThis.OpenFSDTheme : null; + if (T && typeof T.currentTheme === 'function') { + return T.currentTheme(d) === 'dark' ? 'dark' : 'light'; + } + const el = d && d.documentElement; + const v = el && typeof el.getAttribute === 'function' ? el.getAttribute('data-bs-theme') : null; + return v === 'dark' ? 'dark' : 'light'; +} + +/** + * Create OSM (light) or CARTO dark OSM tile layer matching the UI theme. + * @param {typeof globalThis.L} L + * @param {"light"|"dark"|string} [theme] + * @returns {*} Leaflet tile layer + */ +export function createOsmTileLayer(L, theme) { + const t = theme === 'dark' ? 'dark' : 'light'; + const T = typeof globalThis !== 'undefined' ? globalThis.OpenFSDTheme : null; + if (T && typeof T.createLeafletOsmLayer === 'function') { + return T.createLeafletOsmLayer(L, t); + } + // Fallback when theme.js is not loaded (Node tests / isolated pages). + if (t === 'dark') { + return L.tileLayer(OSM_DARK_TILE_URL, { + maxZoom: 20, + subdomains: 'abcd', + attribution: OSM_DARK_ATTRIBUTION, + }); + } + return L.tileLayer(OSM_TILE_URL, { + maxZoom: 19, + attribution: OSM_ATTRIBUTION, + }); +} + /** * Style for a surface kind. Selected uses highlight border color. * @param {string} kind @@ -506,9 +558,11 @@ export function bindTextTooltip(layer, text, opts = {}) { * Create blank tile layer for e2e (no network). Uses L.tileLayer with data-URI * or L.gridLayer empty tiles when available. * @param {typeof globalThis.L} L + * @param {{ theme?: "light"|"dark"|string }} [opts] * @returns {*} Leaflet layer */ -export function createBlankTileLayer(L) { +export function createBlankTileLayer(L, opts = {}) { + const bg = opts.theme === 'dark' ? '#1a2228' : '#dfe6ea'; if (typeof L.gridLayer === 'function') { return L.gridLayer({ attribution: 'blank tiles (e2e)', @@ -517,7 +571,7 @@ export function createBlankTileLayer(L) { const tile = document.createElement('div'); tile.style.width = '256px'; tile.style.height = '256px'; - tile.style.background = '#dfe6ea'; + tile.style.background = bg; return tile; }, }); @@ -531,19 +585,18 @@ export function createBlankTileLayer(L) { /** * Create OSM + Esri basemap layers (or blank when testTiles). + * OSM tiles follow the UI theme (light OSM Standard / dark CARTO). * @param {typeof globalThis.L} L - * @param {{ blank?: boolean }} [opts] + * @param {{ blank?: boolean, theme?: "light"|"dark"|string }} [opts] * @returns {{ osm: *, esri: *, blank: *|null, defaultKey: 'osm'|'blank' }} */ export function createBaseLayers(L, opts = {}) { + const theme = opts.theme === 'dark' ? 'dark' : 'light'; if (opts.blank) { - const blank = createBlankTileLayer(L); + const blank = createBlankTileLayer(L, { theme }); return { osm: blank, esri: blank, blank, defaultKey: 'blank' }; } - const osm = L.tileLayer(OSM_TILE_URL, { - maxZoom: 19, - attribution: OSM_ATTRIBUTION, - }); + const osm = createOsmTileLayer(L, theme); const esri = L.tileLayer(ESRI_TILE_URL, { maxZoom: 19, attribution: ESRI_ATTRIBUTION, @@ -554,10 +607,11 @@ export function createBaseLayers(L, opts = {}) { /** * Initialize Leaflet map on #apted-map (or given element). * Attribution control remains visible; optional setPrefix("") only. + * Listens for openfsd:themechange to swap OSM light/dark tiles. * @param {typeof globalThis.L} L * @param {HTMLElement} mapEl * @param {{ blankTiles?: boolean, center?: [number, number], zoom?: number }} [opts] - * @returns {{ map: *, baseLayers: ReturnType, activeBase: string, setBase: (key: string) => void }} + * @returns {{ map: *, baseLayers: ReturnType, activeBase: string, setBase: (key: string) => void, destroy: () => void }} */ export function createMap(L, mapEl, opts = {}) { const center = opts.center || [30, 0]; @@ -577,7 +631,12 @@ export function createMap(L, mapEl, opts = {}) { map.boxZoom.disable(); } - const baseLayers = createBaseLayers(L, { blank: !!opts.blankTiles }); + const doc = (mapEl && mapEl.ownerDocument) || (typeof globalThis !== 'undefined' ? globalThis.document : null); + const initialTheme = currentUiTheme(doc); + const baseLayers = createBaseLayers(L, { + blank: !!opts.blankTiles, + theme: initialTheme, + }); let activeBase = baseLayers.defaultKey; const layerByKey = { osm: baseLayers.osm, @@ -606,6 +665,58 @@ export function createMap(L, mapEl, opts = {}) { } } + /** + * Rebuild OSM (or blank) basemap when UI theme changes. + * Esri imagery is unchanged. Only re-adds the layer if OSM/blank is active. + * @param {"light"|"dark"|string} theme + */ + function applyOsmTheme(theme) { + const t = theme === 'dark' ? 'dark' : 'light'; + const wasActive = activeBase === 'osm' || (opts.blankTiles && activeBase === 'blank'); + const prev = layerByKey.osm; + if (prev && map.hasLayer(prev)) { + map.removeLayer(prev); + } + let next; + if (opts.blankTiles) { + next = createBlankTileLayer(L, { theme: t }); + baseLayers.blank = next; + layerByKey.blank = next; + } else { + next = createOsmTileLayer(L, t); + } + baseLayers.osm = next; + layerByKey.osm = next; + if (wasActive) { + next.addTo(map); + if (typeof map.invalidateSize === 'function') { + map.invalidateSize({ animate: false }); + } + } + } + + const themeEvent = + (typeof globalThis !== 'undefined' && + globalThis.OpenFSDTheme && + globalThis.OpenFSDTheme.THEME_CHANGE_EVENT) || + 'openfsd:themechange'; + + function onThemeChange(ev) { + const theme = + (ev && ev.detail && ev.detail.theme) || currentUiTheme(doc); + applyOsmTheme(theme); + } + + if (doc && typeof doc.addEventListener === 'function') { + doc.addEventListener(themeEvent, onThemeChange); + } + + function destroy() { + if (doc && typeof doc.removeEventListener === 'function') { + doc.removeEventListener(themeEvent, onThemeChange); + } + } + // Force a remeasure after the first paint of this host (flex/grid settle). if (typeof map.whenReady === 'function') { map.whenReady(() => { @@ -615,7 +726,16 @@ export function createMap(L, mapEl, opts = {}) { }); } - return { map, baseLayers, get activeBase() { return activeBase; }, setBase }; + return { + map, + baseLayers, + get activeBase() { + return activeBase; + }, + setBase, + applyOsmTheme, + destroy, + }; } /** diff --git a/internal/web/static/js/openfsd/airport-editor/ui-toolbar.js b/internal/web/static/js/openfsd/airport-editor/ui-toolbar.js index 6c62218..c22fde6 100644 --- a/internal/web/static/js/openfsd/airport-editor/ui-toolbar.js +++ b/internal/web/static/js/openfsd/airport-editor/ui-toolbar.js @@ -183,7 +183,7 @@ function ensureModeButtons(group) { for (const [mode, label] of modes) { const btn = document.createElement('button'); btn.type = 'button'; - btn.className = 'btn btn-sm btn-outline-dark apted-mode-btn'; + btn.className = 'btn btn-sm btn-outline-secondary apted-mode-btn'; btn.setAttribute('data-mode', mode); btn.setAttribute('aria-pressed', mode === MODE_SELECT ? 'true' : 'false'); btn.title = `${MODE_LABELS[mode]} mode (key ${modes.findIndex((m) => m[0] === mode) + 1})`; diff --git a/internal/web/static/js/openfsd/dashboard.js b/internal/web/static/js/openfsd/dashboard.js index 8fde879..ced04b4 100644 --- a/internal/web/static/js/openfsd/dashboard.js +++ b/internal/web/static/js/openfsd/dashboard.js @@ -30,11 +30,51 @@ document.addEventListener("DOMContentLoaded", async function () { } const map = L.map("map").setView([30, 0], 1); - L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - maxZoom: 19 - }).addTo(map); map.attributionControl.setPrefix(""); + // Theme-aware OSM basemap (light Standard / dark CARTO). Shared with + // airport editor via OpenFSDTheme (theme.js). + let baseLayer = null; + function applyDashboardBasemap(theme) { + const next = + typeof OpenFSDTheme !== "undefined" && + typeof OpenFSDTheme.createLeafletOsmLayer === "function" + ? OpenFSDTheme.createLeafletOsmLayer(L, theme) + : L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: + '© OpenStreetMap', + }); + if (baseLayer) { + map.removeLayer(baseLayer); + } + baseLayer = next; + baseLayer.addTo(map); + } + + const initialTheme = + typeof OpenFSDTheme !== "undefined" && + typeof OpenFSDTheme.currentTheme === "function" + ? OpenFSDTheme.currentTheme(document) + : (document.documentElement.getAttribute("data-bs-theme") === "dark" + ? "dark" + : "light"); + applyDashboardBasemap(initialTheme); + + const themeEvent = + typeof OpenFSDTheme !== "undefined" && OpenFSDTheme.THEME_CHANGE_EVENT + ? OpenFSDTheme.THEME_CHANGE_EVENT + : "openfsd:themechange"; + document.addEventListener(themeEvent, function (ev) { + const theme = + (ev && ev.detail && ev.detail.theme) || + (typeof OpenFSDTheme !== "undefined" && + typeof OpenFSDTheme.currentTheme === "function" + ? OpenFSDTheme.currentTheme(document) + : "light"); + applyDashboardBasemap(theme); + }); + const planeIcon = L.icon({ iconUrl: "/static/images/plane.png", iconSize: [16, 16], diff --git a/internal/web/static/js/openfsd/theme.js b/internal/web/static/js/openfsd/theme.js new file mode 100644 index 0000000..c004e49 --- /dev/null +++ b/internal/web/static/js/openfsd/theme.js @@ -0,0 +1,326 @@ +/** + * openfsd color theme — Bootstrap 5.3 data-bs-theme + localStorage. + * + * Classic (non-module) script: load synchronously in so the first paint + * already has the preferred theme (avoids light→dark flash). Toggle is + * progressive enhancement; without JS the page stays on the default light theme. + * + * Public API: globalThis.OpenFSDTheme + */ +(function (root) { + "use strict"; + + var STORAGE_KEY = "openfsd-theme"; + /** Dispatched on document after data-bs-theme is applied (maps listen for basemap swap). */ + var THEME_CHANGE_EVENT = "openfsd:themechange"; + + /** + * OSM basemap configs (light = OSM Standard; dark = CARTO Dark Matter, OSM data). + * Shared by dashboard + airport editor Leaflet maps. + */ + var OSM_BASEMAP_LIGHT = { + url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + maxZoom: 19, + attribution: + '© OpenStreetMap', + }; + var OSM_BASEMAP_DARK = { + url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", + maxZoom: 20, + subdomains: "abcd", + attribution: + '© OpenStreetMap ' + + '© CARTO', + }; + + /** + * @param {string|null|undefined} value + * @returns {"light"|"dark"|null} + */ + function normalizeTheme(value) { + if (value === "dark" || value === "light") { + return value; + } + return null; + } + + /** + * Resolve effective theme from stored preference and system preference. + * @param {string|null|undefined} stored + * @param {boolean} prefersDark + * @returns {"light"|"dark"} + */ + function resolveTheme(stored, prefersDark) { + var n = normalizeTheme(stored); + if (n) { + return n; + } + return prefersDark ? "dark" : "light"; + } + + /** + * @param {Storage|null|undefined} storage + * @returns {string|null} + */ + function readStored(storage) { + try { + if (!storage || typeof storage.getItem !== "function") { + return null; + } + return storage.getItem(STORAGE_KEY); + } catch (_) { + return null; + } + } + + /** + * @param {Storage|null|undefined} storage + * @param {"light"|"dark"} theme + */ + function writeStored(storage, theme) { + try { + if (!storage || typeof storage.setItem !== "function") { + return; + } + storage.setItem(STORAGE_KEY, theme); + } catch (_) { + /* private mode / quota */ + } + } + + /** + * @param {MediaQueryList|null|undefined} mql + * @returns {boolean} + */ + function systemPrefersDark(mql) { + return !!(mql && mql.matches); + } + + /** + * Read effective theme from a document (data-bs-theme on ). + * @param {Document|null|undefined} doc + * @returns {"light"|"dark"} + */ + function currentTheme(doc) { + var el = doc && doc.documentElement; + var v = + el && typeof el.getAttribute === "function" + ? el.getAttribute("data-bs-theme") + : null; + return normalizeTheme(v) || "light"; + } + + /** + * OSM tile URL + Leaflet options for the given UI theme. + * @param {"light"|"dark"|string} theme + * @returns {{ url: string, maxZoom: number, attribution: string, subdomains?: string }} + */ + function osmBasemap(theme) { + return theme === "dark" ? OSM_BASEMAP_DARK : OSM_BASEMAP_LIGHT; + } + + /** + * Build a Leaflet OSM (or dark OSM) tile layer for the current UI theme. + * @param {*} L Leaflet global + * @param {"light"|"dark"|string} [theme] + * @returns {*} Leaflet tile layer + */ + function createLeafletOsmLayer(L, theme) { + var cfg = osmBasemap(theme === "dark" ? "dark" : "light"); + var opts = { + maxZoom: cfg.maxZoom, + attribution: cfg.attribution, + }; + if (cfg.subdomains) { + opts.subdomains = cfg.subdomains; + } + return L.tileLayer(cfg.url, opts); + } + + /** + * Notify listeners (maps) that the UI theme changed. + * @param {Document|null|undefined} doc + * @param {"light"|"dark"} theme + */ + function dispatchThemeChange(doc, theme) { + if (!doc || typeof doc.dispatchEvent !== "function") { + return; + } + try { + var Ev = + typeof CustomEvent === "function" + ? CustomEvent + : doc.defaultView && doc.defaultView.CustomEvent; + if (typeof Ev !== "function") { + return; + } + doc.dispatchEvent( + new Ev(THEME_CHANGE_EVENT, { + detail: { theme: theme }, + bubbles: true, + }) + ); + } catch (_) { + /* ignore */ + } + } + + /** + * Apply theme to a document root (html element). + * @param {"light"|"dark"|string} theme + * @param {Element|null|undefined} rootEl + * @returns {"light"|"dark"} + */ + function applyTheme(theme, rootEl) { + var t = theme === "dark" ? "dark" : "light"; + if (rootEl && typeof rootEl.setAttribute === "function") { + rootEl.setAttribute("data-bs-theme", t); + var doc = rootEl.ownerDocument || null; + dispatchThemeChange(doc, t); + } + return t; + } + + /** + * @param {"light"|"dark"} current + * @returns {"light"|"dark"} + */ + function nextTheme(current) { + return current === "dark" ? "light" : "dark"; + } + + /** + * Update toggle accessible name / pressed state. + * @param {Element|null|undefined} btn + * @param {"light"|"dark"} theme + */ + function syncToggle(btn, theme) { + if (!btn || typeof btn.setAttribute !== "function") { + return; + } + var dark = theme === "dark"; + btn.setAttribute("aria-pressed", dark ? "true" : "false"); + btn.setAttribute( + "aria-label", + dark ? "Switch to light mode" : "Switch to dark mode" + ); + btn.setAttribute("title", dark ? "Light mode" : "Dark mode"); + } + + /** + * Read system preference when matchMedia is available. + * @param {Window|null|undefined} win + * @returns {boolean} + */ + function prefersDarkFromWindow(win) { + if (!win || typeof win.matchMedia !== "function") { + return false; + } + try { + return systemPrefersDark(win.matchMedia("(prefers-color-scheme: dark)")); + } catch (_) { + return false; + } + } + + /** + * Apply stored/system theme to document. + * @param {Document} doc + * @param {Window} win + * @returns {"light"|"dark"} + */ + function boot(doc, win) { + var storage = win && win.localStorage ? win.localStorage : null; + var theme = resolveTheme(readStored(storage), prefersDarkFromWindow(win)); + applyTheme(theme, doc && doc.documentElement); + return theme; + } + + /** + * Wire [data-js="theme-toggle"] and keep in sync with system when no stored pref. + * @param {Document} doc + * @param {Window} win + */ + function bind(doc, win) { + if (!doc || !win) { + return; + } + + var storage = win.localStorage || null; + var btn = doc.querySelector('[data-js="theme-toggle"]'); + var current = + normalizeTheme( + doc.documentElement && doc.documentElement.getAttribute("data-bs-theme") + ) || "light"; + syncToggle(btn, current); + + if (btn && typeof btn.addEventListener === "function") { + btn.addEventListener("click", function () { + var cur = + normalizeTheme(doc.documentElement.getAttribute("data-bs-theme")) || + "light"; + var next = nextTheme(cur); + applyTheme(next, doc.documentElement); + writeStored(storage, next); + syncToggle(btn, next); + }); + } + + // Follow OS only when the user has not chosen an explicit theme. + if (typeof win.matchMedia === "function") { + try { + var mql = win.matchMedia("(prefers-color-scheme: dark)"); + var onChange = function () { + if (normalizeTheme(readStored(storage))) { + return; + } + var theme = resolveTheme(null, systemPrefersDark(mql)); + applyTheme(theme, doc.documentElement); + syncToggle(btn, theme); + }; + if (typeof mql.addEventListener === "function") { + mql.addEventListener("change", onChange); + } else if (typeof mql.addListener === "function") { + mql.addListener(onChange); + } + } catch (_) { + /* ignore */ + } + } + } + + var api = { + STORAGE_KEY: STORAGE_KEY, + THEME_CHANGE_EVENT: THEME_CHANGE_EVENT, + OSM_BASEMAP_LIGHT: OSM_BASEMAP_LIGHT, + OSM_BASEMAP_DARK: OSM_BASEMAP_DARK, + normalizeTheme: normalizeTheme, + resolveTheme: resolveTheme, + readStored: readStored, + writeStored: writeStored, + systemPrefersDark: systemPrefersDark, + currentTheme: currentTheme, + osmBasemap: osmBasemap, + createLeafletOsmLayer: createLeafletOsmLayer, + dispatchThemeChange: dispatchThemeChange, + applyTheme: applyTheme, + nextTheme: nextTheme, + syncToggle: syncToggle, + boot: boot, + bind: bind, + }; + + root.OpenFSDTheme = api; + + // Immediate apply when loaded as a classic script in the browser. + if (typeof document !== "undefined" && typeof window !== "undefined") { + boot(document, window); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function () { + bind(document, window); + }); + } else { + bind(document, window); + } + } +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/internal/web/templates/airport_editor.html b/internal/web/templates/airport_editor.html index 3f8de65..0e52dc0 100644 --- a/internal/web/templates/airport_editor.html +++ b/internal/web/templates/airport_editor.html @@ -45,11 +45,11 @@ diff --git a/internal/web/templates/landing.html b/internal/web/templates/landing.html index 167da10..2b46e37 100644 --- a/internal/web/templates/landing.html +++ b/internal/web/templates/landing.html @@ -3,9 +3,9 @@ {{ define "body" }}
{{ if .User }} - Go to dashboard + Go to dashboard {{ else }} - Login + Login {{ end }}
{{ end }} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index be1e54d..888f9d2 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -4,8 +4,14 @@ {{ if .CSRFToken }}{{ end }} + {{/* + Theme boot must run before first paint (classic script, not defer/module) + so data-bs-theme matches localStorage / system preference without a flash. + */}} + + @@ -13,10 +19,10 @@ openfsd - {{ template "title" . }} -
+
-
diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html index 6e1529b..4a44d38 100644 --- a/internal/web/templates/login.html +++ b/internal/web/templates/login.html @@ -2,7 +2,7 @@ {{ define "body" }}
-
+ - +
diff --git a/internal/web/templates/sweatbox.html b/internal/web/templates/sweatbox.html index 034ef19..c60d69e 100644 --- a/internal/web/templates/sweatbox.html +++ b/internal/web/templates/sweatbox.html @@ -22,7 +22,7 @@

Sweatbox

-

Control panel · forms work without JS · Refresh reloads snapshot · Manual

+

User Manual

{{ if .FlashSuccess }} diff --git a/internal/web/templates/usereditor.html b/internal/web/templates/usereditor.html index aadd51f..4d7ed7e 100644 --- a/internal/web/templates/usereditor.html +++ b/internal/web/templates/usereditor.html @@ -16,7 +16,6 @@

Users

-

Directory · create and edit in the rail · forms work without JS

{{ .Dir.Total }} total
diff --git a/scripts/check-webjs.sh b/scripts/check-webjs.sh index faa0c61..6458320 100755 --- a/scripts/check-webjs.sh +++ b/scripts/check-webjs.sh @@ -21,13 +21,14 @@ fi echo "check-webjs: node $(node --version)" # Portable: expand test files without bash mapfile/globstar. +# Covers airport-editor/* and top-level modules (e.g. theme.test.js). TESTS=() while IFS= read -r f; do TESTS+=("$f") -done < <(find ./airport-editor -name '*.test.js' | LC_ALL=C sort) +done < <(find . -name '*.test.js' | LC_ALL=C sort) if [[ ${#TESTS[@]} -eq 0 ]]; then - echo "check-webjs: no tests found under webjs/airport-editor" >&2 + echo "check-webjs: no tests found under webjs/" >&2 exit 1 fi diff --git a/webjs/airport-editor/map-layers.test.js b/webjs/airport-editor/map-layers.test.js index bcfe596..050ab9f 100644 --- a/webjs/airport-editor/map-layers.test.js +++ b/webjs/airport-editor/map-layers.test.js @@ -20,8 +20,11 @@ import { escapeHtml, OSM_TILE_URL, OSM_ATTRIBUTION, + OSM_DARK_TILE_URL, + OSM_DARK_ATTRIBUTION, ESRI_TILE_URL, ESRI_ATTRIBUTION, + currentUiTheme, VERTEX_HANDLE_PX, VERTEX_HANDLE_RADIUS, VERTEX_HIT_PX, @@ -181,11 +184,39 @@ test('basemap attribution strings are non-empty', () => { assert.ok(OSM_TILE_URL.includes('openstreetmap')); assert.ok(OSM_ATTRIBUTION.length > 10); assert.ok(OSM_ATTRIBUTION.toLowerCase().includes('openstreetmap')); + assert.ok(OSM_DARK_TILE_URL.includes('cartocdn') || OSM_DARK_TILE_URL.includes('dark')); + assert.ok(OSM_DARK_ATTRIBUTION.toLowerCase().includes('openstreetmap')); + assert.ok(OSM_DARK_ATTRIBUTION.toLowerCase().includes('carto')); assert.ok(ESRI_TILE_URL.includes('arcgisonline')); assert.ok(ESRI_ATTRIBUTION.length > 10); assert.ok(ESRI_ATTRIBUTION.toLowerCase().includes('esri')); }); +test('currentUiTheme falls back without OpenFSDTheme', () => { + // No document / no OpenFSDTheme in Node → light + assert.equal(currentUiTheme(null), 'light'); + assert.equal( + currentUiTheme({ + documentElement: { + getAttribute(name) { + return name === 'data-bs-theme' ? 'dark' : null; + }, + }, + }), + 'dark', + ); + assert.equal( + currentUiTheme({ + documentElement: { + getAttribute() { + return 'light'; + }, + }, + }), + 'light', + ); +}); + test('escapeHtml neutralizes markup in freeform AIR-like strings', () => { assert.equal(escapeHtml(null), ''); assert.equal(escapeHtml('plain'), 'plain'); diff --git a/webjs/package.json b/webjs/package.json index 42b169a..736109f 100644 --- a/webjs/package.json +++ b/webjs/package.json @@ -6,7 +6,7 @@ "node": ">=20" }, "scripts": { - "test": "node --test ./airport-editor/*.test.js", - "test:coverage": "node --experimental-test-coverage --test-coverage-include='../internal/web/static/js/openfsd/airport-editor/**' --test ./airport-editor/*.test.js" + "test": "node --test ./airport-editor/*.test.js ./theme.test.js", + "test:coverage": "node --experimental-test-coverage --test-coverage-include='../internal/web/static/js/openfsd/airport-editor/**' --test-coverage-include='../internal/web/static/js/openfsd/theme.js' --test ./airport-editor/*.test.js ./theme.test.js" } } diff --git a/webjs/theme.test.js b/webjs/theme.test.js new file mode 100644 index 0000000..921630a --- /dev/null +++ b/webjs/theme.test.js @@ -0,0 +1,262 @@ +/** + * Unit tests for openfsd theme.js pure helpers (classic script → OpenFSDTheme). + */ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createContext, runInContext } from "node:vm"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const themePath = join( + __dirname, + "../internal/web/static/js/openfsd/theme.js" +); +const themeSource = readFileSync(themePath, "utf8"); + +/** + * Load theme.js into an isolated context without auto-binding a toggle. + * Boot still runs against the provided documentElement. + */ +function loadTheme(opts = {}) { + const attrs = new Map(); + const documentElement = { + setAttribute(name, value) { + attrs.set(name, String(value)); + }, + getAttribute(name) { + return attrs.has(name) ? attrs.get(name) : null; + }, + }; + + const store = new Map(opts.storeEntries || []); + const localStorage = { + getItem(k) { + return store.has(k) ? store.get(k) : null; + }, + setItem(k, v) { + store.set(k, String(v)); + }, + }; + + const prefersDark = !!opts.prefersDark; + const doc = { + documentElement, + readyState: "complete", + querySelector() { + return opts.toggleBtn || null; + }, + addEventListener() {}, + }; + const win = { + localStorage, + matchMedia() { + return { + matches: prefersDark, + addEventListener() {}, + addListener() {}, + }; + }, + }; + + const sandbox = { globalThis: null, document: doc, window: win }; + sandbox.globalThis = sandbox; + runInContext(themeSource, createContext(sandbox)); + + return { + api: sandbox.OpenFSDTheme, + attrs, + store, + documentElement, + localStorage, + doc, + win, + }; +} + +describe("OpenFSDTheme", () => { + it("exports the public API", () => { + const { api } = loadTheme(); + assert.equal(typeof api.resolveTheme, "function"); + assert.equal(typeof api.applyTheme, "function"); + assert.equal(typeof api.nextTheme, "function"); + assert.equal(api.STORAGE_KEY, "openfsd-theme"); + }); + + it("resolveTheme prefers stored light/dark over system", () => { + const { api } = loadTheme(); + assert.equal(api.resolveTheme("dark", false), "dark"); + assert.equal(api.resolveTheme("light", true), "light"); + assert.equal(api.resolveTheme(null, true), "dark"); + assert.equal(api.resolveTheme(null, false), "light"); + assert.equal(api.resolveTheme("bogus", true), "dark"); + assert.equal(api.resolveTheme("", false), "light"); + }); + + it("normalizeTheme accepts only light|dark", () => { + const { api } = loadTheme(); + assert.equal(api.normalizeTheme("dark"), "dark"); + assert.equal(api.normalizeTheme("light"), "light"); + assert.equal(api.normalizeTheme("auto"), null); + assert.equal(api.normalizeTheme(null), null); + }); + + it("nextTheme toggles", () => { + const { api } = loadTheme(); + assert.equal(api.nextTheme("light"), "dark"); + assert.equal(api.nextTheme("dark"), "light"); + }); + + it("applyTheme sets data-bs-theme on the root", () => { + const { api, attrs, documentElement } = loadTheme(); + assert.equal(api.applyTheme("dark", documentElement), "dark"); + assert.equal(attrs.get("data-bs-theme"), "dark"); + assert.equal(api.applyTheme("nope", documentElement), "light"); + assert.equal(attrs.get("data-bs-theme"), "light"); + }); + + it("readStored/writeStored round-trip and tolerate missing storage", () => { + const { api, localStorage } = loadTheme(); + assert.equal(api.readStored(null), null); + assert.equal(api.readStored(localStorage), null); + api.writeStored(localStorage, "dark"); + assert.equal(api.readStored(localStorage), "dark"); + assert.equal(api.readStored({}), null); + api.writeStored(null, "light"); // no throw + }); + + it("systemPrefersDark mirrors MediaQueryList.matches", () => { + const { api } = loadTheme(); + assert.equal(api.systemPrefersDark(null), false); + assert.equal(api.systemPrefersDark({ matches: true }), true); + assert.equal(api.systemPrefersDark({ matches: false }), false); + }); + + it("syncToggle updates aria-label and pressed", () => { + const { api } = loadTheme(); + const btnAttrs = new Map(); + const btn = { + setAttribute(k, v) { + btnAttrs.set(k, String(v)); + }, + }; + api.syncToggle(btn, "dark"); + assert.equal(btnAttrs.get("aria-pressed"), "true"); + assert.match(btnAttrs.get("aria-label"), /light/i); + api.syncToggle(btn, "light"); + assert.equal(btnAttrs.get("aria-pressed"), "false"); + assert.match(btnAttrs.get("aria-label"), /dark/i); + }); + + it("boot applies stored preference on script load", () => { + const { attrs } = loadTheme({ + storeEntries: [["openfsd-theme", "dark"]], + }); + assert.equal(attrs.get("data-bs-theme"), "dark"); + }); + + it("boot falls back to system preference when unset", () => { + const { attrs } = loadTheme({ prefersDark: true }); + assert.equal(attrs.get("data-bs-theme"), "dark"); + }); + + it("boot defaults to light without preference", () => { + const { attrs } = loadTheme({ prefersDark: false }); + assert.equal(attrs.get("data-bs-theme"), "light"); + }); + + it("osmBasemap returns light OSM and dark CARTO configs", () => { + const { api } = loadTheme(); + const light = api.osmBasemap("light"); + const dark = api.osmBasemap("dark"); + assert.ok(light.url.includes("openstreetmap.org")); + assert.match(light.attribution, /OpenStreetMap/i); + assert.ok(dark.url.includes("cartocdn") || dark.url.includes("dark")); + assert.match(dark.attribution, /OpenStreetMap/i); + assert.match(dark.attribution, /CARTO/i); + assert.equal(dark.subdomains, "abcd"); + }); + + it("createLeafletOsmLayer passes theme-specific url to L.tileLayer", () => { + const { api } = loadTheme(); + const calls = []; + const L = { + tileLayer(url, opts) { + calls.push({ url, opts }); + return { url, opts }; + }, + }; + api.createLeafletOsmLayer(L, "light"); + api.createLeafletOsmLayer(L, "dark"); + assert.equal(calls.length, 2); + assert.ok(calls[0].url.includes("openstreetmap.org")); + assert.equal(calls[0].opts.maxZoom, 19); + assert.ok(calls[1].url.includes("cartocdn") || calls[1].url.includes("dark")); + assert.equal(calls[1].opts.subdomains, "abcd"); + }); + + it("applyTheme dispatches openfsd:themechange", () => { + const events = []; + const attrs = new Map(); + const documentElement = { + setAttribute(name, value) { + attrs.set(name, String(value)); + }, + getAttribute(name) { + return attrs.has(name) ? attrs.get(name) : null; + }, + ownerDocument: null, + }; + const doc = { + documentElement, + readyState: "complete", + querySelector() { + return null; + }, + addEventListener() {}, + dispatchEvent(ev) { + events.push(ev); + return true; + }, + }; + documentElement.ownerDocument = doc; + // Minimal CustomEvent for vm + function CustomEvent(type, init) { + this.type = type; + this.detail = init && init.detail; + this.bubbles = !!(init && init.bubbles); + } + const store = new Map(); + const win = { + localStorage: { + getItem(k) { + return store.has(k) ? store.get(k) : null; + }, + setItem(k, v) { + store.set(k, String(v)); + }, + }, + matchMedia() { + return { matches: false, addEventListener() {}, addListener() {} }; + }, + CustomEvent, + }; + const sandbox = { + globalThis: null, + document: doc, + window: win, + CustomEvent, + }; + sandbox.globalThis = sandbox; + runInContext(themeSource, createContext(sandbox)); + // boot already applied light once + const afterBoot = events.length; + sandbox.OpenFSDTheme.applyTheme("dark", documentElement); + assert.ok(events.length > afterBoot); + const last = events[events.length - 1]; + assert.equal(last.type, "openfsd:themechange"); + assert.equal(last.detail.theme, "dark"); + assert.equal(attrs.get("data-bs-theme"), "dark"); + }); +});