diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 578bf6e78f0..f6799b7a835 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -179,6 +179,55 @@ test.describe('desktop shell smoke', () => { await expect(window.locator('#detail')).toHaveAttribute('role', 'status') }) + test('offline title-bar geometry follows native fullscreen state across reloads', async () => { + test.skip(process.platform !== 'darwin', 'The traffic-light lane is macOS-specific') + app = await launchApp('http://127.0.0.1:1') + const window = await app.firstWindow() + await expect(window.locator('#server')).toBeVisible() + await expect(window.locator('html')).toHaveAttribute('data-sim-desktop-title-bar', 'inset') + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].setFullScreen(true) + }) + await expect(window.locator('html')).toHaveAttribute('data-sim-desktop-title-bar', 'fullscreen') + await window.reload() + await expect(window.locator('#server')).toBeVisible() + await expect(window.locator('html')).toHaveAttribute('data-sim-desktop-title-bar', 'fullscreen') + await expect(window.locator('.desktop-title-bar-page')).toHaveCSS('padding-top', '0px') + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].setFullScreen(false) + }) + await expect(window.locator('html')).toHaveAttribute('data-sim-desktop-title-bar', 'inset') + }) + + test('bundled dialogs follow the app theme independently of the system theme', async () => { + app = await launchApp(origin) + const window = await app.firstWindow() + await expect(window.locator('#app')).toBeVisible() + await app.evaluate(({ nativeTheme }) => { + nativeTheme.themeSource = 'light' + }) + await window.evaluate(() => { + document.documentElement.className = 'dark' + }) + const dialogPromise = app.waitForEvent('window') + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].webContents.emit('unresponsive') + }) + const prompt = await dialogPromise + await expect(prompt.getByRole('dialog')).toBeVisible() + await expect(prompt.locator('html')).toHaveClass('dark') + await expect(prompt.locator('html')).toHaveCSS('color-scheme', 'dark') + await expect(prompt.locator('#dialog-message')).toHaveCSS('-webkit-font-smoothing', 'auto') + await expect(prompt.locator('#dialog-message')).toHaveCSS('font-weight', '400') + await expect(prompt.locator('#dialog-message')).toHaveCSS('font-size', '14px') + await window.evaluate(() => { + document.documentElement.className = 'light' + }) + await expect(prompt.locator('html')).toHaveClass('light') + await expect(prompt.locator('html')).toHaveCSS('color-scheme', 'light') + await prompt.getByRole('button', { name: 'Wait', exact: true }).click() + }) + test('recovery messages use an isolated EMCN dialog with a safe keyboard default', async () => { app = await launchApp('http://127.0.0.1:1') const window = await app.firstWindow() @@ -188,7 +237,9 @@ test.describe('desktop shell smoke', () => { BrowserWindow.getAllWindows()[0].webContents.emit('unresponsive') }) const prompt = await dialogPromise - await expect(prompt.getByRole('dialog', { name: 'Sim', exact: true })).toBeVisible() + await expect( + prompt.getByRole('dialog', { name: 'Sim isn’t responding', exact: true }) + ).toBeVisible() await expect(prompt.getByText('Sim isn’t responding')).toBeVisible() await expect(prompt.getByRole('button', { name: 'Wait', exact: true })).toBeFocused() await expect @@ -213,10 +264,7 @@ test.describe('desktop shell smoke', () => { win.webContents.ipc.removeHandler('shell:configuration') win.webContents.ipc.handle('shell:configuration', () => ({ title: 'Long recovery message', - message: 'Recovery details', - detail: Array.from({ length: 80 }, (_, index) => `Diagnostic detail ${index + 1}`).join( - '\n' - ), + text: Array.from({ length: 80 }, (_, index) => `Diagnostic detail ${index + 1}`).join('\n'), type: 'warning', buttons: ['Wait', 'Reload'], defaultId: 0, @@ -291,8 +339,6 @@ test.describe('desktop shell smoke', () => { }) const closed = picker.waitForEvent('close') - // The main process destroys the window on the key-down, so the key-up half - // of `press` has no target to reach; the close event is the assertion. await picker.keyboard.press('Escape').catch(() => {}) await closed expect(app.windows()).toHaveLength(1) diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 34d390dad9c..25856fb9dc8 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -10,7 +10,7 @@ files: - dist/** - static/** - package.json - - from: ../sim/public/brand/fonts + - from: ../sim/app/_styles/fonts/season to: static filter: - SeasonSansUprightsVF.woff2 diff --git a/apps/desktop/src/main/dialogs.test.ts b/apps/desktop/src/main/dialogs.test.ts index e2915ac5925..41d3ac8b7de 100644 --- a/apps/desktop/src/main/dialogs.test.ts +++ b/apps/desktop/src/main/dialogs.test.ts @@ -121,7 +121,7 @@ describe('showShellDialog', () => { resize?.(sender(win), Number.NaN) expect(win.setContentSize).not.toHaveBeenCalled() resize?.(sender(win), 100000) - expect(win.setContentSize).toHaveBeenCalledWith(500, 820) + expect(win.setContentSize).toHaveBeenCalledWith(440, 820) respond(win, 0) await result }) diff --git a/apps/desktop/src/main/dialogs.ts b/apps/desktop/src/main/dialogs.ts index 23f96c2beac..1f2a5e60236 100644 --- a/apps/desktop/src/main/dialogs.ts +++ b/apps/desktop/src/main/dialogs.ts @@ -4,12 +4,13 @@ import { getErrorMessage } from '@sim/utils/errors' import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' import { app, BrowserWindow, dialog, nativeTheme, session } from 'electron' import { attachLocalPageProtocol, localPageUrl } from '@/main/local-pages' +import { attachShellTheme, backgroundColorFor, getShellTheme } from '@/main/shell-theme' import { attachShellWindowSizing, isShellWindowSender } from '@/main/shell-window' import { createSecureWebPreferences } from '@/main/window-preferences' import type { ShellDialogConfiguration } from '@/shared/shell' const logger = createLogger('DesktopDialogs') -const DIALOG_WIDTH = 500 +const DIALOG_WIDTH = 440 const DIALOG_PARTITION = 'shell-dialogs' interface ShellDialogOptions extends MessageBoxOptions { @@ -39,9 +40,8 @@ export function showShellDialog( buttons.findIndex((label) => /^(cancel|no|close|ok)$/i.test(label)) ) const configuration: ShellDialogConfiguration = { - title: options.title ?? 'Sim', - message: options.message, - detail: options.detail ?? '', + title: options.title ?? options.message, + text: [options.title ? options.message : '', options.detail].filter(Boolean).join('\n\n'), buttons, defaultId: options.defaultId ?? 0, cancelId, @@ -66,7 +66,7 @@ export function showShellDialog( fullscreenable: false, show: false, title: configuration.title, - backgroundColor: nativeTheme.shouldUseDarkColors ? '#1b1b1b' : '#ffffff', + backgroundColor: backgroundColorFor(getShellTheme(), nativeTheme.shouldUseDarkColors), ...(parent && !parent.isDestroyed() ? { parent, modal: true } : {}), webPreferences: createSecureWebPreferences( DIALOG_PARTITION, @@ -75,6 +75,7 @@ export function showShellDialog( ), }) const pageUrl = localPageUrl('dialog.html') + attachShellTheme(win) let settled = false const finish = (response: number) => { if (settled) return diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b273e50f1eb..30972db3c9e 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -78,6 +78,7 @@ import { readSessionUserId, resolveStartRoute, } from '@/main/session-lifecycle' +import { setShellTheme } from '@/main/shell-theme' import { attachTelemetryPolicy } from '@/main/telemetry-policy' import { TerminalRegistry } from '@/main/terminal/registry' import { installTray, type TrayHandle } from '@/main/tray' @@ -110,6 +111,7 @@ function main(): void { const userDataPath = app.getPath('userData') const config = createConfigStore(join(userDataPath, 'settings.json')) + setShellTheme(config.get('themeBackground')) initializeAccountDataRecovery(join(userDataPath, 'account-data-teardown-required.json')) const recoveryOrigin = getAccountDataTeardownOrigin() if (isAccountDataTeardownRequired() && recoveryOrigin && !config.isPersistenceAvailable()) { diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index 83deccabb27..9ebb7d6f0d9 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -665,13 +665,15 @@ describe('registerIpcHandlers', () => { expect(deps.settings.chooseBrowserDownloadDirectory).toHaveBeenCalledTimes(1) }) - it('reports native fullscreen state only to the app origin', async () => { + it('reports native fullscreen state only to the app origin and bundled pages', async () => { const { invoke } = collectHandlers() const getWindowState = invoke.get('desktop:window-state:get') expect(await getWindowState?.(evilEvent)).toEqual({ isFullScreen: false }) expect(await getWindowState?.(appEvent)).toEqual({ isFullScreen: true }) expect(deps.getWindowState).toHaveBeenCalledWith(appSender) + expect(await getWindowState?.(localPageEvent)).toEqual({ isFullScreen: true }) + expect(deps.getWindowState).toHaveBeenCalledWith(localPageSender) }) it('restricts shell-control channels to bundled local pages', () => { diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 4586567439a..78038098bbc 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -376,6 +376,7 @@ export interface IpcDeps { * - `app-origin`: only the remote app origin (main window pages). * - `local-page`: only the bundled pages served from the shell's own scheme * (offline, server) — shell control. + * - `app-or-local-page`: read-only window state used by both hosted and bundled pages. * - `browser-page`: only the built-in browser's own tabs, identified by * WebContents rather than by URL. These carry reports from the browser * preload about untrusted pages, so they are the one inbound surface whose @@ -383,7 +384,7 @@ export interface IpcDeps { * as an instruction. * - `any`: sender-independent channels that validate their input instead. */ -type ChannelGate = 'app-origin' | 'local-page' | 'browser-page' | 'any' +type ChannelGate = 'app-origin' | 'local-page' | 'app-or-local-page' | 'browser-page' | 'any' /** * A desktop surface the user can switch off. Channels that drive one are @@ -816,7 +817,9 @@ export function registerIpcHandlers(deps: IpcDeps): void { }, 'desktop:window-state:get': { kind: 'invoke', - gate: 'app-origin', + gate: 'app-or-local-page', + deviationReason: + 'Bundled offline pages share the app title-bar geometry and need their own native fullscreen state.', passSender: true, denied: { isFullScreen: false }, handler: (sender) => deps.getWindowState(sender as WebContents), @@ -1857,6 +1860,11 @@ export function registerIpcHandlers(deps: IpcDeps): void { const senderAllowed = (event: IpcMainEvent | IpcMainInvokeEvent, gate: ChannelGate): boolean => { if (gate === 'any') return true if (gate === 'app-origin') return isAppOriginSender(event, deps.appOrigin()) + if (gate === 'app-or-local-page') { + return ( + isAppOriginSender(event, deps.appOrigin()) || isLocalPageSender(event, deps.isLocalPageUrl) + ) + } if (gate === 'browser-page') return isAgentWebContents(event.sender) return isLocalPageSender(event, deps.isLocalPageUrl) } diff --git a/apps/desktop/src/main/local-pages.ts b/apps/desktop/src/main/local-pages.ts index 4e34f2e9870..2675fe3fe49 100644 --- a/apps/desktop/src/main/local-pages.ts +++ b/apps/desktop/src/main/local-pages.ts @@ -167,13 +167,13 @@ async function readFirst(rootDirs: readonly string[], name: string): Promise { @@ -149,12 +144,7 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle { title: 'Sim Server', frame: false, show: false, - // System preference only, unlike the main window: that one pre-paints for - // the web app it is about to load, whose theme the user picked in Sim. - // This window loads a bundled page that follows `prefers-color-scheme`, - // so honouring the stored web-app theme here would pre-paint dark behind - // a page about to render light whenever the two disagree. - backgroundColor: backgroundColorFor(undefined, nativeTheme.shouldUseDarkColors), + backgroundColor: backgroundColorFor(getShellTheme(), nativeTheme.shouldUseDarkColors), // Modal only when there is a live parent to attach to. A shell whose // window is gone (or never opened, because the origin failed to load) // still has to be able to reach this. @@ -169,6 +159,7 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle { // ways out must therefore work without the page: Escape is handled here, // and a page that fails to load closes the window instead of leaving a // blank sheet nothing can dismiss. + attachShellTheme(win) const opened = win let closed = false const closeOpened = () => { diff --git a/apps/desktop/src/main/shell-theme.test.ts b/apps/desktop/src/main/shell-theme.test.ts new file mode 100644 index 00000000000..efa71b587bc --- /dev/null +++ b/apps/desktop/src/main/shell-theme.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import type { BrowserWindow as ElectronWindow } from 'electron' +import { attachShellTheme, getShellTheme, setShellTheme } from '@/main/shell-theme' +import { BrowserWindow } from '@/test/electron-mock' + +beforeEach(() => { + setShellTheme(undefined) + vi.clearAllMocks() +}) + +describe('shell theme', () => { + it('retains the resolved app theme and updates only bundled pages', () => { + const local = new BrowserWindow({}) + const app = new BrowserWindow({}) + local.webContents.getURL.mockReturnValue('sim-shell://pages/dialog.html') + app.webContents.getURL.mockReturnValue('https://sim.example') + attachShellTheme(local as unknown as ElectronWindow) + attachShellTheme(app as unknown as ElectronWindow) + setShellTheme('dark') + expect(getShellTheme()).toBe('dark') + expect(local.webContents.send).toHaveBeenCalledWith('shell:theme-changed', 'dark') + expect(app.webContents.send).not.toHaveBeenCalled() + setShellTheme('dark') + expect(local.webContents.send).toHaveBeenCalledOnce() + local.on.mock.calls.find(([event]) => event === 'closed')?.[1]() + app.on.mock.calls.find(([event]) => event === 'closed')?.[1]() + setShellTheme('light') + expect(local.webContents.send).toHaveBeenCalledOnce() + }) + + it('rejects theme reads from foreign documents and subframes', () => { + const win = new BrowserWindow({}) + attachShellTheme(win as unknown as ElectronWindow) + const read = win.webContents.ipc.handle.mock.calls.find( + ([channel]) => channel === 'shell:get-theme' + )?.[1] + if (!read) throw new Error('Missing theme handler') + const event = { sender: win.webContents, senderFrame: win.webContents.mainFrame } + win.webContents.mainFrame.url = 'sim-shell://pages/offline.html?kind=dns' + setShellTheme('light') + expect(read(event)).toBe('light') + expect(() => read({ ...event, senderFrame: { url: event.senderFrame.url } })).toThrow( + 'Untrusted' + ) + win.webContents.mainFrame.url = 'https://untrusted.example' + expect(() => read(event)).toThrow('Untrusted') + win.on.mock.calls.find(([event]) => event === 'closed')?.[1]() + }) +}) diff --git a/apps/desktop/src/main/shell-theme.ts b/apps/desktop/src/main/shell-theme.ts new file mode 100644 index 00000000000..a1eb4afc723 --- /dev/null +++ b/apps/desktop/src/main/shell-theme.ts @@ -0,0 +1,59 @@ +import type { BrowserWindow } from 'electron' +import { isLocalPageUrl, localPageUrl } from '@/main/local-pages' +import type { ShellTheme } from '@/shared/shell' + +let theme: ShellTheme | undefined +const windows = new Set() + +export function getShellTheme(): ShellTheme | undefined { + return theme +} + +function isShellPage(url: string): boolean { + return isLocalPageUrl(url) || url === localPageUrl('dialog.html') +} + +/** Retains Sim's last resolved theme so recovery works even after its renderer stops. */ +export function setShellTheme(next: ShellTheme | undefined): void { + if (theme === next) return + theme = next + for (const win of windows) { + if (!win.isDestroyed() && isShellPage(win.webContents.getURL())) { + win.webContents.send('shell:theme-changed', theme) + } + } +} + +/** Only bundled top-level pages can read the shell's appearance. */ +export function attachShellTheme(win: BrowserWindow): void { + windows.add(win) + win.on('closed', () => windows.delete(win)) + win.webContents.ipc.handle('shell:get-theme', (event) => { + if ( + win.isDestroyed() || + event.sender !== win.webContents || + event.senderFrame !== win.webContents.mainFrame || + !isShellPage(event.senderFrame.url) + ) { + throw new Error('Untrusted shell theme sender') + } + return theme + }) +} + +/** + * Picks the pre-paint window background from the persisted web-app theme so + * dark-mode users never see a white flash before the remote page paints. + */ +export function backgroundColorFor( + theme: 'dark' | 'light' | undefined, + systemPrefersDark: boolean +): string { + if (theme === 'dark') { + return '#0c0c0c' + } + if (theme === 'light') { + return '#ffffff' + } + return systemPrefersDark ? '#0c0c0c' : '#ffffff' +} diff --git a/apps/desktop/src/main/window.test.ts b/apps/desktop/src/main/window.test.ts index 2d2ca5488be..69a02eea19d 100644 --- a/apps/desktop/src/main/window.test.ts +++ b/apps/desktop/src/main/window.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { backgroundColorFor } from '@/main/shell-theme' import { createSecureWebPreferences } from '@/main/window-preferences' vi.mock('electron', () => import('@/test/electron-mock')) @@ -7,7 +8,6 @@ import { BrowserWindow, dialog, screen, systemPreferences } from 'electron' import type { ConfigStore } from '@/main/config' import type { EventRecorder } from '@/main/observability' import { - backgroundColorFor, createMainWindow, ensureMicrophoneAccess, fitBoundsToWorkArea, diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index 52fb546115e..19481b4ff89 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -6,12 +6,11 @@ import { type ConfigStore, isSafeInternalPath, type WindowBounds } from '@/main/ import { showShellDialog } from '@/main/dialogs' import { isAppOrigin, isAuthSurfacePath } from '@/main/navigation' import type { EventRecorder } from '@/main/observability' +import { attachShellTheme, backgroundColorFor, setShellTheme } from '@/main/shell-theme' import { createSecureWebPreferences } from '@/main/window-preferences' const logger = createLogger('DesktopWindow') -const DARK_BACKGROUND = '#0c0c0c' -const LIGHT_BACKGROUND = '#ffffff' const DEFAULT_WIDTH = 1360 const DEFAULT_HEIGHT = 860 const MIN_WIDTH = 800 @@ -20,14 +19,6 @@ const WINDOW_TITLE = 'Sim' const BOUNDS_SAVE_DELAY_MS = 400 const ROUTE_SAVE_DELAY_MS = 500 -const THEME_PROBE_SCRIPT = `(() => { - try { - return document.documentElement.classList.contains('dark') - } catch { - return null - } -})()` - /** * The permission matrix: sanitized clipboard writes and microphone access for * the trusted app origin, default-deny for everything else including unknown @@ -127,23 +118,6 @@ export function setupPermissionHandlers(session: Session, getAppOrigin: () => st }) } -/** - * Picks the pre-paint window background from the persisted web-app theme so - * dark-mode users never see a white flash before the remote page paints. - */ -export function backgroundColorFor( - theme: 'dark' | 'light' | undefined, - systemPrefersDark: boolean -): string { - if (theme === 'dark') { - return DARK_BACKGROUND - } - if (theme === 'light') { - return LIGHT_BACKGROUND - } - return systemPrefersDark ? DARK_BACKGROUND : LIGHT_BACKGROUND -} - /** * Drops persisted bounds that are malformed or implausibly small so a bad * settings file can never produce an unusable window. @@ -276,6 +250,19 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { win.show() }) + attachShellTheme(win) + win.webContents.ipc.on('shell:app-theme', (event, theme: unknown) => { + if ( + event.sender !== win.webContents || + event.senderFrame !== win.webContents.mainFrame || + !isAppOrigin(event.senderFrame.url, deps.appOrigin()) || + (theme !== 'dark' && theme !== 'light') + ) + return + deps.config.set('themeBackground', theme) + setShellTheme(theme) + }) + let boundsTimer: NodeJS.Timeout | undefined const persistBounds = () => { clearTimeout(boundsTimer) @@ -394,17 +381,6 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { win.webContents.setZoomLevel(zoomLevel) } } - const url = win.webContents.getURL() - if (isAppOrigin(url, deps.appOrigin())) { - void win.webContents - .executeJavaScript(THEME_PROBE_SCRIPT, true) - .then((isDark) => { - if (typeof isDark === 'boolean') { - deps.config.set('themeBackground', isDark ? 'dark' : 'light') - } - }) - .catch(() => {}) - } }) let routeTimer: NodeJS.Timeout | undefined diff --git a/apps/desktop/src/preload/index.test.ts b/apps/desktop/src/preload/index.test.ts index 9e7f1a313d1..52293493d63 100644 --- a/apps/desktop/src/preload/index.test.ts +++ b/apps/desktop/src/preload/index.test.ts @@ -1,3 +1,4 @@ +/** @vitest-environment jsdom */ import type { SimDesktopApi } from '@sim/desktop-bridge' import { describe, expect, it, vi } from 'vitest' diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 34c37732078..6e6ed65a51c 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -53,6 +53,10 @@ import { type TerminalToolResponse, } from '@sim/terminal-protocol' import { contextBridge, ipcRenderer } from 'electron' +import { exposeShellTheme, observeAppTheme } from '@/preload/shell-theme' + +exposeShellTheme() +observeAppTheme() const VERSION_ARG_PREFIX = '--sim-desktop-version=' diff --git a/apps/desktop/src/preload/shell-theme.ts b/apps/desktop/src/preload/shell-theme.ts new file mode 100644 index 00000000000..d12f010555b --- /dev/null +++ b/apps/desktop/src/preload/shell-theme.ts @@ -0,0 +1,39 @@ +import { contextBridge, ipcRenderer } from 'electron' +import type { ShellTheme, ShellThemeApi } from '@/shared/shell' + +/** A read-only appearance bridge, also available on the main window's offline page. */ +export function exposeShellTheme(): void { + const api: ShellThemeApi = { + get: () => ipcRenderer.invoke('shell:get-theme'), + onChange: (callback) => { + const listener = (_event: unknown, theme: ShellTheme | undefined) => callback(theme) + ipcRenderer.on('shell:theme-changed', listener) + return () => ipcRenderer.removeListener('shell:theme-changed', listener) + }, + } + contextBridge.exposeInMainWorld('simShellTheme', api) +} + +/** Reports the actual app theme, including changes after hydration or OS theme changes. */ +export function observeAppTheme(): void { + window.addEventListener('DOMContentLoaded', () => { + if (!['https:', 'http:'].includes(location.protocol)) return + let previous: ShellTheme | undefined + const report = () => { + const root = document.documentElement + const theme = root.classList.contains('dark') + ? 'dark' + : root.classList.contains('light') + ? 'light' + : undefined + if (!theme || theme === previous) return + previous = theme + ipcRenderer.send('shell:app-theme', theme) + } + new MutationObserver(report).observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }) + report() + }) +} diff --git a/apps/desktop/src/preload/shell.ts b/apps/desktop/src/preload/shell.ts index 900c7993aef..4f07249d7b8 100644 --- a/apps/desktop/src/preload/shell.ts +++ b/apps/desktop/src/preload/shell.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron' +import { exposeShellTheme } from '@/preload/shell-theme' import type { ShellWindowApi } from '@/shared/shell' const api: ShellWindowApi = { @@ -12,3 +13,4 @@ const api: ShellWindowApi = { } contextBridge.exposeInMainWorld('simShell', api) +exposeShellTheme() diff --git a/apps/desktop/src/renderer/dialog/index.tsx b/apps/desktop/src/renderer/dialog/index.tsx index ee8e252e75c..1032d4a3914 100644 --- a/apps/desktop/src/renderer/dialog/index.tsx +++ b/apps/desktop/src/renderer/dialog/index.tsx @@ -1,6 +1,12 @@ -import { ChipModalBody, ChipModalFooter, ChipModalHeader, ChipModalSurface } from '@sim/emcn' +import { + ChipModalBody, + ChipModalDescription, + ChipModalFooter, + ChipModalHeader, + ChipModalSurface, +} from '@sim/emcn' import { createRoot } from 'react-dom/client' -import { initializeShellPage, observeShellSize, shellWindow } from '@/renderer/shell' +import { initializeShellPage, mountShellModal, shellWindow } from '@/renderer/shell' import type { ShellDialogConfiguration } from '@/shared/shell' import '@/renderer/shell.css' @@ -9,21 +15,18 @@ interface ShellDialogProps { } function ShellDialog({ configuration }: ShellDialogProps) { - const { message, detail, buttons, defaultId, cancelId } = configuration + const { text, buttons, defaultId, cancelId } = configuration const primaryId = buttons.length === 1 ? 0 : buttons.findIndex((_, index) => index !== cancelId) const respond = (response: number) => shellWindow?.respond(response) const close = () => respond(cancelId) return ( { - element?.querySelector('[data-chip-modal-default-action]')?.focus() - return observeShellSize(element) - }} + ref={(element) => mountShellModal(element, close)} role='dialog' aria-modal='true' aria-labelledby='dialog-title' - aria-describedby={detail ? 'dialog-message dialog-detail' : 'dialog-message'} + aria-describedby={text ? 'dialog-message' : undefined} className='max-h-screen' > {configuration.title} -

- {message} -

- {detail ? ( -

- {detail} -

- ) : null} + {text ? {text} : null}
{ - document.title = configuration.title - document.addEventListener('keydown', (event) => { - if (event.key === 'Escape') shellWindow?.respond(configuration.cancelId) - }) - createRoot(container).render() -}) +void Promise.all([initializeShellPage(), shellWindow.getDialogConfiguration()]).then( + ([, configuration]) => { + document.title = configuration.title + createRoot(container).render() + } +) diff --git a/apps/desktop/src/renderer/offline/index.tsx b/apps/desktop/src/renderer/offline/index.tsx index c00417bb57c..2797d496129 100644 --- a/apps/desktop/src/renderer/offline/index.tsx +++ b/apps/desktop/src/renderer/offline/index.tsx @@ -1,7 +1,6 @@ import { useState } from 'react' -import type { SimDesktopApi } from '@sim/desktop-bridge' -import { Chip } from '@sim/emcn' -import { ArrowUpRight, RefreshCw, Server, Wordmark } from '@sim/emcn/icons' +import { observeDesktopTitleBar, type SimDesktopApi } from '@sim/desktop-bridge' +import { Chip, LogoPage, SimWordmark, StatusPageContent } from '@sim/emcn' import { createRoot } from 'react-dom/client' import { initializeShellPage } from '@/renderer/shell' import '@/renderer/shell.css' @@ -58,59 +57,58 @@ function OfflinePage({ isSimCloud }: OfflinePageProps) { } return ( -
-
- -
-
-
-

- {copy.title} -

-

{copy.message}

-
- bridge?.offlineRetry()} - > - Retry - - {isSimCloud ? ( - - Check status - - ) : null} - bridge?.server?.open()}> - Change server - -
+ + } + logo={ + + + + } + > + {actionError || detail}

-
-
-
+ } + > + bridge?.offlineRetry()}> + Retry + + {isSimCloud ? ( + + Check status + + ) : null} + bridge?.server?.open()}> + Change server + + + ) } -initializeShellPage() const container = document.getElementById('root') if (!container) throw new Error('Offline page root is missing') -const root = createRoot(container) -root.render() -void bridge?.server - ?.getConfiguration() - .then(({ isSimCloud }) => { - root.render() - }) - .catch(() => {}) +void initializeShellPage().then(() => { + observeDesktopTitleBar(document.documentElement, navigator.userAgent, bridge) + const root = createRoot(container) + root.render() + void bridge?.server + ?.getConfiguration() + .then(({ isSimCloud }) => { + root.render() + }) + .catch(() => {}) +}) diff --git a/apps/desktop/src/renderer/server/index.tsx b/apps/desktop/src/renderer/server/index.tsx index 3c7b1fdbb94..d4431319936 100644 --- a/apps/desktop/src/renderer/server/index.tsx +++ b/apps/desktop/src/renderer/server/index.tsx @@ -3,23 +3,18 @@ import { ServerModal } from '@/renderer/server/server-modal' import { initializeShellPage, shellWindow } from '@/renderer/shell' import '@/renderer/shell.css' -initializeShellPage() -document.addEventListener('keydown', (event) => { - if (event.key === 'Escape') window.close() -}) - -const server = shellWindow?.server const container = document.getElementById('root') -if (!container) throw new Error('Server modal root is missing') +if (!container || !shellWindow) throw new Error('Server modal host is unavailable') +const server = shellWindow.server const root = createRoot(container) async function renderServerModal() { try { - const configuration = await server?.getConfiguration() + const configuration = await server.getConfiguration() root.render() } catch { root.render() } } -void renderServerModal() +void initializeShellPage().then(renderServerModal) diff --git a/apps/desktop/src/renderer/server/server-modal.tsx b/apps/desktop/src/renderer/server/server-modal.tsx index 6eac19d7d76..66ce047470b 100644 --- a/apps/desktop/src/renderer/server/server-modal.tsx +++ b/apps/desktop/src/renderer/server/server-modal.tsx @@ -2,16 +2,17 @@ import { useRef, useState } from 'react' import type { DesktopServerConfiguration } from '@sim/desktop-bridge' import { ChipModalBody, + ChipModalDescription, ChipModalField, ChipModalFooter, ChipModalHeader, ChipModalSurface, } from '@sim/emcn' -import { observeShellSize } from '@/renderer/shell' +import { mountShellModal } from '@/renderer/shell' import type { ShellWindowApi } from '@/shared/shell' interface ServerModalProps { - server: ShellWindowApi['server'] | undefined + server: ShellWindowApi['server'] configuration?: DesktopServerConfiguration initialError?: string } @@ -20,9 +21,8 @@ function closeWindow() { window.close() } -function focusServerInput(element: HTMLDivElement | null) { - element?.querySelector('input')?.select() - return observeShellSize(element) +function mountServerModal(element: HTMLDivElement | null) { + return mountShellModal(element, closeWindow) } export function ServerModal({ server, configuration, initialError }: ServerModalProps) { @@ -43,10 +43,8 @@ export function ServerModal({ server, configuration, initialError }: ServerModal setError(undefined) setMessage('') try { - const result = await server?.setOrigin(origin) - if (!result) { - setError('The desktop shell is unavailable.') - } else if (!result.ok) { + const result = await server.setOrigin(origin) + if (!result.ok) { setError(result.error) } else if (result.unchanged) { setMessage('Already connected to this server.') @@ -61,12 +59,12 @@ export function ServerModal({ server, configuration, initialError }: ServerModal return ( Sim server -

+ Point this app at your own Sim deployment. Self-hosted servers must use HTTPS; localhost may use HTTP. -

+ { + vi.unstubAllGlobals() +}) + +describe('shell appearance bootstrap', () => { + it('keeps a newer theme received while the initial snapshot is pending', async () => { + let resolveInitial!: (theme: ShellTheme) => void + let update!: (theme: ShellTheme) => void + const api: ShellThemeApi = { + get: () => + new Promise((resolve) => { + resolveInitial = resolve + }), + onChange: (callback) => { + update = callback + return () => {} + }, + } + vi.stubGlobal('simShellTheme', api) + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true, addEventListener: vi.fn() })) + ) + const ready = initializeShellPage() + expect(document.documentElement.className).toBe('dark') + expect(document.documentElement.style.colorScheme).toBe('dark') + update('light') + resolveInitial('dark') + await ready + expect(document.documentElement.className).toBe('light') + expect(document.documentElement.style.colorScheme).toBe('light') + }) + + it('follows system changes only before an app theme is known', async () => { + let update!: (theme: ShellTheme) => void + let systemChanged!: () => void + const media = { + matches: true, + addEventListener: vi.fn((_event, callback) => { + systemChanged = callback + }), + } + vi.stubGlobal( + 'matchMedia', + vi.fn(() => media) + ) + vi.stubGlobal('simShellTheme', { + get: async () => undefined, + onChange: (callback) => { + update = callback + return () => {} + }, + } satisfies ShellThemeApi) + await initializeShellPage() + expect(document.documentElement.className).toBe('dark') + media.matches = false + systemChanged() + expect(document.documentElement.className).toBe('light') + update('dark') + systemChanged() + expect(document.documentElement.className).toBe('dark') + }) +}) diff --git a/apps/desktop/src/renderer/shell.ts b/apps/desktop/src/renderer/shell.ts index 49a284719dc..9dc93c72ca7 100644 --- a/apps/desktop/src/renderer/shell.ts +++ b/apps/desktop/src/renderer/shell.ts @@ -1,17 +1,34 @@ -import type { ShellWindowApi } from '@/shared/shell' +import { focusChipModalContent } from '@sim/emcn' +import type { ShellTheme, ShellThemeApi, ShellWindowApi } from '@/shared/shell' export const shellWindow = (window as Window & { simShell?: ShellWindowApi }).simShell -/** Local windows follow the system theme independently of any reachable deployment. */ -export function initializeShellPage() { - const theme = window.matchMedia('(prefers-color-scheme: dark)') - const syncTheme = () => document.documentElement.classList.toggle('dark', theme.matches) +/** Uses Sim's resolved theme, falling back to the system before an app theme is known. */ +export async function initializeShellPage() { + const api = (window as Window & { simShellTheme?: ShellThemeApi }).simShellTheme + const system = window.matchMedia('(prefers-color-scheme: dark)') + let theme: ShellTheme | undefined + let receivedUpdate = false + const syncTheme = () => { + const dark = theme ? theme === 'dark' : system.matches + document.documentElement.classList.toggle('dark', dark) + document.documentElement.classList.toggle('light', !dark) + document.documentElement.style.colorScheme = dark ? 'dark' : 'light' + } + api?.onChange((next) => { + receivedUpdate = true + theme = next + syncTheme() + }) + syncTheme() + const initialTheme = await api?.get() + if (!receivedUpdate) theme = initialTheme syncTheme() - theme.addEventListener('change', syncTheme) + system.addEventListener('change', syncTheme) } /** Fits the native window to the complete modal, including changing inline messages. */ -export function observeShellSize(element: HTMLDivElement | null) { +function observeShellSize(element: HTMLDivElement | null) { if (!element || !shellWindow) return const resize = () => { const body = element.querySelector('[data-chip-modal-body]') @@ -30,3 +47,21 @@ export function observeShellSize(element: HTMLDivElement | null) { mutations.disconnect() } } + +/** Native host lifecycle; Escape also works when a disabled control leaves focus on the document. */ +export function mountShellModal(element: HTMLDivElement | null, dismiss: () => void) { + if (!element) return + focusChipModalContent(element) + const stopSizing = observeShellSize(element) + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + dismiss() + } + } + element.ownerDocument.addEventListener('keydown', onKeyDown) + return () => { + stopSizing?.() + element.ownerDocument.removeEventListener('keydown', onKeyDown) + } +} diff --git a/apps/desktop/src/shared/shell.ts b/apps/desktop/src/shared/shell.ts index b71858f5fb9..842ef2a339d 100644 --- a/apps/desktop/src/shared/shell.ts +++ b/apps/desktop/src/shared/shell.ts @@ -1,9 +1,15 @@ import type { DesktopServerChangeResult, DesktopServerConfiguration } from '@sim/desktop-bridge' +export type ShellTheme = 'dark' | 'light' + +export interface ShellThemeApi { + get(): Promise + onChange(callback: (theme: ShellTheme | undefined) => void): () => void +} + export interface ShellDialogConfiguration { title: string - message: string - detail: string + text: string buttons: string[] defaultId: number cancelId: number diff --git a/apps/sim/app/(auth)/components/auth-shell.test.tsx b/apps/sim/app/(auth)/components/auth-shell.test.tsx index 2248773b9f6..deeb96a8f7f 100644 --- a/apps/sim/app/(auth)/components/auth-shell.test.tsx +++ b/apps/sim/app/(auth)/components/auth-shell.test.tsx @@ -14,7 +14,6 @@ vi.mock('next/link', () => ({ vi.mock('@/app/_shell/desktop-title-bar', () => ({ DesktopTitleBarLane: () => null })) vi.mock('@/app/(landing)/components/navbar/components', () => ({ LogoMark: ({ children }: { children: ReactNode }) => <>{children}, - SimWordmark: () => 'Sim', })) it('returns home through a document link so route-specific theme defaults reinitialize', () => { diff --git a/apps/sim/app/(auth)/components/auth-shell.tsx b/apps/sim/app/(auth)/components/auth-shell.tsx index d7107dfeb96..1b8cb741c0b 100644 --- a/apps/sim/app/(auth)/components/auth-shell.tsx +++ b/apps/sim/app/(auth)/components/auth-shell.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' +import { SimWordmark } from '@sim/emcn' import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' -import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components' +import { LogoMark } from '@/app/(landing)/components/navbar/components' interface AuthShellProps { /** Centered content column (the form, status copy, etc.). */ diff --git a/apps/sim/app/(interfaces)/chat/components/header/header.tsx b/apps/sim/app/(interfaces)/chat/components/header/header.tsx index cfb5bfa58ca..fd96bb39cd1 100644 --- a/apps/sim/app/(interfaces)/chat/components/header/header.tsx +++ b/apps/sim/app/(interfaces)/chat/components/header/header.tsx @@ -1,9 +1,9 @@ 'use client' +import { SimWordmark } from '@sim/emcn' import Image from 'next/image' import Link from 'next/link' import { GithubIcon } from '@/components/icons' -import { SimWordmark } from '@/app/(landing)/components/navbar/components' import { useBrandConfig } from '@/ee/whitelabeling' interface ChatHeaderProps { diff --git a/apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx b/apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx index 906a040fdd5..6798b467cf9 100644 --- a/apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx +++ b/apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx @@ -5,7 +5,8 @@ import type { ReactNode } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' -vi.mock('@sim/emcn', () => ({ +vi.mock('@sim/emcn', async (importOriginal) => ({ + ...(await importOriginal()), cn: (...values: Array) => values.filter(Boolean).join(' '), Tooltip: { Root: ({ children }: { children: ReactNode }) => <>{children}, diff --git a/apps/sim/app/(landing)/comparisons/components/brand-icon-tile/brand-icon-tile.tsx b/apps/sim/app/(landing)/comparisons/components/brand-icon-tile/brand-icon-tile.tsx index 6c8911a805d..59f9809904b 100644 --- a/apps/sim/app/(landing)/comparisons/components/brand-icon-tile/brand-icon-tile.tsx +++ b/apps/sim/app/(landing)/comparisons/components/brand-icon-tile/brand-icon-tile.tsx @@ -1,7 +1,6 @@ import type { ComponentType, SVGProps } from 'react' -import { cn } from '@sim/emcn' +import { cn, SimWordmark } from '@sim/emcn' import type { CompetitorBrand } from '@/lib/compare/data' -import { SimWordmark } from '@/app/(landing)/components/navbar/components/sim-wordmark' export interface BrandIconTileProps { icon: ComponentType> diff --git a/apps/sim/app/(landing)/components/agent-momentum/agent-momentum.test.tsx b/apps/sim/app/(landing)/components/agent-momentum/agent-momentum.test.tsx index 1713a86b88f..3ee6730420a 100644 --- a/apps/sim/app/(landing)/components/agent-momentum/agent-momentum.test.tsx +++ b/apps/sim/app/(landing)/components/agent-momentum/agent-momentum.test.tsx @@ -4,7 +4,8 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' -vi.mock('@sim/emcn', () => ({ +vi.mock('@sim/emcn', async (importOriginal) => ({ + ...(await importOriginal()), cn: (...values: Array) => values.filter(Boolean).join(' '), })) diff --git a/apps/sim/app/(landing)/components/featured-customer/featured-customer.test.tsx b/apps/sim/app/(landing)/components/featured-customer/featured-customer.test.tsx index 65fe05ab37d..b3b05bcb278 100644 --- a/apps/sim/app/(landing)/components/featured-customer/featured-customer.test.tsx +++ b/apps/sim/app/(landing)/components/featured-customer/featured-customer.test.tsx @@ -7,7 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const motionPreference = vi.hoisted(() => ({ reduced: false })) -vi.mock('@sim/emcn', () => ({ +vi.mock('@sim/emcn', async (importOriginal) => ({ + ...(await importOriginal()), Button: (props: React.ButtonHTMLAttributes) =>