Skip to content

Commit 78064fb

Browse files
benvinegarclaude
andcommitted
feat(viewer): homeView host flag — don't auto-select a session when the host owns Home
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 <noreply@anthropic.com>
1 parent ab5f91a commit 78064fb

5 files changed

Lines changed: 122 additions & 6 deletions

File tree

.changeset/host-home-view.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"sideshow": minor
3+
---
4+
5+
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).

e2e/embed-home-view.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// End-to-end browser proof of the `homeView` host flag: when an embedder owns its
2+
// own session-less landing (e.g. sideshow cloud's "Home" feed), the engine must NOT
3+
// auto-pick a session on boot — it stays session-less so nothing is highlighted
4+
// behind the host's landing. With the flag OFF (self-hosted default) the engine
5+
// auto-selects the latest session exactly as before, so parity is preserved.
6+
//
7+
// Same harness as embed-main-slot.spec.ts: publish a real surface (which creates a
8+
// session), then mount the engine with a router whose route carries NO session
9+
// (`sessionId: null`) — the host's home state — and toggle `homeView`.
10+
import { readFileSync } from "node:fs";
11+
import { fileURLToPath } from "node:url";
12+
import { expect, publish, test } from "./fixtures.ts";
13+
14+
const embedDir = fileURLToPath(new URL("../viewer/dist-embed", import.meta.url));
15+
16+
function contentType(path: string): string {
17+
if (path.endsWith(".js") || path.endsWith(".mjs")) return "text/javascript";
18+
if (path.endsWith(".wasm")) return "application/wasm";
19+
if (path.endsWith(".css")) return "text/css";
20+
return "application/octet-stream";
21+
}
22+
23+
// Mount with a session-less route (the host's home state) and a togglable homeView.
24+
const embedHtml = (homeView: boolean) => `<!doctype html>
25+
<html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%}#m{position:fixed;inset:0}</style></head>
26+
<body><div id="m"></div>
27+
<script type="module">
28+
import { mountViewer } from "/__embed/engine.js";
29+
mountViewer(document.getElementById("m"), {
30+
basePath: "",
31+
homeView: ${homeView ? "true" : "false"},
32+
router: {
33+
get: () => ({ sessionId: null }),
34+
navigate() {},
35+
subscribe() { return () => {}; },
36+
},
37+
});
38+
</script></body></html>`;
39+
40+
async function mount(page: import("@playwright/test").Page, serverUrl: string, homeView: boolean) {
41+
page.on("pageerror", (e) => console.error("[pageerror]", e.message));
42+
page.on("console", (m) => m.type() === "error" && console.error("[console]", m.text()));
43+
const path = `/__embedtest-home-${homeView ? "on" : "off"}`;
44+
await page.route(`**${path}`, (route) =>
45+
route.fulfill({ contentType: "text/html", body: embedHtml(homeView) }),
46+
);
47+
await page.route("**/__embed/**", (route) => {
48+
const name = new URL(route.request().url()).pathname.replace("/__embed/", "");
49+
route.fulfill({ contentType: contentType(name), body: readFileSync(`${embedDir}/${name}`) });
50+
});
51+
await page.goto(`${serverUrl}${path}`);
52+
}
53+
54+
test("homeView: a session-less route lands with NO session selected", async ({ page, server }) => {
55+
// Seed a real session so the sidebar has something to (not) select.
56+
await publish(server.url, { html: "<p>card</p>", title: "Seeded", agent: "e2e" }, "");
57+
58+
await mount(page, server.url, true);
59+
60+
// The session loads into the sidebar...
61+
await expect(page.locator("aside .sess").first()).toBeVisible();
62+
// ...but none is selected, and the engine never auto-opened the session (its
63+
// post cards aren't loaded — the stream stays empty behind the host's home).
64+
await expect(page.locator(".sess.sel")).toHaveCount(0);
65+
await expect(page.locator(".sess[aria-current='true']")).toHaveCount(0);
66+
await expect(page.locator(".card:not(#whatsNew)")).toHaveCount(0);
67+
});
68+
69+
test("homeView OFF (self-hosted default): a session-less route auto-selects the latest", async ({
70+
page,
71+
server,
72+
}) => {
73+
await publish(server.url, { html: "<p>card</p>", title: "Seeded", agent: "e2e" }, "");
74+
75+
await mount(page, server.url, false);
76+
77+
// Parity: with the flag off the engine auto-selects the one session and opens it.
78+
await expect(page.locator(".sess.sel")).toHaveCount(1);
79+
await expect(page.locator("#stream")).toBeVisible();
80+
});

viewer/embed.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ export interface SideshowHost {
3838
* true; self-hosted drives the same flag via a window global. Defaults to off.
3939
*/
4040
screenshots?: boolean;
41+
/**
42+
* The host renders its own session-less landing (a "home" view) when the route
43+
* carries no session. The engine then does NOT auto-pick a session: on boot it
44+
* honors a deep-linked `route.sessionId` but otherwise stays session-less (no
45+
* selection, nothing highlighted), and when the route later becomes session-less
46+
* it clears its selection instead of leaving the last session highlighted behind
47+
* the host's landing. Self-hosted leaves this unset and is unchanged. Defaults to off.
48+
*/
49+
homeView?: boolean;
4150
/**
4251
* The engine calls this with the fully-resolved palette on initial mount, on
4352
* every live theme switch, and on an OS light/dark flip — symmetric with

viewer/src/host.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ export interface SideshowHost {
4747
// tooltip when this is false. Self-hosted drives the same flag via
4848
// window.__SIDESHOW_SCREENSHOTS__. Optional — defaults to off.
4949
screenshots?: boolean;
50+
// The host renders its own session-less landing (a "home" view) when the route
51+
// carries no session, so the engine must NOT auto-pick a session: on boot it
52+
// honors a deep-linked `route.sessionId` but otherwise stays session-less (no
53+
// selection, nothing highlighted), and when the route later becomes session-less
54+
// it CLEARS its selection rather than leaving the last session highlighted behind
55+
// the host's landing. Self-hosted leaves this unset/false and is unchanged — it
56+
// auto-selects the latest session on boot and deselects explicitly via the
57+
// wordmark goHome() instead. Optional — defaults to off.
58+
homeView?: boolean;
5059
// The engine calls this with the fully-resolved palette on initial mount, on
5160
// every live theme switch, and on an OS light/dark flip. Symmetric with
5261
// router.navigate: the engine owns the themes and TELLS the host its colors,

viewer/src/state.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -226,16 +226,23 @@ export async function refreshSessions(targetPostId?: string | null) {
226226

227227
if (!selected() && sessions.length > 0) {
228228
// Check the route first, then localStorage, then fall back to first session.
229+
// A host that owns a session-less landing (homeView) skips that fallback: it
230+
// honors a deep-linked route session but otherwise stays session-less so the
231+
// host's home shows with nothing selected (no auto-open, no highlight).
229232
const route = host().router.get();
230233
const lastId = localStorage.getItem(LAST_SESSION_KEY);
234+
const fallback = host().homeView
235+
? null
236+
: (lastId && sessions.some((s) => s.id === lastId) && lastId) || sessions[0].id;
231237
const target =
232238
(route.sessionId && sessions.some((s) => s.id === route.sessionId) && route.sessionId) ||
233-
(lastId && sessions.some((s) => s.id === lastId) && lastId) ||
234-
sessions[0].id;
235-
await select(target, {
236-
replace: true,
237-
initialPostId: target === route.sessionId ? (route.surfaceId ?? undefined) : undefined,
238-
});
239+
fallback;
240+
if (target) {
241+
await select(target, {
242+
replace: true,
243+
initialPostId: target === route.sessionId ? (route.surfaceId ?? undefined) : undefined,
244+
});
245+
}
239246
}
240247
}
241248

@@ -317,6 +324,12 @@ export function applyRoute(route: Route) {
317324
fromPopState: true,
318325
initialPostId: route.surfaceId ?? undefined,
319326
});
327+
} else if (!route.sessionId && host().homeView && selected()) {
328+
// A host that owns a session-less landing: a route with no session IS that
329+
// home view, so clear the selection — otherwise the previously-open session
330+
// stays highlighted behind the host's home. (Self-hosted leaves homeView off
331+
// and keeps ignoring a null route here; it deselects explicitly via goHome.)
332+
setSelectedInternal(null);
320333
}
321334
}
322335

0 commit comments

Comments
 (0)