From c5888fbf2b6e795e6b5390d4a78fc61cb2d205c8 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 27 Jun 2026 09:43:33 -0400 Subject: [PATCH] feat(viewer): add host-overridable ss:aside-head slot atop the sidebar Mirrors ss:aside-foot at the top of the sidebar, above the session list, so an embedder can project a header (e.g. a workspace picker + Home link). The slot is bare with no fallback children, so with no projection it has zero height and self-hosted rendering is byte-identical to before. Co-Authored-By: Claude Opus 4.8 --- .changeset/aside-head-slot.md | 5 ++ e2e/embed-aside-head-slot.spec.ts | 90 +++++++++++++++++++++++++++++++ viewer/embed.d.ts | 7 +++ viewer/src/App.tsx | 4 ++ viewer/src/host.ts | 5 ++ 5 files changed, 111 insertions(+) create mode 100644 .changeset/aside-head-slot.md create mode 100644 e2e/embed-aside-head-slot.spec.ts diff --git a/.changeset/aside-head-slot.md b/.changeset/aside-head-slot.md new file mode 100644 index 0000000..57f7b90 --- /dev/null +++ b/.changeset/aside-head-slot.md @@ -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. diff --git a/e2e/embed-aside-head-slot.spec.ts b/e2e/embed-aside-head-slot.spec.ts new file mode 100644 index 0000000..ab63ebd --- /dev/null +++ b/e2e/embed-aside-head-slot.spec.ts @@ -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) => ` + +
${slotChild}
+`; + +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('
host header
')); + 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); + }); +}); diff --git a/viewer/embed.d.ts b/viewer/embed.d.ts index 687bc35..17424b0 100644 --- a/viewer/embed.d.ts +++ b/viewer/embed.d.ts @@ -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"; /** diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx index 22c82bd..9d346aa 100644 --- a/viewer/src/App.tsx +++ b/viewer/src/App.tsx @@ -195,6 +195,10 @@ export default function App() {