Skip to content
Open
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
27 changes: 22 additions & 5 deletions interface/opencode-embed-src/embed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider, useTheme, type DesktopTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta"
import { MemoryRouter, Route, createMemoryHistory } from "@solidjs/router"
import { ErrorBoundary, lazy, onMount, type ParentProps, Show, Suspense } from "solid-js"
import { createEffect, createSignal, ErrorBoundary, lazy, onMount, type ParentProps, Show, Suspense } from "solid-js"
import { render } from "solid-js/web"
// Theme overrides use `var(--color-*)` SpaceUI tokens rather than static hex,
// so the embed tracks whichever Spacebot theme class is on <html> (dark,
Expand Down Expand Up @@ -124,8 +124,9 @@ function ServerKey(props: ParentProps) {

/**
* Registers and activates a custom theme + color scheme inside the
* ThemeProvider. Runs once on mount — theme changes propagate
* reactively through OpenCode's own effect in the ThemeProvider.
* ThemeProvider. Theme registration runs once on mount; colorScheme
* tracks props reactively so the host can flip light/dark without
* remounting (preserves session state).
*/
function ThemeInjector(props: ParentProps & { theme?: DesktopTheme; colorScheme?: ColorScheme }) {
const ctx = useTheme()
Expand All @@ -135,6 +136,9 @@ function ThemeInjector(props: ParentProps & { theme?: DesktopTheme; colorScheme?
ctx.registerTheme(theme)
ctx.setTheme(theme.id)
}
})
// Reactive — re-fires whenever the host updates the colorScheme signal
createEffect(() => {
if (props.colorScheme) {
ctx.setColorScheme(props.colorScheme)
}
Expand Down Expand Up @@ -180,6 +184,13 @@ export type MountOpenCodeHandle = {
* e.g. handle.navigate("/<base64dir>/session/<sessionId>")
*/
navigate: (route: string) => void

/**
* Update the active color scheme ("light" | "dark" | "system") at runtime.
* Lets host apps re-theme the embed when their own theme changes,
* without remounting the embedded SolidJS app.
*/
setColorScheme: (scheme: ColorScheme) => void
}

/**
Expand All @@ -195,7 +206,10 @@ export function mountOpenCode(
container: HTMLElement,
config: MountOpenCodeConfig,
): MountOpenCodeHandle {
const { serverUrl, initialRoute = "/", colorScheme = "dark" } = config
const { serverUrl, initialRoute = "/", colorScheme: initialColorScheme = "dark" } = config
// Reactive signal so the host can flip color scheme post-mount
// (handle.setColorScheme) without remounting the SolidJS tree.
const [colorScheme, setColorScheme] = createSignal<ColorScheme>(initialColorScheme)
// Resolve theme: undefined → default Spacebot theme, null → no injection
const theme = config.theme === undefined
? (spacebotTheme as DesktopTheme)
Expand Down Expand Up @@ -238,7 +252,7 @@ export function mountOpenCode(
<MetaProvider>
<Font />
<ThemeProvider>
<ThemeInjector theme={theme} colorScheme={colorScheme}>
<ThemeInjector theme={theme} colorScheme={colorScheme()}>
<LanguageProvider>
<UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
Expand Down Expand Up @@ -284,5 +298,8 @@ export function mountOpenCode(
navigate: (route: string) => {
memory.set({ value: route })
},
setColorScheme: (scheme: ColorScheme) => {
setColorScheme(scheme)
},
}
}
27 changes: 14 additions & 13 deletions interface/opencode-embed-src/spacebot-theme.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@
"input-selected": "var(--color-app-selected)",
"text-base": "var(--color-ink)",
"text-weak": "var(--color-ink-dull)",
"text-weaker": "var(--color-ink-faint)",
"text-weaker": "#5a5a66",
"text-strong": "var(--color-ink)",
"text-interactive-base": "var(--color-accent)",
"text-interactive-base": "#5818b8",
"icon-base": "var(--color-ink-dull)",
"icon-weak-base": "var(--color-ink-faint)",
"icon-strong-base": "var(--color-ink)",
Expand Down Expand Up @@ -77,23 +77,23 @@
"surface-diff-add-base": "#0e2018",
"surface-diff-delete-base": "#200e10",
"surface-diff-hidden-base": "#18181f",
"syntax-comment": "var(--color-ink-faint)",
"syntax-string": "#5cf0b0",
"syntax-primitive": "#f06060",
"syntax-property": "#c080f0",
"syntax-type": "#f0c070",
"syntax-constant": "#70d0f0",
"syntax-comment": "#5a5a66",
"syntax-string": "#066b30",
"syntax-primitive": "#a00808",
"syntax-property": "#5818b8",
"syntax-type": "#5d3800",
"syntax-constant": "#0a4d85",
"syntax-keyword": "var(--color-ink-dull)",
"syntax-operator": "var(--color-ink-dull)",
"syntax-variable": "var(--color-ink)",
"syntax-object": "var(--color-ink)",
"syntax-punctuation": "var(--color-ink-dull)",
"syntax-info": "#70d0f0",
"syntax-info": "#0a4d85",
"markdown-heading": "var(--color-accent-faint)",
"markdown-text": "var(--color-ink)",
"markdown-link": "var(--color-accent)",
"markdown-link-text": "var(--color-accent)",
"markdown-code": "#5cf0b0",
"markdown-link": "#5818b8",
"markdown-link-text": "#5818b8",
"markdown-code": "#a00808",
"markdown-block-quote": "var(--color-ink-dull)",
"markdown-emph": "#f0c070",
"markdown-strong": "var(--color-accent-faint)",
Expand All @@ -102,7 +102,8 @@
"markdown-list-enumeration": "var(--color-accent-faint)",
"markdown-image": "var(--color-accent-faint)",
"markdown-image-text": "var(--color-accent-faint)",
"markdown-code-block": "var(--color-ink)"
"markdown-code-block": "var(--color-ink)",
"text-interactive-strong": "#5818b8"
}
},
"dark": {
Expand Down
86 changes: 85 additions & 1 deletion interface/src/components/OpenCodeEmbed.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {useState, useEffect, useRef} from "react";
import {useTheme} from "../hooks/useTheme";

/** RFC 4648 base64url encoding (no padding), matching OpenCode's directory encoding. */
export function base64UrlEncode(value: string): string {
Expand All @@ -14,10 +15,15 @@ export function base64UrlEncode(value: string): string {
let embedAssetsPromise: Promise<{
mountOpenCode: (
el: HTMLElement,
config: {serverUrl: string; initialRoute?: string},
config: {
serverUrl: string;
initialRoute?: string;
colorScheme?: "light" | "dark" | "system";
},
) => {
dispose: () => void;
navigate: (route: string) => void;
setColorScheme?: (scheme: "light" | "dark" | "system") => void;
};
cssText: string;
}> | null = null;
Expand Down Expand Up @@ -84,8 +90,60 @@ function loadEmbedAssets() {
* Multiple OpenCodeEmbed instances can coexist (e.g. orchestration view);
* the portal CSS is only removed when the last instance unmounts.
*/
/**
* SpaceUI theme tokens (CSS custom properties) that need to cross the Shadow
* DOM boundary so the embedded OpenCode SPA can theme its prompt-input,
* buttons, etc. against the active Spacebot theme. Sourced from
* spaceui/packages/tokens/src/css/themes/<theme>.css — keep in sync when
* spaceui adds/removes tokens.
*/
const FORWARDED_THEME_TOKENS = [
"--color-accent", "--color-accent-faint", "--color-accent-deep",
"--color-ink", "--color-ink-dull", "--color-ink-faint",
"--color-sidebar", "--color-sidebar-box", "--color-sidebar-line",
"--color-sidebar-ink", "--color-sidebar-ink-dull", "--color-sidebar-ink-faint",
"--color-sidebar-divider", "--color-sidebar-button", "--color-sidebar-selected",
"--color-sidebar-shade",
"--color-app", "--color-app-box", "--color-app-dark-box", "--color-app-darker-box",
"--color-app-light-box", "--color-app-overlay", "--color-app-input", "--color-app-focus",
"--color-app-line", "--color-app-divider", "--color-app-button", "--color-app-hover",
"--color-app-selected", "--color-app-selected-item", "--color-app-active",
"--color-app-shade", "--color-app-frame", "--color-app-slider",
"--color-app-explorer-scrollbar",
"--color-menu", "--color-menu-line", "--color-menu-ink", "--color-menu-faint",
"--color-menu-hover", "--color-menu-selected", "--color-menu-shade",
];

/**
* Read the current values of FORWARDED_THEME_TOKENS from the document root
* and inject them as `:host { --foo: bar; ... }` into the given style
* element. This makes the tokens available inside the Shadow DOM so the
* OpenCode embed's CSS can resolve var(--color-app-box) etc. against the
* active Spacebot theme.
*/
function forwardThemeTokens(styleEl: HTMLStyleElement) {
const styles = getComputedStyle(document.documentElement);
const declarations = FORWARDED_THEME_TOKENS
.map((name) => {
const value = styles.getPropertyValue(name).trim();
return value ? `${name}: ${value};` : "";
})
.filter(Boolean)
.join("\n\t");
styleEl.textContent = `:host {\n\t${declarations}\n}`;
}

let portalCssRefCount = 0;

/**
* Map a Spacebot theme to OpenCode's color scheme. Vanilla is the only
* dedicated light theme in the Spacebot palette today; everything else is
* a dark variant. If a future theme adds light variants, extend this list.
*/
function colorSchemeForTheme(theme: string): "light" | "dark" {
return theme === "vanilla" ? "light" : "dark";
}

export function OpenCodeEmbed({
port,
sessionId,
Expand All @@ -101,7 +159,12 @@ export function OpenCodeEmbed({
const handleRef = useRef<{
dispose: () => void;
navigate: (route: string) => void;
setColorScheme?: (scheme: "light" | "dark" | "system") => void;
} | null>(null);
// Style element inside the shadow root that mirrors :root's SpaceUI
// theme tokens. Refresh on every theme change via the effect below.
const themeForwardRef = useRef<HTMLStyleElement | null>(null);
const {theme} = useTheme();

// Route through the Spacebot proxy so it works for hosted/Tailscale
// users, not just local dev. The proxy handles forwarding to the
Expand Down Expand Up @@ -256,6 +319,15 @@ export function OpenCodeEmbed({
// Clear any previous content
shadow.innerHTML = "";

// Forward SpaceUI theme tokens into the shadow as :host CSS
// custom properties so OpenCode's embedded styles + our overrides
// can resolve var(--color-app-box) etc. against the active theme.
const themeForward = document.createElement("style");
themeForward.id = "spacebot-theme-forward";
shadow.appendChild(themeForward);
forwardThemeTokens(themeForward);
themeForwardRef.current = themeForward;

// Inject the OpenCode CSS into the shadow root
const style = document.createElement("style");
style.textContent = cssText;
Expand Down Expand Up @@ -319,6 +391,7 @@ export function OpenCodeEmbed({
const handle = mountOpenCode(mountDiv, {
serverUrl,
initialRoute: initialRouteRef.current,
colorScheme: colorSchemeForTheme(theme),
});

handleRef.current = handle;
Expand Down Expand Up @@ -354,6 +427,17 @@ export function OpenCodeEmbed({
};
}, [serverUrl]);

// Re-forward SpaceUI theme tokens AND update OpenCode's color scheme
// whenever the active Spacebot theme changes — both without remounting,
// preserving session state.
useEffect(() => {
const styleEl = themeForwardRef.current;
if (styleEl) {
forwardThemeTokens(styleEl);
}
handleRef.current?.setColorScheme?.(colorSchemeForTheme(theme));
}, [theme]);

// Navigate the embedded app when the route changes (directory discovered
// via SSE probe, or props changed). This avoids remounting the entire
// SolidJS app just to change routes.
Expand Down
Loading