Skip to content

fix(web): the nav may not say where you are until the rows agree (#754) - #757

Merged
eaitbrahim merged 2 commits into
mainfrom
fix-754-nav-loading-state
Sep 7, 2026
Merged

fix(web): the nav may not say where you are until the rows agree (#754)#757
eaitbrahim merged 2 commits into
mainfrom
fix-754-nav-loading-state

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Closes #754.

show() set document.title and aria-current="page", then awaited the read. For the length of the fetch every sighted signal said "you are on Balances" over Positions' rows — a stale figure under a fresh label, the one claim _session_banner refuses to make and the balances tiles carry an as-of stamp to prevent.

The claim waits for the DOM that backs it

commitNavigation is now the single place that says "you are here" — tab title and aria-current together — and paint calls it after rebuildInto, on all three branches. A stopped view and a refused view are outcomes of this navigation too; leaving the nav on the previous route while the content reports this one is the same mismatch in different clothes.

A click is still acknowledged, with a weaker claim

Deferring aria-current removes every immediate signal from a click, which on a slow route reads as a dead link and invites a second one. data-pending is that signal, and it is deliberately not aria-current: dotted rather than solid, muted rather than foreground. A pending link that looked like the current one would restore exactly the confusion the deferral removes.

No is_navigating flag, because one already exists

aria-busy is raised in exactly one place and always was — show, plus index.html's initial value for the first paint. Neither the 15-second poll nor an SSE repaint raises it, so the visual hangs off it directly; a second flag would be a redundant statement of one fact. Pinned by counting the assignments rather than locating one.

Content dims rather than clears — clearing costs a layout jump and claims more than is true — and takes pointer-events: none, because acting on a figure about to be replaced is the failure mode of a dimmed view left live. The bar is indeterminate: the server sends one response, not a stream, and a percentage would be a claim about something nothing counts.

Two of my own tests were vacuous, and mutation is what found them

The first show() assertion failed against a correct implementation — the new comment explains that it no longer sets aria-current, and a raw substring scan cannot tell an explanation from an assignment. A test a docstring can break is one a docstring can satisfy.

Worse: the CSS assertion was "aria-busy" in _CSS, which survived renaming the selector to #content[data-nope="true"], because the comment above the rule still said the word. Both scans now strip comments first and assert the selector.

Four mutants killed — commit-before-swap, renamed busy selector, dropped pending rule, removed keyframes.

Reduced motion needed no new rule

The blanket query — written before there was any animation to honour — already covers keel-busy, flattening it to a static bar rather than removing the signal. Its comment claimed the file had no animation; updated to say what it now does.

Checks

769 passed (tests/web/ + test_console_thinness.py), ruff clean.

Unchanged and listed in the module docstring: no test here proves the dim is perceptible, that a pending link reads as pending, or that any browser honours the query. Those are hand checks against a running keel serve.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6

eaitbrahim and others added 2 commits September 7, 2026 13:00
`show()` set `document.title` and `aria-current="page"`, then awaited the read.
For the length of the fetch every sighted signal said "you are on Balances" over
Positions' rows -- a stale figure under a fresh label, which is the one claim
`_session_banner` refuses to make and the balances tiles carry an as-of stamp to
prevent.

THE CLAIM NOW WAITS FOR THE DOM THAT BACKS IT

`commitNavigation` is the single place that says "you are here" -- the tab title
and `aria-current` together -- and `paint` calls it after `rebuildInto`, on all
three branches. A stopped view and a refused view are outcomes of this
navigation too; leaving the nav on the previous route while the content reports
this one is the same mismatch wearing different clothes.

AND A CLICK IS STILL ACKNOWLEDGED, WITH A WEAKER CLAIM

Deferring `aria-current` removes every immediate signal from a click, which on a
slow route reads as a dead link and invites a second one. `data-pending` is that
signal and is deliberately NOT `aria-current`: dotted rather than solid, muted
rather than foreground. A pending link that looked like the current one would
restore exactly the confusion the deferral removes.

THE BUSY STATE WAS ALREADY NAVIGATION-ONLY

`aria-busy` is raised in exactly one place and always was -- `show`, plus
`index.html`'s initial value for the first paint. Neither the 15-second poll nor
an SSE repaint raises it, so hanging the visual off it needs no new flag; a
redundant `is_navigating` would be a second statement of one fact. Pinned by
COUNTING the assignments rather than locating one.

The content dims rather than clears (clearing costs a layout jump and claims
more than is true) and takes `pointer-events: none`, because acting on a figure
about to be replaced is the failure mode of a dimmed view left live. The bar is
indeterminate: the server sends one response, not a stream, and a percentage
would be a claim about something nothing counts.

TWO VACUOUS TESTS, BOTH CAUGHT BY MUTATION

The first cut of the show() assertion failed against a CORRECT implementation,
because the new comment explains that it no longer sets `aria-current` and a raw
substring scan cannot tell an explanation from an assignment. A test a docstring
can break is one a docstring can satisfy; both scans now strip comments first.

Worse, the CSS assertion was `"aria-busy" in _CSS`, which SURVIVED renaming the
selector to `#content[data-nope="true"]` -- the comment above the rule still said
the word. It asserts the selector against comment-stripped CSS now. Four mutants
killed: commit-before-swap, the renamed busy selector, the dropped pending rule,
and the removed keyframes.

Reduced motion needed no new rule: the blanket query written before there was any
animation to honour covers `keel-busy`, flattening it to a static bar rather than
removing the signal. That comment is updated to say so, having claimed there was
no animation in the file.

769 web + thinness tests pass. What no test here can prove is unchanged and
listed in the module docstring: whether the dim is perceptible, whether a pending
link reads as pending, whether any browser honours the query. Hand checks against
a running `keel serve`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6
…ed the view

Review of this PR's own diff. Two defects, and the first meant half the feature
did not exist.

THE BAR NEVER DREW

`#content[aria-busy="true"] #view::before` asks for a `#view` INSIDE `#content`.
The shell nests it the other way -- `main#view > div#content` -- so the rule
matched no document this client ever produces. The dim worked; the bar was
inert from the first commit.

`test_the_stylesheet_gives_the_busy_state_a_visible_form` stayed green
throughout, because a selector's TEXT being present says nothing about whether
it selects anything, and CSS has no other way to fail. The new test reads the
nesting out of `index.html` and compares the rule against it, rather than
against the stylesheet's own claim. `:has(> ...)` now, verified by re-running
the original selector as a mutant.

A RENDERER THAT THREW WOULD HAVE LEFT THE VIEW DEAD

`paint` had no `try`/`finally`. `read` resolves on a transport failure rather
than rejecting, but `mount` is a renderer and renderers throw; `paint` is called
as `void paint(...)`, so nothing catches it.

Before this branch a stranded `aria-busy` was harmless -- nothing styled it. As
of this branch it carries `opacity: 0.55` AND `pointer-events: none`, so the
same throw would leave the view dimmed with every control inside it dead: sort
headers, status tabs, the timeline chip. The stale-but-usable view this change
set out to improve on was strictly better than that.

The flag now comes down in a `finally`. `commitNavigation` deliberately stays
INSIDE the try: a render that threw is not an arrival, and claiming one would
put the nav label back out of step with the rows, which is the whole bug.

Nothing awaits between the `route !== current` guard and the block, so `current`
cannot move under it and the flag lowered is always this route's own.

AND CLEARING THE MARKER IS NOT THE SAME EVENT AS ARRIVING

`commitNavigation` owned both, so a failed render left the clicked link marked
pending for the life of the page. `clearPending` is separate and runs in the
`finally`: the read being over is true on every path; arrival is not.

`pointer-events: none` is KEPT, now that the flag is guaranteed to come down.
Acting on a figure about to be replaced is the failure mode of a dimmed view
left live, and that argument only holds while the dim is guaranteed to end.

6,322 passed / 3 skipped, ruff clean. Three mutants killed, one of them the
shipped bug itself: the inside-out selector, the finally removed, and arrival
claimed in the finally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6
@eaitbrahim
eaitbrahim merged commit bc889da into main Sep 7, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the fix-754-nav-loading-state branch September 7, 2026 17:39
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.

[web] Navigating shows the previous view's figures under the new view's nav highlight — the loading signal is assistive-only

1 participant