From 78064fbfe2175879c8def8c3bf8b7fd2ca96a578 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 27 Jun 2026 21:09:32 -0400 Subject: [PATCH] =?UTF-8?q?feat(viewer):=20homeView=20host=20flag=20?= =?UTF-8?q?=E2=80=94=20don't=20auto-select=20a=20session=20when=20the=20ho?= =?UTF-8?q?st=20owns=20Home?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a host owns its own session-less landing (e.g. sideshow cloud's Home feed), auto-selecting a session on boot leaves it highlighted in the sidebar behind the host's landing, and navigating back to the session-less route never clears it (applyRoute ignores a null route). Add an opt-in `homeView` flag to SideshowHost: when set, the engine honors a deep-linked route session but otherwise stays session-less on boot (no selection, no highlight), and clears its selection when the route becomes session-less. Self-hosted leaves the flag unset and is unchanged (auto-selects the latest on boot; deselects via the wordmark goHome). Co-Authored-By: Claude Opus 4.8 --- .changeset/host-home-view.md | 5 +++ e2e/embed-home-view.spec.ts | 80 ++++++++++++++++++++++++++++++++++++ viewer/embed.d.ts | 9 ++++ viewer/src/host.ts | 9 ++++ viewer/src/state.ts | 25 ++++++++--- 5 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 .changeset/host-home-view.md create mode 100644 e2e/embed-home-view.spec.ts diff --git a/.changeset/host-home-view.md b/.changeset/host-home-view.md new file mode 100644 index 0000000..a299f68 --- /dev/null +++ b/.changeset/host-home-view.md @@ -0,0 +1,5 @@ +--- +"sideshow": minor +--- + +Add a `homeView` flag to the embed host contract. When a host owns its own session-less landing (e.g. a "home" feed), the engine no longer auto-selects a session on boot — it honors a deep-linked route session but otherwise stays session-less so nothing is highlighted behind the host's landing, and it clears the selection when the route later becomes session-less. Self-hosted leaves the flag unset and is unchanged (auto-selects the latest session on boot). diff --git a/e2e/embed-home-view.spec.ts b/e2e/embed-home-view.spec.ts new file mode 100644 index 0000000..ea2961d --- /dev/null +++ b/e2e/embed-home-view.spec.ts @@ -0,0 +1,80 @@ +// End-to-end browser proof of the `homeView` host flag: when an embedder owns its +// own session-less landing (e.g. sideshow cloud's "Home" feed), the engine must NOT +// auto-pick a session on boot — it stays session-less so nothing is highlighted +// behind the host's landing. With the flag OFF (self-hosted default) the engine +// auto-selects the latest session exactly as before, so parity is preserved. +// +// Same harness as embed-main-slot.spec.ts: publish a real surface (which creates a +// session), then mount the engine with a router whose route carries NO session +// (`sessionId: null`) — the host's home state — and toggle `homeView`. +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { expect, publish, test } from "./fixtures.ts"; + +const embedDir = fileURLToPath(new URL("../viewer/dist-embed", import.meta.url)); + +function contentType(path: string): string { + if (path.endsWith(".js") || path.endsWith(".mjs")) return "text/javascript"; + if (path.endsWith(".wasm")) return "application/wasm"; + if (path.endsWith(".css")) return "text/css"; + return "application/octet-stream"; +} + +// Mount with a session-less route (the host's home state) and a togglable homeView. +const embedHtml = (homeView: boolean) => ` + +
+`; + +async function mount(page: import("@playwright/test").Page, serverUrl: string, homeView: boolean) { + page.on("pageerror", (e) => console.error("[pageerror]", e.message)); + page.on("console", (m) => m.type() === "error" && console.error("[console]", m.text())); + const path = `/__embedtest-home-${homeView ? "on" : "off"}`; + await page.route(`**${path}`, (route) => + route.fulfill({ contentType: "text/html", body: embedHtml(homeView) }), + ); + await page.route("**/__embed/**", (route) => { + const name = new URL(route.request().url()).pathname.replace("/__embed/", ""); + route.fulfill({ contentType: contentType(name), body: readFileSync(`${embedDir}/${name}`) }); + }); + await page.goto(`${serverUrl}${path}`); +} + +test("homeView: a session-less route lands with NO session selected", async ({ page, server }) => { + // Seed a real session so the sidebar has something to (not) select. + await publish(server.url, { html: "

card

", title: "Seeded", agent: "e2e" }, ""); + + await mount(page, server.url, true); + + // The session loads into the sidebar... + await expect(page.locator("aside .sess").first()).toBeVisible(); + // ...but none is selected, and the engine never auto-opened the session (its + // post cards aren't loaded — the stream stays empty behind the host's home). + await expect(page.locator(".sess.sel")).toHaveCount(0); + await expect(page.locator(".sess[aria-current='true']")).toHaveCount(0); + await expect(page.locator(".card:not(#whatsNew)")).toHaveCount(0); +}); + +test("homeView OFF (self-hosted default): a session-less route auto-selects the latest", async ({ + page, + server, +}) => { + await publish(server.url, { html: "

card

", title: "Seeded", agent: "e2e" }, ""); + + await mount(page, server.url, false); + + // Parity: with the flag off the engine auto-selects the one session and opens it. + await expect(page.locator(".sess.sel")).toHaveCount(1); + await expect(page.locator("#stream")).toBeVisible(); +}); diff --git a/viewer/embed.d.ts b/viewer/embed.d.ts index 687bc35..e598237 100644 --- a/viewer/embed.d.ts +++ b/viewer/embed.d.ts @@ -38,6 +38,15 @@ export interface SideshowHost { * true; self-hosted drives the same flag via a window global. Defaults to off. */ screenshots?: boolean; + /** + * The host renders its own session-less landing (a "home" view) when the route + * carries no session. The engine then does NOT auto-pick a session: on boot it + * honors a deep-linked `route.sessionId` but otherwise stays session-less (no + * selection, nothing highlighted), and when the route later becomes session-less + * it clears its selection instead of leaving the last session highlighted behind + * the host's landing. Self-hosted leaves this unset and is unchanged. Defaults to off. + */ + homeView?: boolean; /** * The engine calls this with the fully-resolved palette on initial mount, on * every live theme switch, and on an OS light/dark flip — symmetric with diff --git a/viewer/src/host.ts b/viewer/src/host.ts index fe118c6..66a7f8b 100644 --- a/viewer/src/host.ts +++ b/viewer/src/host.ts @@ -47,6 +47,15 @@ export interface SideshowHost { // tooltip when this is false. Self-hosted drives the same flag via // window.__SIDESHOW_SCREENSHOTS__. Optional — defaults to off. screenshots?: boolean; + // The host renders its own session-less landing (a "home" view) when the route + // carries no session, so the engine must NOT auto-pick a session: on boot it + // honors a deep-linked `route.sessionId` but otherwise stays session-less (no + // selection, nothing highlighted), and when the route later becomes session-less + // it CLEARS its selection rather than leaving the last session highlighted behind + // the host's landing. Self-hosted leaves this unset/false and is unchanged — it + // auto-selects the latest session on boot and deselects explicitly via the + // wordmark goHome() instead. Optional — defaults to off. + homeView?: boolean; // The engine calls this with the fully-resolved palette on initial mount, on // every live theme switch, and on an OS light/dark flip. Symmetric with // router.navigate: the engine owns the themes and TELLS the host its colors, diff --git a/viewer/src/state.ts b/viewer/src/state.ts index adb3f7a..64c6e34 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -226,16 +226,23 @@ export async function refreshSessions(targetPostId?: string | null) { if (!selected() && sessions.length > 0) { // Check the route first, then localStorage, then fall back to first session. + // A host that owns a session-less landing (homeView) skips that fallback: it + // honors a deep-linked route session but otherwise stays session-less so the + // host's home shows with nothing selected (no auto-open, no highlight). const route = host().router.get(); const lastId = localStorage.getItem(LAST_SESSION_KEY); + const fallback = host().homeView + ? null + : (lastId && sessions.some((s) => s.id === lastId) && lastId) || sessions[0].id; const target = (route.sessionId && sessions.some((s) => s.id === route.sessionId) && route.sessionId) || - (lastId && sessions.some((s) => s.id === lastId) && lastId) || - sessions[0].id; - await select(target, { - replace: true, - initialPostId: target === route.sessionId ? (route.surfaceId ?? undefined) : undefined, - }); + fallback; + if (target) { + await select(target, { + replace: true, + initialPostId: target === route.sessionId ? (route.surfaceId ?? undefined) : undefined, + }); + } } } @@ -317,6 +324,12 @@ export function applyRoute(route: Route) { fromPopState: true, initialPostId: route.surfaceId ?? undefined, }); + } else if (!route.sessionId && host().homeView && selected()) { + // A host that owns a session-less landing: a route with no session IS that + // home view, so clear the selection — otherwise the previously-open session + // stays highlighted behind the host's home. (Self-hosted leaves homeView off + // and keeps ignoring a null route here; it deselects explicitly via goHome.) + setSelectedInternal(null); } }