From ccfe55d87c480f79730a4410b615dc7f3f65fab9 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 30 Jun 2026 22:54:47 -0400 Subject: [PATCH] feat(viewer): report resolved theme id + mode to the host on theme change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `onThemeChange` handed the host only the resolved token VALUES, not which theme/scheme produced them. That's enough for a host that paints its own chrome from the tokens, but not for one that re-renders surfaces out-of-band: the cloud's feed preview frames are opaque-origin sandboxes served by `/s/:id`, which needs `?theme=&mode=` to reproduce the viewer's exact look. Without the ids, cross-workspace previews (the org feed) fell back to each source workspace's persisted theme instead of the viewer's. Add a second `meta: { theme, mode }` argument carrying the resolved theme id and light/dark scheme the engine already knows (it builds its own surface URLs from them). Additive: the tokens argument is unchanged and the default self-hosted host ignores it — parity preserved. Co-Authored-By: Claude Opus 4.8 --- .changeset/theme-change-meta.md | 11 +++++++++++ e2e/embed-theme.spec.ts | 7 ++++++- viewer/embed.d.ts | 8 +++++++- viewer/src/host.ts | 9 ++++++++- viewer/src/theme.ts | 4 +++- 5 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 .changeset/theme-change-meta.md diff --git a/.changeset/theme-change-meta.md b/.changeset/theme-change-meta.md new file mode 100644 index 0000000..026624d --- /dev/null +++ b/.changeset/theme-change-meta.md @@ -0,0 +1,11 @@ +--- +"sideshow": minor +--- + +Embed host contract: `onThemeChange` now receives a second `meta` argument — +`{ theme, mode }` — naming the resolved theme id and light/dark scheme behind the +tokens it already reports. Hosts that re-render surfaces out-of-band (e.g. +server-side preview frames they can't theme from the token values alone) can pass +those identifiers to `/s/:id?theme=&mode=` to reproduce the exact look; hosts that +only paint from the token values ignore it. Additive — the tokens argument is +unchanged and the default self-hosted host is unaffected. diff --git a/e2e/embed-theme.spec.ts b/e2e/embed-theme.spec.ts index c43a8c4..ef2ec97 100644 --- a/e2e/embed-theme.spec.ts +++ b/e2e/embed-theme.spec.ts @@ -36,7 +36,7 @@ const embedHtml = (sessionId: string) => ` navigate() {}, subscribe() { return () => {}; }, }, - onThemeChange(tokens) { window.__themeCalls++; window.__tokens = tokens; }, + onThemeChange(tokens, meta) { window.__themeCalls++; window.__tokens = tokens; window.__meta = meta; }, }); `; @@ -69,11 +69,15 @@ test("embedded engine pushes the resolved palette to the host on mount and on th await expect.poll(() => page.evaluate(() => window.__tokens?.["--accent"])).toBe("#0969da"); const callsAfterMount = await page.evaluate(() => window.__themeCalls); expect(callsAfterMount).toBeGreaterThan(0); + // The same push names the resolved theme + scheme behind those tokens, so a + // host can reproduce them out-of-band via /s/:id?theme=&mode=. + expect(await page.evaluate(() => window.__meta)).toEqual({ theme: "github", mode: "light" }); // Switching the theme via the engine's own picker pushes the new palette — // no host-side scraping involved. await page.locator("#themeSel").selectOption("gruvbox"); await expect.poll(() => page.evaluate(() => window.__tokens?.["--bg"])).toBe("#f9f5d7"); + await expect.poll(() => page.evaluate(() => window.__meta?.theme)).toBe("gruvbox"); expect(await page.evaluate(() => window.__themeCalls)).toBeGreaterThan(callsAfterMount); }); @@ -81,5 +85,6 @@ declare global { interface Window { __themeCalls: number; __tokens?: Record; + __meta?: { theme: string; mode: "light" | "dark" }; } } diff --git a/viewer/embed.d.ts b/viewer/embed.d.ts index 41b973e..958db6a 100644 --- a/viewer/embed.d.ts +++ b/viewer/embed.d.ts @@ -66,8 +66,14 @@ export interface SideshowHost { * `router.navigate`. A host mirrors the tokens onto its own chrome instead of * scraping computed styles across the shadow boundary. Optional — the trivial * self-hosted host omits it. + * + * `meta` names the resolved theme + scheme behind those tokens. A host that + * re-renders surfaces out-of-band (e.g. server-side preview frames it can't + * theme from the token values alone) needs the identifiers to reproduce the + * exact look via `/s/:id?theme=&mode=`; a host that only paints from the tokens + * can ignore it. Additive — the tokens argument is unchanged. */ - onThemeChange?(tokens: ThemeTokens): void; + onThemeChange?(tokens: ThemeTokens, meta: { theme: string; mode: "light" | "dark" }): void; /** * Called once, after the engine's first session-list fetch resolves and the * initial board (a session, or the empty-board onboarding) is decided — the diff --git a/viewer/src/host.ts b/viewer/src/host.ts index 2bac1f8..cd56a16 100644 --- a/viewer/src/host.ts +++ b/viewer/src/host.ts @@ -10,6 +10,7 @@ // the embedder's host before renders. import type { ThemeTokens } from "../../server/theme-tokens.ts"; +import type { Mode } from "../../server/themes.ts"; export type Route = { sessionId?: string | null; surfaceId?: string | null }; export type LiveTransport = "sse" | "ws"; @@ -71,7 +72,13 @@ export interface SideshowHost { // router.navigate: the engine owns the themes and TELLS the host its colors, // so an embedder can mirror them onto its own chrome without reaching across // the shadow boundary. Optional — the trivial self-hosted host omits it. - onThemeChange?(tokens: ThemeTokens): void; + // + // `meta` names the resolved theme + scheme behind those tokens. A host that + // re-renders surfaces out-of-band (e.g. server-side preview frames it can't + // theme from the token values alone) needs the identifiers to reproduce the + // exact look via `/s/:id?theme=&mode=`; a host that only paints from the tokens + // can ignore it. Additive — the tokens argument is unchanged. + onThemeChange?(tokens: ThemeTokens, meta: { theme: string; mode: Mode }): void; // The engine calls this once, after its first session-list fetch resolves and // the initial board (a session, or the empty-board onboarding) has been // decided — i.e. the moment the engine knows what to show. An embedding host diff --git a/viewer/src/theme.ts b/viewer/src/theme.ts index 2c42745..cfb00da 100644 --- a/viewer/src/theme.ts +++ b/viewer/src/theme.ts @@ -78,7 +78,9 @@ darkQuery?.addEventListener("change", (e) => { // them across the shadow boundary. Optional on the contract — the trivial // self-hosted host omits onThemeChange, so this no-ops there. function emitThemeTokens() { - host().onThemeChange?.(themeTokens(themeById(activeThemeState()), resolvedMode())); + const theme = activeThemeState(); + const mode = resolvedMode(); + host().onThemeChange?.(themeTokens(themeById(theme), mode), { theme, mode }); } const STYLE_ID = "ss-theme-vars";