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/aside-head-slot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": minor
---

Add a host-overridable `ss:aside-head` slot at the top of the sidebar, above the session list (mirrors `ss:aside-foot`). Self-hosted rendering is unchanged; an embedder can project a sidebar header — e.g. a workspace picker and a pinned Home link — above the session list.
90 changes: 90 additions & 0 deletions e2e/embed-aside-head-slot.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// End-to-end browser proof for the `ss:aside-head` slot — the host-overridable
// region at the top of the sidebar, above the session list. Empty by default
// (self-hosted shows nothing here); an embedder projects a `slot="ss:aside-head"`
// child to render its own sidebar header. Same embed harness as
// embed-aside-empty-slot.spec.ts: the embed page + built dist-embed bundle are
// served on the server's own origin so same-origin /api/* reads hit real data.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { expect, test } from "./fixtures.ts";
import type { Page } from "@playwright/test";

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";
}

// Mounts the engine in "full" layout over an empty board. `slotChild` is the
// light-DOM child (with a slot= attribute) projected into the mount element, or
// none. The router points at no session so the board renders the sidebar.
const embedHtml = (slotChild: string) => `<!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">${slotChild}</div>
<script type="module">
import { mountViewer } from "/__embed/engine.js";
mountViewer(document.getElementById("m"), {
basePath: "",
router: {
get: () => ({}),
navigate() {},
subscribe() { return () => {}; },
},
});
</script></body></html>`;

function serveEmbed(page: Page, html: string) {
page.on("pageerror", (e) => console.error("[pageerror]", e.message));
page.on("console", (m) => m.type() === "error" && console.error("[console]", m.text()));
return Promise.all([
page.route("**/__embedtest", (route) =>
route.fulfill({ contentType: "text/html", body: html }),
),
page.route("**/__embed/**", (route) => {
const name = new URL(route.request().url()).pathname.replace("/__embed/", "");
route.fulfill({ contentType: contentType(name), body: readFileSync(`${embedDir}/${name}`) });
}),
]);
}

test.describe("embedded engine: ss:aside-head slot", () => {
test("nothing is injected when no header is projected (self-hosted parity)", async ({
page,
server,
}) => {
await serveEmbed(page, embedHtml(""));
await page.goto(`${server.url}/__embedtest`);

// The sidebar (full layout) renders normally, with the Brand wordmark.
await expect(page.locator("aside")).toBeVisible();
await expect(page.locator("aside .brand")).toBeVisible();

// The slot is mounted (so an embedder can project into it) but it carries no
// fallback children — nothing is shown above the session list by default.
await expect(page.locator("aside slot[name='ss:aside-head']")).toHaveCount(1);
await expect(page.locator("#hostHead")).toHaveCount(0);
});

test("projected header renders above the session list through the shadow boundary", async ({
page,
server,
}) => {
await serveEmbed(page, embedHtml('<div slot="ss:aside-head" id="hostHead">host header</div>'));
await page.goto(`${server.url}/__embedtest`);

// The host's light-DOM projection shows.
const hostHead = page.locator("#hostHead");
await expect(hostHead).toBeVisible();
await expect(hostHead).toContainText("host header");

// It sits ABOVE the session list — its top edge is above #sessionList's top.
const headBox = await hostHead.boundingBox();
const listBox = await page.locator("aside #sessionList").boundingBox();
expect(headBox).not.toBeNull();
expect(listBox).not.toBeNull();
expect(headBox!.y).toBeLessThan(listBox!.y);
});
});
7 changes: 7 additions & 0 deletions viewer/embed.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ export function mountViewer(el: Element, host?: SideshowHost): ViewerHandle;
* mount element. Regions, not strings — kept small and coarse on purpose.
*/
export declare const SLOTS: {
/**
* Sidebar header: the host-overridable region at the top of the sidebar, above
* the session list. Empty by default (self-hosted shows nothing here); project a
* `slot="ss:aside-head"` child for a host header — e.g. a workspace picker and a
* pinned Home link.
*/
readonly asideHead: "ss:aside-head";
/** Sidebar footer: doc links, connect action, theme picker. */
readonly asideFoot: "ss:aside-foot";
/**
Expand Down
4 changes: 4 additions & 0 deletions viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ export default function App() {
<aside>
<Brand />
<UpdateBanner />
{/* Host-overridable region (SLOTS.asideHead): the sidebar header, above the
session list. Empty by default (self-hosted shows nothing here); an embedder
projects its own header — e.g. a cloud workspace picker + pinned Home link. */}
<slot name={SLOTS.asideHead} />
<div id="sessionList">
<For each={sessionGroups()}>
{(group) => (
Expand Down
5 changes: 5 additions & 0 deletions viewer/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ export interface SideshowHost {
// These are *regions*, not individual strings — keep the list small and coarse.
// Adding one is a deliberate contract change shared with every embedder.
export const SLOTS = {
// Sidebar header: the host-overridable region at the TOP of the sidebar, above
// the session list (`#sessionList`, App.tsx). Empty by default (self-hosted shows
// nothing here); an embedder projects a header — e.g. a cloud workspace picker +
// a pinned Home link.
asideHead: "ss:aside-head",
// Sidebar footer: design-guide / agent-setup links, the connect action, and the
// theme picker. (`#onboard` aside, App.tsx)
asideFoot: "ss:aside-foot",
Expand Down
Loading