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
4 changes: 1 addition & 3 deletions apps/desktop/src/main/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1167,9 +1167,7 @@ export function registerRpc(contents: WebContents, options: RegisterRpcOptions =
return { done: true }
})

// Live theme change: repaint native chrome (Windows overlay symbols + the OS
// light/dark hint). Window material is fixed at creation, so a glass <-> opaque
// switch only takes full effect on the next launch.
// Live theme change: repaint Windows overlay symbols and the OS light/dark hint.
r.register('theme.apply', (params) => {
const win = BrowserWindow.fromWebContents(contents)
if (!win) return { applied: false }
Expand Down
39 changes: 11 additions & 28 deletions apps/desktop/src/main/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,48 +18,31 @@ describe('theme registries agree across packages', () => {
})

describe('themeWindowChrome', () => {
it('requests platform translucency for glass themes', () => {
expect(themeWindowChrome(themes.obsidian, 'win32')).toMatchObject({
backgroundColor: '#00000000',
backgroundMaterial: 'acrylic',
})
expect(themeWindowChrome(themes.obsidian, 'darwin')).toMatchObject({
vibrancy: 'under-window',
visualEffectState: 'active',
})
expect(themeWindowChrome(themes.obsidian, 'linux')).toMatchObject({ transparent: true })
})

it('gives opaque themes a solid background and no translucency', () => {
for (const platform of ['win32', 'darwin', 'linux'] as const) {
const chrome = themeWindowChrome(themes.graphite, platform)
expect(chrome.backgroundMaterial).toBeUndefined()
expect(chrome.vibrancy).toBeUndefined()
expect(chrome.transparent).toBeUndefined()
it('gives every theme a solid background', () => {
for (const theme of Object.values(themes)) {
const chrome = themeWindowChrome(theme)
expect(chrome.backgroundColor).toMatch(/^#[0-9a-f]{6}$/)
}
})

it('never flashes black for light themes', () => {
const chrome = themeWindowChrome(themes.porcelain, 'win32')
const chrome = themeWindowChrome(themes.porcelain)
const red = parseInt(chrome.backgroundColor.slice(1, 3), 16)
expect(red).toBeGreaterThan(0xd0)
// Overlay symbols must be dark on a light backdrop.
expect(parseInt(chrome.symbolColor.slice(1, 3), 16)).toBeLessThan(0x80)
})

it('derives the overlay symbol color from the theme foreground', () => {
expect(themeWindowChrome(themes.obsidian, 'win32').symbolColor).toMatch(/^#[0-9a-f]{6}$/)
expect(themeWindowChrome(themes.obsidian, 'win32').symbolColor).not.toBe(
themeWindowChrome(themes.porcelain, 'win32').symbolColor,
expect(themeWindowChrome(themes.obsidian).symbolColor).toMatch(/^#[0-9a-f]{6}$/)
expect(themeWindowChrome(themes.obsidian).symbolColor).not.toBe(
themeWindowChrome(themes.porcelain).symbolColor,
)
})

it('differs between a glass theme and an opaque theme', () => {
const glass = themeWindowChrome(themes.nocturne, 'win32')
const opaque = themeWindowChrome(themes.verdant, 'win32')
expect(glass.backgroundColor).not.toBe(opaque.backgroundColor)
expect(glass.backgroundMaterial).toBe('acrylic')
expect(opaque.backgroundMaterial).toBeUndefined()
it('uses each theme background rather than a shared transparent value', () => {
expect(themeWindowChrome(themes.nocturne).backgroundColor).not.toBe(
themeWindowChrome(themes.verdant).backgroundColor,
)
})
})
50 changes: 7 additions & 43 deletions apps/desktop/src/main/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,9 @@ import { appDisplayName } from './dev-instance'
import { getSettingsStore } from './store'

/**
* Platform chrome strategy (PLAN §8):
* - Windows: hidden frame + native titleBarOverlay (snap/max/min preserved),
* backgroundMaterial 'acrylic' when the active theme opts into glass
* - macOS: hiddenInset traffic lights + vibrancy 'under-window' (glass only)
* - Linux: hidden frame; custom controls ship in the renderer titlebar;
* transparent window for glass themes (compositor blur not guaranteed)
*
* Opaque themes get a solid `backgroundColor` from their own `bg` token, so a
* light theme never flashes black before the renderer paints.
* The native window always uses a solid `backgroundColor` from the active
* theme's `bg` token, so no desktop content bleeds through and a light theme
* never flashes black before the renderer paints.
*
* Window bounds persist across launches via the settings store.
*/
Expand All @@ -31,32 +25,12 @@ export const PACKAGED_CONTENT_SECURITY_POLICY =
export interface ThemeWindowChrome {
backgroundColor: string
symbolColor: string
transparent?: true
backgroundMaterial?: 'acrylic'
vibrancy?: 'under-window'
visualEffectState?: 'active'
}

/**
* Native window options derived from a theme. Glass themes request the
* platform's translucency and a fully transparent backdrop; opaque themes get
* the theme's own background so the first frame matches the UI.
*/
export function themeWindowChrome(
theme: Theme,
platform: NodeJS.Platform = process.platform,
): ThemeWindowChrome {
/** Native window colors derived from a theme. */
export function themeWindowChrome(theme: Theme): ThemeWindowChrome {
const symbolColor = oklchToHex(theme.colors.fg) ?? FALLBACK_SYMBOL
if (!theme.glass) {
return { backgroundColor: oklchToHex(theme.colors.bg) ?? FALLBACK_BG, symbolColor }
}
const translucent =
platform === 'win32'
? { backgroundMaterial: 'acrylic' as const }
: platform === 'darwin'
? { vibrancy: 'under-window' as const, visualEffectState: 'active' as const }
: { transparent: true as const }
return { backgroundColor: '#00000000', symbolColor, ...translucent }
return { backgroundColor: oklchToHex(theme.colors.bg) ?? FALLBACK_BG, symbolColor }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Live themes retain stale backdrop

After a live theme change, theme.apply leaves backgroundColor at its launch value. applyThemeToWindow updates only native theme and Windows symbols. A renderer repaint can expose the stale color behind the new palette.

Learn more

The BrowserWindow is now always opaque and receives the launch theme's backgroundColor during construction. Theme persistence invokes theme.apply after every theme save, but that handler only updates nativeTheme.themeSource and Windows titlebar symbols. Electron keeps the original BrowserWindow background until the process recreates the window or code calls its background-color setter. Any interval where Chromium does not cover the native surface can therefore reveal the previous theme.

Example: Launch with Obsidian, then switch to Porcelain. The renderer becomes light while the BrowserWindow background remains Obsidian dark. If the renderer reloads or briefly loses its painted surface, the window flashes dark instead of Porcelain's background.

Recommended fix: In applyThemeToWindow, apply chrome.backgroundColor to the live BrowserWindow with Electron's background-color API, while retaining the existing destroyed-window guard and titlebar update.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

/** The theme the window should paint, per persisted settings. */
Expand All @@ -66,8 +40,7 @@ export function persistedTheme(): Theme {

/**
* Repaints native chrome for a live theme change: the Windows overlay symbol
* color and the OS-level light/dark hint. Window material cannot change after
* creation, so glass transitions land on the next launch.
* color and the OS-level light/dark hint.
*/
export function applyThemeToWindow(win: BrowserWindow, theme: Theme): void {
const chrome = themeWindowChrome(theme)
Expand Down Expand Up @@ -97,15 +70,6 @@ export function createMainWindow(): BrowserWindow {
process.platform === 'win32'
? { color: '#00000000', symbolColor: chrome.symbolColor, height: 38 }
: false,
// Glass: the desktop shows through the shell chrome. Windows gets DWM
// acrylic; macOS gets native vibrancy; Linux composites its own blur via
// CSS backdrop-filter inside a transparent window. Opaque themes skip all
// three and render on the solid backgroundColor above.
...(chrome.backgroundMaterial ? { backgroundMaterial: chrome.backgroundMaterial } : {}),
...(chrome.vibrancy
? { vibrancy: chrome.vibrancy, visualEffectState: chrome.visualEffectState }
: {}),
...(chrome.transparent ? { transparent: true } : {}),
webPreferences: {
preload: join(import.meta.dirname, '../preload/index.cjs'),
sandbox: true,
Expand Down
10 changes: 5 additions & 5 deletions apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,7 @@ function Shell() {

if (settingsOpen || hubOpen) {
return (
<div className="ari-glass-pane flex h-full flex-col">
<div className="ari-wallpaper-pane flex h-full flex-col">
<Titlebar
activeTool={settingsOpen ? 'settings' : 'github'}
onSelectTool={selectWorkspaceTool}
Expand Down Expand Up @@ -778,7 +778,7 @@ function Shell() {

if (galleryOpen) {
return (
<div className="ari-glass-pane flex h-full flex-col bg-bg">
<div className="ari-wallpaper-pane flex h-full flex-col bg-bg">
<header className="flex h-[var(--ari-titlebar-height)] shrink-0 items-center gap-2 pl-3">
<span className="text-fg text-xs font-semibold tracking-[0.18em]">ARI</span>
<span className="text-fg-subtle text-xs">/</span>
Expand All @@ -800,7 +800,7 @@ function Shell() {
}

return (
<div className="ari-glass-pane flex h-full flex-col">
<div className="ari-wallpaper-pane flex h-full flex-col">
<Titlebar
activeTool={settingsOpen ? 'settings' : (fullPage ?? inspector)}
onSelectTool={selectWorkspaceTool}
Expand All @@ -809,7 +809,7 @@ function Shell() {
/>
<div className="flex min-h-0 flex-1">
{sidebarOpen ? (
<aside className="ari-glass flex shrink-0 flex-col" style={{ width: sidebar.width }}>
<aside className="flex shrink-0 flex-col bg-surface-0" style={{ width: sidebar.width }}>
<SidebarHeader
onSearch={() => sidebarSearchRef.current?.focus()}
onCollapse={toggleSidebar}
Expand Down Expand Up @@ -1076,7 +1076,7 @@ function Shell() {
type="button"
aria-label="Close inspector"
onClick={() => setInspector(null)}
className="flex h-6 w-6 items-center justify-center rounded-md text-fg-subtle transition-colors hover:bg-glass-hover hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
className="flex h-6 w-6 items-center justify-center rounded-md text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
<X size={13} />
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ export function ShipSection({
disabled={busy || !hasChanges}
placeholder={hasChanges ? 'Commit message — stages all changes' : 'Worktree clean'}
aria-label="Commit message"
className="h-7 min-w-0 flex-1 rounded-md border border-border bg-glass-input px-2 text-xs text-fg placeholder:text-fg-subtle focus:border-border-strong focus:outline-none"
className="h-7 min-w-0 flex-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg placeholder:text-fg-subtle focus:border-border-strong focus:outline-none"
/>
<button
type="button"
Expand All @@ -253,15 +253,15 @@ export function ShipSection({
onChange={(e) => setPrTitle(e.target.value)}
aria-label="Pull request title"
placeholder="PR title"
className="h-7 w-full rounded-md border border-border bg-glass-input px-2 text-xs text-fg placeholder:text-fg-subtle focus:border-border-strong focus:outline-none"
className="h-7 w-full rounded-md border border-border bg-surface-1 px-2 text-xs text-fg placeholder:text-fg-subtle focus:border-border-strong focus:outline-none"
/>
<textarea
value={prBody}
onChange={(e) => setPrBody(e.target.value)}
aria-label="Pull request description"
placeholder="Description (optional)"
rows={3}
className="w-full resize-none rounded-md border border-border bg-glass-input px-2 py-1 text-xs text-fg placeholder:text-fg-subtle focus:border-border-strong focus:outline-none"
className="w-full resize-none rounded-md border border-border bg-surface-1 px-2 py-1 text-xs text-fg placeholder:text-fg-subtle focus:border-border-strong focus:outline-none"
/>
<div className="flex items-center gap-2">
<button
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/src/features/composer/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const MAX_HEIGHT = 260
const RUNNING_PLACEHOLDER = 'Message will queue…'

/**
* Message composer: one glass plate. Draft on top; agent + permission on
* Message composer: one elevated surface. Draft on top; agent + permission on
* the left of the foot, stash + send on the right. Enter sends, Shift+Enter
* breaks the line. The @file popover sits above the field. Pasted or
* dropped images land in an attachment strip inside the plate and are handed
Expand Down Expand Up @@ -397,7 +397,7 @@ export function Composer({
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={transitions.fadeUp}
className="mb-2 rounded-md border border-border bg-glass-input py-1.5 text-xs text-fg-muted"
className="mb-2 rounded-md border border-border bg-surface-1 py-1.5 text-xs text-fg-muted"
>
<div className="flex items-center gap-2 px-3">
<Clock size={12} className="shrink-0 text-fg-subtle" />
Expand Down Expand Up @@ -551,7 +551,7 @@ export function Composer({
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 4, scale: 0.97 }}
transition={transitions.menuIn}
className="ari-glass-overlay absolute bottom-full right-0 z-40 mb-2 max-h-72 w-80 overflow-y-auto rounded-lg border border-border p-1 shadow-2"
className="absolute bottom-full right-0 z-40 mb-2 max-h-72 w-80 overflow-y-auto rounded-lg border border-border bg-surface-2 p-1 shadow-2"
role="menu"
aria-label="Stashed prompts"
>
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/src/features/composer/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@

## Evidence

- Local: M22 cloned T3 (provider-chip pill, left send, `focus-within` halo, icon rail in an earlier pass). Neighbors use ghost chips, `rounded-lg`, `focus-visible` rings, Geist, glass overlays.
- Local: M22 cloned T3 (provider-chip pill, left send, `focus-within` halo, icon rail in an earlier pass). Neighbors use ghost chips, `rounded-lg`, `focus-visible` rings, Geist, and elevated overlays.
- Contrast: T3 (icon rail, starred recents, Ctrl+N, left send) and Cursor (right circular send). Ari is agent-first, not model-marketplace-first.
- Inference: users pick an *agent* then a model; the chrome should say that.

## Thesis

One glass plate. Identity is a **mono letter mark** (C Claude, X Codex, O OpenCode, A Ari Core) plus the model name — not an all-caps vendor chip and not a logo rail. Context (agent, permission) sits left; prompt actions (stash, send) sit right. Send is a rounded-md square, not a circle, so it shares geometry with the chips. No leftover `focus-within` halo after clicking a chip; keyboard rings stay on `:focus-visible`.
One elevated surface. Identity is a **mono letter mark** (C Claude, X Codex, O OpenCode, A Ari Core) plus the model name — not an all-caps vendor chip and not a logo rail. Context (agent, permission) sits left; prompt actions (stash, send) sit right. Send is a rounded-md square, not a circle, so it shares geometry with the chips. No leftover `focus-within` halo after clicking a chip; keyboard rings stay on `:focus-visible`.

## Picker (2026-08-25 revision)

Expand All @@ -42,7 +42,7 @@ The dock above the plate is preserved by leaving it in normal flow: the rail's `

## Semantic reuse

- Color: `bg-glass-input`, `border-border`, `bg-surface-2` hover/active, `bg-accent` only on an armed send, `text-fg-subtle` for marks/hints.
- Color: `bg-surface-1`, `border-border`, `bg-surface-2` hover/active, `bg-accent` only on an armed send, `text-fg-subtle` for marks/hints.
- Type: Geist UI + Geist Mono for the mark and hints.
- Radius: plate `rounded-lg`; chips/send `rounded-md`; mark `rounded-sm`.
- Motion: named `transition-colors` / `transition-transform`; chevron rotates; `motion-reduce:transition-none`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function FilePopup({ items, onSelect, onClose }: FilePopupProps) {
initial="hidden"
animate="visible"
variants={menuInVariants}
className="ari-glass-overlay overflow-hidden rounded-md border border-border shadow-lg"
className="overflow-hidden rounded-md border border-border bg-surface-2 shadow-lg"
>
<ul role="listbox" aria-label="File mentions" className="max-h-[232px] overflow-y-auto p-1">
{items.map((path, index) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ export function ModelSelector({
<div
role="presentation"
onKeyDown={onMenuKeyDown}
className="ari-glass-overlay absolute bottom-full left-0 z-50 mb-2 flex w-[25rem] flex-col overflow-hidden rounded-lg border border-border shadow-2"
className="absolute bottom-full left-0 z-50 mb-2 flex w-[25rem] flex-col overflow-hidden rounded-lg border border-border bg-surface-2 shadow-2"
>
<div className="relative border-b border-border">
<Search
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ function HunkRow({ line, path, onComment }: { line: DiffLine; path?: string; onC
rows={2}
aria-label={`Review note for ${path ?? 'line'}`}
placeholder="Review note — sent with your next message (Mod+Enter to save)"
className="w-full resize-none rounded-sm border border-border bg-glass-input px-2 py-1 font-mono text-xs text-fg placeholder:text-fg-subtle focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
className="w-full resize-none rounded-sm border border-border bg-surface-1 px-2 py-1 font-mono text-xs text-fg placeholder:text-fg-subtle focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
/>
<div className="mt-1 flex items-center justify-end gap-1">
<button
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/src/features/focus/FocusPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const iconButton =
'flex size-7 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-2 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring disabled:opacity-40'

const fieldInput =
'h-7 rounded-md border border-border bg-glass-input px-2 text-xs text-fg placeholder:text-fg-subtle focus:border-border-strong focus:outline-none'
'h-7 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg placeholder:text-fg-subtle focus:border-border-strong focus:outline-none'

/**
* Compact header pill combining music state and an optional focus timer.
Expand Down
Loading
Loading