diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts index 9a4313ab..ae8b3c1a 100644 --- a/frontend/e2e/channel.spec.ts +++ b/frontend/e2e/channel.spec.ts @@ -29,4 +29,55 @@ test.describe("channel view", () => { await expect(page.getByTestId("channel-title")).toHaveCount(0); await expect(page.getByTestId("video-card").first()).toBeVisible(); }); + + // Bug 1 (Platform S4): opening a channel and coming Back restores the feed's scroll position + // rather than dumping you at the top. The shell owns the scroller and remembers each view's offset. + test("Back restores the feed scroll position", async ({ page }) => { + await openApp(page); + await expect(page.getByTestId("video-card").first()).toBeVisible(); + + const main = page.getByRole("main"); + await main.evaluate((el) => el.scrollBy(0, 3000)); + // Let any page-fetch triggered by the scroll settle, so the same content is cached for the + // return trip (a fetch still in flight at navigation would leave a shorter list to restore into). + await page.waitForTimeout(600); + const saved = await main.evaluate((el) => el.scrollTop); + expect(saved).toBeGreaterThan(1500); + + await page.getByTestId("video-card-channel").first().click(); + await expect(page.getByTestId("channel-title")).toBeVisible(); + await page.getByTestId("channel-back").click(); + await expect(page.getByTestId("video-card").first()).toBeVisible(); + + // Restored to roughly where we were, not reset to the top. Tolerance absorbs the virtualizer's + // estimate-vs-measured row heights differing slightly between the two mounts. + await expect + .poll(() => page.getByRole("main").evaluate((el) => el.scrollTop), { timeout: 5000 }) + .toBeGreaterThan(saved - 500); + }); + + // Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays + // put while only the video grid scrolls. + test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => { + await openApp(page); + await page.getByTestId("video-card-channel").first().click(); + const count = page.getByTestId("feed-result-count"); + const title = page.getByTestId("channel-title"); + await expect(count).toBeVisible(); + await expect(page.getByTestId("video-card").first()).toBeVisible(); + const titleTop = await title.evaluate((el) => el.getBoundingClientRect().top); + const countTop = await count.evaluate((el) => el.getBoundingClientRect().top); + + // Keep nudging until the grid is tall enough to actually scroll (it may still be filling in). + await expect + .poll(async () => { + await page.getByRole("main").evaluate((el) => el.scrollBy(0, 1500)); + return page.getByRole("main").evaluate((el) => el.scrollTop); + }) + .toBeGreaterThan(400); + + // The chrome didn't move — banner title and toolbar are in exactly the same place. + expect(await title.evaluate((el) => el.getBoundingClientRect().top)).toBe(titleTop); + expect(await count.evaluate((el) => el.getBoundingClientRect().top)).toBe(countTop); + }); }); diff --git a/frontend/e2e/feed.spec.ts b/frontend/e2e/feed.spec.ts index 95de5ce0..724652c2 100644 --- a/frontend/e2e/feed.spec.ts +++ b/frontend/e2e/feed.spec.ts @@ -47,4 +47,35 @@ test.describe("feed", () => { .poll(async () => (await renderedIds()).some((id) => id && !before.includes(id))) .toBe(true); }); + + // Bug 4 (Platform S4): the toolbar (show/content chips, count, sort, view) is fixed chrome — it + // pins in the shell's toolbar band above the scroller, so it stays put while the cards scroll. + test("the feed toolbar stays pinned while cards scroll", async ({ page }) => { + await openApp(page); + const count = page.getByTestId("feed-result-count"); + await expect(count).toBeVisible(); + const topBefore = await count.evaluate((el) => el.getBoundingClientRect().top); + + await page.getByRole("main").evaluate((el) => el.scrollBy(0, 4000)); + + await expect(count).toBeInViewport(); + const topAfter = await count.evaluate((el) => el.getBoundingClientRect().top); + expect(Math.abs(topAfter - topBefore)).toBeLessThan(4); + }); + + // Bug 3 (Platform S4): changing a filter is a fresh result set, so the scroll jumps back to the + // top rather than stranding you deep in the old list's offset. + test("a new filter resets the scroll to the top", async ({ page }) => { + await openApp(page); + await expect(page.getByTestId("video-card").first()).toBeVisible(); + + await page.getByRole("main").evaluate((el) => el.scrollBy(0, 4000)); + expect(await page.getByRole("main").evaluate((el) => el.scrollTop)).toBeGreaterThan(500); + + // Switch the show filter → a different result set → back to the top. + await page.getByTestId("feed-show-all").click(); + await expect + .poll(() => page.getByRole("main").evaluate((el) => el.scrollTop)) + .toBeLessThan(50); + }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4c649dc4..3767d49d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -53,7 +53,7 @@ import NavSidebar from "./components/NavSidebar"; import Sidebar from "./components/Sidebar"; import PlaylistsRail from "./components/PlaylistsRail"; import BackToTop from "./components/BackToTop"; -import PageScroller from "./components/PageScroller"; +import PageShell from "./components/PageShell"; import GlassTuner from "./components/GlassTuner"; import ChatDock from "./components/ChatDock"; import Toaster from "./components/Toaster"; @@ -204,7 +204,11 @@ export default function App() { // of dumping the user on whatever tab they last left open. const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed"); const channelsView: ChannelsView = - channelsViewRaw === "discovery" ? "discovery" : "subscribed"; + channelsViewRaw === "discovery" + ? "discovery" + : channelsViewRaw === "blocked" + ? "blocked" + : "subscribed"; // Plex module filters (its own left-sidebar filter section) — per-account persisted. // The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library). // (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".) @@ -632,6 +636,17 @@ export default function App() {
- {t("channels.discovery.intro")} -
- {query.isLoading ? ( -{t("channels.discovery.intro")}
+{t("channels.blocked.hint")}
-{t("channels.blocked.empty")}
+ ) : ( + <> +{t("channels.blocked.hint")}
+ > + )} +{t("config.intro")}
- -{t("config.intro")}
+