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() {