From d2cb9445da6b1a19d30de648a0d401c2a5f32305 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 11 Oct 2026 16:15:17 +0200 Subject: [PATCH] fix(workspace): slide the content and header into place when a source opens Opening an Xtream portal from the dashboard inserted the 323 px context panel and the header Back in the frame that swaps the page: the content pane jumped right by the panel's width and the switcher, search and actions by Back's slot. That one frame was J2's whole layout shift (0.2327: 0.222 for the content pane, 0.011 for the header, scored with the pane's distance), plus 0.0001 when the category search and sort buttons appeared with the categories. LayoutSlideDirective animates these moves as a 180 ms FLIP: the new layout applies at once and only a transform animates, so nothing waits and no layout shift is recorded. The content surface covers the strip it uncovers while sliding, Back fades in, and reduced motion keeps the jump. The category search and sort buttons render disabled while the list loads. Locally J2 renderer.layoutShiftScore reads 0 (was 0.233), J1 0 / 0, J3 0.001, J4 0. Co-Authored-By: Claude Opus 5.5 --- .changes/workspace-layout-slide.md | 9 + docs/architecture/performance-journeys.md | 38 +++- docs/architecture/workspace-shell.md | 55 +++++- .../workspace-context-panel.component.html | 8 +- .../workspace-context-panel.component.spec.ts | 29 +++ .../workspace-context-panel.component.ts | 19 +- .../workspace-shell-header.component.html | 8 +- .../workspace-shell-header.component.scss | 13 ++ .../workspace-shell-header.component.spec.ts | 2 + .../workspace-shell-header.component.ts | 15 ++ .../layout-slide.directive.spec.ts | 187 ++++++++++++++++++ .../workspace-shell/layout-slide.directive.ts | 178 +++++++++++++++++ .../workspace-shell.component.html | 4 + .../workspace-shell.component.scss | 14 ++ .../workspace-shell.component.spec.ts | 6 + .../workspace-shell.component.ts | 2 + 16 files changed, 572 insertions(+), 15 deletions(-) create mode 100644 .changes/workspace-layout-slide.md create mode 100644 libs/workspace/shell/feature/src/lib/workspace-shell/layout-slide.directive.spec.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-shell/layout-slide.directive.ts diff --git a/.changes/workspace-layout-slide.md b/.changes/workspace-layout-slide.md new file mode 100644 index 000000000..1e59813ba --- /dev/null +++ b/.changes/workspace-layout-slide.md @@ -0,0 +1,9 @@ +--- +type: fix +area: workspace +--- + +Opening a source from the dashboard no longer makes the page jump: the content +slides aside for the category panel, and the header controls slide over for +the Back button. The category list's search and sort buttons are in place +while the list loads. diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 12d9c575b..021eb3c42 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -1279,9 +1279,41 @@ measured iterations of the 11 `master` runs from 2026-10-03 08:25 to `open-source/renderer.layoutShiftScore` read 0.222 in that window and 0.233 in every iteration of every `master` run from 84aef83a6 (#1814, page Back -buttons moved into the header) on, so its value is 0.233 with `evidenceRun` -37372780064 (b78224376). The 0.011 that #1814 added is not explained yet; -lowering it back is a separate change. +buttons moved into the header) on, so its value was 0.233 with `evidenceRun` +37372780064 (b78224376). + +Where the 0.233 came from (`evidence.layoutShift.shifts`, a 1280×720 window; +the score of a shift is the area of every node that moved times the largest +distance any of them moved, as a fraction of the viewport): + +| Shift, ms after the click | Nodes that moved | Score | +| ------------------------- | ---------------------------------------------------------- | ------ | +| ~40 (page swap) | `main.workspace-content` x 60 → 383, width −323 | 0.222 | +| | switcher, `label.search-field`, `div.header-actions` x +44 | 0.011 | +| ~50-70 (categories) | `div.context-header__actions` x −60, width +60 | 0.0001 | + +The first two are one frame: the frame that replaces the dashboard with the +portal page also inserts the 323 px context panel the dashboard has no room +for, and the history Back that #1814 shows once an in-app previous entry +exists. The header items move only 44 px, but a shift is scored with the +largest distance in it, the content pane's 323 px, which is why Back added +0.011 rather than about 0.0015. The third is the category search and sort +buttons appearing when the categories arrive. The skeletons of the category +list and the grid are replaced by content without any shift. Nothing was +hidden behind the startup overlay: the "Syncing playlist" overlay of the +cached catalog load flashes for about two frames after the shift and covers +no further one. + +The content frame and the header controls now slide into place instead of +jumping ([Layout slides](./workspace-shell.md#layout-slides)): the new layout +applies in the same frame and only a transform animates, which Chromium does +not record as a layout shift. The category search and sort buttons render +disabled while the categories load. Locally (macOS, 2026-10-11) J2 then read +0 in every iteration with no shift recorded, J1 0 / 0, J3 0.001 and J4 0; +`renderer.domMutationsToFirstPage` rose by 22 (the slide's class and style +changes) and the wall-clock entries did not move. Under +`prefers-reduced-motion` there is no slide, so a user with that preference +still sees the jump that the journey no longer measures. Being deterministic is not the same as being validated. Principle 3 of the plan promotes a counter to a guardrail once a PR has shown that lowering it diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index a161a3eee..f6242c6a6 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -127,6 +127,49 @@ synthesized mask, unlike any content layer) may span half the content area, and the backdrop-filtered controls must really be composited, so the check cannot pass on a page with nothing to clip. +### Layout slides + +When a route change adds or removes the context panel, the content frame +moves by the panel's width (323 px on desktop), and when Back or the phone +drawer toggle appears or leaves, the header controls after it move by the +slot's width (44 px). Both happen in the frame that swaps the page, so they +used to be one jump: a layout shift of 0.233 in the open-source journey, +0.222 of it the content frame (see +[performance journeys](./performance-journeys.md#j2-open-source-open-a-source-to-a-browsable-list)). + +`LayoutSlideDirective` (`workspace-shell/layout-slide.directive.ts`) turns +these jumps into a 180 ms slide, the duration of the context panel's own +width transition. The new layout applies at once; each target starts +translated back to the position it had and animates its transform to none +(FLIP), so nothing waits for the animation and Chromium records no layout +shift for a transform. The shell's `.workspace-body` slides +`.workspace-content-frame` when `showContextPanel` changes; the header slides +the drawer toggle, switcher, search and action groups when its leading +navigation (Back, drawer toggle) changes. + +1. **Previous positions.** `offsetLeft` values, which ignore transforms, are + recorded after every render in which the slide key or the record key + changed (the header's record key covers the other inputs that move its + controls: settings route, no playlists, parental lock, shortcuts) and + when the host or a target resizes (window resize, a context panel folding + or being dragged wider). A slide therefore starts where the target was + last laid out, and a target that has no recorded position or did not move + is left alone. +2. **The uncovered strip.** The frame is narrower beside the panel than + without it, so the side it moves away from would show the body colour + during the slide. While a slide runs the target carries + `layout-slide-active` and the uncovered widths as `--layout-slide-start` + and `--layout-slide-end`; the frame's stylesheet extends the content + surface over that strip with an `::after` below the scroller. +3. **Back fades in** over the same 180 ms, so the switcher still sliding away + from its slot does not draw over the arrow. +4. **Reduced motion.** Under `prefers-reduced-motion` there is no animation: + the jump (and its layout shift) remains. +5. **Not covered.** A slide does not change what the route renders. Going + back from a portal to the dashboard still shows one frame with the new + dashboard below the old page before the old page leaves; that jump is a + routing issue, not a layout slide. + `WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is kept as a thin template-facing API and delegates ownership to component-scoped services: @@ -215,9 +258,10 @@ return logic (`backClicked`). 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. +TV), where a disabled arrow would stay for the whole session. When Back first +appears or leaves, which happens only at the start of the history and together +with a route change, the switcher and search slide by the slot's width instead +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. @@ -263,7 +307,10 @@ Xtream and Stalker category panels preserve provider/server category order by default. The panel header exposes a sort menu next to category search with `Server sorting`, `A-Z`, and `Z-A`; when alphabetical sorting is active, synthetic "all categories" entries stay pinned before sorted provider -categories. +categories. Search and sort render disabled while the categories load (beside +the disabled Manage categories button), so the header row has its final width +under the skeleton instead of growing by two buttons when the list arrives; +they are hidden only once a load ended with no categories. On live sections the category panel can be folded away independently of the channels list; the folded panel is reachable as a popover from the channels diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html index fc4b6440b..d6ae3db4e 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html @@ -3,12 +3,15 @@

{{ title() | translate }}

- @if (canSearchCategories()) { + + @if (showCategoryListActions()) { } - @if (canSearchCategories()) { + @if (showCategoryListActions()) {