feat(workspace): route platform Back gestures to the header Back (#1943)

* feat(workspace): route platform Back gestures to the header Back

The mouse Back button, Alt+Left (Cmd+[ on macOS) and, in Electron, the
window's app-command browser-backward (Windows/Linux) and trackpad swipe
(macOS) now run the workspace header's Back, one step each.

- WorkspaceBackGesturesService (shell-provided) dispatches every gesture
  through WorkspaceBackNavigationService.goBack(). A gesture is swallowed
  while a dialog, menu, the phone drawer or a fullscreen player is up, and
  left to the browser only where the header has no Back.
- The chord is a navigate-back entry in the shortcuts dialog, handled in a
  capture-phase listener of WorkspaceKeyboardShortcutsService; ignored while
  typing, repeats swallowed.
- Electron forwards app-command and swipe over the push-only channel
  WINDOW:BACK_REQUESTED (preload onWindowBackRequested), gated in the
  renderer on RuntimeCapabilitiesService.supportsWindowBackGestures.
- A mouse press that also arrives as app-command goes back once, however
  long the button is held; Electron's own Chromium history step on the
  mouse button is always prevented.
- No Forward binding.

Docs: workspace-shell (Platform gestures), portal-detail-navigation,
electron-security, UI guidelines, README shortcuts table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(workspace): drop NgZone from the Back gestures service

Master runs zoneless change detection and no longer uses NgZone; the
bridge callback calls goBack() directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspace): keep Back gestures where the Fullscreen API is missing

iPhone Safari has no unprefixed document.fullscreenElement; the
undefined value read as "a player is fullscreen" and blocked every
gesture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspace): keep a held Back chord's repeats swallowed after Back

Repeats asked for the current result, so once the first press reached a
page with no Back left they were released to the browser, which could take
another history step. Repeats now follow the chord's first press until a
key is released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(electron): one Back per press of the Linux Browser Back key

On Linux Electron raises app-command browser-backward for every
unhandled event of the Browser Back key (NativeWindowViews::
HandleKeyboardEvent), keydown and keyup alike, so one press went back
twice. The main process now consumes that key in before-input-event and
requests Back once per press; mouse Back buttons still arrive as
app-command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 22:15:42 +02:00
1 parent 002faebb08
commit aecb9acb8e
44 files changed
+1758 -8

No files matched your search

+73 -4
View File
@@ -97,8 +97,9 @@ The shell is intentionally split into four persistent regions:
or Sources when the dashboard is off).
2. Top header:
1. Leading Back slot: the current page's registered Back, else browser
history while an in-app previous page exists, else nothing. See
[Header Back](#header-back).
history while an in-app previous page exists, else nothing. The mouse
Back button, Alt+← / Cmd+[ and the Electron window gestures run it.
See [Header Back](#header-back).
2. Playlist switcher.
3. Route-aware search input and command palette trigger.
4. Add source action.
@@ -319,13 +320,80 @@ of jumping (see [Layout slides](#layout-slides)).
There is no Forward button: Stalker inline details are store state, not
history entries, so Forward would skip them.
**Platform gestures.** The mouse Back button, Alt+← (Cmd+[ on macOS) and, in
Electron, the window's `app-command` `browser-backward` (Windows and Linux:
mouse Back buttons and the Browser Back key) and trackpad `swipe` left (macOS)
run the same action as the header button, through
`WorkspaceBackGesturesService` (shell-provided). Each gesture has one of
three results (`WorkspaceBackGestureResult`):
- `back`: the header has a target and nothing sits above the page, so
`WorkspaceBackNavigationService.goBack()` runs. "Above the page" is document
fullscreen (a fullscreen player), an open phone context drawer, a showing
CDK backdrop, or a visible `[role="dialog"]`, `[role="alertdialog"]` or
`[role="menu"]` outside hidden, `inert` or `aria-hidden` subtrees.
- `blocked`: something sits above the page. Nothing navigates, and the
gesture is still swallowed, because browser Back would navigate under the
dialog (which then closes) or out of the fullscreen player.
- `none`: the header has no Back. The gesture stays the browser's: in the PWA
it may leave the app, as browser Back does.
Unless the result is `none`, the browser's default is prevented (the mouse
press on `mouseup` and `auxclick`, listened to in the capture phase), so the
browser cannot take a second history step. In Electron the mouse Back button
is prevented even then: Electron's Chromium steps through history on an
unprevented press itself, also to entries from before a reload, which reloads
the app.
- **Chord.** `navigate-back` in `keyboard-shortcut-definitions.ts` lists it in
the shortcuts dialog; `isWorkspaceBackShortcut()` matches it, on macOS also
by the `BracketLeft` key position for layouts that type `[` with Option.
`WorkspaceKeyboardShortcutsService` handles it in a capture-phase document
listener and stops a chord that runs Back there, so a focused carousel, tab
list, guide or player does not also read it as a plain arrow; a blocked
chord is prevented but still reaches the dialog or menu. It is ignored
while typing (Alt and Option arrows edit text). A held chord runs Back
once: its repeats follow its first press until a key is released, so after
a press that ran Back they are swallowed even when the page it reached has
no Back left, and the browser cannot step on them.
- **Electron.** `attachWindowBackGestures()`
(`apps/electron-backend/src/app/services/window-back-gestures.ts`) forwards
the two window events over the main → renderer push channel
`WINDOW:BACK_REQUESTED` with the source (`app-command` or `swipe`); the
preload's `onWindowBackRequested` passes only those two values on. The
renderer subscribes only when
`RuntimeCapabilitiesService.supportsWindowBackGestures` reports the
subscription, so an older preload forwards nothing. `browser-forward` and
`swipe` right are not forwarded. `swipe` needs macOS's "Swipe between
pages" set to two or three fingers. On Linux Electron raises `app-command`
for the keydown and the keyup of the Browser Back key alike, so the main
process takes that key in `before-input-event` instead (Back once per
press, the key never reaches the page) and leaves `app-command` to mouse
Back buttons.
- **One press, two events.** In Electron on Windows and Linux a mouse Back
press also reaches the window as `app-command`: with the release on
Windows, on press on Linux, and before or after the renderer sees the
button. Whichever half comes first runs Back and the other is consumed. A
command that arrives while the button is down belongs to that press however
long it is held; otherwise a command and a press (or a press and a command)
within `BACK_PRESS_TWIN_MS` (500 ms) of each other pair. Each press pairs
once, so two quick presses still go back twice, and the chord and the swipe
never pair.
Electron E2E `back-gestures.e2e.ts` drives each gesture (the mouse through
CDP's `back` button, the window events emitted on the `BrowserWindow`) and
checks that each goes back one history entry, a paired press goes back once
(also when held), Forward does nothing, and the gestures wait while typing
(the chord) or with a dialog open. Web E2E `workspace-header-back.e2e.ts`
checks that the mouse Back button and the chord step once in the browser.
**Phone width.** `phoneDrawerToggle` sets how Back shares the leading slot with
the context drawer toggle. `replace` (the default) takes the toggle's slot: a
detail page's drawer belongs to the list that Back returns to. `beside` keeps
both: the settings drawer holds the page's own sections. `yield` hides Back
while the toggle shows: the history fallback must not cost a category list its
only way into the drawer, and two navigation icons do not fit beside the
switcher. System and browser Back still work there.
switcher. The platform gestures above and browser Back still work there.
**Left in place.** These controls stay inside their surface on purpose:
@@ -517,7 +585,8 @@ Keyboard shortcut help is shell-owned:
1. `WorkspaceKeyboardShortcutsService` is provided by `WorkspaceShellComponent`.
It owns the workspace-scoped `document:keydown` listener for `?` /
`Shift+/`.
`Shift+/`, and a capture-phase one for the Back chord (see
[Header Back](#header-back)).
2. The listener ignores events from inputs, textareas, selects, and
content-editable elements via `isTypingInInput(...)`.
3. `libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts` is the