Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/theme-change-meta.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion e2e/embed-theme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const embedHtml = (sessionId: string) => `<!doctype html>
navigate() {},
subscribe() { return () => {}; },
},
onThemeChange(tokens) { window.__themeCalls++; window.__tokens = tokens; },
onThemeChange(tokens, meta) { window.__themeCalls++; window.__tokens = tokens; window.__meta = meta; },
});
</script></body></html>`;

Expand Down Expand Up @@ -69,17 +69,22 @@ 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);
});

declare global {
interface Window {
__themeCalls: number;
__tokens?: Record<string, string>;
__meta?: { theme: string; mode: "light" | "dark" };
}
}
8 changes: 7 additions & 1 deletion viewer/embed.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion viewer/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
// the embedder's host before <App/> 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";
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion viewer/src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading