feat(shell): global back and forward in the top bar

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
This commit is contained in:
2026-08-19 18:08:55 -04:00
parent a7ac2b4a3e
commit 018d857746
14 changed files with 512 additions and 15 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M502.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L402.7 224 32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l370.7 0-105.4 105.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160z"/></svg>

After

Width:  |  Height:  |  Size: 532 B

@@ -0,0 +1,133 @@
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import { HistoryController } from '@store/controllers/history-controller';
/**
* Global back and forward, in the top bar (#6).
*
* **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. What the report describes as "back is tab-scoped" is that
* the *only* way back was a detail view's own button, which vanishes
* the moment you leave for another tab: the album you were reading is
* still one entry away, and nothing on screen says so or offers it.
*
* Four things about this are load-bearing.
*
* **It asks the shell rather than the History API.** `history.length`
* counts entries this app did not push and never shrinks, and there is
* no way to ask where in the list you are -- so a control derived from
* it is confidently wrong at both ends. `historyStore` is the shell's
* own numbering.
*
* **A control that cannot act is `disabled`, not hidden.** This is the
* one place in the app where that is right rather than the fault
* `library-status-indicator` was: back and forward are a *pair* whose
* positions the user learns, and a button that disappears at the end
* of the list moves the other one under the cursor. It is also what
* every browser does, which is the whole design brief here.
*
* **The buttons dispatch the events the rest of the app already
* dispatches**, `navigate-back` and `navigate-forward`, rather than
* calling `history.back()` themselves. The shell owns the guard -- one
* press is one entry, and at the root there is nothing of ours to go
* back to -- and a second caller reaching for `history` directly is
* how the old `navStack` came to disagree with the platform.
*
* **It is desktop chrome.** Below 600px the phone has a system back
* gesture (and, on Android, a hardware/gesture Back that this app
* hooks), the top bar is 3.25em with three other things in it, and two
* more 32px targets there would be the first thing to overflow. Hidden
* by `index.css` at that width, next to the rest of the phone header's
* concessions.
*/
@customElement('nav-history')
export class NavHistory extends LitElement {
private historyCtrl = new HistoryController(this);
static override styles = [designTokens, css`
:host {
display: flex;
align-items: center;
gap: 0.25em;
/* A grid item's implicit minimum is its content; this one
genuinely cannot shrink, so it says so rather than
letting the header widen the body. */
flex: 0 0 auto;
}
button {
display: flex;
align-items: center;
justify-content: center;
width: 2em;
height: 2em;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--yj-text-primary, #f8f9fa);
cursor: pointer;
font-size: 1em;
}
button:hover:not(:disabled) {
background-color: var(--yj-bg-overlay, #495057);
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
button:disabled {
/* Not a contrast failure: a disabled control is exempt from
1.4.3, and the pair has to read as unavailable rather
than merely quiet. */
color: var(--yj-text-tertiary, #868e96);
cursor: default;
}
`];
private go(direction: 'back' | 'forward') {
this.dispatchEvent(new CustomEvent(`navigate-${direction}`, {
bubbles: true,
composed: true,
}));
}
override render() {
const { canBack, canForward } = this.historyCtrl.depth;
return html`
<button
type="button"
data-testid="history-back"
aria-label="Back"
?disabled=${!canBack}
@click=${() => this.go('back')}
>
<wa-icon name="arrow-left"></wa-icon>
</button>
<button
type="button"
data-testid="history-forward"
aria-label="Forward"
?disabled=${!canForward}
@click=${() => this.go('forward')}
>
<wa-icon name="arrow-right"></wa-icon>
</button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'nav-history': NavHistory;
}
}
+1
View File
@@ -19,6 +19,7 @@ regular/heart
regular/star
solid/arrow-down-wide-short
solid/arrow-left
solid/arrow-right
solid/arrow-rotate-right
solid/arrows-rotate
solid/arrow-up-short-wide
@@ -400,6 +400,20 @@ async function dispatch(action: string): Promise<void> {
break;
}
// The keyboard half of #6. It dispatches the same events the
// header's buttons and the detail views' own back buttons do,
// rather than calling `history.back()` here: the shell owns the
// guard that stops a press at the root leaving the app, and a
// second caller reaching for `history` directly is how the old
// `navStack` came to disagree with the platform.
case 'nav.back':
document.dispatchEvent(new CustomEvent('navigate-back'));
break;
case 'nav.forward':
document.dispatchEvent(new CustomEvent('navigate-forward'));
break;
case 'nav.queue': {
const queuePanel = document.getElementById(
'queue-panel',
+12
View File
@@ -103,6 +103,18 @@ export const SHORTCUT_META: Record<string, ShortcutMeta> = {
scope: 'global',
defaultKey: 'Q',
},
'nav.back': {
label: 'Back',
category: 'Navigation',
scope: 'global',
defaultKey: 'Alt+Left',
},
'nav.forward': {
label: 'Forward',
category: 'Navigation',
scope: 'global',
defaultKey: 'Alt+Right',
},
'app.shortcuts': {
label: 'Keyboard Shortcuts',
category: 'App',
@@ -0,0 +1,40 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from 'lit';
import { historyStore, type HistoryDepth } from '../history-store';
/**
* HistoryController connects a Lit component to the HistoryStore.
*
* Usage in a component:
*
* private historyCtrl = new HistoryController(this);
*
* render() {
* const { canBack } = this.historyCtrl.depth;
* }
*/
export class HistoryController implements ReactiveController {
private host: ReactiveControllerHost;
private unsubscribe?: () => void;
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
hostConnected(): void {
this.unsubscribe = historyStore.subscribe(() => {
this.host.requestUpdate();
});
}
hostDisconnected(): void {
this.unsubscribe?.();
}
get depth(): HistoryDepth {
return historyStore.get();
}
}
+66
View File
@@ -0,0 +1,66 @@
/**
* How far the session can go back and forward.
*
* The History API exposes `length` and nothing useful: it counts
* entries the app did not push, does not say where in the list the
* current entry is, and `popstate` fires *identically* whether the
* user went back or forward. So a control that wants to grey itself
* out has to be told, and the shell is the only thing in a position to
* know (#6).
*
* Two rules follow from how the shell counts, and both are the reason
* this is a pair of booleans rather than one depth:
*
* **Forward is not "back, negated".** `pushedEntries` -- the counter
* this replaces -- decremented on every `popstate`, which made a
* forward navigation look like a second back. The shell keeps an index
* per entry and a high-water mark instead, and publishes the two
* answers rather than the arithmetic.
*
* **Back stops at the app's own floor.** The launch entry is
* *replaced*, not pushed, so that one back press from the root exits
* the app on Android; `canBack` is false there, which is what stops
* the header's own button being the thing that quits.
*/
type Subscriber = () => void;
export interface HistoryDepth {
canBack: boolean;
canForward: boolean;
}
class HistoryStore {
private depth: HistoryDepth = { canBack: false, canForward: false };
private subscribers = new Set<Subscriber>();
get(): HistoryDepth {
return this.depth;
}
/** Called by the shell whenever an entry is pushed or restored. */
setDepth(canBack: boolean, canForward: boolean): void {
if (
canBack === this.depth.canBack &&
canForward === this.depth.canForward
) {
return;
}
this.depth = { canBack, canForward };
this.notify();
}
subscribe(fn: Subscriber): () => void {
this.subscribers.add(fn);
return () => this.subscribers.delete(fn);
}
private notify(): void {
this.subscribers.forEach((fn) => fn());
}
}
export const historyStore = new HistoryStore();
+3
View File
@@ -11,6 +11,9 @@ export { searchStore } from './search-store';
export { SearchController } from './controllers/search-controller';
export { activeViewStore } from './active-view-store';
export { ActiveViewController } from './controllers/active-view-controller';
export { historyStore } from './history-store';
export type { HistoryDepth } from './history-store';
export { HistoryController } from './controllers/history-controller';
export { shortcutsStore } from './shortcuts-store';
export type { ShortcutsState } from './shortcuts-store';
export { ShortcutsController } from './controllers/shortcuts-controller';