Skip to content

A pressed chip inside a segment reads as pressed, in every theme - #484

Merged
thebhef merged 18 commits into
mainfrom
fix-cursor-selection
Sep 23, 2026
Merged

thebhef merged 18 commits into
mainfrom
fix-cursor-selection

Conversation

@thebhef

@thebhef thebhef commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Which cursor mode the plot toolbar is in was unreadable in the dark
theme: the pressed segment and the unpressed ones beside it were, to
the eye, the same chip.

A lone command chip says it is pressed two ways — it tints its hairline
with --accent-marker and lifts its surface one step to
--surface-hover. A chip inside a ChipSegment has no hairline: the
group draws the outline once and the chips inside drop theirs, so half
of that signal is deleted and the one surface step is left carrying the
whole state on its own. One step is not enough to carry it. Measured as
a WCAG contrast ratio against the chip's own --surface-toolbar-control
surface, the pressed fill sat at 1.03 in dark, 1.04 in light and 1.04 in
lighthk — 1.00 being literally the same colour.

A pressed segment now takes --accent-selected-bg with
--text-on-accent over it: the app's existing selected fill, the one a
selected palette entry, a selected BLF marker row and a selected logger
file row already take, rather than a new colour invented for the
toolbar. That reads at 2.37 / 5.61 / 4.87 against the unpressed chips in
the three themes, with the label and icon at 6.41 over the new fill.
Hover and :active restate the fill because the chip's own rules for
those set the background at a higher specificity, and a state that
washes out under the pointer is worse than one that was never drawn.

The fix is in .chip-seg, so it reaches every segment that is a
"which one is on" decision — the plot toolbar's cursor modes and solo
paging, and the trace panel's trace mode. The run-control segment is
unaffected: its chips are commands, not toggles, and carry no
aria-pressed.

chipCssTestKit grew the colour half of the walk it already did for
geometry. The geometric invariant it was written for — a state moves
nothing — could not have caught this, because the bug was that the
state moved nothing and changed nothing else either. themeTokens
resolves a rule's var(--token) against each theme's :root block, so
the new test reads the three themes rather than the one jsdom happens
to have live.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

thebhef commented Sep 10, 2026 •

Copy link
Copy Markdown
Owner Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

thebhef and others added 18 commits September 23, 2026 08:50
…491)

Nothing on the wire said which protocol revision either side spoke.
That was survivable while the GUI shipped in the same bundle as the
server it talked to; it stopped being survivable once the python client
module and its CLI existed and third-party clients became the point. A
mismatch presented as `UNIMPLEMENTED`, `connect_flow.rs` had no arm for
it, and the retry loop dialled an incompatible server forever — so it
looked exactly like a server that keeps flapping.

The rule (owner ruling, ADR 0059) is the protobuf package-major
convention. `cannet.v1` is the major and it is already in every gRPC
method path, so both peers state it on every request; inside a major
only additive changes land, and a breaking change is a new package
served beside the old one for a deprecation window. Rejected: a version
integer with exact match (ADR 0011's pattern — right for a project file,
wrong for a wire, because it forces every client to upgrade the day a
breaking change ships) and a negotiated min/current range. ADR 0051 §6's
"one monotonic Extension API version carried by cannet.proto" is amended
to be this package major, so tasks 145 and 69 share one number.

`cannet.proto` states the rule in its header — above `syntax`, because
protoc folds a declaration's leading comments into every language's
generated docstrings and the first placement put forty lines of policy
into `CannetServerStub.__doc__` — and `Envelope` reserves the tag-9 hole
so nothing can pick it up later.

`proto/cannet_info.proto` is a second file declaring `package cannet;`,
deliberately unversioned and frozen, carrying one unary `ServerInfo`
that answers with the packages served, the build version and the
instance name. Outside every major on purpose: a server that has dropped
`cannet.v1` still has to be able to tell a `cannet.v1` client so.
`cannet_wire::PROTOCOL_PACKAGE` is the one string both sides compare.

Every server mode answers it — the proxy, `debug replay`, `debug vbus`,
and `serve_virtual_bus_ephemeral` — as does the python sidecar, whose
stubs are regenerated from both protos. `cannet-client` calls it inside
`ConnectConfig::client()`, the single choke point `list_interfaces`,
`watch_interfaces` and `connect_and_subscribe` already shared, and
refuses with `ConnectionError::IncompatibleProtocol`. `UNIMPLEMENTED` on
any RPC becomes `ConnectionError::Unimplemented`; `classify` sends both
to `Outcome::Fatal`, so neither retries. Both read "serves cannet.v2;
this client speaks cannet.v1".

`ServerInfo` is unauthenticated, so a client that cannot speak to the
server at all is told that rather than that its token is wrong. That
forced a real change: the token gate was a server-wide `Server::layer`,
and a tonic interceptor is handed a `Request<()>` whose URI has already
been dropped, so it cannot tell which service a call is for. The gate
moved onto the services (`auth::gated`), and `tests/auth.rs` is now what
holds the line — it asserts each gated RPC refuses an absent and a wrong
token, and that `ServerInfo` answers with neither.

mDNS gains `proto=`, the comma-separated packages served; `ver=` stays
the build string it always was. Advisory — absent for a hand-added
server and for `--no-mdns` — so the Servers panel greys out a row it
cannot speak to before dialling it, and `ServerInfo` on the connection
is the gate. `run_proxy`'s advertisement block moved into `advertise()`
to stay under the 100-line clippy limit.

CI gains two jobs. `wire-breaking` runs `buf breaking` (pinned 1.47.2,
CI-only, no codegen role) against the last release tag —
`GIT_LFS_SKIP_SMUDGE` is set because buf clones this repository and has
no LFS endpoint for the example captures it does not need. `wire-gencode`
runs `scripts/regen-proto-gencode.sh`, which drives every package's own
`scripts/regen_proto.sh` found under `servers/` and `libs/`, then fails
on any diff; nothing checked that the committed stubs still matched the
proto before. `regen_proto.sh` gained `newline=""` on its import-rewrite
step, without which a Windows regeneration rewrites every line of the
gencode and that job can never be green.

Tests, written against a fake server that serves only `cannet.v2` while
serving the real `cannet.v1` service, so the refusal cannot be an
accident of a missing RPC: `crates/cannet-client/tests/protocol_gate.rs`
covers the sentence, all three entry points, a server with no
`ServerInfo` at all, a server serving both majors (accepted — that is
the deprecation window), and an ordinary server. Every existing test
server in the workspace had to mount `ServerInfo`; without it they fail
with `Unimplemented("")`, which is how the gate was falsified before the
new tests were written.

Two inherited breakages fixed in passing rather than reported and left:
`apps/gui/src-tauri/src/interfaces.rs` was not rustfmt-clean (7d18a42 —
CI runs no `cargo fmt --check`, only the pre-commit hook, which
`--no-verify` skips), and three files carried CRLF against LF blobs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#466)

`clients/cannet-python-client` is a uv-managed sibling of the sidecar
that registers `CannetBus(can.BusABC)` under python-can's `can.interface`
entry-point group, so `can.Bus(interface="cannet", server=..., channel=...)`
opens a remote bus with no cannet-specific import and no credential
pasted into application code. Full BusABC citizenship: `_recv_internal`
(so `recv`, iteration and `Listener`/`Notifier` work), `send`, `shutdown`,
`state`, `channel_info`.

`server=` is resolved against the GUI's own trust store — `servers.json`
in the platform config dir under `dev.cannet.app` — whose location and
schema are now documented as a read contract, in the package README and
in `trust.py`. The library never writes it: accepting a server means
comparing a fingerprint against what the server printed, and only a
person can do that. The plan it derives is the GUI's (ADR 0041):
loopback is plaintext whatever is stored, a pin never degrades to
plaintext, a plaintext target carries no token, and an unknown
non-loopback server raises rather than inventing an answer.

Pinning is by certificate rather than by fingerprint, because Python's
gRPC exposes no verifier hook: the library fetches the server's
certificate, checks its SHA-256 against the stored pin, and hands that
exact certificate to gRPC as the channel's sole root — equivalent in
what it accepts. A second handshake, trusting the fetched certificate as
its own root, reads back the SAN list so the target-name override names
something the certificate actually carries. Reviewers should note this
path has no end-to-end coverage: both hardware-free debug servers
terminate no TLS, so the fingerprint form, the pin comparison and the
name choice are unit-tested and the handshake is not.

Session semantics are `cannet-client`'s, so a hardware server sees the
same client whichever language is talking to it. `ConfigureBus` precedes
the `Subscribe` it configures. A bare `virtual:` id is a factory and
waits for its `InterfaceAllocated` (ADR 0021). The three per-frame codes
are tallied on `bus.rejections` and the stream goes on; everything else,
including a code this build does not recognise, ends the session and is
raised. One consequence worth reading twice: the wire does not
acknowledge an ordinary `Subscribe`, so an unknown interface surfaces on
the first `recv` rather than out of the constructor — waiting on a reply
a server has no reason to send would trade a working session for a
stall.

Everything both ends of the wire have to agree on is a package of its
own, `libs/cannet-python-wire`: the checked-in `_proto` gencode and its
`regen_proto.sh`, the `Frame`/`FrameKind` pair, and the four mappers —
`message_to_frame` / `frame_to_message` either side of a python-can
`Message`, `frame_to_proto` / `proto_to_frame` either side of the
generated type. The sidecar and the client both take it as a path
dependency, so there is one encoding of the wire in this repository and
nothing for a second copy to drift from. The cut is at the seam and not
past it: the hardware driver, its adapters and the gRPC service stay in
the sidecar, and `driver.py` re-exports `Frame`/`FrameKind` because they
remain part of the contract an alternative-driver author reads. A client
that never speaks to a sidecar does not depend on one. `frame_to_message`
leaves timestamp and direction unset — it is the transmit direction,
where neither means anything — so the receive path supplies both.

Tests are hardware-free throughout: the units cover the trust-store
contract, the fingerprint form, the envelope order and the batch
routing; the integration half opens real buses against `cannet-server
debug vbus` and `debug replay` on explicit loopback, and skips when the
binary is not built. A `python-client` CI job builds the server and runs
them for real, and four pre-commit hooks mirror the sidecar's. The wire
package carries the tests of the code in it — the kind ladder, the
proto round-trip, the timestamp fallbacks — and gets the same four
hooks and its own `python-wire` lane; the sidecar's and the client's
mypy/pytest hooks watch its paths too, because a change there can break
either.

All three Python packages hold ruff below 0.16. Adding a package beside
the sidecar at 0.16 would have put two ruffs in the tree that do not
agree about the same code: ruff 0.16 *changes* its default rule
selection rather than only widening it, dropping E4xx and adding
`I`/`UP`/`S`/`PL`/`RUF`. Under 0.16 the sidecar reports 213 findings,
among them 50 deliberate `# noqa: BLE001` / `E402` suppressions that
`RUF100` deletes and five `S110`/`S112` best-effort `except: pass`
shutdown paths whose only remedy is to start logging. Moving every
package to 0.16 is that change, not this one.

Also drops 17 inherited `(task 129)` references from `apps/gui/src/`
comments (from #450). `CLAUDE.md` forbids naming a task in source; every
one was a bare parenthetical carrying nothing else, so it goes.

The client asks `ServerInfo` before it opens a session (ADR 0059), on
the channel it has just dialled and before any credential goes out, and
raises `IncompatibleProtocol` — "serves cannet.v2; this client speaks
cannet.v1" — when the server's package list does not hold ours. A
server that does not implement `ServerInfo` at all is refused the same
way: it predates the rule, so nothing it answers can be read as
agreement about the wire. `cannet_python_wire` grows `PROTOCOL_PACKAGE`,
read off the gencode's own descriptor rather than written out, so the
sidecar and the client compare against the package their stubs were
actually generated from; the sidecar's `info.py` reads it from there
instead of carrying its own copy.

The wire package pins `grpcio-tools` to 1.80.x, and that pin is
load-bearing rather than tidiness: every generated stub records the
toolchain that emitted it and refuses to import under an older runtime,
and the consumers lock grpcio 1.80.0 / protobuf 6.33.6. The lock as
first resolved here reached grpcio-tools 1.84 while the committed
gencode came from 1.80 — so the stubs were not reproducible from the
lock, and regenerating under it produced `_pb2_grpc.py` demanding
grpcio >= 1.81.1 and `_pb2.py` carrying protobuf 7.x gencode, both of
which import-error in the sidecar and the client. Nothing caught that
before, because nothing regenerated; the gencode-drift CI job does now.
`technology-inventory.md` records the pin and the rule it comes from
beside the existing grpcio entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…#467)

Phase 2 of task 136: the SNTP clock probe and server detection the
core-bus phase left as gaps.

cannet_python_client/clock.py ports crates/cannet-client/src/clock.rs's
RFC 4330 offset/delay math and its SessionClock/OffsetSlew state
machines verbatim in behaviour (minus the u64/i128 overflow dance,
which Python's arbitrary-precision integers make unnecessary). Session
drives it from a dedicated thread rather than folding it into the
existing receive-pump thread: Python has no equivalent of
tokio::select! over a stream and a timer together, and a thread with
blocking waits gets the same two guarantees more directly — a session
is usable at the speed of its subscribe, and a peer that never answers
a round costs nothing but an Unsupported reading once its 2s deadline
passes, never a stall. close() wakes that thread on the same event the
round waits on, so shutdown never pays the deadline either. Every
delivered frame's timestamp is corrected before it reaches recv().

Detection reuses the trust store the same way the bus constructor
does: CannetBus._detect_available_configs() (the BusABC hook behind
can.detect_available_configs()) dials ListInterfaces on every trusted
server with a short timeout and returns one config per interface
offered; an unreachable or untrusted server contributes nothing rather
than failing the scan.

CI gained a step that regenerates the checked-in _proto stubs from
cannet.proto and diffs the tree, so the gencode shared between the
sidecar and this client can't drift silently.

Tests: 27 new unit tests for the clock arithmetic (a port of clock.rs's
own suite), an integration test proving a real debug server's probe
gets measured, a fake in-process gRPC peer proving an unanswered probe
degrades to raw timestamps without blocking session open or close, and
a detection suite covering reachable/unreachable/untrusted servers
plus the end-to-end can.detect_available_configs() path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The python client did full ADR 0041 auth on the wire but only ever
*read* the trust store, so a machine with no GUI on it could not
onboard against a server at all short of hand-editing servers.json.
This adds `cannet-client`, a console script on the client package with
three subcommands — `list`, `connect`, `forget` — that is the Servers
panel's workflow on a terminal, writing the same per-machine
`servers.json`. Accept a server either way and every client on the
machine inherits the decision; ADR 0041 and ADR 0032 are amended to say
the store now has a second writer.

The decisions live in the library, not in the command (ADR 0003
applied to a terminal rather than a WebView): `browse` is a browse-only
mDNS stack over `zeroconf` — it never registers a service — `servers`
holds the list merge and what a name on the command line means,
`connect` is the four-path state machine, and `trust` grew the write
half of the store. `cli` parses arguments, puts the flow's questions to
the person and renders what comes back; it decides nothing, which is
why the tests for the rules need no terminal and the tests for the
terminal need no rules.

`connect` mirrors `connect_flow.rs` path for path: loopback is
plaintext unasked, a pinned server is dialled pinned, first contact
prints the observed `SHA256:…` for comparison against the server's
startup banner and asks for the banner's token, and an endpoint that
never answers with a certificate is reached in the clear only after an
explicit yes. A certificate that is not the pinned one is refused with
both fingerprints, stores nothing and sends nothing; a *pinned* server
that is merely down is not offered in the clear at all, since that
would make "the server is down" a route to dropping its protection.
Whatever it stored, it then proves with one authenticated
`ListInterfaces` and prints the `can.Bus(...)` call for what it found.

A name resolves without touching the network wherever it can: the
store answers first, an address is taken as typed, and only a bare name
nothing is stored for is looked for among the advertising servers — by
instance name or by the machine's own — with two answers refused rather
than guessed at.

Worth a reviewer's eye: the store writes are additive. The GUI owns the
schema and may grow fields, so a write reads the raw document and puts
back every key it did not touch, on the entry being edited and on every
other, and replaces the file by temp sibling + rename the way the GUI's
own writer does. Accepting an identity clears an earlier unprotected
choice; accepting an unprotected connection drops any pin and token,
because a credential never rides an unencrypted channel.

The default suite stays off the network and off multicast: the browse,
the certificate probe and the verifying RPC are injected, and the one
end-to-end case runs against `cannet-server debug vbus` on explicit
loopback like the rest of the integration half. `zeroconf` is recorded
as adopted in the technology inventory, browse-only and distinct from
the Rust crate of the same name rejected in task 43.

`list` gains a PROTOCOL column: what each server says it speaks, from
the `proto=` TXT key (ADR 0059 — the wire's version is its protobuf
package). A server serving a major this client does not is rendered
"cannet.v2 (not spoken here)", because it otherwise reads as a
reachable server that refuses every connect; a server that advertised
nothing reads `-`, which is "did not say" and not "serves nothing", and
is dialled normally. `connect`'s verifying `ListInterfaces` now asks
`ServerInfo` first like every other connection this package makes, and
translates the refusal into `Refused` carrying the sentence both sides
are named in.

The in-process fake in `test_session_clock.py` had to mount
`ServerInfo` too — a fake without it is refused before the test's own
subject is reached — so `conftest.py` grows `add_server_info`, which
also answers with a package list a test can choose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 1 of task 137's export/logger dialog: the shared machinery both
the export dialog (phase 2) and project loggers (phase 3) resolve
their name/folder templates through, plus the machine-scoped state
that pre-fills the next export.

export_template.rs resolves {project}/{logger}/{start}/{now} tokens.
A bare token is ISO 8601 basic with a timezone offset (chrono's own
%z), and an explicit {token:<fmt>} passes straight through to
chrono's strftime formatter with no subset, per the owner's ruling —
validated first against Item::Error so a bad specifier reports a
polished message instead of hitting chrono's to_string panic. {start}
on an unanchored capture resolves as {now}, and the result carries
that fact for a future preview to announce. Folder templates root a
relative result at the project directory, and error when the project
has none the user chose (read as !ProjectDir::is_auto_located(), the
closest fit for "project-directory mode" — no ruling defined the term
precisely, so this is flagged in the status log for the owner).

export_state.rs persists the last-used folder, format, and name
template at user scope (export.json under app_config_dir), following
the servers.json precedent; default template {project}-{start}.

Both are wired behind Tauri commands (preview_export_template,
get_export_state, set_export_state) sized for a live preview and for
phase 2 to read/write directly. No GUI in this phase. chrono becomes
a direct cannet-gui dependency (clock feature, for {now}'s wall
clock), matching plans/technology-inventory.md's existing entry for
this extension.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…in the background (#469)

Export stopped being "open the OS picker and block the GUI until the
file is on disk". It now opens a dialog that settles the two things a
picker cannot express — what the file is called, and which slice of the
capture goes into it — and then hands the write to a host thread that
reports progress and can be stopped.

The **name** is the template machinery from the previous change, given a
surface: an editable template with the resolved name beside it as a
label. The preview is the host's answer (`preview_export_template`), not
a second implementation of the tokens in JS, so an invalid token or
strftime format is rejected with the host's own message and Export… goes
disabled. The template the export was made with is remembered, alongside
the folder and the format.

The **format is still chosen in the picker**, which is where people
expect it. The sticky last-used format reaches it as filter *order*,
because the order a save dialog is handed its filters is the only handle
there is on which one it opens with.

The **range** defaults to both bounds unset: the whole capture, up to
the live edge when the write finishes. A timeline over the capture's
extent carries the bounds, with the capture's own events as clickable
ticks and presets for the whole capture, the trailing 1/5/30 minutes,
and the window a plot is showing. Each bound is also a text field taking
`[Nd ]HH:MM[:SS]` or bare seconds from the start; a bound sitting on an
event reads back as that event. A capture with no wall-clock anchor
takes and shows seconds-from-start only — there are no instants to name
(ADR 0024).

The **day prefix** is what lets a clock name an instant in a capture
that runs longer than a day: `3d 12:30:00` is half past noon on the
capture's fourth day, counted in local calendar days from the day it
started, so a DST transition inside the span does not shift every later
day's index. A bound past the first midnight also *renders* with its
prefix — without one, two instants three days apart read as the same
string, and committing what the field showed silently moved the bound
three days. A bare clock keeps its short meaning (the capture's own day,
or the next one when it is earlier than the start); an explicit day
never rolls, so one landing before the start is refused rather than
pushed forward.

The bound fields are deliberately *not* the shared `Combobox`. That
control is a filtered select: its text box is an fzf filter and Enter
prefers the matching option to what was typed, so typing a time selected
the event whose rendered time happened to fuzzy-match it. Here the typed
text is the primary input, so the field keeps the anatomy and inverts
the precedence.

Host-side, `save_capture` returns as soon as the write starts.
`ExportRun` carries the three things that are about *how* a capture is
written rather than about its format — the range predicate, a paced
progress sink, and a cooperative cancel flag — and both writers take
one. The range bounds every content kind alike (frames, notes, and
MDF's file-backed signal samples) so an exported slice is one consistent
capture whose file starts where the slice does. `cancel_export` flips
the flag, the writer stops at its next frame, and the partial file is
removed: neither writer uses a temp sibling, so a cancelled export
otherwise leaves a truncated capture wearing the name the user chose.
That is the opposite of a cancelled *import*, which keeps what it read,
and both doc comments now say why.

Worth a reviewer's attention: `ExportRun`'s progress sink is a
`Box<dyn Fn>` rather than the `AppHandle` it looks like it should be.
An `AppHandle` field would be part of the struct's drop glue, and the
writers' tests construct an `ExportRun` — which linked Tauri's whole
window graph into the lib test binary, whose loader then failed on
`comctl32!TaskDialogIndirect` (side-by-side ComCtl32 v6, which a test
binary has no manifest for). The test binary would not start at all.
The sink keeps the emitting closure reachable only from the command
that builds it.

Also here: the capture's extent as a host command rather than something
the dialog derives from rows it fetched, the plot's current x window
published for the one preset that needs it, and the status-bar chip that
reports the write and carries its Cancel.

A **fresh install's first export** opens the picker too. The host
omits `folder` from `get_export_state` when nothing has been exported
yet — serde skips a `None` rather than sending `null` — so the
frontend's check is `== null`, not `=== null`, and `ExportStateRecord`
spells the field the way the wire does (`folder?: string | null`).
With no remembered folder the picker is seeded with just the file
name and opens wherever the OS last left it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Export names one file and writes a slice of what has already been
captured. A logger is the standing counterpart: a project element with
its own panel, pointed at a templated folder and file, that keeps
writing BLF to disk while it is switched on. Both fields resolve through
the same host command the export dialog's preview uses, so the tokens
and their strftime formats stay the model's; `{logger}` is the fifth,
available only inside one, and a logger's `{now}` is the moment logging
started rather than the moment a field was read.

A template's separators are the running OS's. `resolve` renders every
result in `MAIN_SEPARATOR` whichever way the template was typed, so the
default folder `logs/{logger}` is one subdirectory on Windows and on
macOS alike, and a project written on either opens correctly on the
other. Keeping that policy in the model is what leaves the panel a
pass-through — the preview appends the format's extension and nothing
else — and the run path a plain join of two already-native halves. The
price, stated on the helper: a literal backslash cannot appear *in* a
name on Unix, because a template separator is a separator everywhere.

Logging runs exactly while the logger is enabled and something is
connected. That rule lives in one place: `connection_state::emit` — the
single write path both `set_and_emit` and `remove_and_emit` funnel
through — reconciles the loggers, so "the state changed" and "the
loggers followed it" cannot drift apart. The decision itself is a pure
function over (configs, running, connected), tested without an app, a
filesystem, or a connection. Unlike the RBS Run flag (ADR 0028), the
enabled flag is saved with the project: logging writes locally and puts
nothing on a bus, so opening a project can resume one without
transmitting.

The writer does not sit on the ingest path. `run_pump` is the obvious
hook and the wrong one: it would put a probe and a frame clone on the
hot path of every session, logger or not. Instead a writer thread
follows the capture model's own index — remember how far you have
written, ask the trace store for what has been appended since, write
that. Nothing is added to a session with no logger running, every frame
source is covered without naming one, and the render-tier runs show no
change in frame rate or mean lag with a logger writing throughout. What
it gives up is a guarantee against eviction outrunning the writer, so
`settle_cursor` detects exactly that and says so on the system log
rather than leaving a silent gap.

Reaching the size cap closes the file and opens the next, with `-002`,
`-003`… on the last path segment before its extension; a run that starts
where a file already sits takes the next suffix the same way. One rule,
`first_free_part`, applied at the start and at every roll. The cap is
read from `BlfCaptureWriter::bytes_on_disk`, new here — the header plus
every flushed container, counted rather than seeked — because
`FinishedCapture::byte_size` is only correct after `finish` consumes the
writer, which is no use to a file deciding whether to keep going.

Worth a reviewer's attention: stopping a logger **waits** for its writer
thread, and the exit path stops every logger after `disconnect_on_exit`.
The first live run left a capture whose header said `file_size = 0` —
the process walked away while the thread was still asleep between polls,
so `finish` never ran and the file was readable only through the
reader's recovery path. A condvar-backed stop signal wakes the thread
instead of polling it out, which is what makes the wait cheap enough to
put on the disconnect path.

**A logger writes BLF, and the panel carries no control saying so.** A
two-option select whose second option is permanently disabled asks a
question that has one answer, and reads as a broken feature rather than
an absent one. The element and the project file keep their `format`
field — the host's `LoggerConfig` never had one, but the preview's
extension reads it and a project should record what was written — so
the control can come back the day live MDF logging exists.

The folder's file gridview is the next phase; the panel stops after
folder, file, preview and the size cap, and the writing status belongs
in that list rather than in a status line here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
)

Phase 4 of 4 for task 137. The project logger's panel (phase 3) had
controls and a live write, but nothing that showed the folder it was
writing into. This adds the recursive file listing ruling 9/17/25
asked for, built on the app's real gridview layer rather than a new
control: directories as branches, columns for name/size/trace
start/end (ISO)/duration/message count/modified time, and the file
a logger is writing right now rendered as the list's own live row —
no separate status line.

A directory row is marked with a trailing separator, and which one is
not this view's to decide: it reads it off the resolved folder it was
handed, which is a path in the running OS's own form
(`logPathSeparator`). The same reason the host renders a template
natively — a Windows-shaped path on a mac is wrong, and the frontend
has no other way to tell the platforms apart.

Host side, `log_files.rs` walks a folder recursively and serves each
finished file's header metadata (start, end, message count) from a
per-file cache keyed by (size, modified time), so a listing that
finds nothing changed about a file pays nothing for it. The file
currently open for writing is never header-scanned — its path
matches a running logger's status, and its row carries that
status's live bytes/frame count instead, which is also simpler than
it sounds: the file already exists on disk in its real place the
moment the writer opens it, so the ordinary recursive walk finds it
without any client-side tree splicing.

Import — the per-row button (Icon.tsx's `import` shape), the row's
context menu, and Space on the cursor row — all open the app's
existing "Import trace…" flow with a preset path, never a fork of
it: `ProjectContextValue` gained `onImportCapture`, which is
`handleImportTrace` itself, so the unsaved-capture guard, the
channel-mapping dialog and its range picker are the same ones a
manual import gets. A directory's context menu offers only Show in
Explorer (reveal.rs, hand-rolled per-platform rather than a new
plugin dependency for one action); a file's adds Import.

Perf run (ev-zonal, 60s, scrub) shows rx/tx ~1603/1610 fps and
memory within the band phase 3's runs already established — nothing
here moved it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A math signal is a series the user defines from other signals — a sum,
a difference, an exponential filter, a statistic over a set, a constant
line. It joins DBC-backed and file-backed series as a third decode
provenance in the signal cache, on the rule those two already follow:
provenance decides the fill, and only the fill. Pyramids, the paged
serve, `signal_min_max`, eviction and completeness are all inherited.

Why host-side at all: the exponential filter is a sequential
recurrence, so it has to run over the raw level-0 series. Computed in
the frontend it would run over whatever the current zoom had already
decimated, and the same signal would filter differently at every zoom
level. ADR 0007 and ADR 0025 say the model works and the renderer
paints; this is a case where the alternative is not merely off-pattern
but wrong.

The fill is incremental, like the decode path and unlike the one-shot
file-backed one. A round reads each operand's unread level-0 tail,
merges it onto the sample-and-hold union of the operands' timelines,
runs the kernel and appends. Two rules make that sound while a capture
is still growing: a block stops at the *earliest* of the operands'
newest sample times, because emitting past it would mean a later sample
from a slower operand needing insertion before an existing row; and no
row is emitted until every operand has a value, so no kernel sees a
hole. The stateful kernels' carry — a filter's running output, an
integrator's accumulator, a trailing window's running sums — lives
beside the series and survives rounds, which a test pins by building a
series across four serves of a growing capture and comparing it with
one built in a single pass.

Identity and caching follow ADR 0047 and ADR 0054 compositionally. The
series key's provenance flag set grows a fourth member, `s|x|f|m`,
mirrored byte for byte host and frontend; a math series has no bus and
no message, so its name slot carries the definition's stable id. The
fingerprint covers the function, its parameters, the resolved operand
list in order, and each operand's own fingerprint — so a DBC edit under
an operand, a signal joining a pattern-defined set, or a redefinition
three levels down all reach it, and the dependent pyramid is retired
exactly as a decoded one whose definition moved. What it deliberately
does not cover is the display name and the unit, so a rename is free.
Completeness recurses the same way: a serve over a math series whose
operand is still decoding reports the partial answer ADR 0049 asks for
rather than a finished-looking curve over half a capture.

One thing to look at closely: **a math pyramid is neither persisted nor
parked**, which is the opposite of what every other provenance does.
Both mechanisms reopen a pyramid from its level files alone, and a math
series reopened that way would have no fill state — no operand cursors,
no held values, no kernel carry — so it could never be extended again,
while reporting itself never-complete forever. Persisting all of that
beside the samples would be a second serialisation format for something
cheap to recompute: rebuilding a math series reads samples that are
already decoded, a fraction of the decode a persisted pyramid exists to
avoid. So `persist` skips them and a fingerprint change drops rather
than parks them. This was found by a failing test, not by reasoning
ahead — the note in the task file has the walk.

Definitions live in a central host-side registry and persist in the
project file by stable id, so every surface that will show a math
signal in phase 2 edits the same model. A set's membership is manual
picks union live regex matches over the canonical path (ADR 0020,
ADR 0038): a new matching signal joins on its own and reflows the
fingerprint. Math-on-math is allowed; cycles are refused when a
definition is written, so no serve can meet one.

A pattern matches the bus *name*, so the project's bus list is standing
host state rather than something a caller brings: installed from the
file the open path parsed, cleared when the project closes, and
refreshed by the push the frontend already makes on every bus add /
rename / remove — the one the RBS bus keys resolve against. A project
whose sets are all pattern-defined therefore resolves them on the first
serve after an open, instead of waiting for whichever call happened to
arrive with the names attached.

A write to the registry announces **twice**, because it moves two
things. The listing moves — every surface that lists a definition
re-reads it — and so does what the capture decodes to: a math series is
a decode provenance, so redefining one changes the decoded model and is
announced as one (ADR 0053 §2), which is the only carrier the
frontend's re-anchor epoch has. Announcing only the listing would leave
a plotted series drawing the numbers it was computed with before the
edit — the host recomputes correctly and nothing ever asks it again.
Tauri's `Emitter` is sealed, so the pair goes through a small in-crate
sink trait: that is the seam a test has on this rule, and a rule with
no seam is one that silently loses a half.

The kernels run over contiguous aligned `f64` columns on the shared
timeline, which is the shape that auto-vectorizes. Whether an explicit
SIMD crate pays was settled by benchmark rather than assumed: `wide`
came within a few percent of the scalar loops on the pointwise
reductions and lost to them at eight and thirty-two operands, winning
only on the single-column scaling kernel that is already the cheapest
in the set. At these sizes the loops are memory-bandwidth bound, so
arithmetic width is not what decides them. Rejected, with the numbers,
in `technology-inventory.md`.

No GUI: this phase stops at the command surface phase 2 calls. The
frontend change is the mirrored flag and its types, nothing else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#473)

The Database panel gains a third root beside the DBC and file-backed
branches: **Computed**, listing every math signal the registry holds.
Right-clicking the tree offers the sixteen functions; picking one
writes the definition immediately and expands its row, which *is* its
editor — there is no read-only detail stage and no dialog anywhere.

The editor is one component, mounted here and (next) on a signal
panel row and a plot's side list. Its operand sections are the Signals
panel's section idiom with the affordances a fixed section suppresses:
which sections exist is the function's arity — A and B for a pair,
Signal for one, Signals for a set — so there is nothing to add or
remove, each says its own validity, and a section is filled by
dragging a signal in, by a single-slot fzf combobox over a trimmed
catalog, or (a set) by the same SignalPatternEditor a signal view's
section uses. Sections and fields stack full width, because the
narrowest surface this has to fit is a plot's side list.

**A fixed section's operand is a slot**, and the stored form has room
for an empty one: a definition's picks are one entry per slot, `null`
where it stands empty, so a signal dropped on B while A is empty is
B's operand and stays B's. Only a *set* closes up as it is edited,
because its picks are a membership and their order is only the order
the user built it in. A pair whose A is empty holds one operand, not
two, so it fails the arity it always failed and serves nothing until
it is finished. Trailing empties are not stored, so a definition whose
slots fill from the front writes exactly the dense list it always
wrote and a project file that predates the hole reads back unchanged.

**Every field commits as it is left.** There is no Save: text applies
on blur or Enter and abandons on Escape (the shared ValidatedInput,
ADR 0027), a combobox on the pick, an operand on the drop or the ✕.
Each commit is one host write and one undo step on the existing
panel-edit stack, so Mod+Z reverses a math edit exactly as it reverses
a decoder pick. A write carries the definition and nothing else — the
project's bus list is standing host state, so a name-anchored pattern
resolves without the caller saying what the buses are called, and the
undo step carries only what it puts back.

Deletion is the tree's job, not the editor's, and it is **one click**
on a quiet trash pinned to the end of the definition's name. Both
halves of that are owner rulings that reverse earlier ones, and both
follow from the same fact: the delete records an undo step carrying
the whole definition back, so it is not the irreversible removal the
app's two-stage control exists for (the two-stage remove is for what
undo cannot reach — ADR 0058). A far-end placement was tried first and
read as low quality on the bench: it moves as the panel is resized, so
what sits under the cursor changes with the width. Riding the name
keeps it still, and the disclosure still leads the row, so expanding a
definition and deleting it are not neighbouring targets. The undo path
is what the DOM test now pins — the recorded step is replayed and the
row comes back — because that, not a confirmation prompt, is the
safety story.

The consequence is host-side, and it is the part worth reviewing: the
registry now **stores unfinished definitions** rather than refusing
them. A math signal exists from the moment its function is picked, so
`define`/`update` no longer validate; `MathDefinition::validate`
became the listing's `invalid` reason (gaining `Unnamed` — with no
Save to press, "the name is required" can only mean "invalid until
named"), the kernels already answered an unmet arity empty, and an
operand naming no definition stopped being an error, since it would
otherwise make a dependent of a deleted definition uneditable. Only a
duplicate id and a cycle are still refused. An empty slot is the same
kind of state and reads the same way: `MathOperands::filled()` is the
one path from the stored picks to operands, so validation, resolution,
the cycle walk and the one derived name all skip it rather than each
deciding for itself what a hole means.

Also fixes a phase-1 wire defect: `MathSignalRecord` serialised the
resolved operand list under the same key as the flattened definition's
own `operands`, so a JSON reader kept only the last and no editor
could ever prefill a stored pattern. It is `resolvedOperands` now,
pinned by a test, alongside one pinning the snake_case parameter names
the tagged function variants carry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A math signal was created and edited under the Database panel's
Computed branch and went no further: it could not be dragged, no plot
or signal view could show one, and a save carried none. This finishes
the surface.

The drag payload gains a math provenance slot, so a Computed row drags
like any other signal — carrying the definition's stable id, never its
display name. The plot's side list and a signal view's rows both grow a
math row: the app's standard disclosure, one bus color chip per bus
feeding the series (transitively, through any math operands, resolved
host-side), and "Math - Multiple Busses" when several do. In a signal
view those chips sit in the **message** cell: that is the table's
provenance cell — the one that speaks when bus / ECU / message cannot,
which is why the file-backed badge is already there — and it is the
direct analogue of the side list's message line, which renders the same
chips before the same label. The bus cell names the buses feeding the
row, rather than repeating that label. Expanding a row opens the same
in-place editor phase 2 built, on whichever surface invoked it.

Every drop target that can now receive that payload forwards the flag:
the plot's side list, a signal view's wire selection, and — the one this
branch first missed — the math editor's own operand sections, which
narrow a dragged ref to a `MathOperandRef` field by field. Math-on-math
is documented and already worked through the operand combobox, so a
dropped math signal that arrived with the flag stripped named a DBC
identity nothing decodes and read back as "operand missing". A DOM test
drops a `math: true` payload on a `Signal` section and pins the
committed pick.

The Database panel's own value column carries computed signals as well,
so a definition shows what it currently evaluates to right where it is
edited. A math row keys like every other value row — ADR 0052's rule
one provenance further: no bus, message id 0, the definition's stable
id in the signal slot under a `math` flag — so the panel's existing
paged `fetch_signal_page` serves a computed value beside a decoded one,
for the viewport's rows only. The listing those rows read moved out of
the panel into a shared `mathSignalsContext`, on one set of host
events, which is what keeps this panel, a plot's side list and a signal
view from answering differently about the same definition. It carries
no bus-name map — the project's bus list is standing host state,
installed on open and refreshed on every bus add / rename / remove —
but the project's buses stay a refetch *dependency* of it, because a
rename moves what a name-anchored pattern selects and that sync emits
no listing event.

That column also made the row's layout matter. `.dbc-row` is a flex row
whose items shrank in proportion to their content, so a long comment
kept a large share of the width while the name ellipsized and the value
cell lost its tail — which is where the unit sits, and a value without
its unit is the one thing on the row that misleads. The comment yields
first now (a shrink factor that swamps the others', down to a `6ch`
stub that still says a comment is there); the name and the value hold
their content until it is spent. It is flex geometry, so jsdom cannot
assert it: both rules carry the constraint they encode as a comment,
and the check is an eyeball on a narrow panel.

Three things had to be built under that.

The `math` flag was dropped from the plot's `sample_signals` and
`signal_min_max` queries, so a math series dropped on a plot would have
been keyed as a DBC identity nothing decodes and served empty. Both
queries now carry it, and the panel test asserts the points reach the
canvas rather than only the request.

A signal view's math rows are host rows: `select_math` is
`select_file_backed`'s shape one provenance further — only a manual key
selects one, since a math series has no canonical path for a pattern to
match. Their values come through `math_latest`, which runs
`slice_many`'s prologue: a math pyramid is session-scoped, so a row has
to drive the fill rather than read what happens to be there. It answers
a `MathLatest` — newest sample, sample count and the series' **rate**,
so the `msg/s` column reads a computed row's cadence instead of
standing blank. That cadence is the operands', but the fold lays every
computed sample on the union of their timelines, which makes it a fact
about the filled series and puts it where the cache already measures
one. A capture-constant reports none: an `hline` or a `statistic` is
two points spanning the capture, not a series sampled at `2/span` Hz.
Expansion made the row pitch variable, using the `VariableRowHeights`
the shared viewport scaffold already offers the by-id table.

Math series stay out of the **View signals** panel. That panel is a
database-mapping repair surface — every status it reports is a claim
about what a DBC now says — and no database ever bore on a math series:
pushed, one appeared there under its definition's stable id, flagged
over a unit only its own editor can set. The two builders that turn a
view's configuration into pushed references (`viewSignalsPush.ts`) drop
math-provenance picks. The frontend can decide that on its own, because
provenance rides on every persisted pick, so the wire shape needs no
math flag; and a pattern can never reach one either, since the catalog
those views match against holds no math signals.

`hline` and `statistic` each draw as one solid horizontal line, and the
model owes them two separate things to get there.

*What the series holds.* A capture reaches a serve in pieces — a live
bus grows it, a stopped one decodes under a budget — so a constant's
fill runs several rounds, and a statistic's answer over what has decoded
moves with every one of them. Appending each round's answer left the
earlier, partial ones beneath the line, so a statistic drew as the
rising staircase that produced it rather than as the line it names. A
round now returns the *whole* line at the value it holds and replaces
the series with it (`SignalCache::rewrite`), which also resets the
widen-only extent the plot auto-normalises against: a stale floor there
scales the axis to a value the series no longer holds. It costs two
pushes, a constant being two points, and it keeps a live `hline` at two
points rather than one per serve as well. Two tests hold that line down
from both directions a capture can arrive in: a definition that existed
before the first frame, and one defined part-way through a capture whose
operand pyramid several serves had already built, after which the
capture keeps growing — a flat line at every serve, at a value that
moves, with the extent tracking it.

*How it is styled.* Two points spanning the capture is the shape ADR
0026's first rule dashes on both wings, as a hold past their own data —
but a value the user authored is the data, not the plot inferring. The
suppression is in the model, at the one place that classifies, so the
renderer still styles what it is told. ADR 0026 records the exception
and its scope: a decoded one-sample series is still dashed.

Export: MDF carries each math series as an already-decoded channel under
a Computed acquisition group, on the file-backed precedent. The
definition is not written — there is nowhere in the format to say "the
median of these six signals" that a reader would understand, and the
project file keeps it. BLF carries none and now names the computed
signals it drops beside the file-backed ones.

The ADR 0031 harness grows its math case behind `--math-on-start`: a set
function over a live pattern, an exponential filter, a capture-wide
statistic, and one math-over-math chain, defined over whatever project
the launch opened. A flag rather than a project of its own because
growing `examples/ev-zonal` would invalidate every reading taken against
it. Defining costs nothing on its own — a math pyramid is built by a
serve — so the flag also puts the series into the open plot areas and
signal views, which is what a person measuring would do by dragging them
there; nothing reaches the project file.

Reviewers should look at `select_math` and `math_latest` (a signal
view's poll now drives a fill, and answers the row's statistics with
it), at `MathFill::constant` — which is what the rewrite, the styling
and the absent rate all turn on — and the ADR amendment beside it, and
at `withPerfMathCase`, which writes into live element configs at
launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A math signal's unit was a label. It becomes the conversion **target**:
at resolve time each member's own unit — the string its database
carries, or a per-operand override — is read through a new host-side
`units` facade and converted to it. So a pattern-defined set that
matches milliamps beside amps now computes in amps with a correct
factor *per member*, and a definition stores intent rather than
numbers: editing what a unit string means rescales every channel that
depends on it, without touching any definition.

Unset, or set to something the facade does not recognise, nothing
converts — which is exactly what every definition written before this
did, so no project changes behaviour on load.

Beside the unit path sits the unit-free one, for what a database does
not describe cleanly: a manual `(gain, offset)` on each operand
(applied *ahead* of the conversion, so it corrects the raw value into
the unit the operand claims to be in) and on the definition's output
(applied after the function). A member that cannot be converted passes
through unscaled and is **reported** in the resolved model —
`ResolvedMath::unconverted`, which the Phase-2 editor will flag —
rather than converted wrongly.

Convertibility accounts for the **function's time dimension**. An
integration multiplies by seconds, so a current operand asked for
amp-hours is not the mismatch that comparing two dimensions pointwise
makes it: integrating amps already produces coulombs, and a coulomb is
an amp-hour ÷3600. Resolve therefore tries the operand's own family
first — `mA` on an amp operand still converts the operand and
integrates in milliamps — and only then its *integral*, where the
operand converts just as far as its family's canonical rate and the
**output** affine carries the canonical integral to the target. A
target in neither dimension is the dead end it always was, badged as
before. With no target at all, the derived unit names the integral
where that is true of the samples: an `A` operand integrates into
`coulomb`, an `mA` one keeps `mA·s`, because nothing converts and
millicoulombs are not a unit this table carries.

Where a reviewer should look:

- `units.rs` is the whole `runtime_units` surface: `Affine`,
  `Dimension`, the curated unit table, `convert`, `integral_of` and
  `recognize` (customization dict, then built-in spellings exactly,
  then case-insensitively where exactly one matches — so `mV` and `MV`
  cannot collapse into each other). Three policies are ours rather than
  the library's, and the technology inventory now records why: the
  library ships **no offsets at all** and does not build its
  absolute-temperature quantity, so the °C/°F constants are here; its
  base-dimension equality calls `rpm` convertible to `Hz` and `N·m` to
  `J`, so conversion is offered only within the facade's own dimension
  grouping (a test pins that the grouping is never looser than the
  library's); and it exposes no dimensional algebra, so the two
  rate↔integral pairings behind `integral_of` — current × t = charge,
  canonical coulomb; power × t = energy, canonical joule — are the
  facade's own. The ratio dimension carries a bare 0–1 scalar beside
  the percentage: unit id `ratio`, **shown as `ratio`** — which is
  also how a DBC spells it, so the string this scale renders is one
  `recognize` carries straight back to it — and converting to and from
  `%` and `ppm` by the plain power of ten. That round trip is
  load-bearing rather than cosmetic: every surface that reports a
  signal's unit hands on a *string*, and resolve reads that string
  back, so a scale whose display nothing placed would derive nothing,
  convert no member, and serve the same number whichever target was
  asked for. Two facade tests pin it — the powers of ten, and that
  each of the three scales reads as a string recognition returns to —
  beside an end-to-end resolve test: a set collected on the bare scale
  serves ×100 for a `%` target and ×10⁶ for `ppm`.
- **Which of those scales a `%` signal is on is settled by the
  spelling**, and by nothing else. A DBC's unit field is free text and
  the number's observed range is data, not a declaration, so the
  string is the only thing that can say: `%` recognises as the 0–100
  `percent`, and `%1.0` — the range written into the spelling, a
  convention already in the wild — recognises as the bare 0–1 `ratio`.
  Both are rows of the built-in recognition table, which makes them
  *defaults*: `recognize` reads the customization dict first, so a
  project whose databases mean the other thing by either remaps it
  like any other spelling, and the settings-view units table lists
  them on their units' rows because it derives every row's spellings
  from that table through `recognize` itself rather than enumerating a
  second list. Range inference and a `BA_` attribute were both
  considered and dropped; the DBC unit string stays the single
  facility.
- `MathOperand` wraps `MathOperandRef` with `serde(flatten)`. The ref
  stays `Eq + Hash` — it is the key membership is deduped and
  cycle-walked on — so the `f64`s ride the wrapper, and an old
  project file's picks deserialize unchanged.
- `signal_fingerprint::math_encoding` now mixes every operand's
  effective `(gain, offset)` and the output pair, so a changed scalar
  or customization parks the pyramid computed under the old numbers
  (ADR 0047). That is also what makes the integrated conversion safe to
  carry on the output affine: switching an integration's target from
  coulombs to amp-hours recomputes rather than relabels. Two tests
  asserted the contract this changes — a unit edit used to be free —
  and were narrowed to an *unrecognised* unit string, which still is.
- The kernels gained one pass, and only one. `math_kernels::scale`
  returns on `Affine::is_identity()` before touching the slice, so a
  definition that scales nothing pays one comparison per column per
  block; the integrated conversion needed no kernel change at all,
  because the output affine was already applied post-function to every
  provenance.

The customization dict is `Settings::unit_customizations`, the first
`Scope::Workspace` key: it interprets *this project's* databases, so it
lives in the project's `.cannet/settings.json` and travels with it
(ADR 0042 §3). One consequence worth a look: a project's settings file
now always carries that key, empty or not, because a workspace-scoped
key's home *is* that file. The test that asserted the older, stronger
"a project overriding nothing is never written" invariant is rewritten
to the real rule, which still forbids promoting any user or
user-overridable key into a project's file.

Scope note: the settings-view units section and the editor controls are
Phase 2. A `Backing::Field` setting must have a descriptor, and a
descriptor naming an unregistered renderer shows a visible error, so
this ships the descriptor plus a minimal `UnitCustomizations` renderer
that lists the project's rows and removes one.

A customization edit reaches the plots, and only a customization edit
does. The dict takes part in every operand's conversion, so moving one
moves each dependent series' fingerprint and ADR 0047 retires the
pyramids computed under the old numbers — but a host rebuild nobody is
told about is invisible (ADR 0053 §2), so the write also announces the
decode-model change, exactly as the definition CRUD does. The
announcement is gated on the *inputs*, not on the write:
`set_settings` runs on every preference, and announcing on each would
re-anchor every plot and every windowed view on a theme change. So
`unit_inputs()` is read before the write and compared after, and
`apply_unit_change` takes both sides — which is also what makes the
rule testable in both directions without touching the process-global
settings cache. `unit_inputs` is the list to extend when a new input
to a conversion factor appears; leaving one out is the same silent
failure in a new dress. It replaces `math_model_needs_rebuilding`,
whose job it now does through the one write path.

Also fixed, inherited from f51f5f6 and red under
`cargo clippy --workspace --all-targets -- -D warnings`:
`format_push_string`, `similar_names` and `cast_possible_truncation` in
`signal_cache`'s math test fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nit string means (#477)

Phase 1 gave a math signal a conversion target and a set of unit-free
scalars, and no way to type any of them. This is the editing surface for
both, plus the settings section that decides what a database's unit
*string* means for this project.

In the editor, each **picked** operand now carries a line of unit-free
corrections under its row — a manual gain and offset, and a source-unit
override that says what unit those corrected samples are in. The
definition takes an output gain and offset under its unit, and the unit
itself becomes a picker over the host's library, grouped by what each
unit measures, that still accepts anything typed into it (a DBC unit the
library does not carry is exactly the case the manual scalars exist for,
so refusing it would refuse the workaround). A member the host could not
convert wears a quiet ≠ with the reason on hover, which is what makes
"passes through unscaled" visible rather than merely true.

A member a *pattern* collected gets no scaling row: it is not a stored
operand, so there is nowhere to write one, and the host takes it with
the conversion alone.

In Settings → DBC, `unit-customizations` grows from a list-and-remove
into the section the grooming ruled: type the spelling as the database
writes it, pick what it means from the library, Add. No dialog; the row
commits the whole dict through the settings write like every other
setting, and the dict still travels with the project.

Two things worth a reviewer's attention.

**One host command, and the policy it forced.** `units::list_units`
serves a `UnitListing` — a `UnitInfo` plus its dimension's picker label
and a `spelling`. The spelling exists because a target-unit picker has
to commit a unit *string*, and `recognize` is what reads that string
back. Probing every unit's display through `recognize` found three that
do not round-trip: `coulomb` ("C"), `newton-millimeter` ("Nmm") and
`scalar`. Offering those would have committed a unit that silently
converts nothing. Guessing "C" into the recognition table was the wrong
fix — it is the Celsius spelling too, and this module's rule is that
nothing is guessed — so `recognize` now also matches a unit's **own id**
exactly, and a listing's spelling is the display where that recognises
back and the id otherwise. The id pass sits after the exact-spelling
pass and before the case-insensitive one, so no existing recognition
changes meaning; "C" is still nothing.
`every_listed_spelling_recognises_back_to_its_unit` pins the contract
the picker depends on. Two names, deliberately: the target unit commits
a spelling (it becomes the series' label), while a source-unit override
and a customization commit an **id** (the host resolves it with
`units::get`). An option whose two differ says so — `C (coulomb)`.

**The unconverted flag is keyed by reference, not by index.**
`ResolvedMath::unconverted` indexes the host's resolution order; a
section renders its picks plus its own frontend pattern resolution,
whose order need not be that one. The editor turns the indices into a
set of reference keys and each row asks the set, so a pattern-collected
member is flagged correctly too.

Also fixed, with the test first: `definitionOf` — the stored half of a
listing record, which every editor commit is built from — did not carry
`outputGain`/`outputOffset`. Left alone it would have silently dropped a
definition's output scalars on the next unrelated edit, from the moment
this commit let a user set them.

`unitLibrary.ts` is the frontend mirror, shaped like `hostSettings`:
hydrated once before first render because the list never changes while
the app runs, and read synchronously by the three pickers that offer it.
No unit table lives in the frontend — which units exist, how they group
and how each is spelled are all the host's answers.

Three test fixtures (`DatabasePanel.math.dom`, `PlotPanel.dom`) built
listing records without the `operandAffines`/`unconverted` the host
always sends; they were completed rather than the component made
defensive against a shape the host cannot produce.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A DBC unit is free text, so a project can carry spellings the unit
facade has no recognition for — and until now the only place that
showed was a math channel quietly passing an operand through
unscaled. The signal-mapping panel is where signals and their
databases are already reconciled, so it is where the strings that
need a customization belong.

`ViewSignalRow` grows one boolean, `unit_unrecognized`, computed
host-side in `view_signals::row` from the string the row actually
renders (the serving database's unit, or the view's own record when
nothing decodes) through `units::recognize` with the project's
customization dict. The frontend does not re-implement the
recognition; it renders the answer. A blank unit is never flagged,
and that is decided on the host: `recognize`'s first pass is the
customization map, so a dict keyed on the empty string would
otherwise place one — the `!unit.trim().is_empty()` guard sits in
front of the call.

The panel shows it two ways. Each flagged row wears a `≠` beside
its signal name, naming the string and pointing at Settings → Units,
in the same register as the math editor's unconverted-operand flag;
and the toolbar grows a fourth filter, an `Unknown unit (N)` chip
wearing the same glyph. Fourth filter rather than sixth status: an
unplaceable unit is orthogonal to whether the signal decodes — a
Decoded row can have one — so it ANDs with the status and bus
selections instead of joining them, and it is a plain boolean rather
than a selection set because a unit either needs a customization or
does not. The badge sits in the signal cell rather than a new
column, which would have changed every persisted layout for a flag
blank on almost every row.

Keeping the flag current needed a new refresh trigger. The panel
refetches on `view-signals-changed` and on the DBC generation; a
customization edit is neither — it is a settings write, announced
only through the frontend settings cache's own subscribers. So the
panel reads `useSetting("unit_customizations")` purely to put it in
its fetch's dependency list; the host re-reads the dict from
`settings::effective()` on each call, and that cache is what
`set_settings` refreshes. The DOM test drives the whole path: a
mounted panel, a customization written from outside it, badge gone
with no remount.

Worth a look: `row` hit clippy's argument and line limits on the way
through, so the two project-wide inputs every row reads identically
(`bus_names` and the customization dict) became a `ProjectFacts`
struct built once per build instead of per row, and the flag
predicate moved into its own function. No behaviour rides on either
move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
)

Unit identity becomes typed base x SI prefix, and compositions of those
are how the host answers what a function produces. The `units` facade
takes the crate's enumerated prefixed variant where it has one and
composes base x 10^n where it does not, so `nAh` — which the crate stops
short of tabulating — is reached at all; a cross-check test asks the
crate for every prefixed variant it does enumerate and asserts the
composed factor is the same number, refusing to pass on fewer than a
hundred pairs.

Phase 1 recorded "no dimensional algebra" as a library gap and that was
wrong: `UnitDefinition` implements `Mul` and `Div`. `units::Composed` is
built on it, and the hand-written rate/integral pairing table is gone —
composition subsumes it and reaches further, since nothing had to name
the millicoulomb for `mA · s` to be one. The real gap is the way back,
so `Composed::named` searches the facade's own base x prefix space for
the entry whose definition matches. The technology inventory is
corrected here, with a second gap recorded.

Every math function now derives an output unit: duty and frequency state
theirs, a product composes its operands', integration and derivative
compose with a new time-unit field on the function (default seconds; an
existing definition deserialises unchanged), and the pointwise functions
inherit the first operand's unit — converting the others to it, so a set
of `mA` beside `A` computes in one unit with no target set.

The inheritance reads only the members the project can place. An
operand with a blank or unrecognised unit is silence, not a dimension
disagreement, so it is skipped rather than vetoing the set: a wide
pattern match that sweeps in one blank-unit signal still derives the
volts its placed members agree on, wherever that member falls in the
order. Without this a pattern-fed `range` served no unit at all, which
left the editor's "(from the operands)" option with nothing behind it
and no way to clear an override. An additive set that genuinely mixes
dimensions — `V` beside `A`, both placed — still derives nothing and
badges its members, and the unplaced member stays badged as unplaced.

Resolve tries the operands' own family first, so an integration asked
for its operand's family keeps pointwise semantics, and only where no
operand reached the target does the function's own composition carry the
conversion on the output. That one rule replaced the integration special
case and serves the derivative and the product too.

Derivative joins the engine: plain consecutive-sample slope, no
smoothing and no window, its slope held in the carry so a paged serve
and a whole one agree. Its unit is `operand / [t]`, and the division can
land in a named family — an `Ah` counter over `d/ds` is `Ah/s`, which is
a current, so asking for `A` is the x3600 the analysis produces.

Two stores land beside that. Per-signal unit reinterpretation
(`signal_units`) is what View signals writes when a database's label is
simply wrong: any unit, kinds may cross, and no scaling — correcting a
label does not move a number. It persists with the project and has one
application point, which the picker catalog, the signal rows, the
View-signals chip and the math catalog all read. And unit-string
mappings gain a user scope beside the workspace one, joined into a
single dict where the project wins.

A unit is handed between those surfaces **as a unit**, never as the
string it reads as. `units::UnitReading` is that hand-off — the `UnitId`
and the spelling it shows, together — and a string becomes one at
exactly two ingest points: a database's own unit field, and the user's
customization dict, both through `UnitReading::declared`. Everything
else passes the typed form through. The distinction is not academic:
`coulomb` reads `C`, which recognition refuses on purpose because a DBC
that writes `C` means Celsius about as often, so a hand-off through the
spelling loses precisely the units a user had to pick deliberately. The
math catalog carries readings, `derived_unit` and `effective_affines`
take units the caller already placed, and `recognition_of` reports a
reading instead of parsing one; View signals' unplaceable flag is the
reading's too, so it judges a database's wording and never a unit the
user chose. The one string left in the model is a `UnitTarget::Spelled`
out of a project file written before the typed form, recognised once at
resolve — which is ingest.

One consequence is worth calling out: the customization dict is read
where the catalog is *built* rather than inside `MathModel::resolve`,
which is the same boundary a DBC string already crossed there.
`set_settings` drops the math-model cache, so editing a mapping still
rebuilds every entry and rescales every dependent channel.

The owner's nAh report is explained. A test builds the panel's rows for
one unplaceable unit string in four reference shapes: a manual pick
flags, a pattern match flags, a busless reference has no unit to flag,
and a file-backed reference produced no row at all — `build_rows` had
skipped those. The justification for the skip stopped being true when
the panel grew a resolved-unit chip and a reinterpretation store, so an
imported channel is a row now, reading Decoded with nothing to repair
and so out of the attention count, with its unit flagged and
reinterpretable like any other.

Reviewers should look at `units::Composed` (named-versus-spelled: `A · s`
is a coulomb but reads `A·s`, since a contraction is used only where the
name spells what the factors spell), at `COMPOSITION_ORDER` (two
families can share a base dimension), at `units::UnitReading` (which
hand-offs are ingest and which are pass-through), and at the seam Phase
6 removes — `MathDefinition::unit` is an untagged `UnitTarget`, and the
editor still commits spellings the host recognises into the typed
form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A unit's identity is a base unit times an SI prefix, so choosing one is
two columns and not a flat list of pre-composed spellings. This replaces
the math editor's combobox with that picker and gives the same control
to the two other surfaces that name a unit: the signal-mapping panel's
resolved-unit chip and the plot side list's readout.

What tells those three apart is one argument. Everywhere a unit is
*applied* — a math definition's conversion target, a plot series'
display unit — the picker is kind-locked, because choosing there is a
real conversion; in the signal-mapping panel it is not, because
reassigning a unit there is reinterpretation: the database's label was
wrong about what the signal measures, kinds may cross, and no scaling is
applied. Nothing about which units exist, how they group, how a
(base, prefix) pair is spelled, what converts to what or by how much is
decided in the frontend: `units::list_unit_picker` serves the picker
model (composed spellings included), `resolve_display_units` answers the
plot's whole per-series question, and `list_unit_mappings` places every
DBC string through the one recognition path.

Converting a plot series takes effect where its values are *drawn*, not
only where its unit is named: the affine the host answers carries the
drawn values, the side list's readout and the range the axis scales to.
It changes nothing about what is fetched — the host serves the same raw
values whatever unit they are read in — so a pick repaints the window
already in hand instead of refetching, the path a `hidden` toggle and a
manual y bound already take, and the only thing that redraws a
*stopped* panel at all: its window never moves, so without it the label
moved alone. It is also what carries a restored panel's saved choice
into the first window it drew, which was fetched before the host
answered what that choice converts by. The host's all-time extent is
cached as the host served it and carried into the drawn unit where the
range is read, so a repaint can put that one answer through whatever
factor is now in force.

Time is the function's parameter for integration and derivative, so
their function row now carries the choice — `operand · [t]`,
`d(operand) / d[t]` — over the library's time units, and Derivative
joins the creation menu. Changing the time unit re-derives, which clears
a target named against the old composition. The unit button reads the
unit it resolves to and nothing else — where the derivation came from
and what it converts by (`A·h → C (×3600)`) are its hover text, because
a control that spells one answer three ways over is not explaining it.
There is no reset affordance either: clearing an override is picking
the derivation again, and the picker always carries a row that does
it — the composition itself where the table names a unit for it, a
synthesised rung where it names none, and a plain "from the operands"
row for a set whose mixed members compose nothing at all. Resolve grew
the three facts that needs: the derivation spelled as its factors, the
unit that derivation *is* where the table names one, and the conversion
to a named target.

The library is the only way to name a target unit: there is no box for
a spelling of your own. One an older project file stored still loads,
still resolves and still displays — picking from the picker replaces
it — but nothing new is typed, since a name nothing converts through
looked exactly like a unit that did. What a pattern collects folds the
same way, into one row per pattern reading the pattern and its count
(`Cell.* (24 matches)`) that opens to the members on demand: a set over
a pack is the pattern the user wrote, not two dozen rows under it.
Picks stay listed — each of those was chosen by hand.

The plot's per-unit lanes converge through the readout chip. mV is a
different unit than V, so per-unit collection stays honest by default;
converting a series merges it into the target unit's lane, and the
values drawn and the axis's all-time extent both go through the host's
affine. The lane grouping takes a unit resolver rather than a rewritten
`SignalRef`, because those refs are what a hidden or recolored
pattern row materialises from and a display spelling written into one
would persist as the recorded unit.

The chip is handed the unit it is converting, never the string that
unit reads as. A series the project reinterpreted spells whatever its
new unit spells, and two of those — `C` for a coulomb, `Nmm` for a
newton-millimeter — are spellings recognition refuses on purpose, so a
query carrying only the spelling had no source, no kind and no picker,
and the series converted nothing. So the query carries `source`: the
unit the model already placed, which `resolve_display_units` uses
outright and only falls back to reading a string when a caller has
none. The catalog and the math listing each report the typed half of
the unit they already spelled, and the panel keys them by `signalKey`
to answer per series — so nothing in the frontend reads a unit out of a
spelling, and reinterpreting a signal re-asks without the stored
`SignalRef` moving.

Settings → Units is rebuilt as the ruling asks: one row per unit — the
library's whole base-unit set plus anything a config names — carrying
the strings that read as it, with project and user checkboxes on every
row. That makes the table the editor of both scopes, so the user-scope
key is now `EDITED_ELSEWHERE` rather than a second row over the same
fact; promoting copies rather than moves, and the host's join (project
over user) settles a string both scopes hold.

The table re-asks the host when a write is *accepted*, not when one is
made. `list_unit_mappings` answers out of the host's settings cache, and
a commit reaches the section as a new value the instant it is made —
optimistically, an `updateSettings` read-modify-write round trip before
the host has it — so keying the fetch on that value asked a question the
host could not yet answer and, the value having already settled to what
the section held, never asked again: a spelling added or deleted showed
up only after the panel was reopened. The fetch is keyed on the settings
store's own publish instead, which fires when a write is accepted and
again on a re-hydrate after a hand-edit.

Every list of units comes out in one order, and it is not the order this
module's table is written in. `UNITS` is grouped by hand for reading,
and inheriting that put the settings table's thirty base rows in curated
order followed by a tail of twenty-one prefixed rows in the ASCII order
of whichever spelling reached them first — `mV` thirty rows below `V`,
and the picker's base column and the source-unit list each carrying
their own slice of the same arbitrary sequence. `units::list_order` now
sorts every surface that lists units: dimension label alphabetically,
then the base unit's display, then up the prefix ladder. Voltage reads
mV, V, kV, MV; charge reads mAh, Ah, C; and a unit is looked for in the
same place wherever it is offered.

The signal-mapping panel's unit column sorts like its neighbours. What
the chip reads is a value the host already carries on the row, so
`sort_rows` takes a `unit` key — case-folded, so mV and MV sit beside V
instead of on either side of it — and a row declaring no unit groups at
the end whichever way the sort runs, the rule the database column
already follows for a row nothing decodes.

Reviewers: the two indexing rules are the sharp edges. `recognition` and
`unconverted` are index-parallel with the *host's* resolution order, so
the operand chip keys on `operandRefKey` and its test reverses the
fixture's order to prove it; and `displayUnitsKey` is sorted, because an
order-dependent memo key re-asked the host on every reorder and
re-rendered the whole plot stack (a render-count test caught it). The
key now covers the placed unit as well, for the same reason the query
does.

`MathOperand::source_unit` is deliberately left naming a library unit
by its stable id: it is resolved by an exact lookup and never through
recognition, which is the same mechanism the customization dict's
values use. Its one limit is that an id cannot name a composition the
table has no entry for (`nAh`).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…panel (#483)

Task 140 phase 1: the project panel's Elements, Logical-buses and DBC
rows trade text buttons for icons, and the RBS/Logger rows gain a
play/stop toggle with no state label.

- Icon.tsx registry gains `enter` (Focus/Open — copied verbatim from
  the accepted design prototype's candidate-gallery pick, an arrow
  into a doorway) and `refresh` (Discover — a circular arrow drawn
  fresh in the registry's idiom, distinct from `loop`'s bidirectional
  cycle). Pinned by Icon.dom.test.tsx.
- New IconButton.tsx: the quiet single-click icon button —
  TwoStageRemoveButton's look without the arm step — for a
  registry-undoable remove, "go to this element", a refresh, and the
  run toggle.
- ElementRow: Remove is now a single-click trash IconButton. Element
  removal already rides the registry's undo (App.tsx's removeElement
  arms pendingElementEditRef unconditionally on any kind), so it needs
  no confirm step, unlike the bus/DBC rows below. Focus/Open collapse
  into one `enter` icon button — "take me to it," whichever state
  applies.
- A logger or RBS row also gets a play/stop RunToggle, reading and
  writing exactly the state each element's own panel already reads
  and writes (owner ruling: the toggle changes no behavior). The
  logger toggle flips `element.enabled` — the same field
  LoggerPanel.tsx's checkbox writes — tinted by whether anything is
  connected (the same test LoggerPanel.tsx itself uses for its own
  message). The RBS toggle (RbsRunToggle) calls the existing
  `rbs_view` / `rbs_set_run` commands — the same ones the RBS panel's
  Run chip already uses — and tints from that element's own buses'
  `connected` flags, not a project-wide one, so a config scoped to an
  idle bus reads armed while some other bus stays live. No new host
  state, no new host commands.
- Bus and DBC row Remove become TwoStageRemoveButton (two-stage
  confirm, trash glyph). Checked first: neither handleRemoveBus nor
  handleRemoveDbc touches pendingElementEditRef, and ADR 0050's
  allowlist puts the DBC set and connection config outside undo
  outright — both stay non-undoable, so per the phase ruling they keep
  the confirm step rather than going single-click.
- ConnectionManagement.tsx: both "Discover" text buttons
  (LocalInterfacesRow, ServerSection) become `refresh` icon buttons.
  ServerSection kept its existing `discover interfaces on <address>`
  aria-label, so ServerSections.dom.test.tsx needed no change.
- App.elementUndo.dom.test.tsx's buttonIn helper now also matches a
  button's aria-label, since Remove/Focus/Open carry no visible text
  anymore. ProjectPanel.dom.test.tsx gained single-click-remove and
  logger-toggle cases. New ProjectPanel.runToggle.dom.test.tsx pins
  the RBS toggle's both directions: an external rbs-changed (as the
  panel's own Run chip would cause) reaching the row, and a row click
  reaching the host through the identical rbs_set_run command.

Copied the task's grooming notes, phases and exit criteria from
doc-closeout (this branch's base predates that grooming) into
0140-project-state-items.md alongside this phase's status log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Which cursor mode the plot toolbar is in was unreadable in the dark
theme: the pressed segment and the unpressed ones beside it were, to
the eye, the same chip.

Follow, a few chips to the left on the same bar, is the reference. It
is a bare `ChipButton`, so being pressed is what
`.chip-button[aria-pressed="true"]` does — an `--accent-marker`
hairline, an `--surface-hover` fill and `--text-label` over it, three
signals together. A chip inside a `ChipSegment` has no hairline: the
group draws the outline once and the chips inside drop theirs, which
deletes the first of the three and leaves the other two carrying the
state alone. They cannot. Measured as a WCAG contrast ratio against the
chip's own `--surface-toolbar-control` surface, that fill sat at 1.03
in dark, 1.04 in light and 1.04 in lighthk — 1.00 being literally the
same colour.

A pressed segment now takes all three, Follow's exactly, so the two
kinds of toggle on one toolbar look like one language. The accent edge
comes back as a 1px inset ring rather than a border: same token, same
width, but no box model change, so the chip stays 20px and its icon
does not shift by a pixel when the mode is turned on. Hover and
`:active` restate the treatment because the chip's own rules for those
set the background at a higher specificity, and a state that washes out
under the pointer is worse than one that was never drawn.

That edge reads at 8.56 / 4.97 / 4.32 against the unpressed chips beside
it in the three themes, with the label and icon at 13.0 / 15.5 / 13.4
over the pressed fill.

The fix is in `.chip-seg`, so it reaches every segment that is a
"which one is on" decision — the plot toolbar's cursor modes and solo
paging, and the trace panel's trace mode. The run-control segment is
unaffected: its chips are commands, not toggles, and carry no
`aria-pressed`.

`chipCssTestKit` grew the colour half of the walk it already did for
geometry. The geometric invariant it was written for — a state moves
nothing — could not have caught this, because the bug was that the
state moved nothing *and* said nothing either; `box-shadow` is
classified as paint rather than geometry for the same reason it is used
here. `themeTokens` resolves a rule's `var(--token)` against each
theme's `:root` block, so the new tests read the three themes rather
than the one jsdom happens to have live, and one of them renders a lone
pressed chip beside a pressed segment to hold the two treatments
together.

Co-Authored-By: Claude Fable 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