Skip to content

Hold node tracks on the ADS-B hex that claimed them - #333

Merged
jehanazad merged 5 commits into
mainfrom
feat/known-track-hold
Sep 8, 2026
Merged

jehanazad merged 5 commits into
mainfrom
feat/known-track-hold

Conversation

@jehanazad

@jehanazad jehanazad commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Once a node track is linked to an ADS-B hex, it stays linked. Today claiming re-asks from scratch every frame whether a transponder fix explains a detection; when the node's tags stop and the cached fix ages past KNOWN_CLAIM_MAX_FIX_AGE_S (45 s) the answer flips to no for an aircraft that has done nothing unusual, its echoes fall into the dark pool, and the dark solver mints an mn-dark-* ghost beside the aircraft the lane was following a second earlier.

A claim is evidence on its own terms — this node's echo of this hex sat at that (delay, Doppler) — so the node's own measured track can predict the next frame with no transponder in the loop. That is path H, and it runs after path 1 and before path 2 so a linked track cannot be peeled off by another hex's dead-reckoned fix. There is no maximum hold duration.

Changes

  • state.known_track_holds (node_id -> hex -> entry): the last two claim samples per (node, hex) plus the last fix, written on every claim from every path. Expired against frame time (KNOWN_HOLD_MAX_GAP_S, 8 s) when that node's next frame arrives, and pruned per hex by feed_gc.prune_stale_stores so a node that stops sending cannot pin memory.
  • Path H (known_claiming._claim_holds): predicts the frame's observation from the held track — d(delay_us)/dt = -doppler_hz * 1e6 / fc_hz, Doppler propagated at the clipped two-sample rate — and assigns Hungarian one-to-one under 1.5 µs + 1.0 µs/s · dt / 20 Hz + 10 Hz/s · dt gates (bases set 5σ above the sim's own measurement noise, differenced). Claims record hold: true, hold_gap_s, and the stored fix with its original fix_ts_ms.
  • Ghost-lock guard: a hold may outlive the transponder but never contradict it. With a fresh cached fix on file, the hold-matched detection must also pass path 2's age-scaled gate for that hex; otherwise the hold is dropped (known_hold_dropped_disagree) and the hex falls through to path 2 unchanged.
  • Known lane: past the fix-age cap _build_solver_input seeds from the lane's own last mn-adsb-<hex> solve (filter velocity when available), dead-reckoned to the newest claim epoch, altitude still pinned from the last fix. seed_source ("kf"/"fix") rides on s_in and the history record.
  • Display: singly-claimed hold entries whose fix has aged out are skipped rather than drawn at the stale fix; entries carry adsb_stale.
  • Rollback / A/B: KNOWN_HOLD_MAX_GAP_S=0 disables the feature and the store, and GET/PUT /api/test/known-hold (admin) sets it live. New counters in the solver-stats known_claims block: hold_claims, hold_expired, hold_dropped_disagree, holds.

Test coverage

backend/tests/test_known_track_hold.py, 10 tests: tagged-then-silent claim, gap expiry, the ghost-lock guard, path H beating dark follow to a detection, the zero-gap rollback lever, and the known-lane seed in its fresh / stale-with-prior / stale-without-prior cases.

Two go through retina_simulation. The delay-rate sign is verified empirically — consecutive noise-free frames of the simulator's own moving aircraft — rather than trusted from the derivation, because a convention flip anywhere upstream would double the error instead of cancelling it. The integration test runs one node for 30 s of tags then 90 s of silence with the simulator's clutter present, asserting the link holds on >= 85 % of the frames the aircraft was detected in with zero clutter claims. Both seed the RNG: the simulator draws spawns, misses and noise from the global one.

Full backend suite: 3087 passed, 2 skipped. (Four probe_helpers subprocess tests fail in this working tree for an unrelated environmental reason — the child interpreter gets no PYTHONPATH for the in-tree libs/, so it imports an older installed retina_analytics whose InterNodeAssociator signature differs. Untouched by this branch.)

Review notes

  • Ordering is the requirement, not an implementation detail: path 1 > path H > path 2 > path 3.
  • The guard is what makes an unbounded hold safe to bind; it is the same concern dark_follow's two-strike guard exists for, resolved differently because here a live transponder is available to disagree.
  • multinode_to_aircraft reads nothing from state.adsb_aircraft, so an mn-adsb entry already keeps rendering from its radar solves after the cache entry is pruned — only the callsign lookup in the single-node section degrades (to None). The single-node stale-fix skip is the only display change needed.
  • Nothing deployed.

Live verification (test fleet, simulator transponder outages)

A/B on the same build with the hold switched off then on through PUT /api/test/known-hold, 15-minute captures each, frac_adsb_outage 0.5 (retina-simulation #13 / #332), frac_dark 0 so every dark-lane key is a ghost of an ADS-B aircraft.

hold off hold on
dark-lane ghost solves (15 min) 1392 224
dark solves on transponder-silent aircraft 267 184
distinct dark keys on silent aircraft 23 13
new dark keys minted (15 min) 36 12
silent aircraft shown as themselves after 45 s of silence (in multi-node coverage at outage start) 44 % 54 %
known-lane solves published 2637 5236
hold claims / expired / dropped-disagree 2629 / 423 / 46

Two remaining mechanisms, both outside this PR and addressed in a follow-up stacked on it: the filter's velocity keeps reading the stale ADS-B heading (a turning aircraft's kf-seeded solve drifts to a "ghost" label and never re-anchors), and nodes that newly acquire the silent aircraft have no hold, so their detections still mint a twin.

Also in this PR since the first review: a hold claim that agrees with a live fix now carries and refreshes that fix, so path H outranking path 2 on a node without tags cannot freeze the fix at the first claim.

🤖 Generated with Claude Code

jehanazad and others added 4 commits September 8, 2026 06:59
Claiming asks the same question from scratch every frame: does a transponder
fix explain this detection right now?  When a node's tags stop and the cached
fix ages past KNOWN_CLAIM_MAX_FIX_AGE_S the answer turns to no for an aircraft
that has not moved, changed or gone anywhere — its echoes drop into the dark
pool, the tracker forms a track, and the dark solver mints a new key beside
(or on top of) the aircraft the lane was following a second earlier.

But a claim is evidence in its own right: it says this node's echo of this hex
sat at that (delay, Doppler).  The next frame's echo is one frame of motion
away, and the measured Doppler says how far — so the node's own measured track
predicts the next observation with no transponder in the loop at all.

state.known_track_holds keeps the last two samples per (node, hex), and path H
claims against them with the same Hungarian one-to-one the other paths use.
It runs after path 1 and before path 2, which is the requirement rather than an
ordering detail: once a node track is linked to a hex it must not be peelable
to another hex's dead-reckoned fix.  No maximum duration — while the track
keeps matching, it stays linked — bounded instead by the 8 s frame-time gap it
may bridge, by feed_gc for a node that stops sending, and by the ghost-lock
guard: a hold may outlive the transponder but never contradict it, so with a
fresh fix on file the matched detection must pass path 2's gate too.

KNOWN_HOLD_MAX_GAP_S=0 is the rollback lever and is total — the store is not
even written, so off is the behaviour that predates path H.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A held claim can outlive its transponder by any amount — that is the point of
the hold — and _build_solver_input then dead-reckons a position from minutes
ago at a heading from minutes ago.  The guess drifts kilometres, _attempt
measures displacement against it, labels the honest solve a ghost, and the
aircraft stops being published exactly when the hold made it visible.

Past the fix-age cap the lane's own last mn-adsb-<hex> solve is the better
prior: a radar measurement, seconds old, of the same aircraft, with a filter
velocity behind it.  Altitude stays pinned from the last fix — barometric
altitude does not go stale the way a position does.  s_in and the history
record carry seed_source ("kf"/"fix") so the two populations are separable.

The display side needed one change and no more.  multinode_to_aircraft reads
nothing from state.adsb_aircraft, so an mn-adsb entry keeps rendering from its
radar solves after the cache entry is pruned (only the callsign lookup in the
single-node section degrades, to None).  The singly-claimed section, though,
draws the fix ITSELF, so a hold whose fix has aged out would paint the aircraft
where it was rather than where it is, more wrongly the longer the hold
succeeds: those are skipped, and entries carry adsb_stale so the map can say a
drawn position is coasting rather than measured.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GET/PUT /api/test/known-hold sets KNOWN_HOLD_MAX_GAP_S on a running backend so
the feature can be A/B'd without a redeploy; 0 turns path H off and clears the
store, so "off" is genuinely the pre-feature behaviour rather than a hold that
never matches.  Admin-gated on put_simulation_config's precedent: it changes
which detections leave the dark pool for every node at once.

The solver-stats known_claims block gains hold_claims, hold_expired,
hold_dropped_disagree and the store's current size, so the funnel is legible:
a store that grows while claims does not is holds that never match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ten tests: the tagged-then-silent claim, gap expiry, the ghost-lock guard, the
hold beating dark follow to a detection, the zero-gap rollback lever, the
known-lane stale-fix seed in all three of its cases, and two that go through
retina_simulation rather than a hand-built frame.

The physics check is empirical on purpose.  d(delay_us)/dt = -doppler_hz * 1e6
/ fc_hz falls out of the bistatic definitions, but a sign convention flip
anywhere between the generator and the propagation would double the error
instead of cancelling it, so the test measures consecutive noise-free frames of
the simulator's own moving aircraft and asserts the predicted rate agrees with
the measured one in sign and magnitude.  The integration test then runs one
node for 30 s of tags and 90 s of silence with the simulator's clutter present,
and asserts the link holds on >= 85 % of the frames the aircraft was detected
in with zero clutter claims.  Both seed the RNG: the simulator draws spawns,
misses and noise from the global one, so an unseeded rate assertion measures
the test run order.

test_solver_stats' key-set assertions grow the four new counters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@claude

This comment has been minimized.

Path H outranks path 2, so on a node without tags every claim after the
first is a hold claim. A hold claim always carried the STORED fix, which
meant the entry's fix never refreshed while the transponder was live: after
45 s the known lane would read a live aircraft as silent and seed from its
own filter, and the single-node display would drop it as stale. When the
consistency check finds a fresh fix that agrees, the claim now carries that
fix (the same record path 2 builds) and refreshes the hold with it; only a
genuinely silent hex keeps the original fix and its epoch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Claude finished @jehanazad's task in 4m 58s —— View job


Code review

Todo list

  • Gather context (PR body, changed files, prior review)
  • Understand the request (custom instruction: run /code-review on this PR)
  • Diff against origin/main and trace the newest commit (459569e, pushed after the previous review ran) end to end
  • Post review findings

Review

The previous review pass on this PR (07:00 UTC) covered commits through d5f6fd5/429c252. This synchronize event adds one more commit, 459569e ("Carry a live, agreeing fix on a hold claim and refresh the store with it"), which changes _claim_holds so a hold claim whose held track agrees with a fresh, live transponder fix now carries that fix (fix_refreshed: True) and refreshes state.known_track_holds[...]['fix'], instead of always carrying the original stale-and-frozen fix. I re-traced the whole hold path with this change in place.

Bug: adsb_stale no longer reflects "no longer refreshed by a transponder" for a fix_refreshed hold claim.

backend/services/aircraft_feed.py:323-363 (_claimed_single_node_entries):

_fix_age_s = (float(newest["ts_ms"]) - float(fix.get("fix_ts_ms") or 0)) / 1000.0
...
"adsb_stale": _fix_age_s > 0.0 and bool(newest.get("hold")),

This field was added in 634b820, back when every hold claim always carried the stored (never-refreshed) fix — at that point _fix_age_s > 0.0 was a correct proxy for "this hold is coasting on an aging fix." 459569e broke that invariant: a hold claim can now carry a fix pulled fresh off state._adsb_for_seeding() this very frame (extra["fix_refreshed"] = True, backend/services/known_claiming.py:624-627), because the transponder is live and the held track agrees with it. But fix.get("fix_ts_ms") there is st.get("timestamp_ms", 0) — the ADS-B ingestion pipeline's own clock — which will essentially never equal newest["ts_ms"] (the radar frame's clock) to the millisecond. So _fix_age_s is virtually always > 0 even immediately after a refresh, and a hold claim that a live transponder just confirmed this frame still gets marked "adsb_stale": true.

That's the opposite of the flag's own stated purpose ("True when the drawn fix is no longer being refreshed by a transponder … so the map can say the position is coasting rather than measured" — aircraft_feed.py:358-362): a fix_refreshed claim is, by construction, currently being refreshed and measured, not coasting.

Impact today: low — adsb_stale has no consumer yet (only written in aircraft_feed.py, not read anywhere else in the tree, frontend included), so nothing currently renders incorrectly. But it's a latent correctness bug in exactly the code this PR's last commit touched, and will silently mislabel live-confirmed holds the moment a consumer is wired up.

Suggested fix:

"adsb_stale": _fix_age_s > 0.0 and bool(newest.get("hold")) and not newest.get("fix_refreshed"),

Fix this →

Everything else re-checked in this pass held up: the ghost-lock guard's gate (KNOWN_CLAIM_DELAY_GATE_US * scale / KNOWN_CLAIM_DOPPLER_GATE_HZ * scale in _fresh_fix_prediction) mirrors path 2's own gate exactly; _touch_hold's None if extra.get("hold") and not extra.get("fix_refreshed") else fix correctly keeps the original fix_ts_ms on a genuinely-silent hold while refreshing it on an agreeing one; the new test_fresh_fix_that_agrees_refreshes_the_hold test passes only because its synthetic fix and frame timestamps happen to be set numerically equal (ts + 1000 on both), which is why it didn't catch the adsb_stale interaction — it doesn't exercise _claimed_single_node_entries at all.

No other issues found in this delta. Everything from the prior review (path ordering, rollback lever, GC pruning, state reset, counters/contract exemptions) still holds against the current HEAD.

@jehanazad
jehanazad merged commit 5c39531 into main Sep 8, 2026
29 checks passed
Babissimo added a commit that referenced this pull request Sep 15, 2026
The console has only ever had the light palette, which makes it the odd
one out next to the map — the map has themed since it borrowed dash's
whole vocabulary, and the data explorer has answered the OS preference
all along. Someone working across the three at night meets a white page
on two of them.

The mechanism is the map's, with the two themes the other way round.
The map carries dark on its base selector and light behind the
attribute, because dark is its default and the default must never be
the theme that flashes on first paint; light is the default here, so
light stays on the bare selector and dark is what the attribute buys.
The palette is the map's dark column wholesale rather than a new one:
the brand guide already records the two as counterparts, and the
estate's own §10 says drift is its main failure mode.

The control has three states rather than the map's two. `system` stamps
no attribute and lets a prefers-color-scheme block answer, so the OS
preference is honoured with no JavaScript and keeps working when the OS
changes its mind mid-session; light and dark override it. That also
means the data explorer, which has behaved that way since it was
written, can gain a switch without regressing anyone.

Three things the diff does not explain on its own:

- Recharts paints from props, not from the cascade, so `var()` cannot
  reach it. The chart chrome moves into a second, hand-kept copy of the
  tokens, and a test asserts the two agree. AnomalyPage was already
  passing `stroke="var(--border)"` into SVG presentation attributes,
  where CSS does not resolve var() at all — those axes were drawing at
  the initial value, and now draw.

- Dark's accent is a bright sky blue, so the white that inks the
  primary button and the sidebar mark drops to about 1.8:1 there. Both
  now take `--accent-ink`, near-black on dark. The map still inks
  `#fff` on the same fill; that is its bug to fix, not one to copy.

- The pre-paint script is a file, not an inline tag. Every page vhost
  sends `script-src 'self'`, which does not cover inline code, while
  the dev server sends no CSP — an inline version works in every local
  check and silently never runs once deployed.

A colour written into a page is pinned to one theme, and no token block
covers a chart's props or an inline style, so the pages get their own
sweep. It catches both shapes this change had: an Area whose stroke
follows the palette while its fill does not, and a `color: "white"`
sitting on a .btn-primary, where an inline style outranks the class
rule and quietly undoes --accent-ink.

Leaflet needs three rules rather than one. Its tiles take a filter,
since OSM does not theme; its container takes the sunk tier; and its own
attribution and zoom controls are hardcoded white and #333 in
leaflet.css, so on a dark card they read as two bright chips stuck to
the corners until they are told otherwise.

Verified in a browser across all three settings: system follows the OS
and follows it changing, an explicit choice overrides it and survives a
reload, and the tooltip, grid and axes take their themed values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Babissimo added a commit that referenced this pull request Sep 15, 2026
The console has only ever had the light palette, which makes it the odd
one out next to the map — the map has themed since it borrowed dash's
whole vocabulary, and the data explorer has answered the OS preference
all along. Someone working across the three at night meets a white page
on two of them.

The mechanism is the map's, with the two themes the other way round.
The map carries dark on its base selector and light behind the
attribute, because dark is its default and the default must never be
the theme that flashes on first paint; light is the default here, so
light stays on the bare selector and dark is what the attribute buys.
The palette is the map's dark column wholesale rather than a new one:
the brand guide already records the two as counterparts, and the
estate's own §10 says drift is its main failure mode.

The control has three states rather than the map's two. `system` stamps
no attribute and lets a prefers-color-scheme block answer, so the OS
preference is honoured with no JavaScript and keeps working when the OS
changes its mind mid-session; light and dark override it. That also
means the data explorer, which has behaved that way since it was
written, can gain a switch without regressing anyone.

Three things the diff does not explain on its own:

- Recharts paints from props, not from the cascade, so `var()` cannot
  reach it. The chart chrome moves into a second, hand-kept copy of the
  tokens, and a test asserts the two agree. AnomalyPage was already
  passing `stroke="var(--border)"` into SVG presentation attributes,
  where CSS does not resolve var() at all — those axes were drawing at
  the initial value, and now draw.

- Dark's accent is a bright sky blue, so the white that inks the
  primary button and the sidebar mark drops to about 1.8:1 there. Both
  now take `--accent-ink`, near-black on dark. The map still inks
  `#fff` on the same fill; that is its bug to fix, not one to copy.

- The pre-paint script is a file, not an inline tag. Every page vhost
  sends `script-src 'self'`, which does not cover inline code, while
  the dev server sends no CSP — an inline version works in every local
  check and silently never runs once deployed.

A colour written into a page is pinned to one theme, and no token block
covers a chart's props or an inline style, so the pages get their own
sweep. It catches both shapes this change had: an Area whose stroke
follows the palette while its fill does not, and a `color: "white"`
sitting on a .btn-primary, where an inline style outranks the class
rule and quietly undoes --accent-ink.

Leaflet needs three rules rather than one. Its tiles take a filter,
since OSM does not theme; its container takes the sunk tier; and its own
attribution and zoom controls are hardcoded white and #333 in
leaflet.css, so on a dark card they read as two bright chips stuck to
the corners until they are told otherwise.

The switch is three icons rather than three words: Feather's monitor,
sun and moon, hand-inlined in the same house style as the sidebar's set
(24-unit box, no fill, 2-unit round-capped stroke in currentColor) since
no icon package is installed and three glyphs do not justify adding one.
Drawing in currentColor is what lets the active segment tint itself from
the same rule that sets its ink. A glyph names nothing on its own, so
each button carries an aria-label and a matching title, and the svg is
aria-hidden so the name is not read twice.

Verified in a browser across all three settings: system follows the OS
and follows it changing, an explicit choice overrides it and survives a
reload, and the tooltip, grid and axes take their themed values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Babissimo added a commit that referenced this pull request Sep 15, 2026
The console has only ever had the light palette, which makes it the odd
one out next to the map — the map has themed since it borrowed dash's
whole vocabulary, and the data explorer has answered the OS preference
all along. Someone working across the three at night meets a white page
on two of them.

The mechanism is the map's, with the two themes the other way round.
The map carries dark on its base selector and light behind the
attribute, because dark is its default and the default must never be
the theme that flashes on first paint; light is the default here, so
light stays on the bare selector and dark is what the attribute buys.
The palette is the map's dark column wholesale rather than a new one:
the brand guide already records the two as counterparts, and the
estate's own §10 says drift is its main failure mode.

The control has three states rather than the map's two. `system` stamps
no attribute and lets a prefers-color-scheme block answer, so the OS
preference is honoured with no JavaScript and keeps working when the OS
changes its mind mid-session; light and dark override it. That also
means the data explorer, which has behaved that way since it was
written, can gain a switch without regressing anyone.

Three things the diff does not explain on its own:

- Recharts paints from props, not from the cascade, so `var()` cannot
  reach it. The chart chrome moves into a second, hand-kept copy of the
  tokens, and a test asserts the two agree. AnomalyPage was already
  passing `stroke="var(--border)"` into SVG presentation attributes,
  where CSS does not resolve var() at all — those axes were drawing at
  the initial value, and now draw.

- Dark's accent is a bright sky blue, so the white that inks the
  primary button and the sidebar mark drops to about 1.8:1 there. Both
  now take `--accent-ink`, near-black on dark. The map still inks
  `#fff` on the same fill; that is its bug to fix, not one to copy.

- The pre-paint script is a file, not an inline tag. Every page vhost
  sends `script-src 'self'`, which does not cover inline code, while
  the dev server sends no CSP — an inline version works in every local
  check and silently never runs once deployed.

A colour written into a page is pinned to one theme, and no token block
covers a chart's props or an inline style, so the pages get their own
sweep. It catches both shapes this change had: an Area whose stroke
follows the palette while its fill does not, and a `color: "white"`
sitting on a .btn-primary, where an inline style outranks the class
rule and quietly undoes --accent-ink.

Leaflet needs three rules rather than one. Its tiles take a filter,
since OSM does not theme; its container takes the sunk tier; and its own
attribution and zoom controls are hardcoded white and #333 in
leaflet.css, so on a dark card they read as two bright chips stuck to
the corners until they are told otherwise.

The switch is three icons rather than three words: Feather's sun,
monitor and moon, ordered light to dark with the neutral between them, hand-inlined in the same house style as the sidebar's set
(24-unit box, no fill, 2-unit round-capped stroke in currentColor) since
no icon package is installed and three glyphs do not justify adding one.
Drawing in currentColor is what lets the active segment tint itself from
the same rule that sets its ink. A glyph names nothing on its own, so
each button carries an aria-label and a matching title, and the svg is
aria-hidden so the name is not read twice.

Verified in a browser across all three settings: system follows the OS
and follows it changing, an explicit choice overrides it and survives a
reload, and the tooltip, grid and axes take their themed values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Babissimo added a commit that referenced this pull request Sep 15, 2026
The console has only ever had the light palette, which makes it the odd
one out next to the map — the map has themed since it borrowed dash's
whole vocabulary, and the data explorer has answered the OS preference
all along. Someone working across the three at night meets a white page
on two of them.

The mechanism is the map's, with the two themes the other way round.
The map carries dark on its base selector and light behind the
attribute, because dark is its default and the default must never be
the theme that flashes on first paint; light is the default here, so
light stays on the bare selector and dark is what the attribute buys.
The palette is the map's dark column wholesale rather than a new one:
the brand guide already records the two as counterparts, and the
estate's own §10 says drift is its main failure mode.

The control has three states rather than the map's two. `system` stamps
no attribute and lets a prefers-color-scheme block answer, so the OS
preference is honoured with no JavaScript and keeps working when the OS
changes its mind mid-session; light and dark override it. That also
means the data explorer, which has behaved that way since it was
written, can gain a switch without regressing anyone.

Three things the diff does not explain on its own:

- Recharts paints from props, not from the cascade, so `var()` cannot
  reach it. The chart chrome moves into a second, hand-kept copy of the
  tokens, and a test asserts the two agree. AnomalyPage was already
  passing `stroke="var(--border)"` into SVG presentation attributes,
  where CSS does not resolve var() at all — those axes were drawing at
  the initial value, and now draw.

- Dark's accent is a bright sky blue, so the white that inks the
  primary button and the sidebar mark drops to about 1.8:1 there. Both
  now take `--accent-ink`, near-black on dark. The map still inks
  `#fff` on the same fill; that is its bug to fix, not one to copy.

- The pre-paint script is a file, not an inline tag. Every page vhost
  sends `script-src 'self'`, which does not cover inline code, while
  the dev server sends no CSP — an inline version works in every local
  check and silently never runs once deployed.

A colour written into a page is pinned to one theme, and no token block
covers a chart's props or an inline style, so the pages get their own
sweep. It catches both shapes this change had: an Area whose stroke
follows the palette while its fill does not, and a `color: "white"`
sitting on a .btn-primary, where an inline style outranks the class
rule and quietly undoes --accent-ink.

Leaflet needs three rules rather than one. Its tiles take a filter,
since OSM does not theme; its container takes the sunk tier; and its own
attribution and zoom controls are hardcoded white and #333 in
leaflet.css, so on a dark card they read as two bright chips stuck to
the corners until they are told otherwise.

The switch is three icons rather than three words: Feather's sun,
monitor and moon, ordered light to dark with the neutral between them, hand-inlined in the same house style as the sidebar's set
(24-unit box, no fill, 2-unit round-capped stroke in currentColor) since
no icon package is installed and three glyphs do not justify adding one.
Drawing in currentColor is what lets the active segment tint itself from
the same rule that sets its ink. A glyph names nothing on its own, so
each button carries an aria-label and a matching title, and the svg is
aria-hidden so the name is not read twice.

Choosing role=radiogroup over three independent toggles is what obliges
the keyboard half: the set is announced as one control with three
options, so it is one tab stop rather than three (a roving tabIndex on
whichever is checked) and the arrows move within it, selecting as they
go. Home and End reach the ends. Everything else passes through —
without preventDefault the arrows scroll the dropdown while the
selection moves underneath.

Verified in a browser across all three settings: system follows the OS
and follows it changing, an explicit choice overrides it and survives a
reload, and the tooltip, grid and axes take their themed values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant