feat(workspace): move page Back buttons into the header and add a history fallback (#1814)

This commit is contained in:
4gray authored and GitHub committed 2026-10-04 12:16:39 +02:00
1 parent 6f247fb538
commit 84aef83a6c
40 files changed
+902 -216

No files matched your search

+16 -1
View File
@@ -189,6 +189,18 @@ scrim after three idle seconds, with a 32px mute toggle in the corner. It
never starts under `prefers-reduced-motion` or with `saveData`, and stops
while the hero is off screen or the window is unfocused.
## Back Navigation
Page-level Back lives only in the workspace header's leading slot (see
[Header Back](./workspace-shell.md#header-back)). A routed page, or the shell
it renders in, registers it with `registerWorkspaceBack()` instead of drawing
an arrow, so Back keeps one position and one look on every page and never
floats over a scroll owner. Without a registration the header falls back to
browser history while an in-app previous page exists, and shows nothing
otherwise. An arrow that returns within a menu, dialog or player panel is not
page navigation and stays in that surface; an error state may repeat the
header's Back as a labelled recovery button beside its other actions.
## Electron Drag Regions
Every interactive descendant of a drag region—including buttons, links,
@@ -890,7 +902,10 @@ Prefer removing a control over shrinking everything around it:
- Counts and subtitles that a neighbouring control already states.
Never drop the only way back to a hidden surface. A collapse toggle that is
reachable by touch needs its restore affordance to be reachable too.
reachable by touch needs its restore affordance to be reachable too. For the
same reason the header's history Back yields to the context drawer toggle,
and Settings keeps the toggle beside its Back; only a detail page's Back,
whose list shows the toggle again, takes the toggle's slot.
## Typography
+27 -20
View File
@@ -23,27 +23,29 @@ do not reach global player shortcuts. Descendant controls retain their native
keys and Tab order. Entering watch still scrolls to the top; Back and saved
catalog scroll positions retain the existing navigation contract below.
The shell owns the page's Back action, but the workspace header renders it.
While `backAvailable()` is true, the shell registers a target with
`WorkspaceBackNavigationService` (`@iptvnator/portal/shared/data-access`). The
target, the `WorkspaceBackTarget` contract in `@iptvnator/portal/shared/util`,
carries a label (the host's `backLabel`, else the translated "Back"), whether
Escape currently runs it, and `run()`, which emits `backClicked`. The service
keeps a stack in which the newest registration wins, and each release removes
only its own target. A loading shell replaced by the loaded one therefore
cannot clear its successor, whichever is destroyed first. The header shows the target as an `arrow_back`
icon button in its leading slot (`data-test-id="workspace-header-back"`), to
the right of the macOS traffic lights. That is where desktop apps and Material's
top app bar keep navigation. The header never scrolls, so the control stays
visible over long episode lists, and nothing floats over the scroll owner:
detail columns keep symmetric insets and their full width. The button has
Electron `no-drag` hit testing. In browse its tooltip and `aria-keyshortcuts`
The shell owns the page's Back action, but the workspace header renders it
(the general contract, other pages and the history fallback are in
[Header Back](./workspace-shell.md#header-back)). While `backAvailable()` is
true, the shell registers a target through `registerWorkspaceBack()`
(`@iptvnator/portal/shared/data-access`). It carries the host's `backLabel`
(else the translated "Back"), whether Escape currently runs it, and `run()`,
which emits `backClicked`. The newest registration wins and each release
removes only its own target, so a loading shell replaced by the loaded one
cannot clear its successor, whichever is destroyed first. The header shows the
target as an `arrow_back` icon button in its leading slot
(`data-test-id="workspace-header-back"`), to the right of the macOS traffic
lights. That is where desktop apps and Material's top app bar keep navigation.
The header never scrolls, so the control stays visible over long episode
lists, and nothing floats over the scroll owner: detail columns keep symmetric
insets and their full width. In browse its tooltip and `aria-keyshortcuts`
advertise Escape, and an Escape pressed on the focused button runs Back
itself, because the shell's browse Escape requires focus inside the page. At
≤640 px Back takes the context drawer toggle's slot (one navigation icon); the
list it returns to shows the toggle again. This replaced #1763's 72 px lane
reserved beside a sticky in-page arrow, along with its phone bar. Electron E2E
`detail-header-back.e2e.ts` covers 1280, 780 and 375 px in browse and watch.
list it returns to shows the toggle again, and there the history fallback
yields to it. This replaced #1763's 72 px lane reserved beside a sticky
in-page arrow, along with its phone bar. Electron E2E
`detail-header-back.e2e.ts` covers 1280, 780 and 375 px in browse and watch,
and the history fallback on the list Back returns to.
The header Back is route-level in both states: it emits `backClicked`
whether or not inline playback is active, so the arrow keeps one meaning and
@@ -67,8 +69,13 @@ keep working on the page.
Hosts without browse navigation set `backAvailable=false`: M3U uses its channel
sidebar, and collection bootstrap placeholders have no return handler. They
register no header Back in either state and have no browse Escape action; their
watch exits are the bar's Close player button and Escape. Loading/error shells
with a return handler keep Back available.
watch exits are the bar's Close player button and Escape. The header may still
show the history fallback there (a generic Back to the previous page, without
Escape) when the page was reached by in-app navigation. Loading/error shells
with a return handler keep Back available. The downloads offline and recording
error states additionally keep a labelled "Back to Downloads" button beside
Retry or Remove: it is the error state's recovery action and runs the same
handler as the header Back.
## Summary
+68 -4
View File
@@ -93,10 +93,9 @@ The shell is intentionally split into four persistent regions:
4. No brand mark: it only repeated the first workspace link (Dashboard,
or Sources when the dashboard is off).
2. Top header:
1. Leading Back slot, shown while the current page registers a target
with `WorkspaceBackNavigationService` (detail pages today). At
≤640 px it takes the context drawer toggle's place. See
[Portal Detail Navigation](./portal-detail-navigation.md).
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).
2. Playlist switcher.
3. Route-aware search input and command palette trigger.
4. Add source action.
@@ -130,6 +129,71 @@ When adding shell behavior, prefer placing it in the service that owns the
nearest existing state. Keep `WorkspaceShellFacade` as a stable re-export layer
for the template unless the template contract itself intentionally changes.
## Header Back
The header's leading slot is the workspace's one page-level Back. Pages do not
render an arrow of their own: they register a `WorkspaceBackTarget`
(`@iptvnator/portal/shared/util`) with `WorkspaceBackNavigationService`
(`@iptvnator/portal/shared/data-access`), normally through
`registerWorkspaceBack()`, which registers for the calling component's
lifetime while its optional `available` predicate holds. The newest
registration wins, and each release removes only its own target. The button
(`data-test-id="workspace-header-back"`) sits beside the macOS traffic lights,
never scrolls, and has Electron `no-drag` hit testing. A target supplies its
label (else the translated "Back"), whether Escape on the page runs it, and
`run()`.
| Page | Registered by | Back runs | ≤640 px |
| --- | --- | --- | --- |
| Portal, collection, offline and recording details | `PortalDetailShellComponent` while `backAvailable()` | the host's `backClicked` | replaces the drawer toggle |
| Xtream and Stalker Discover and actor pages | `DiscoverViewComponent`, `ActorViewComponent` | the route's `Location.back()` | (no drawer) |
| In-portal search, Xtream and Stalker | `SearchLayoutComponent` while `backAvailable()` and no inline detail replaces the results | `Location.back()` | (no drawer) |
| Settings | `WorkspaceSettingsContextPanelComponent`, which exists exactly while the settings route shows | `Location.back()` | beside the drawer toggle |
Detail-page semantics (Escape, browse and watch) are in
[Portal Detail Navigation](./portal-detail-navigation.md#detail-scroll-and-focus).
**History fallback.** Without a registration, the header shows Back while the
previous history entry is an in-app one, and runs `Location.back()`. The
service reads that from the Navigation API: the previous entry must be
same-document (`NavigationHistoryEntry.sameDocument`), so the router pushed it
after this document loaded. Entries from before a reload or from another page
of the origin never count, and the fallback can neither leave nor reload the
app. `currententrychange` keeps it current through pushes, replacements,
traversals and guard-cancelled Back navigations that the router rewrites.
Without the Navigation API (older Safari and Firefox, jsdom) there is no
fallback; registered pages are unaffected. The fallback reads "Back" and
advertises no Escape, because no page handles one for it. Pages that set
`backAvailable=false`, such as M3U details, therefore show it too when they
were reached by navigation.
When there is nowhere to go, the slot is empty rather than a disabled arrow.
Sessions often start on a page that never navigates (an M3U playlist or live
TV), where a disabled arrow would stay for the whole session. The cost is one
shift of the switcher and search when Back first appears or leaves, which
happens only at the start of the history and together with a route change.
There is no Forward button: Stalker inline details are store state, not
history entries, so Forward would skip them.
**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.
**Left in place.** These controls stay inside their surface on purpose:
1. Downloads offline and recording detail error states keep their labelled
"Back to Downloads" button beside Retry or Remove. It is the error state's
recovery action, not page chrome; the header shows the same Back.
2. Back controls internal to a surface, which leave a panel rather than the
page: the Embedded MPV dock panel and the alternative-sources panel inside
the VOD "…" menu.
3. The M3U player sidebar's Home button, which renders only outside the
workspace shell.
## Context Panel Rules
The shell decides which secondary panel to show from the current route: