diff --git a/packages/gatekeeper-context/app/main.tsx b/packages/gatekeeper-context/app/main.tsx index 6393c090..5e902c3e 100644 --- a/packages/gatekeeper-context/app/main.tsx +++ b/packages/gatekeeper-context/app/main.tsx @@ -8,18 +8,22 @@ import type { RpcStub } from 'capnweb' import type { ContextApi } from '../src/context-types' import ContextLibraryPage from './ContextLibraryPage' import { ContextApiProvider, PresentationProvider, type PresentAck } from './bridge' -import { applyThemeMode, type ResolvedThemeMode } from './theme' +import { applyAccentVariables, applyThemeMode, type ResolvedThemeMode } from './theme' import './styles.css' import ErrorBoundary from './ErrorBoundary' import { installErrorReporting, reportIssue } from './error-reporting' installErrorReporting() -// The only capability the iframe exposes back to the host: a receiver for theme-mode pushes. +// The only capability the iframe exposes back to the host: a receiver for theme pushes. class AppIframe extends RpcTarget { setThemeMode(mode: ResolvedThemeMode): void { applyThemeMode(mode) } + + setAccentVariables(variables: Record | null): void { + applyAccentVariables(variables) + } } interface HostCapability extends RpcTarget { @@ -28,6 +32,7 @@ interface HostCapability extends RpcTarget { setPresenting(active: boolean): Promise // Returns the current resolved theme mode and calls back on `receiver` whenever it changes. subscribeTheme(receiver: AppIframe): Promise + subscribeAccent(receiver: AppIframe): Promise | null> } function main() { @@ -42,6 +47,7 @@ function main() { const host = newMessagePortRpcSession(port1, iframe) // The initial mode comes back from the call; later changes arrive via iframe.setThemeMode(). host.subscribeTheme(iframe).then(applyThemeMode).catch(() => {}) + host.subscribeAccent(iframe).then(applyAccentVariables).catch(() => {}) createRoot(root, { onUncaughtError: (error) => reportIssue('context.react-root', error, { diff --git a/packages/gatekeeper-context/app/theme.ts b/packages/gatekeeper-context/app/theme.ts index e66c5269..4f111168 100644 --- a/packages/gatekeeper-context/app/theme.ts +++ b/packages/gatekeeper-context/app/theme.ts @@ -12,6 +12,8 @@ // The two concrete modes the host resolves `light`/`dark`/`system` down to before pushing it here. export type ResolvedThemeMode = "light" | "dark"; +const appliedAccentVariables = new Set(); + // Seed from the pre-paint `data-mode` the bootstrap script in index.html set from the OS preference, // so imperative widgets that read getThemeMode() before the host's RPC arrives start correct. let current: ResolvedThemeMode = @@ -32,6 +34,16 @@ export function applyThemeMode(mode: ResolvedThemeMode): void { for (const listener of listeners) listener(mode); } +export function applyAccentVariables(values: Record | null): void { + const root = document.documentElement; + for (const variable of appliedAccentVariables) root.style.removeProperty(variable); + appliedAccentVariables.clear(); + for (const [variable, value] of Object.entries(values ?? {})) { + root.style.setProperty(variable, value); + appliedAccentVariables.add(variable); + } +} + // Subscribe to mode changes. Returns an unsubscribe function. export function subscribeThemeMode( listener: (mode: ResolvedThemeMode) => void, diff --git a/packages/gatekeeper-scheduler/app/main.tsx b/packages/gatekeeper-scheduler/app/main.tsx index 48614ae8..c0568cce 100644 --- a/packages/gatekeeper-scheduler/app/main.tsx +++ b/packages/gatekeeper-scheduler/app/main.tsx @@ -3,7 +3,7 @@ import { RpcTarget, newMessagePortRpcSession, type RpcStub } from "capnweb"; import SchedulerPage, { type ScheduleManagementClient } from "./SchedulerPage"; import ErrorBoundary from "./ErrorBoundary"; import { installErrorReporting, reportIssue } from "./error-reporting"; -import { applyThemeMode, type ResolvedThemeMode } from "./theme"; +import { applyAccentVariables, applyThemeMode, type ResolvedThemeMode } from "./theme"; import "./styles.css"; installErrorReporting(); @@ -12,11 +12,16 @@ class AppIframe extends RpcTarget { setThemeMode(mode: ResolvedThemeMode): void { applyThemeMode(mode); } + + setAccentVariables(variables: Record | null): void { + applyAccentVariables(variables); + } } interface HostCapability extends RpcTarget { readonly ui: RpcStub; subscribeTheme(receiver: AppIframe): Promise; + subscribeAccent(receiver: AppIframe): Promise | null>; openWorkspace(workspaceId: string, gadgetId?: number): Promise; resolveWorkspaceTitles(ids: string[]): Promise<(string | null)[]>; openPrompt(prompt: string): Promise; @@ -34,6 +39,7 @@ function main() { .subscribeTheme(iframe) .then(applyThemeMode) .catch(() => {}); + host.subscribeAccent(iframe).then(applyAccentVariables).catch(() => {}); createRoot(element, { onUncaughtError: (error) => diff --git a/packages/gatekeeper-scheduler/app/theme.ts b/packages/gatekeeper-scheduler/app/theme.ts index 81f9609b..57cf22c7 100644 --- a/packages/gatekeeper-scheduler/app/theme.ts +++ b/packages/gatekeeper-scheduler/app/theme.ts @@ -1,6 +1,18 @@ export type ResolvedThemeMode = "light" | "dark"; +const appliedAccentVariables = new Set(); + export function applyThemeMode(mode: ResolvedThemeMode): void { document.documentElement.dataset.mode = mode; document.documentElement.style.colorScheme = mode; } + +export function applyAccentVariables(values: Record | null): void { + const root = document.documentElement; + for (const variable of appliedAccentVariables) root.style.removeProperty(variable); + appliedAccentVariables.clear(); + for (const [variable, value] of Object.entries(values ?? {})) { + root.style.setProperty(variable, value); + appliedAccentVariables.add(variable); + } +} diff --git a/packages/workshop-frontend/src/SandboxedGatekeeperApp.tsx b/packages/workshop-frontend/src/SandboxedGatekeeperApp.tsx index 6344fbe6..8b50f566 100644 --- a/packages/workshop-frontend/src/SandboxedGatekeeperApp.tsx +++ b/packages/workshop-frontend/src/SandboxedGatekeeperApp.tsx @@ -3,9 +3,11 @@ import { flushSync } from 'react-dom' import { RpcStub, RpcTarget, newMessagePortRpcSession } from 'capnweb' import { useNavigate } from '@tanstack/react-router' import type { GatekeeperUiFrame } from '@gadgets/workshop-shared/gatekeeper' +import { isHexColor } from '@gadgets/workshop-shared/api' import { createRateLimitedCapability } from './rateLimitedCapability' import { useTheme } from './ThemeContext' -import type { ResolvedThemeMode } from './theme' +import { accentVars, type ResolvedThemeMode } from './theme' +import { useServerConfig } from './ServerConfigContext' import { forwardTrustedFrameError } from './errorReporting' import { useAuthenticatedApi } from './AuthContext' import { @@ -19,6 +21,14 @@ interface ThemeReceiver extends RpcTarget { setThemeMode(mode: ResolvedThemeMode): void } +interface AccentReceiver extends RpcTarget { + setAccentVariables(variables: Record | null): void +} + +function accentVariablesFor(color: string | null): Record | null { + return color && isHexColor(color) ? accentVars(color) : null +} + // The content-pane rect, in viewport coordinates, that the app pins its page to while the iframe // is full-viewport. type OverlayRect = { left: number; top: number; width: number; height: number } @@ -84,7 +94,9 @@ class GatekeeperAppHostImpl extends RpcTarget { readonly #resolveWorkspaceTitles: ResolveWorkspaceTitles #presenting = false #themeMode: ResolvedThemeMode + #accentVariables: Record | null #themeReceiver: RpcStub | null = null + #accentReceiver: RpcStub | null = null // Presentation changes are coalesced to a single apply per animation frame (see #applyPending). #pendingActive: boolean | null = null #pendingResolvers: ((ack: PresentAck) => void)[] = [] @@ -94,12 +106,14 @@ class GatekeeperAppHostImpl extends RpcTarget { capability: any, present: PresentController, themeMode: ResolvedThemeMode, + accentVariables: Record | null, openTarget: OpenTarget, openPrompt: OpenPrompt, resolveWorkspaceTitles: ResolveWorkspaceTitles, ) { super() this.#themeMode = themeMode + this.#accentVariables = accentVariables const { capability: ui, dispose } = createRateLimitedCapability(capability, { maxConcurrency: 8, maxCallsPerMinute: 600, @@ -147,12 +161,24 @@ class GatekeeperAppHostImpl extends RpcTarget { return this.#themeMode } + subscribeAccent(receiver: RpcStub): Record | null { + this.#accentReceiver?.[Symbol.dispose]?.() + this.#accentReceiver = receiver.dup() + return this.#accentVariables + } + #dropThemeReceiver(receiver: RpcStub) { if (this.#themeReceiver !== receiver) return receiver[Symbol.dispose]?.() this.#themeReceiver = null } + #dropAccentReceiver(receiver: RpcStub) { + if (this.#accentReceiver !== receiver) return + receiver[Symbol.dispose]?.() + this.#accentReceiver = null + } + // Push a new mode to a subscribed app; a no-op until (and unless) the app subscribes. updateTheme(mode: ResolvedThemeMode) { this.#themeMode = mode @@ -166,6 +192,19 @@ class GatekeeperAppHostImpl extends RpcTarget { } } + updateAccentVariables(variables: Record | null) { + this.#accentVariables = variables + const receiver = this.#accentReceiver + if (!receiver) return + + try { + Promise.resolve(receiver.setAccentVariables(variables)) + .catch(() => this.#dropAccentReceiver(receiver)) + } catch { + this.#dropAccentReceiver(receiver) + } + } + // Queue a presentation change; the latest requested state is applied on the next frame. setPresenting(active: boolean): Promise { return new Promise((resolve) => { @@ -194,6 +233,8 @@ class GatekeeperAppHostImpl extends RpcTarget { this.#disposeRateLimiter() this.#themeReceiver?.[Symbol.dispose]?.() this.#themeReceiver = null + this.#accentReceiver?.[Symbol.dispose]?.() + this.#accentReceiver = null if (this.#frameId !== null) { cancelAnimationFrame(this.#frameId) this.#frameId = null @@ -224,13 +265,19 @@ export default function SandboxedGatekeeperApp({ frame, gatekeeperVendorId }: { const invalidatedRef = useRef(false) const [overlay, setOverlay] = useState(null) const overlayRef = useRef(null) - // Push the Workshop's resolved light/dark mode to the app whenever it changes. + // Push the Workshop's resolved light/dark mode and deployment accent into the sandboxed app. const { resolvedThemeMode } = useTheme() + const accentColor = useServerConfig()?.accentColor ?? null const themeModeRef = useRef(resolvedThemeMode) + const accentVariablesRef = useRef(accentVariablesFor(accentColor)) themeModeRef.current = resolvedThemeMode + accentVariablesRef.current = accentVariablesFor(accentColor) useEffect(() => { hostRef.current?.updateTheme(resolvedThemeMode) }, [resolvedThemeMode]) + useEffect(() => { + hostRef.current?.updateAccentVariables(accentVariablesFor(accentColor)) + }, [accentColor]) const setOverlayPhase = useCallback((next: OverlayState) => { if (overlayRef.current === next) return @@ -313,6 +360,7 @@ export default function SandboxedGatekeeperApp({ frame, gatekeeperVendorId }: { capabilityRef.current, present, themeModeRef.current, + accentVariablesRef.current, openTarget, openPrompt, resolveWorkspaceTitles, diff --git a/packages/workshop-frontend/src/theme.ts b/packages/workshop-frontend/src/theme.ts index 10fe1de0..d38e9fc3 100644 --- a/packages/workshop-frontend/src/theme.ts +++ b/packages/workshop-frontend/src/theme.ts @@ -63,7 +63,7 @@ export function applyStoredThemeMode(): ResolvedThemeMode { // Variables derived from the seed, as (name -> value-template) pairs. `light-dark()` keeps custom // deployment accents mode-aware without needing to reapply them when the user toggles themes. -function accentVars(seed: string): Record { +export function accentVars(seed: string): Record { return { '--color-kumo-brand': `light-dark(${seed}, oklch(from ${seed} 0.45 c h))`, // Slightly darker for hover/pressed states.