Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/host-home-view.md
Original file line number Diff line number Diff line change
@@ -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).
80 changes: 80 additions & 0 deletions e2e/embed-home-view.spec.ts
Original file line number Diff line number Diff line change
@@ -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) => `<!doctype html>
<html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%}#m{position:fixed;inset:0}</style></head>
<body><div id="m"></div>
<script type="module">
import { mountViewer } from "/__embed/engine.js";
mountViewer(document.getElementById("m"), {
basePath: "",
homeView: ${homeView ? "true" : "false"},
router: {
get: () => ({ sessionId: null }),
navigate() {},
subscribe() { return () => {}; },
},
});
</script></body></html>`;

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: "<p>card</p>", 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: "<p>card</p>", 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();
});
9 changes: 9 additions & 0 deletions viewer/embed.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions viewer/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 19 additions & 6 deletions viewer/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
}
}

Expand Down Expand Up @@ -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);
}
}

Expand Down
Loading