Skip to content
Closed
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
10 changes: 8 additions & 2 deletions packages/gatekeeper-context/app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | null): void {
applyAccentVariables(variables)
}
}

interface HostCapability extends RpcTarget {
Expand All @@ -28,6 +32,7 @@ interface HostCapability extends RpcTarget {
setPresenting(active: boolean): Promise<PresentAck>
// Returns the current resolved theme mode and calls back on `receiver` whenever it changes.
subscribeTheme(receiver: AppIframe): Promise<ResolvedThemeMode>
subscribeAccent(receiver: AppIframe): Promise<Record<string, string> | null>
}

function main() {
Expand All @@ -42,6 +47,7 @@ function main() {
const host = newMessagePortRpcSession<HostCapability>(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, {
Expand Down
12 changes: 12 additions & 0 deletions packages/gatekeeper-context/app/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

// 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 =
Expand All @@ -32,6 +34,16 @@ export function applyThemeMode(mode: ResolvedThemeMode): void {
for (const listener of listeners) listener(mode);
}

export function applyAccentVariables(values: Record<string, string> | 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,
Expand Down
8 changes: 7 additions & 1 deletion packages/gatekeeper-scheduler/app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -12,11 +12,16 @@ class AppIframe extends RpcTarget {
setThemeMode(mode: ResolvedThemeMode): void {
applyThemeMode(mode);
}

setAccentVariables(variables: Record<string, string> | null): void {
applyAccentVariables(variables);
}
}

interface HostCapability extends RpcTarget {
readonly ui: RpcStub<ScheduleManagementClient>;
subscribeTheme(receiver: AppIframe): Promise<ResolvedThemeMode>;
subscribeAccent(receiver: AppIframe): Promise<Record<string, string> | null>;
openWorkspace(workspaceId: string, gadgetId?: number): Promise<void>;
resolveWorkspaceTitles(ids: string[]): Promise<(string | null)[]>;
openPrompt(prompt: string): Promise<void>;
Expand All @@ -34,6 +39,7 @@ function main() {
.subscribeTheme(iframe)
.then(applyThemeMode)
.catch(() => {});
host.subscribeAccent(iframe).then(applyAccentVariables).catch(() => {});

createRoot(element, {
onUncaughtError: (error) =>
Expand Down
12 changes: 12 additions & 0 deletions packages/gatekeeper-scheduler/app/theme.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
export type ResolvedThemeMode = "light" | "dark";

const appliedAccentVariables = new Set<string>();

export function applyThemeMode(mode: ResolvedThemeMode): void {
document.documentElement.dataset.mode = mode;
document.documentElement.style.colorScheme = mode;
}

export function applyAccentVariables(values: Record<string, string> | 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);
}
}
52 changes: 50 additions & 2 deletions packages/workshop-frontend/src/SandboxedGatekeeperApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -19,6 +21,14 @@ interface ThemeReceiver extends RpcTarget {
setThemeMode(mode: ResolvedThemeMode): void
}

interface AccentReceiver extends RpcTarget {
setAccentVariables(variables: Record<string, string> | null): void
}

function accentVariablesFor(color: string | null): Record<string, string> | 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 }
Expand Down Expand Up @@ -84,7 +94,9 @@ class GatekeeperAppHostImpl extends RpcTarget {
readonly #resolveWorkspaceTitles: ResolveWorkspaceTitles
#presenting = false
#themeMode: ResolvedThemeMode
#accentVariables: Record<string, string> | null
#themeReceiver: RpcStub<ThemeReceiver> | null = null
#accentReceiver: RpcStub<AccentReceiver> | null = null
// Presentation changes are coalesced to a single apply per animation frame (see #applyPending).
#pendingActive: boolean | null = null
#pendingResolvers: ((ack: PresentAck) => void)[] = []
Expand All @@ -94,12 +106,14 @@ class GatekeeperAppHostImpl extends RpcTarget {
capability: any,
present: PresentController,
themeMode: ResolvedThemeMode,
accentVariables: Record<string, string> | null,
openTarget: OpenTarget,
openPrompt: OpenPrompt,
resolveWorkspaceTitles: ResolveWorkspaceTitles,
) {
super()
this.#themeMode = themeMode
this.#accentVariables = accentVariables
const { capability: ui, dispose } = createRateLimitedCapability(capability, {
maxConcurrency: 8,
maxCallsPerMinute: 600,
Expand Down Expand Up @@ -147,12 +161,24 @@ class GatekeeperAppHostImpl extends RpcTarget {
return this.#themeMode
}

subscribeAccent(receiver: RpcStub<AccentReceiver>): Record<string, string> | null {
this.#accentReceiver?.[Symbol.dispose]?.()
this.#accentReceiver = receiver.dup()
return this.#accentVariables
}

#dropThemeReceiver(receiver: RpcStub<ThemeReceiver>) {
if (this.#themeReceiver !== receiver) return
receiver[Symbol.dispose]?.()
this.#themeReceiver = null
}

#dropAccentReceiver(receiver: RpcStub<AccentReceiver>) {
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
Expand All @@ -166,6 +192,19 @@ class GatekeeperAppHostImpl extends RpcTarget {
}
}

updateAccentVariables(variables: Record<string, string> | 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<PresentAck> {
return new Promise((resolve) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -224,13 +265,19 @@ export default function SandboxedGatekeeperApp({ frame, gatekeeperVendorId }: {
const invalidatedRef = useRef(false)
const [overlay, setOverlay] = useState<OverlayState>(null)
const overlayRef = useRef<OverlayState>(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
Expand Down Expand Up @@ -313,6 +360,7 @@ export default function SandboxedGatekeeperApp({ frame, gatekeeperVendorId }: {
capabilityRef.current,
present,
themeModeRef.current,
accentVariablesRef.current,
openTarget,
openPrompt,
resolveWorkspaceTitles,
Expand Down
2 changes: 1 addition & 1 deletion packages/workshop-frontend/src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
export function accentVars(seed: string): Record<string, string> {
return {
'--color-kumo-brand': `light-dark(${seed}, oklch(from ${seed} 0.45 c h))`,
// Slightly darker for hover/pressed states.
Expand Down
Loading