Global back and forward, and the launch entry that made Back a lie #144

Merged
logan merged 4 commits from feat/6-global-back-forward into main 2026-08-19 22:27:39 +00:00
Collaborator

Adds the affordance #6 asks for, and fixes the launch-entry defect that
building it exposed.

Commits

commit issue what
018d857 #6 <nav-history> in the top bar, Alt+Left/Alt+Right, and per-entry indices replacing the one-way depth counter
603728a #142 the configured landing page replaces the launch entry instead of stacking on it
4f47c85 #6, #142 five e2e journeys, one of which pins #142
ea16e07 CLAUDE.md

The stack was already global; the affordance was not

Every navigation has been a history entry since the Android back
gesture landed, and popstate restores any of them in either
direction — back-navigation.spec.ts has asserted goForward() since
it was written. So "back is tab-scoped" is about what is on screen: the
only way back was a detail view's own button, which leaves with the
view it belongs to. Measured, the report's own journey now works —
Artists → an artist → click Tracks → Back returns to the artist.

Forward is not back negated, and that is why pushedEntries is gone
rather than extended.
popstate carries no direction and fires
identically both ways, so one counter decremented on every pop reads a
forward as a second back — two Forward presses would have claimed the
app was at its root. Each entry carries yjIdx; the shell keeps the
current index and a high-water mark, which also survives a jump of more
than one (history.go(-n), and a long-press on a browser's back
button). A navigation from the middle truncates the forward list, as a
browser does.

A disabled control, deliberately. This is the documented exception
to library-status-indicator's rule that a control which cannot act
should not be a button: back and forward are a pair whose positions the
user learns, and hiding the unavailable one moves the other under the
cursor. It is also what every browser does, which is the design brief.

The buttons ask the shell, dispatching navigate-back /
navigate-forward rather than calling history themselves — the shell
owns the guard that stops a press at the root leaving the app, and a
second caller reaching for history is how the old navStack came to
disagree with the platform. The keyboard bindings dispatch the same two
events, so there is one path.

What building it exposed — #142

With a Back button that greys itself out, the root state became
visible: it rendered enabled, and pressing it did nothing.

The app navigates twice on startup — the eager navigate → home that
paints without waiting for the backend, and the configured page
GetDefaultPage() resolves to later. Only the first replaced the
launch entry, so a fresh session was one entry deep before anyone had
touched anything. On desktop that press was invisibly wasted; on
Android webView.canGoBack() was true, so the press that should have
exited the app was swallowed
— precisely the fault the
replace-the-launch-entry rule exists to prevent, defeated by there
being two launch navigations. It is guarded on still being at index 0,
because that backend call is asynchronous and a slow answer must not
overwrite an entry the user made in the meantime.

Where it lives, and where it does not

The pair sits beside the brand (margin-right: auto collects the
header's free space after it, since the bar is space-between and four
children left it floating mid-header — a screenshot found that).

It stands down below 900px, the same line the sidebar's labels and
the subtitle are already given up at. Measured at 600×600, the bottom
of the Compact band: the top bar is 611px inside a 600px viewport
before this component exists
, and 695px with it. So keeping it there
would have widened an existing violation of the promise that nothing
scrolls sideways at a supported size. That pre-existing 11px is filed
as #143 with the measurement — layout-overflow.spec.ts checks
1440, 900, 800 and the phone widths, and 900 is the narrowest content
area
, but the narrowest header is a different question and it lands
at 600.

Nothing becomes unreachable as a result, which is the rule that decides
it: Alt+Left/Alt+Right are global at every width, the detail views
keep their own back buttons, and the phone has the platform's gesture.
A lone Forward button with no platform Back beside it would have been a
stranger thing than none.

Verification

  • make ui-test — 935 passed (83 files)
  • make e2e — 146 passed
  • make lint — 0 issues, all three build configurations
  • make test — all three passes green (backend/shortcuts changed)
  • tsc --noEmit in frontend/ and e2e/; make css-check
  • By hand at 1280/900/899/800/600/390: the depth is correct at both
    ends, Alt+Left/Alt+Right walk it, a bare Left still seeks, and
    the shell's width below 900 is byte-identical to before
  • Checked negatively: with the _replace flag off, the launch
    assertion fails with enabled — so that test pins #142 rather than
    merely passing beside it

One addition worth flagging: solid/arrow-right was not in the bundled
icon set (arrow-left was, from the five detail views' back buttons),
so names.txt grew one line and fetch-icons.mjs re-vendored — Font
Awesome Free, as that script insists.

Closes #6
Closes #142

Adds the affordance #6 asks for, and fixes the launch-entry defect that building it exposed. ## Commits | commit | issue | what | |---|---|---| | `018d857` | #6 | `<nav-history>` in the top bar, `Alt+Left`/`Alt+Right`, and per-entry indices replacing the one-way depth counter | | `603728a` | #142 | the configured landing page replaces the launch entry instead of stacking on it | | `4f47c85` | #6, #142 | five e2e journeys, one of which pins #142 | | `ea16e07` | — | `CLAUDE.md` | ## The stack was already global; the affordance was not Every navigation has been a history entry since the Android back gesture landed, and `popstate` restores any of them in either direction — `back-navigation.spec.ts` has asserted `goForward()` since it was written. So "back is tab-scoped" is about what is on screen: the only way back was a detail view's own button, which leaves with the view it belongs to. Measured, the report's own journey now works — Artists → an artist → click Tracks → Back returns to the artist. **Forward is not back negated, and that is why `pushedEntries` is gone rather than extended.** `popstate` carries no direction and fires identically both ways, so one counter decremented on every pop reads a forward as a second back — two Forward presses would have claimed the app was at its root. Each entry carries `yjIdx`; the shell keeps the current index and a high-water mark, which also survives a jump of more than one (`history.go(-n)`, and a long-press on a browser's back button). A navigation from the middle truncates the forward list, as a browser does. **A disabled control, deliberately.** This is the documented exception to `library-status-indicator`'s rule that a control which cannot act should not be a button: back and forward are a pair whose positions the user learns, and hiding the unavailable one moves the other under the cursor. It is also what every browser does, which is the design brief. **The buttons ask the shell**, dispatching `navigate-back` / `navigate-forward` rather than calling `history` themselves — the shell owns the guard that stops a press at the root leaving the app, and a second caller reaching for `history` is how the old `navStack` came to disagree with the platform. The keyboard bindings dispatch the same two events, so there is one path. ## What building it exposed — #142 With a Back button that greys itself out, the root state became visible: it rendered **enabled**, and pressing it did nothing. The app navigates twice on startup — the eager `navigate → home` that paints without waiting for the backend, and the configured page `GetDefaultPage()` resolves to later. Only the first *replaced* the launch entry, so a fresh session was one entry deep before anyone had touched anything. On desktop that press was invisibly wasted; **on Android `webView.canGoBack()` was true, so the press that should have exited the app was swallowed** — precisely the fault the replace-the-launch-entry rule exists to prevent, defeated by there being two launch navigations. It is guarded on still being at index 0, because that backend call is asynchronous and a slow answer must not overwrite an entry the user made in the meantime. ## Where it lives, and where it does not The pair sits beside the brand (`margin-right: auto` collects the header's free space after it, since the bar is `space-between` and four children left it floating mid-header — a screenshot found that). **It stands down below 900px**, the same line the sidebar's labels and the subtitle are already given up at. Measured at 600×600, the bottom of the Compact band: the top bar is **611px inside a 600px viewport before this component exists**, and 695px with it. So keeping it there would have widened an existing violation of the promise that nothing scrolls sideways at a supported size. That pre-existing 11px is filed as **#143** with the measurement — `layout-overflow.spec.ts` checks 1440, 900, 800 and the phone widths, and 900 is the narrowest *content area*, but the narrowest *header* is a different question and it lands at 600. Nothing becomes unreachable as a result, which is the rule that decides it: `Alt+Left`/`Alt+Right` are global at every width, the detail views keep their own back buttons, and the phone has the platform's gesture. A lone Forward button with no platform Back beside it would have been a stranger thing than none. ## Verification - `make ui-test` — 935 passed (83 files) - `make e2e` — 146 passed - `make lint` — 0 issues, all three build configurations - `make test` — all three passes green (`backend/shortcuts` changed) - `tsc --noEmit` in `frontend/` and `e2e/`; `make css-check` - By hand at 1280/900/899/800/600/390: the depth is correct at both ends, `Alt+Left`/`Alt+Right` walk it, a bare `Left` still seeks, and the shell's width below 900 is byte-identical to before - Checked negatively: with the `_replace` flag off, the launch assertion fails with *enabled* — so that test pins #142 rather than merely passing beside it One addition worth flagging: `solid/arrow-right` was not in the bundled icon set (`arrow-left` was, from the five detail views' back buttons), so `names.txt` grew one line and `fetch-icons.mjs` re-vendored — Font Awesome **Free**, as that script insists. Closes #6 Closes #142
logan added 4 commits 2026-08-19 22:10:11 +00:00
The history stack has been global since the Android back gesture landed
-- every navigation is an entry and `popstate` restores any of them in
either direction. What the report describes as "back is tab-scoped" is
that the only way back was a detail view's own button, which leaves the
screen with the view it belongs to: click over to Tracks and the album
you were reading is still one entry away with nothing on screen saying
so.

`<nav-history>` is that affordance, plus `nav.back` / `nav.forward` on
Alt+Left / Alt+Right -- the browser's own combination, and clear of the
bare arrows that seek, since a binding matches on its full canonical
string.

Forward is not back negated, which is why the old `pushedEntries`
counter is gone rather than extended: `popstate` carries no direction
and fires identically both ways, so one counter decremented on every
pop reads a forward as a second back. Each entry carries its index and
the shell keeps the current one and a high-water mark, which also
survives a jump of more than one.

The buttons dispatch the events the rest of the app already dispatches
rather than calling `history` themselves -- the shell owns the guard
that stops a press at the root leaving the app, and a second caller
reaching for history is how the old `navStack` came to disagree with
the platform.

Below 900px the control stands down: the top bar is what runs out of
room first below that, and nothing becomes unreachable -- the shortcuts
are global at every width and the phone has the platform's gesture.

Closes #6
The app navigates twice on startup and both are deliberate: the eager
`navigate -> home` that paints without waiting for the backend, and the
configured page `GetDefaultPage()` resolves to a moment later. Only the
first replaced the launch entry, so the second stacked on it and a
fresh session was already one entry deep before the user had touched
anything.

The first back press therefore replayed home over home. On desktop that
was invisible until this branch drew a Back button, which rendered live
at the root and did nothing; on Android `webView.canGoBack()` was true,
so the press that should have exited the app was swallowed -- the exact
fault the replace-the-launch-entry rule exists to prevent, defeated by
there being two launch navigations rather than one.

Guarded on still being at index 0 rather than on a flag: that call is
asynchronous and the user can navigate while it is in flight, so past
the root this is an ordinary navigation and a slow answer cannot
overwrite an entry they made.

Closes #142
Five journeys the control has to get right: nothing offered at the root
in either direction, walking both ways with the availability changing
as it goes, reaching the detail view a tab click left behind (which is
the report), the forward list being dropped when the user navigates
from the middle, and the control standing down below 900px.

The first fails on the build before the launch entry was fixed --
enabled, and doing nothing when pressed. It is the assertion that pins
that defect, which otherwise has no visible symptom on desktop at all.
docs: record the back/forward chrome and the two launch navigations
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 7m4s
ea16e07c46
Both belong beside the rules that already keep the history stack
honest: the depth counting, because forward is the case one counter
cannot express, and the second launch navigation, because it is what
silently defeated the first rule on the list.
logan merged commit ea53d4f15b into main 2026-08-19 22:27:39 +00:00
Sign in to join this conversation.