Pickup notes for contributors and agents. Architecture, decisions, tests, SignalK setup.
Safety-critical marine chartplotter. Blazor WASM, net10.0, no server-side Blazor. Runs on iPad / phone / 21" helm touchscreen. Any exception reaching Blazor's error UI is a user-visible failure on a boat with flaky wifi. Benchmark UX against dedicated commercial chartplotter hardware.
SignalK server --WS deltas--> SignalkClient
| fans out
v
NavigationData AisStore TrackBuffer ServerNotificationStore
| | | |
+----------------+-------------+--------------+
v
Pages + Components observe these stores; Services/Map/* are
controllers that diff store state and push to JS interop.
JS (leafletInterop.js + helpers): renderer ONLY. C# resolves
classification, colour, CPA, ETA, etc. and pushes shaped payloads.
JS-side decisions (e.g. "is this ship a SART?") are a code smell.
SignalkClient owns the WebSocket and routes deltas to the four stores above. New paths wire into one of those stores; don't subscribe elsewhere.
Subscription tiers. Single SubscriptionTier table near the top of
SignalkClient.cs: SelfFast, SelfFastNotifications, SelfSlow, Ais,
ServerNotifications. Each entry: context glob, paths, period, policy
(ideal vs instant). Adding a path = add it to the matching tier or add a
new tier entry. Don't sprinkle ad-hoc subscribe calls; reconnect replays
from the table.
Services/Map/* controllers wrap JS interop and own diff-and-push state
(ServerAnchorSync, ServerTrackController, ...). Pages call into them;
controllers decide when JS actually needs an update. Stops pages from
spamming interop on every render.
*Services/Api/Api.cs is one HTTP client per SignalK resource sharing
ResourceHttp. Wire-shape contracts are pinned by tests
(AnchorAlarmApiTests, WaypointCourseApiTests).
*Services/Alarms/AlarmRule.cs is rule-per-file, DI-registered,
IAlarmRule.Check(ctx) returns 0..N alarms. Manager sorts by
(severity desc, TTI asc, priority asc) and surfaces the head.
- C# over JS. JS is a thin renderer. Decisions, math, classification live in C# with C# tests. JS gets a typed payload and renders it.
- Plugin-required, not manual fallback. When a feature depends on a SignalK plugin (e.g. signalk-anchoralarm-plugin v2.0.0+), surface a clear "plugin vX.Y+ required" toast plus a Paths-page callout and stop. No shadow client-side implementation that drifts from server reality.
sealedby default. No inheritance hierarchy.- Alarm severity is a token, not a colour. Reference
--sev-ok / --sev-warn / --sev-dangerinapp.css :root. Never hardcode red or green. - Persistence through
IKeyValueStore. No rawlocalStorage.*in JS. Keys end in.v1so schemas can migrate. MarkerLayerJS class for any new resource type. No hand-rolled{}+deletedicts.async voidonly on Blazor event handlers, body wrapped intry/catch (ObjectDisposedException). Page switches tear down the SynchronizationContext mid-render.- Every
module.InvokeVoidAsynccatchesJSDisconnectedException. - Zero compiler warnings. Treat the analyzer as authoritative.
- No history-flavoured comments. Present-tense rationale only.
No "was X / now Y", no "from PR #N", no "tech-debt pass dropped Z".
Git blame carries history; comments carry the why of the
current shape. The version-history block in
service-worker.jsis the one intentional exception (it IS the SW changelog).
Six test surfaces, each does one job. Run the appropriate one for the change you made; CI runs all of them.
OnaPlotter.Tests/*.cs (root). Pure logic, services, math, store
classes. TUnit runner.
dotnet test OnaPlotter.Tests/OnaPlotter.Tests.csprojOne file per unit. Equivalence classes + boundaries + a realistic case.
FakeSettings is the shared IAppSettings stub.
TestClock.FixedUtcNow is the shared deterministic time seed
(2025-01-01T12:00:00Z); use it instead of DateTime.UtcNow when
a test needs a reference instant so the suite stays Repeatable
across DST flips and leap seconds.
OnaPlotter.Tests/Components/*Tests.cs. Renders Razor components in
isolation, exercises DOM + parameter contracts.
dotnet test OnaPlotter.Tests/OnaPlotter.Tests.csproj # same runnerFor any component whose UX you'd describe in helm-words ("tap chip
previews ring; tap Set commits"), pin the wire contract here. See
AnchorEditPanelTests, HudAnchorCardTests.
OnaPlotter.Tests/*FuzzTests.cs. Property-based: feed a seeded PRNG of
inputs through a pure-math invariant. Used for Cpa, Colregs,
RouteProgress, AnchorRadiusHeuristic, NavigationData, AisVessel,
AnchorTideAlarmRule.
dotnet test OnaPlotter.Tests/OnaPlotter.Tests.csproj # same runnerAdd when the function under test has algebraic invariants (commutativity, monotonicity, bounded output range) that a hand-picked case set wouldn't catch. Seed the PRNG so failures reproduce.
OnaPlotter.Tests/*ApiTests.cs. ApiTestHelpers.MockClient captures
URL + method + JSON body for every call. Pin shape so a refactor that
renames an arg or drops a field breaks the test, not the helm trying to
drop anchor at sundown.
OnaPlotter/wwwroot/js/*.test.js. Pure-JS modules (geoMath, geomPixels,
markerLayer, popupHelpers, overzoomLayer, chartDownshift, aisLayer).
node:test runner.
node --test 'OnaPlotter/wwwroot/js/*.test.js'JS hosts only renderers + low-level geometry helpers; everything else is C#. So this surface stays small.
OnaPlotter.UiTests/. End-to-end against a live deployment.
cd OnaPlotter.UiTests
npm install && npm run install:deps # one-time
BASE_URL=http://localhost:5282 npm run test:smoke # walks every route
FUZZ_SEED=12345 npm run test:fuzz # 40 random clickssmoke.spec.jswalks every top-level route, exercises all map control buttons, opens/closes layers panel. Fails if#blazor-error-uiever becomes visible or the page raises errors.fuzz.spec.jsis a seeded-PRNG walk over safe interactive selectors (.ctrl-btn,.chart-quick-chip,.vessel-row, ...). Avoids destructive actions (route edit, MOB).- When fuzz finds a crash, reproduce with the logged
FUZZ_SEED, fix the C#/JS root cause, then add the specific sequence as a named smoke test (so it becomes a regression case rather than a fuzz-might-find-it-again case).
Coverage report: dotnet run --project OnaPlotter.Tests - --coverage
produces a .coverage file; merge with dotnet-coverage merge ... -f cobertura and run reportgenerator. Don't chase the overall line rate
(Map.razor is a 2000-line view); target stateful sub-components with
bUnit instead.
Two flavours: a dev SK with fake data for fast iteration, and a staged-bundle deploy for verifying what the helm will actually see.
scripts/dev-sk.sh up # signalk-server + fake-data pump
scripts/dev-sk.sh down
scripts/dev-sk.sh logsPosition circling 1 km, 5 AIS targets, wind + depth. Seed
deterministic (FAKE_SEED=42) so scripted scenarios reproduce. Server
on http://localhost:3000, admin UI at /admin/ (creds admin / admin from docker/signalk-config/security.json).
Point OnaPlotter at it: edit OnaPlotter/wwwroot/appsettings.json ->
"SignalK": { "ServerUrl": "http://localhost:3000" }, then
dotnet run --project OnaPlotter. App on
http://localhost:5282/.
The fake-data SK doesn't ship plugins. To exercise the anchor / tide-alarm flows you need them installed:
scripts/dev-sk-plugins.sh # installs anchoralarm + tides-apiIdempotent. Lands the plugins in the named volume so they survive a
down/up.
For verifying the deployed shape (subpath base href, service worker, PWA manifest) the helm will actually load:
rm -rf OnaPlotter/obj/Release OnaPlotter/bin/Release deploy/publish
dotnet publish OnaPlotter/OnaPlotter.csproj -c Release -o deploy/publish
pwsh ./deploy/stage-only.ps1
docker compose -f docker/docker-compose.dev.yml \
-f docker/docker-compose.testing.yml restart skServed at http://localhost:3000/signalk-onaplotter/.
stage-only.ps1 does NOT run dotnet publish itself; it stages
whatever's in deploy/publish/. Forgetting the publish step ships
yesterday's WASM with today's HTML.
Components/Pages/*.razor+ code-behind.Map.razor.csis the resource CRUD + routing.Components/Map/*- HUD, controls, per-resource*Section.razorrows in LayersPanel.Services/Api/*Api.cs- one HTTP client per SignalK resource.Services/Alarms/*AlarmRule.cs- rule-per-file, DI-registered.Services/Map/*- diff-and-push controllers around JS interop.Utilities/*.cs- pure math (Cpa, Colregs, Format, AisPalette, GeoBearing, RouteProgress).Models/*.cs- value records (TrackPoint, TrackBbox, ...).wwwroot/js/leafletInterop.js+ sibling modules - renderer.
master is the deploy branch and the only long-lived branch. Commit
directly to master. Topic branches under .claude/worktrees/ are
fine for in-flight work; push them straight to master via
git push origin <branch>:master (fast-forward only) when ready -
don't open a long-lived integration branch in between. When the local
master worktree is locked (worktree dir under .claude/worktrees/),
direct push is the only way; don't checkout master from the main
worktree.
Squash before push. Semantic prefix in subject: feat(scope):,
fix(scope):, refactor(scope):, chore(scope):. Helm is the user;
quote them in the body when the change traces back to a verbatim request.
Never mention AI.
Service-worker cache stickiness. /_framework/*.wasm and
blazor.boot.json go through cache-first. Component-shape change without
bumping CACHE_NAME in OnaPlotter/wwwroot/service-worker.js =
render-time crash for any returning helm
(Object of type ... does not have a property matching the name 'X').
The version-history block in service-worker.js is the canonical
changelog; add a one-line entry per bump.
Resource payload shape.
{ name, feature: { type, geometry, properties: {...} } }. properties
is mandatory for SignalK resource-API interop, even when near-empty.
Notes are the exception: bare position: { latitude, longitude }.
Razor string-parameter binding. On a string-typed parameter,
Param="var" is a literal, not an expression. Typed parameters
(bool, int, collections) don't have this trap.
<RouteEditPanel Name="@routeEditName" /> @* correct *@
<RouteEditPanel Name="routeEditName" /> @* passes the 14-char literal *@Alarm stack order. (severity desc, TTI asc, priority asc). SHALLOW
at TTI=0 beats pending CPA regardless of rule priority. Set
AlarmInfo.TimeToEventMinutes when the rule knows when the event fires.
Long-press gesture. Fires after 700 ms of stillness within an 8 px
envelope, cancelled by Leaflet's movestart/dragstart/zoomstart.
Don't retune without testing on a rocking boat with cold fingers.
Three independent first-run signals (welcome card, touch coachmark, offline toast) each gate on their own KV key. Don't collapse them.
Country flag in AIS popup hits
/signalk/v2/api/resources/flags/mmsi/{mmsi} (the signalk-flags
plugin). Relative URL resolves against the page origin, which is the SK
server when deployed as a webapp. Pointing at a remote server via
SignalK:ServerUrl 404s the flag and hides via onerror. Don't make
anything safety-critical depend on it.
Chart overzoom vs overlays. recomputeChartOverzoom() in
leafletInterop.js picks the chart with the highest native maxZoom and
stretches it past its limit. Overlays (OpenSeaMap seamarks, anything
whose id is openseamap / contains seamark or whose tile URL has
/seamark/) are excluded from the "top native" race; otherwise an
overlay's native 18 beats a base chart's native 17 and the base chart
disappears past its limit. New overlay plugins: match the heuristic or
extend isOverlayChart.
Anchor flow is plugin-driven (v2.0.0+). Drop = POST /plugins/anchoralarm/dropAnchor (empty body, plugin reads GPS).
SetMaxRadius = PUT navigation.anchor.maxRadius {value: N}. Raise =
PUT navigation.anchor.position {value: null}. Panel uses pick-then-Set
with on-map preview: chip taps fire OnPreviewRadius (visual only via
MapAnchorJs.UpdateAnchorRadiusAsync); Set fires OnSetRadius (the PUT).
_anchorPreviewActive tracks "preview pushed but not committed" so
cancel paths can revert the on-map ring to server MaxRadius.
Cruise/Race mode is a preset, not a lockout. Mode flips defaults and surfaces mode-specific overlays; every individual toggle still works in either mode.
Skip rounds with nothing to say.
- Review as if presenting to senior engineers who know the domain: correctness, maintainability, performance, UX. Fix what you find; flag larger refactors without doing them.
- Tests at the right level (unit / component / fuzz / contract / smoke). 100% of the unit under change.
- Sailor-persona check for larger changes: cruiser + race crew.
- If component shape changed, bump
CACHE_NAMEinservice-worker.jswith a one-line entry in the version block.
Tracked as GitHub issues, not here. If something is safety-adjacent or mid-migration, note it as a footgun above instead.