Skip to content

Commit 7895d43

Browse files
benvinegarclaude
andcommitted
feat(viewer): add host-overridable ss:main slot for the main content pane
Adds a fourth host-overridable region to the embeddable engine: `ss:main`, wrapping the whole main content pane (onboarding + session stream). The slot's fallback is the engine's normal board, so a plain embed and self-hosted sideshow are unchanged. Unlike the always-on footer/empty/session-action overrides, this one is meant to be projected conditionally: an embedder projects a `slot="ss:main"` child only while its own full-pane view is active (e.g. sideshow cloud's Settings page), taking over the main area while the sidebar — session list and account footer — stays put. When no child is assigned, the engine falls back to the board. - host.ts: add `main: "ss:main"` to SLOTS - App.tsx: wrap the <main> body in <slot name={SLOTS.main}> with the board as fallback - embed.d.ts: document the new region in the public contract - e2e: prove the host pane takes over while the sidebar stays and the board hides Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7f86b13 commit 7895d43

4 files changed

Lines changed: 98 additions & 4 deletions

File tree

e2e/embed-main-slot.spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// End-to-end browser proof that an embedder can take over the engine's MAIN
2+
// content pane through the shadow boundary via the `ss:main` slot — the seam the
3+
// sideshow cloud uses to render its full-page "Settings" view in the main area
4+
// while the engine's sidebar (session list + account footer) stays put.
5+
//
6+
// Same harness as embed-slots.spec.ts. We mount in the default "full" layout with
7+
// a real session in view, and project a light-DOM `<div slot="ss:main">` child of
8+
// the mount element. The engine's <slot name="ss:main"> projects it in place of
9+
// the board, so the host pane shows and the engine's own #stream does not.
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+
const embedHtml = (sessionId: string) => `<!doctype html>
24+
<html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%}#m{position:fixed;inset:0}</style></head>
25+
<body><div id="m"><div slot="ss:main" id="hostMain"><h2>Host settings pane</h2></div></div>
26+
<script type="module">
27+
import { mountViewer } from "/__embed/engine.js";
28+
mountViewer(document.getElementById("m"), {
29+
basePath: "",
30+
router: {
31+
get: () => ({ sessionId: ${JSON.stringify(sessionId)} }),
32+
navigate() {},
33+
subscribe() { return () => {}; },
34+
},
35+
});
36+
</script></body></html>`;
37+
38+
test("embedded engine: ss:main slot takes over the main pane while the sidebar stays", async ({
39+
page,
40+
server,
41+
}) => {
42+
const surface = await publish(
43+
server.url,
44+
{ html: "<p>board card</p>", title: "Board card", agent: "e2e" },
45+
"",
46+
);
47+
48+
page.on("pageerror", (e) => console.error("[pageerror]", e.message));
49+
page.on("console", (m) => m.type() === "error" && console.error("[console]", m.text()));
50+
51+
await page.route("**/__embedtest", (route) =>
52+
route.fulfill({ contentType: "text/html", body: embedHtml(surface.sessionId) }),
53+
);
54+
await page.route("**/__embed/**", (route) => {
55+
const name = new URL(route.request().url()).pathname.replace("/__embed/", "");
56+
route.fulfill({ contentType: contentType(name), body: readFileSync(`${embedDir}/${name}`) });
57+
});
58+
59+
await page.goto(`${server.url}/__embedtest`);
60+
61+
// The host's light-DOM pane projects into the main slot and is visible.
62+
const hostMain = page.locator("#hostMain");
63+
await expect(hostMain).toBeVisible();
64+
await expect(page.locator("main slot[name='ss:main']")).toHaveCount(1);
65+
66+
// The sidebar (full layout) stays — the override is the main pane only, not the
67+
// whole viewport.
68+
await expect(page.locator("aside")).toBeVisible();
69+
70+
// The engine's own board content is replaced: with a child assigned to ss:main,
71+
// the slot's fallback (#sessionView stream) stays in the DOM — native <slot>
72+
// mechanics — but is not displayed, so the host pane is what the user sees.
73+
await expect(page.locator("#stream")).toBeHidden();
74+
});

viewer/embed.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ export declare const SLOTS: {
6464
readonly empty: "ss:empty";
6565
/** Per-session actions in the session header, beside the stream/timeline toggle. */
6666
readonly sessionActions: "ss:session-actions";
67+
/**
68+
* The whole main content pane (onboarding + session stream). Fallback is the
69+
* normal board; project a `slot="ss:main"` child to take over the main area
70+
* (e.g. a cloud Settings page) while the sidebar stays. Meant to be projected
71+
* conditionally — when no child is assigned the engine shows the board.
72+
*/
73+
readonly main: "ss:main";
6774
};
6875

6976
export type SlotName = (typeof SLOTS)[keyof typeof SLOTS];

viewer/src/App.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,15 @@ export default function App() {
187187
if (nearBottom()) setPillTarget(null);
188188
}}
189189
>
190-
<Show when={!streamMode()}>
191-
<Onboard />
192-
</Show>
193-
<SessionView />
190+
{/* Host-overridable main pane (SLOTS.main). Fallback is the normal
191+
board; an embedder projects a `slot="ss:main"` child to take over the
192+
pane (e.g. a cloud Settings page) while the sidebar stays. */}
193+
<slot name={SLOTS.main}>
194+
<Show when={!streamMode()}>
195+
<Onboard />
196+
</Show>
197+
<SessionView />
198+
</slot>
194199
</main>
195200
</div>
196201
<Show when={!streamMode()}>

viewer/src/host.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ export const SLOTS = {
6767
// Empty by default (self-hosted has no actions here); an embedder projects
6868
// session-scoped controls such as a cloud "Share" button. (`.session-head`, App.tsx)
6969
sessionActions: "ss:session-actions",
70+
// The whole main content pane (onboarding + session stream). Fallback is the
71+
// engine's normal board; an embedder projects a full-pane view here — e.g. a
72+
// cloud "Settings" page — to take over the main area while the sidebar (session
73+
// list, account footer) stays put. Unlike the always-on footer/empty overrides,
74+
// this is meant to be projected *conditionally*: project a child only while the
75+
// host view is active, and the engine falls back to the board when it's gone.
76+
// (`<main>`, App.tsx)
77+
main: "ss:main",
7078
} as const;
7179

7280
export type SlotName = (typeof SLOTS)[keyof typeof SLOTS];

0 commit comments

Comments
 (0)