From 7f9e79b2e17c6abd7d49d9d8f399b7e63e478866 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 19 Aug 2026 18:48:46 -0500 Subject: [PATCH 01/12] feat(desktop): back the HUD band with the same window material the app uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HUD asked for vibrancy directly and always with the 'hud' material — one of the two rungs the macOS census rejected, because it collapses into under-window on blur and so changed the frost the moment another app took focus. It also ignored the translucency setting entirely: Glass off still frosted, and Windows got nothing at all. hudFrostFor is the mapping for a transparent window, beside vibrancyFor in the shared module both processes read. Two gates give it its answer: the renderer's report that the band actually covers the window, and the user's Glass setting. Off resolves to no material rather than a resting one, since a transparent window has no opaque page to hide an unwanted frost behind. Windows 11 rides setBackgroundMaterial through the same call, so the HUD follows the frost ladder on both platforms. Main self-diffs and keys the latch to the window, so a Settings change re-frosts a live HUD, a tint drag touches nothing native, and a HUD respawned on another profile is not mistaken for the window that already carried the material. --- apps/desktop/electron/hud-ipc.ts | 90 ++++++++++++++++++---- apps/desktop/electron/main.ts | 11 ++- apps/desktop/electron/preload.ts | 5 +- apps/desktop/electron/translucency.test.ts | 48 ++++++++++++ apps/desktop/electron/translucency.ts | 1 + apps/desktop/src/global.d.ts | 2 +- apps/shared/src/translucency.ts | 27 +++++++ 7 files changed, 168 insertions(+), 16 deletions(-) diff --git a/apps/desktop/electron/hud-ipc.ts b/apps/desktop/electron/hud-ipc.ts index 8a8b7cf000..3eff9c1488 100644 --- a/apps/desktop/electron/hud-ipc.ts +++ b/apps/desktop/electron/hud-ipc.ts @@ -4,15 +4,84 @@ // latch when handing the session back to the app window. import { type BrowserWindow, ipcMain } from 'electron' +import { hudFrostFor, type TranslucencyState } from './translucency' + export interface HudIpcDeps { isMac: boolean + isWindows: boolean + glassSupported: boolean + /** Main's authoritative translucency state (Settings → Appearance). */ + getTranslucencyState: () => TranslucencyState getHudWindow: () => BrowserWindow | null openHudWindow: (sessionId: null | string, profile: null | string) => void closeHudWindow: () => void setHudSessionId: (sessionId: null | string) => void } -export function registerHudIpc({ isMac, getHudWindow, openHudWindow, closeHudWindow, setHudSessionId }: HudIpcDeps) { +export function registerHudIpc({ + isMac, + isWindows, + glassSupported, + getTranslucencyState, + getHudWindow, + openHudWindow, + closeHudWindow, + setHudSessionId +}: HudIpcDeps) { + // Whether the band currently covers the window below the bar. The renderer + // is the only party that can know this (it measures the transcript), and it + // is half of the frost decision — the other half is the user's setting, + // which main owns. Latched so a Settings change can re-decide without + // waiting for the HUD to report again. + let bandShowing = false + let applied: null | string = null + let appliedTo: BrowserWindow | null = null + + // Real frosted glass behind the band — the thing CSS backdrop-filter cannot do, + // because Chromium composites a transparent window's page against nothing and + // the desktop is not in its backdrop root. The material IS the window's content + // view, so it frosts the whole rectangle; the HUD's layout leaves no dead + // margins for that reason, and it only turns on while the band is showing + // (idle HUD mode must be the bar and nothing else). + // + // Diffed before issuing: `setVibrancy` carries a 150ms animation that restarts + // if re-issued, so a repeated call would keep the material from ever settling + // (the same churn the chat windows' native-diff contract exists to prevent). + // + // The diff is keyed to the WINDOW as well as the value. A HUD respawn (the + // profile switch in openHudWindow destroys and rebuilds it) hands back a + // fresh window carrying no material, and a latch that only remembered the + // value would recognise its own last answer and skip — leaving the new HUD + // unfrosted until something else happened to change the signature. + const applyHudFrost = () => { + const hudWindow = getHudWindow() + + if (!hudWindow || hudWindow.isDestroyed()) { + applied = null + appliedTo = null + + return + } + + const frost = hudFrostFor(getTranslucencyState(), bandShowing) + const signature = `${frost.vibrancy ?? 'off'}:${frost.backgroundMaterial}` + + if (applied === signature && appliedTo === hudWindow) { + return + } + + applied = signature + appliedTo = hudWindow + + if (isMac && typeof hudWindow.setVibrancy === 'function') { + hudWindow.setVibrancy(frost.vibrancy) + } + + if (isWindows && glassSupported && typeof hudWindow.setBackgroundMaterial === 'function') { + hudWindow.setBackgroundMaterial(frost.backgroundMaterial) + } + } + ipcMain.handle('hermes:hud:open', async (_event, request) => { openHudWindow( typeof request?.sessionId === 'string' ? request.sessionId : null, @@ -22,18 +91,9 @@ export function registerHudIpc({ isMac, getHudWindow, openHudWindow, closeHudWin return { ok: true } }) - // Real frosted glass behind the band — the thing CSS backdrop-filter cannot do, - // because Chromium composites a transparent window's page against nothing and - // the desktop is not in its backdrop root. Vibrancy IS the window's content - // view, so it frosts the whole rectangle; the HUD's layout leaves no dead - // margins for that reason, and the renderer only turns it on while the band is - // showing (idle HUD mode must be the bar and nothing else). - ipcMain.handle('hermes:hud:vibrancy', (_event, on) => { - const hudWindow = getHudWindow() - - if (hudWindow && !hudWindow.isDestroyed() && isMac) { - hudWindow.setVibrancy(on ? 'hud' : null) - } + ipcMain.handle('hermes:hud:frost', (_event, showing) => { + bandShowing = Boolean(showing) + applyHudFrost() return { ok: true } }) @@ -125,4 +185,8 @@ export function registerHudIpc({ isMac, getHudWindow, openHudWindow, closeHudWin return { ok: true } }) + + // Main re-applies the frost when the translucency SETTING changes, since the + // band's own report only fires when the band itself moves. + return { applyHudFrost } } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 09170cc630..4afca3a9c2 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -12136,8 +12136,11 @@ registerPetOverlayIpc({ }) // --- HUD mode (chrome-free floating chat) — see hud-ipc.ts. --------------- -registerHudIpc({ +const hudIpc = registerHudIpc({ isMac: IS_MAC, + isWindows: IS_WINDOWS, + glassSupported: GLASS_SUPPORTED, + getTranslucencyState: () => translucencyState, getHudWindow: () => hudWindow, openHudWindow, closeHudWindow, @@ -13897,6 +13900,12 @@ ipcMain.on('hermes:translucency', (_event, payload) => { scheduleTranslucencyWrite() + // The HUD's frost reads the same setting but answers on its own terms (see + // hudFrostFor) — and it is a transparent window, so it is deliberately not + // in the chat fan-out below. It self-diffs, so an unrelated change costs + // nothing native. + hudIpc.applyHudFrost() + if (changed.backing || changed.material || changed.opacity) { for (const win of BrowserWindow.getAllWindows()) { applyWindowTranslucency(win, changed) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 2d1ba597ce..4b745b1353 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -75,7 +75,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', { setIgnoreMouse: ignore => ipcRenderer.send('hermes:hud:ignore-mouse', ignore), moveBy: delta => ipcRenderer.send('hermes:hud:move-by', delta), setBounds: bounds => ipcRenderer.send('hermes:hud:set-bounds', bounds), - setVibrancy: on => ipcRenderer.invoke('hermes:hud:vibrancy', on), + // Whether the band covers the window below the bar. Main pairs it with the + // user's translucency setting to decide the native frost (macOS vibrancy / + // Windows 11 DWM backdrop) — see hudFrostFor. + setFrost: showing => ipcRenderer.invoke('hermes:hud:frost', showing), // The HUD tells main which session it is on; main hands that back to the // app window when the HUD closes, so the app can re-home onto it. setSession: sessionId => ipcRenderer.send('hermes:hud:session', sessionId), diff --git a/apps/desktop/electron/translucency.test.ts b/apps/desktop/electron/translucency.test.ts index 6795fdd5d7..351fc72576 100644 --- a/apps/desktop/electron/translucency.test.ts +++ b/apps/desktop/electron/translucency.test.ts @@ -23,6 +23,7 @@ import { glassMaterialsFor, glassSupportedOn, glassSurfaceKeep, + hudFrostFor, normalizeMaterial, normalizeMode, normalizeScope, @@ -245,6 +246,53 @@ describe('vibrancyFor', () => { }) }) +// The HUD is a transparent window, so its frost has no opaque page to hide +// behind: every state that isn't "frost wanted" has to resolve to no material +// at all, or the band leaves a grey slab hanging over another app. +describe('hudFrostFor', () => { + it('wears the chosen frost on both platforms while the band is showing', () => { + expect(hudFrostFor(glass(60, 'header'), true)).toEqual({ vibrancy: 'header', backgroundMaterial: 'mica' }) + expect(hudFrostFor(glass(60, 'under-window'), true)).toEqual({ + vibrancy: 'under-window', + backgroundMaterial: 'acrylic' + }) + }) + + // The material is the whole window rectangle and nothing on the page can + // clip it, so a hidden band must mean no frost — this is the veto that keeps + // idle HUD mode the bar and nothing else. + it('is off whenever the band is not covering the window', () => { + expect(hudFrostFor(glass(60, 'header'), false)).toEqual({ vibrancy: null, backgroundMaterial: 'none' }) + }) + + // ...and the setting is the other veto: Glass off, or the tint at zero, + // means the HUD never frosts however engaged the band is. + it('is off whenever glass itself is off', () => { + expect(hudFrostFor(clear(60), true)).toEqual({ vibrancy: null, backgroundMaterial: 'none' }) + expect(hudFrostFor(glass(0, 'header'), true)).toEqual({ vibrancy: null, backgroundMaterial: 'none' }) + }) + + // Unlike a chat window, which keeps 'sidebar' under its titlebar band in + // every non-glass state. Pinning this is what stops someone "fixing" the + // null into a resting material and painting the slab back. + it('resolves off to no material at all, not to a resting one', () => { + expect(hudFrostFor(clear(60), true).vibrancy).toBeNull() + expect(vibrancyFor(clear(60))).toBe('sidebar') + }) + + // The tint is painted by the renderer, exactly as it is for a chat window — + // dragging it must not re-issue setVibrancy, whose 150ms animation restarts + // on every call and never lets the material settle. + it('does not move any native property as the tint slider is dragged', () => { + for (let intensity = 1; intensity <= 100; intensity += 1) { + expect(hudFrostFor(glass(intensity, 'popover'), true)).toEqual({ + vibrancy: 'popover', + backgroundMaterial: 'tabbed' + }) + } + }) +}) + describe('glassSupportedOn', () => { it('is on for macOS regardless of kernel version', () => { expect(glassSupportedOn('darwin')).toBe(true) diff --git a/apps/desktop/electron/translucency.ts b/apps/desktop/electron/translucency.ts index 6e524a8d8f..72f936e9cc 100644 --- a/apps/desktop/electron/translucency.ts +++ b/apps/desktop/electron/translucency.ts @@ -26,6 +26,7 @@ export { glassMaterialsFor, glassSupportedOn, glassSurfaceKeep, + hudFrostFor, normalizeMaterial, normalizeMode, normalizeScope, diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 1bd046c80d..4dcefedaf7 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -96,7 +96,7 @@ declare global { setIgnoreMouse: (ignore: boolean) => void moveBy: (delta: { x: number; y: number; width: number; height: number }) => void setBounds: (bounds: { x: number; y: number; width: number; height: number }) => void - setVibrancy: (on: boolean) => Promise<{ ok: boolean }> + setFrost: (showing: boolean) => Promise<{ ok: boolean }> setSession: (sessionId: null | string) => void onGoto: (callback: (sessionId: string) => void) => () => void onChanged: (callback: (state: { open: boolean; sessionId: null | string }) => void) => () => void diff --git a/apps/shared/src/translucency.ts b/apps/shared/src/translucency.ts index da32fd102f..fe8ea43b5f 100644 --- a/apps/shared/src/translucency.ts +++ b/apps/shared/src/translucency.ts @@ -274,6 +274,33 @@ export function glassMaterialsFor(isWindows: boolean): readonly GlassMaterial[] return isWindows ? WINDOWS_GLASS_MATERIALS : GLASS_MATERIALS } +/** + * The native frost a HUD-style transparent window should carry. + * + * Two gates, because the HUD's frost answers to more than the setting. The + * material is the WINDOW's — nothing on the page can clip it — so it is only + * ever right while the band actually covers the window below the bar; + * `showing` is the renderer's answer to that (see `useHudGlass`). The setting + * is the other half: Glass off, or the tint at zero, means no frost at all. + * + * The off answer is `null` rather than a resting material, which is the one + * way this differs from `vibrancyFor`. A chat window is opaque and keeps + * 'sidebar' under its titlebar band whatever the setting says; a transparent + * window has no opaque page to hide an unwanted material behind, so off has + * to mean off or the frost is a grey slab hanging over someone else's app. + */ +export function hudFrostFor( + state: TranslucencyState, + showing: boolean +): { backgroundMaterial: WindowsBackgroundMaterial; vibrancy: GlassMaterial | null } { + const active = showing && glassActive(state) + + return { + vibrancy: active ? state.material : null, + backgroundMaterial: active ? backgroundMaterialFor(state) : 'none' + } +} + /** * The rung the picker highlights. A frost with no rung of its own here — a * Mac's 'header' read on Windows — folds onto the rung that renders the same From cba8efce2101ebed649a5fb6d65dbdb1aabf0cc5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 19 Aug 2026 18:49:58 -0500 Subject: [PATCH 02/12] feat(desktop): paint the HUD band as the app's thread surface under Glass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The band wore its own card tint at a hardcoded 80/92%, so a HUD beside the docked window read as a lookalike rather than the same surface, and the Tint slider moved one and not the other. It now paints --ui-bg-chrome at --translucency-glass-keep: one painter, one token, one lever. That needed the setting and the surface rewrite to stop being one flag. data-hermes-glass means "this window's field surfaces may be rewritten" and is deliberately false in the HUD, which owns its own backgrounds; the new data-hermes-glass-on means "the user's Glass setting is live" and is published everywhere, along with the tint number the band reads. The 0.5rem side inset drops to zero while glass is on. It exists to keep an opaque sheet clear of the bar's corner controls, but the frost is the whole window — an inset sheet left a hairline of bare untinted material down both sides. An open completion drawer now drops the frost along with the band it belongs to. The drawer takes the band to 25% and blurs it while the native material stayed at full strength, which is the same bare slab in a different disguise. It mounts without a focus change, so it is observed rather than passed in, coalesced to a frame because the shell mutates with every streamed token. --- apps/desktop/src/app/hud/glass.ts | 75 +++++++++++++++---- apps/desktop/src/store/translucency.test.ts | 22 ++++++ apps/desktop/src/store/translucency.ts | 22 ++++-- apps/desktop/src/styles.css | 80 ++++++++++++++++----- 4 files changed, 163 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/app/hud/glass.ts b/apps/desktop/src/app/hud/glass.ts index 572f68b58f..2a917e4433 100644 --- a/apps/desktop/src/app/hud/glass.ts +++ b/apps/desktop/src/app/hud/glass.ts @@ -3,14 +3,18 @@ import { type RefObject, useEffect } from 'react' /** The caret is in the composer — see the `:has()` rules in styles.css. */ const TYPING_SELECTOR = '[data-slot="composer-rich-input"]:focus' +/** An open completion list owns the surface; the band falls back behind it. */ +const DRAWER_SELECTOR = '[data-slot="composer-completion-drawer"]' + /** * Native frost behind the band. * - * macOS vibrancy, not CSS — `backdrop-filter` reaches nothing here, because a - * transparent window's backdrop root is the document and the desktop was never - * in it. Vibrancy is composited by WindowServer BELOW the web contents, which - * is what lets it see the desktop and also what makes it untouchable from the - * page: no mask, clip or stacking order can shape it. + * A platform material, not CSS — `backdrop-filter` reaches nothing here, + * because a transparent window's backdrop root is the document and the desktop + * was never in it. The material is composited BELOW the web contents (macOS + * vibrancy via WindowServer, Windows 11 via the DWM backdrop), which is what + * lets it see the desktop and also what makes it untouchable from the page: no + * mask, clip or stacking order can shape it. * * That is survivable because the band is a flat panel. It was NOT survivable * while the band carried a vertical gradient — the frost stayed a slab under a @@ -31,38 +35,79 @@ const TYPING_SELECTOR = '[data-slot="composer-rich-input"]:focus' * document.activeElement, which stays put when the window is blurred and would * latch the frost on forever once the user had ever typed here. * - * `backing` is the veto over both of those. Because the frost is the window and - * not the sheet, it is only ever right when the sheet covers the window; short - * of that the excess is frost over empty space. Gating the caller's `engaged` - * alone would not do it — focus turns the frost on by itself, which is how a - * brand new thread still frosted its whole empty window. + * Two vetoes sit over that: + * + * - `backing` — because the frost is the window and not the sheet, it is only + * ever right when the sheet covers the window; short of that the excess is + * frost over empty space. Gating the caller's `engaged` alone would not do + * it — focus turns the frost on by itself, which is how a brand new thread + * still frosted its whole empty window. + * - An open completion drawer, which drops the band to 25% and blurs it + * (see the `composer-completion-drawer` rule in styles.css). Full-strength + * frost behind a band that has deliberately stepped back is the same bare + * slab in a different disguise. Observed rather than passed in: the drawer + * mounts inside the composer subtree from three different call sites, so a + * prop would need every one of them to remember. + * + * Whether the frost is wanted AT ALL is the user's translucency setting, and + * that answer lives in main (`hudFrostFor`) next to the state it reads. This + * hook reports what the band is doing; it does not decide the material. */ export function useHudGlass(rootRef: RefObject, engaged: boolean, backing: boolean): void { useEffect(() => { const root = rootRef.current - const setVibrancy = window.hermesDesktop?.hud?.setVibrancy + const setFrost = window.hermesDesktop?.hud?.setFrost - if (!root || !setVibrancy) { + if (!root || !setFrost) { return } let on: boolean | null = null const apply = () => { - const next = backing && (engaged || root.querySelector(TYPING_SELECTOR) !== null) + const next = + backing && + root.querySelector(DRAWER_SELECTOR) === null && + (engaged || root.querySelector(TYPING_SELECTOR) !== null) if (on !== next) { on = next - void setVibrancy(next) + void setFrost(next) } } + // The drawer mounts and unmounts without any focus change, so neither + // focusin/focusout nor a re-render is guaranteed to follow it. Coalesced + // to a frame: this observes the whole shell, and a streaming reply mutates + // the transcript tens of times a second — the drawer's state cannot change + // more than once per paint, so re-deciding per mutation is pure churn. + let frame: null | number = null + + const schedule = () => { + if (frame === null) { + frame = requestAnimationFrame(() => { + frame = null + apply() + }) + } + } + + const observer = new MutationObserver(schedule) + + observer.observe(root, { childList: true, subtree: true }) + apply() root.addEventListener('focusin', apply) root.addEventListener('focusout', apply) return () => { - void setVibrancy(false) + void setFrost(false) + observer.disconnect() + + if (frame !== null) { + cancelAnimationFrame(frame) + } + root.removeEventListener('focusin', apply) root.removeEventListener('focusout', apply) } diff --git a/apps/desktop/src/store/translucency.test.ts b/apps/desktop/src/store/translucency.test.ts index a2861a952f..1e9e0cadc9 100644 --- a/apps/desktop/src/store/translucency.test.ts +++ b/apps/desktop/src/store/translucency.test.ts @@ -400,6 +400,28 @@ describe('glass is confined to chat windows', () => { // The mode is still the user's choice — only the page rewrite is withheld. expect($translucency.get().mode).toBe('glass') expect(document.documentElement.hasAttribute('data-hermes-glass')).toBe(false) + }) + + // The HUD paints its band from the app's field mix, so it needs the setting + // and the tint number even though its surfaces must not be rewritten. The + // two flags are what keep those separable: keying the band off + // `data-hermes-glass` would silently never match. + it('still publishes the live setting and the tint to a special-purpose window', () => { + setSearch('?win=hud') + setTranslucency(60) + setTranslucencyMode('glass') + + expect(document.documentElement.hasAttribute('data-hermes-glass-on')).toBe(true) + expect(document.documentElement.style.getPropertyValue('--translucency-glass-keep')).toBe('40%') + }) + + it('withdraws both flags when glass is switched off', () => { + setSearch('?win=hud') + setTranslucency(60) + setTranslucencyMode('glass') + setTranslucencyMode('clear') + + expect(document.documentElement.hasAttribute('data-hermes-glass-on')).toBe(false) expect(document.documentElement.style.getPropertyValue('--translucency-glass-keep')).toBe('') }) diff --git a/apps/desktop/src/store/translucency.ts b/apps/desktop/src/store/translucency.ts index b37bc25989..4bb0c89944 100644 --- a/apps/desktop/src/store/translucency.ts +++ b/apps/desktop/src/store/translucency.ts @@ -262,24 +262,38 @@ const applyGlassSurfaces = ({ intensity, mode, scope }: TranslucencyState): void } const root = document.documentElement - const glassOn = mode === 'glass' && intensity > 0 && GLASS_SUPPORTED && isChatWindow() + // Is the user's Glass setting live at all — the same answer in every window. + const glassLive = mode === 'glass' && intensity > 0 && GLASS_SUPPORTED + // ...and may THIS window's field surfaces be rewritten for it. Only real + // chat windows: the HUD, pet overlay, quick entry and wake indicator are + // transparent windows that own their backgrounds, and the surface rewrite + // would fight them. The HUD still wants the first answer, because its band + // paints the app's field mix from `--translucency-glass-keep` and its native + // frost is gated on the setting being on (see the `[data-hud-glass]` rules + // and hudFrostFor) — which is why these are two flags and not one. + const glassOn = glassLive && isChatWindow() // Clear mode fades the whole window uniformly, so overlay text and the // covered transcript blend; styles.css strengthens the overlay scrim while // this attribute is present. Native opacity applies in every window kind, so // no chat-window gate. const clearOn = mode === 'clear' && intensity > 0 + root.toggleAttribute('data-hermes-glass-on', glassLive) root.toggleAttribute('data-hermes-glass', glassOn) root.toggleAttribute('data-hermes-clear', clearOn) - if (glassOn) { - root.setAttribute('data-hermes-glass-scope', scope) + if (glassLive) { root.style.setProperty('--translucency-glass-keep', `${glassSurfaceKeep(intensity)}%`) } else { - root.removeAttribute('data-hermes-glass-scope') root.style.removeProperty('--translucency-glass-keep') } + if (glassOn) { + root.setAttribute('data-hermes-glass-scope', scope) + } else { + root.removeAttribute('data-hermes-glass-scope') + } + if (glassOn && scope === 'sidebar') { startRailTracking() } else { diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 3816915bf8..ada6d1d210 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -2579,19 +2579,32 @@ button[data-slot='aui_msg-reactions'] svg { scroll or a stray click while you're aiming at the app behind it. Focus the composer and it becomes a real scrollable surface. This also means hover alone can't reveal the band anymore — hover is not engagement. */ -/* The band's sheet, behind the transcript: a tint wearing the same gradient - mask as the text, so the whole surface ramps out together and the band has no - top edge at all. - - No desktop blur under it, and that is a limit rather than an omission. CSS - backdrop-filter reaches nothing here — a transparent window's backdrop root - is the document, and the desktop was never in it (verified on the real - window, not assumed). macOS vibrancy does see the desktop, but WindowServer - composites it below the web contents after this process has finished drawing, - so no mask, clip or stacking order can shape it; left on under a fading tint - it stays a flat slab and the top of the band goes pale exactly where it - should be disappearing. The way through is NSVisualEffectView.maskImage - behind a small native addon, which is its own change. */ +/* The band's sheet, behind the transcript: a tint the whole surface ramps out + with, so the band has no top edge at all. + + CSS backdrop-filter reaches nothing here — a transparent window's backdrop + root is the document, and the desktop was never in it (verified on the real + window, not assumed). The blur is a native platform material instead, and + because it is composited below the web contents after this process has + finished drawing, no mask, clip or stacking order can shape it: it is the + whole window rectangle or nothing. That is why it is switched on only while + the band covers the window (useHudGlass) and why the sheet must be a flat + panel — under a fading GRADIENT the frost stayed a slab and the top of the + band went pale exactly where it should have been disappearing. Shaping it + would need NSVisualEffectView.maskImage behind a native addon. + + Under Glass the sheet wears the SAME paint the docked thread does: + `--ui-bg-chrome` kept at `--translucency-glass-keep`, the one number + store/translucency publishes from the Tint slider (see the + `[data-hermes-glass]` block). One painter, one token, one lever — the band + reads as the app's thread surface rather than as a HUD-only lookalike that + drifts the first time either side is touched. + + With Glass off there is no material behind the window, so the sheet keeps + its own card tint. Heavier than the text in front of it, deliberately: with + no blur to separate the band from what it lies over, the sheet is the only + thing keeping half-opacity text off someone else's UI. Fade the words, keep + the paper. */ [data-hud-shell] [data-hud-glass] { position: absolute; right: var(--hud-band-inset); @@ -2600,10 +2613,6 @@ button[data-slot='aui_msg-reactions'] svg { z-index: 0; pointer-events: none; border-radius: 0.75rem 0.75rem 0 0; - /* Heavier than the text in front of it, deliberately: with no blur to - separate the band from what it lies over, the sheet is the only thing - keeping half-opacity text off someone else's UI. Fade the words, keep the - paper. */ background: color-mix(in srgb, var(--dt-card) 80%, transparent); /* Slides down behind the bar as it fades. A TRANSFORM, not height: it is composited, so it stays smooth where animating height re-lays-out the panel @@ -2637,6 +2646,43 @@ button[data-slot='aui_msg-reactions'] svg { transition-duration: var(--hud-reveal); } +/* ── Under Glass ───────────────────────────────────────────────────────────── + The band becomes the app's thread surface: the docked window's field mix, + over the platform material. + + Keyed to `data-hermes-glass-on` — "the user's Glass setting is live" — and + NOT to `data-hermes-glass`, which additionally means "this window's field + surfaces may be rewritten" and is false here by design: the HUD is a + transparent window that owns its own backgrounds (isChatWindow). Two flags, + because the HUD wants the setting without the rewrite. */ +:root[data-hermes-glass-on] [data-hud-shell] [data-hud-glass] { + background: color-mix(in srgb, var(--ui-bg-chrome) var(--translucency-glass-keep, 100%), transparent); +} + +/* The band spans the window edge to edge under glass. The 8px side inset keeps + the sheet clear of the bar's corner controls when it is an opaque panel — + but the frost is the WINDOW, so an inset sheet leaves a hairline of bare + untinted material down both sides. Nothing to hold off the material's edge: + the window's own rounded corners are where the band ends. */ +:root[data-hermes-glass-on] [data-hud-shell] { + --hud-band-inset: 0px; +} + +:root[data-hermes-glass-on] [data-hud-shell] [data-hud-glass] { + border-radius: 0.75rem 0.75rem 0 0; +} + +/* Engaged, the field steps up the same way the docked thread's does — but + never below the resting tint, so a high lever cannot make focusing the + composer paint MORE transparent than glancing at it. */ +:root[data-hermes-glass-on] [data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-glass] { + background: color-mix( + in srgb, + var(--ui-bg-chrome) min(100%, calc(var(--translucency-glass-keep, 100%) + 12%)), + transparent + ); +} + /* Engaged means the caret is in the composer, not merely that the window holds focus somewhere. Activating a window restores focus to whatever had it last, so `:focus-within` counted grabbing the bar to DRAG the HUD as sitting down From 701314c6dd7ecf0b289a8b3ae5af9ca694888409 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 19 Aug 2026 18:50:53 -0500 Subject: [PATCH 03/12] feat(desktop): fold the HUD's voice controls into one menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dictation, spoken replies, the wake word and start-conversation were four separate icon buttons in a Spotlight bar a few hundred pixels wide — most of the row spent on toggles that are set once and rarely touched. In the HUD they collapse into a single menu; the docked composer has the width and keeps them inline, same controls and same state. The trigger is not a static glyph. It reports the loudest live voice state — recording, transcribing, listening for the wake word, speaking replies — and lights while any is on, because a folded menu that looked idle with the mic open would be a worse trade than the space it saves. The three toggles are checkbox rows that hold the menu open on select, so the state you just changed is the state you can see. The shared control class names move to a module of their own so the row and the menus it renders can wear them without importing each other, and the pressed-toggle tint stops being written out at each of its four sites. --- .../src/app/chat/composer/control-classes.ts | 25 +++ .../src/app/chat/composer/controls.tsx | 51 +++--- .../src/app/chat/composer/voice-menu.tsx | 162 ++++++++++++++++++ apps/desktop/src/i18n/ar.ts | 1 + apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + 9 files changed, 224 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/control-classes.ts create mode 100644 apps/desktop/src/app/chat/composer/voice-menu.tsx diff --git a/apps/desktop/src/app/chat/composer/control-classes.ts b/apps/desktop/src/app/chat/composer/control-classes.ts new file mode 100644 index 0000000000..82285928c7 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/control-classes.ts @@ -0,0 +1,25 @@ +import { cn } from '@/lib/utils' + +// Shared class names for the composer's control row, in a module of their own +// so both the row (`controls.tsx`) and the menus it renders can wear them +// without importing each other in a cycle. + +export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md' + +export const GHOST_ICON_BTN = cn( + ICON_BTN, + 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground' +) + +// Send/voice-conversation primary: solid foreground-on-background circle +// (reads as black-on-white in light mode, white-on-black in dark mode) to +// match the reference composer's high-contrast CTA. Keeps the pill itself +// neutral and lets the action visually dominate the row. +export const PRIMARY_ICON_BTN = cn( + 'size-(--composer-control-primary-size,var(--composer-control-size)) shrink-0 rounded-full p-0', + 'bg-foreground text-background hover:bg-foreground/90', + 'disabled:bg-foreground/30 disabled:text-background disabled:opacity-100' +) + +/** A toggle that is currently ON — dictation, spoken replies, the wake word. */ +export const ACTIVE_ICON_BTN = 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary' diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 286493d006..c5ce2c2462 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -7,26 +7,18 @@ import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { AudioLines, Ear, EarOff, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' +import { $hudMode } from '@/store/hud' import { $wakeWord, toggleWakeWord } from '@/store/wake-word' +import { ACTIVE_ICON_BTN, GHOST_ICON_BTN, PRIMARY_ICON_BTN } from './control-classes' import type { ConversationStatus } from './hooks/use-voice-conversation' import { ModelPill } from './model-pill' import type { ChatBarState, VoiceStatus } from './types' +import { VoiceMenu } from './voice-menu' -export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md' -export const GHOST_ICON_BTN = cn( - ICON_BTN, - 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground' -) -// Send/voice-conversation primary: solid foreground-on-background circle -// (reads as black-on-white in light mode, white-on-black in dark mode) to -// match the reference composer's high-contrast CTA. Keeps the pill itself -// neutral and lets the action visually dominate the row. -export const PRIMARY_ICON_BTN = cn( - 'size-(--composer-control-primary-size,var(--composer-control-size)) shrink-0 rounded-full p-0', - 'bg-foreground text-background hover:bg-foreground/90', - 'disabled:bg-foreground/30 disabled:text-background disabled:opacity-100' -) +// Re-exported: `context-menu.tsx` and other row neighbours have always reached +// for these here, and the row is where they read as belonging. +export { ACTIVE_ICON_BTN, GHOST_ICON_BTN, ICON_BTN, PRIMARY_ICON_BTN } from './control-classes' interface ConversationProps { active: boolean @@ -70,6 +62,7 @@ export function ComposerControls({ }) { const { t } = useI18n() const c = t.composer + const hudMode = useStore($hudMode) if (conversation.active) { return @@ -84,9 +77,27 @@ export function ComposerControls({ return (
- - - + {/* The HUD is a Spotlight bar a few hundred pixels wide, so the four + separate voice toggles fold into one menu there and leave the row to + the input. The docked composer has the width and keeps them inline — + same controls, same state, different budget. */} + {hudMode ? ( + + ) : ( + <> + + + + + )} {showQueueButton ? ( }> + + + + { + triggerHaptic('open') + onStartConversation() + }} + > + + {c.startVoice} + + + {/* Checkbox items, because all three are toggles the user is reading + the CURRENT state of — the reason they were pressed-state buttons + before. A plain row would fold that state away with the menu. */} + { + // Keep the menu open: dictation is a mode you watch, and closing + // on select hides the recording state the trigger just entered. + event.preventDefault() + triggerHaptic(dictating ? 'close' : 'open') + onDictate() + }} + > + {dictationLabel} + + { + event.preventDefault() + triggerHaptic(autoSpeak ? 'close' : 'open') + onToggleAutoSpeak() + }} + > + {autoSpeak ? : } + {autoSpeak ? c.stopSpeakingReplies : c.speakReplies} + + { + event.preventDefault() + triggerHaptic(wakeListening ? 'close' : 'open') + void toggleWakeWord() + }} + > + {wakeListening ? : } + {wakeLabel} + + + + ) +} diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index d848009254..5ed5db5686 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -1765,6 +1765,7 @@ export const ar = defineLocale({ endShort: 'إنهاء', stopDictation: 'إيقاف الإملاء', transcribingDictation: 'جار تفريغ الإملاء', + voiceControls: 'صوت', voiceDictation: 'إملاء صوتي', lookupLoading: 'جار البحث...', lookupNoMatches: 'لا توجد نتائج', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index f4217322e0..5e94e4c4f0 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2253,6 +2253,7 @@ export const en: Translations = { endShort: 'End', stopDictation: 'Stop dictation', transcribingDictation: 'Transcribing dictation', + voiceControls: 'Voice', voiceDictation: 'Voice dictation', speakReplies: 'Read replies aloud', stopSpeakingReplies: 'Stop reading replies aloud', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 550f16062d..7864578edc 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1954,6 +1954,7 @@ export const ja = defineLocale({ endShort: '終了', stopDictation: '口述を停止', transcribingDictation: '口述を文字起こし中', + voiceControls: '音声', voiceDictation: '音声口述', speakReplies: '返信を読み上げる', stopSpeakingReplies: '返信の読み上げを停止', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 47210ce65b..afd3fda150 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1913,6 +1913,7 @@ export interface Translations { endShort: string stopDictation: string transcribingDictation: string + voiceControls: string voiceDictation: string speakReplies: string stopSpeakingReplies: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 51ac095878..23a3232625 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1892,6 +1892,7 @@ export const zhHant = defineLocale({ endShort: '結束', stopDictation: '停止聽寫', transcribingDictation: '正在轉寫聽寫', + voiceControls: '語音', voiceDictation: '語音聽寫', speakReplies: '朗讀回覆', stopSpeakingReplies: '停止朗讀回覆', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 46708723c5..613679cd72 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2439,6 +2439,7 @@ export const zh: Translations = { endShort: '结束', stopDictation: '停止听写', transcribingDictation: '正在转写听写', + voiceControls: '语音', voiceDictation: '语音听写', speakReplies: '朗读回复', stopSpeakingReplies: '停止朗读回复', From de322448ac435b720177b94496e51ae6c10ad543 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 19 Aug 2026 18:51:20 -0500 Subject: [PATCH 04/12] feat(desktop): move the HUD's way out onto the bar and drop the strip above it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exit chip floated over the composer in a 26px transparent strip reserved for it (--hud-chip-strip), hidden until you hovered the bar. Under glass that strip is bare untinted material across the top of the HUD — a band of chrome above the surface, present in every state, holding a control you cannot see. It rides the composer's controls row now, next to send. That costs no reserved space and takes about 120 lines of CSS with it: the chip needed its own placement, hover reveal, leave-hold, and an opaque card to stay legible over an unknown desktop. None of that applies to a button on the bar, which is already our surface — the problem was the placement, not the control. Trade-off worth naming: the way out is now always visible in the HUD rather than revealed on hover. It is one more permanent glyph on a Spotlight bar, in exchange for an escape hatch that no longer depends on discovering it. --- .../src/app/chat/composer/controls.test.tsx | 39 +++++ .../src/app/chat/composer/controls.tsx | 29 +++- apps/desktop/src/app/hud/hud-shell.tsx | 23 --- apps/desktop/src/styles.css | 146 ++---------------- 4 files changed, 78 insertions(+), 159 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/controls.test.tsx b/apps/desktop/src/app/chat/composer/controls.test.tsx index cceb970b9f..7e90654477 100644 --- a/apps/desktop/src/app/chat/composer/controls.test.tsx +++ b/apps/desktop/src/app/chat/composer/controls.test.tsx @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { ChatBarState } from '@/app/chat/composer/types' import { I18nProvider } from '@/i18n' +import { $hudMode } from '@/store/hud' import { applyWakeStartResult, applyWakeStatus, resetWakeWordState } from '@/store/wake-word' import { ComposerControls } from './controls' @@ -57,6 +58,44 @@ async function expectShortcutTooltip(label: string, shortcut: string) { afterEach(() => { cleanup() + $hudMode.set(false) +}) + +// The HUD is a Spotlight bar a few hundred pixels wide: the four voice +// controls fold into one menu there, and the way out of HUD mode joins the +// row instead of floating above the bar in a reserved strip. The docked +// composer keeps every control inline and shows no exit. +describe('HUD mode', () => { + it('keeps the voice controls inline and offers no exit in the docked composer', () => { + renderControls() + + expect(screen.getByLabelText('Voice dictation')).toBeTruthy() + expect(screen.getByLabelText('Read replies aloud')).toBeTruthy() + expect(screen.queryByLabelText('Exit HUD mode')).toBeNull() + expect(screen.queryByLabelText('Voice')).toBeNull() + }) + + it('folds them into one menu and offers the way out in the HUD', () => { + $hudMode.set(true) + renderControls() + + expect(screen.getByLabelText('Voice')).toBeTruthy() + expect(screen.getByLabelText('Exit HUD mode')).toBeTruthy() + + // Folded away, not duplicated — the whole point is the row's width back. + expect(screen.queryByLabelText('Voice dictation')).toBeNull() + expect(screen.queryByLabelText('Read replies aloud')).toBeNull() + }) + + // A collapsed menu that looked idle while the mic was open would be a worse + // trade than the space it saves, so the trigger reports the live state. + it('reports a live voice state on the collapsed trigger', () => { + $hudMode.set(true) + renderControls({ voiceStatus: 'recording' }) + + expect(screen.getByLabelText('Stop dictation')).toBeTruthy() + expect(screen.queryByLabelText('Voice')).toBeNull() + }) }) describe('ComposerControls shortcut tooltips', () => { diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index c5ce2c2462..baf71028ec 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -7,7 +7,7 @@ import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { AudioLines, Ear, EarOff, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' -import { $hudMode } from '@/store/hud' +import { $hudMode, closeHud } from '@/store/hud' import { $wakeWord, toggleWakeWord } from '@/store/wake-word' import { ACTIVE_ICON_BTN, GHOST_ICON_BTN, PRIMARY_ICON_BTN } from './control-classes' @@ -153,10 +153,37 @@ export function ComposerControls({ )} + {/* The way out of HUD mode, riding the controls row rather than floating + above the bar. The old chip lived in a 26px transparent strip reserved + over the composer (--hud-chip-strip), which under glass is bare + untinted material with a hidden button in it — a band of chrome above + the surface, paid for in every state, for a control that is invisible + until hovered. Here it costs no reserved space and sits with the other + things you can press. */} + {hudMode ? : null}
) } +function ExitHudButton() { + const { t } = useI18n() + + return ( + + + + ) +} + function ConversationPill({ disabled, level, diff --git a/apps/desktop/src/app/hud/hud-shell.tsx b/apps/desktop/src/app/hud/hud-shell.tsx index 9b5d175503..0053d2cb9b 100644 --- a/apps/desktop/src/app/hud/hud-shell.tsx +++ b/apps/desktop/src/app/hud/hud-shell.tsx @@ -2,18 +2,12 @@ import { useStore } from '@nanostores/react' import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router' -import { TitlebarIcon } from '@/app/shell/titlebar-icon' -import { Button } from '@/components/ui/button' -import { Tip } from '@/components/ui/tooltip' -import { useI18n } from '@/i18n' import { chatMessageText } from '@/lib/chat-messages' -import { closeHud } from '@/store/hud' import { $activeSessionAwaitingInput } from '@/store/prompts' import { $busy, $messages } from '@/store/session' import { RICH_INPUT_SLOT } from '../chat/composer/rich-editor' import { WiredPane } from '../contrib/wiring' -import { titlebarButtonClass } from '../shell/titlebar' import { useHudClickThrough } from './click-through' import { useHudGlass } from './glass' @@ -170,7 +164,6 @@ function useHudHeld(): boolean { * `useRecentActivity`). */ export function HudShell() { - const { t } = useI18n() const [recent, holdBand] = useRecentActivity() const held = useHudHeld() @@ -388,22 +381,6 @@ export function HudShell() { - {/* The way back — without it the only exits are ⌘⇧H and ⌘W, both - invisible. Placed and revealed entirely from styles.css. */} - - - - {/* The resize handle: bottom-right corner, the one sanctioned way to change the HUD's size. Invisible chrome — a hot corner, not a button — so it never reads as part of the surface. `data-hud-grabbing` diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index ada6d1d210..fdedeb3cea 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -2503,12 +2503,6 @@ button[data-slot='aui_msg-reactions'] svg { /* The band is narrower than the bar, centred under it, so the bar's corner controls sit clear of the sheet's edge instead of on top of it. */ --hud-band-inset: 0.5rem; - /* Clear space above the composer for the exit chip. */ - --hud-chip-strip: 1.625rem; - /* How long the exit chip stays after you stop pointing at the HUD. Long - enough to cross the gap between the bar and the chip without it - evaporating, short enough that it isn't furniture. */ - --hud-exit-hold: 600ms; } /* Chat surface carries nothing in HUD mode — the visual is the BAND below. */ @@ -2849,17 +2843,10 @@ button[data-slot='aui_msg-reactions'] svg { Parked in the top half of the screen (data-hud-edge='top', broadcast by main on move/resize), the whole HUD mirrors vertically: composer hugs the window's top edge, the band hangs BELOW it, and text melts at the bottom. - Same surfaces, same variables — only the anchors swap. - - The bar would sit flush against the window's top edge, leaving no "above the - composer" to put anything in — which is where the exit chip belongs. Reserve - the strip as dock padding rather than moving the bar: --hud-bar-height is - measured from the dock's box, so the band's offset picks the gap up on its - own instead of needing a second variable kept in sync. */ + Same surfaces, same variables — only the anchors swap. */ [data-hud-shell][data-hud-edge='top'] [data-slot='composer-dock'] { top: 0 !important; bottom: auto !important; - padding-top: var(--hud-chip-strip) !important; } /* Flipped, the band hangs from the bar instead of standing on it. */ @@ -3074,128 +3061,17 @@ button[data-slot='aui_msg-reactions'] svg { } /* The exit button. HUD mode has no titlebar, so this is the only visible way - back; it rides the bar's outer edge at the right. + back — it rides the composer's controls row (see ExitHudButton in + composer/controls.tsx) and therefore needs no placement, no reveal, and no + substrate of its own here: it is one of the bar's buttons and wears what + they wear. - It wears the BAR'S material, not the desktop's. Every shipped control that - floats over content the app does not own resolves this the same way — give - the control its own substrate and let the glyph read against that, never - against the backdrop: - - - Apple HIG, Materials: "Materials help visually separate foreground - elements, such as text and controls, from background elements." Controls - sit ON a material, never directly on content; even `clear` Liquid Glass - is specced with a 35%-opacity dimming layer behind it over bright - content. - https://developer.apple.com/design/human-interface-guidelines/materials - - Firefox picture-in-picture, the closest analogue we have (a small - always-on-top window over arbitrary content): the close/unpip buttons - are `background-color: rgba(255,255,255,.8)` with a `#000` glyph — an - opaque chip, not a bare icon. - toolkit/themes/shared/pictureinpicture/player.css - - Discord's overlay redesign: "This layer provided contrast against the - game making the UI easier to see." - https://discord.com/blog/redesigning-the-discord-overlay - - Anything that instead tries to derive contrast from the backdrop is a dead - end here. mix-blend-difference composites against the PAGE behind the - element, and behind a transparent Electron window that is nothing — the - desktop is composited by WindowServer after Chromium has finished drawing - the frame (the same reason backdrop-filter can't frost the HUD). A glyph - halo has the same flaw from the other side: it guesses one backdrop - luminance and is grime over everything else. - - So: the composer bar's exact tokens — same fill, same hairline, same bottom - shadow. The bar is the HUD's solved case for "our surface, over an unknown - desktop, in either appearance", and theme plus OS light/dark (mode 'system') - come free with it, because --dt-card and --ui-text-primary are the pair the - whole app is built on. The chip inverts with the appearance instead of - betting on one. - - Placed from --hud-bar-height rather than CSS anchor(). Lightning CSS drops a - whole rule containing an `anchor()` on the vertical axis, so the flipped-edge - override never reached the browser and the chip rendered off screen. */ -[data-hud-shell] [data-hud-exit] { - left: auto; - top: auto; - right: 0.375rem; - bottom: calc(var(--hud-bar-height, var(--composer-fallback-height)) + 0.375rem); - /* Square. `size="icon-titlebar"` sizes width and height from two DIFFERENT - vars (--titlebar-control-size / --titlebar-control-height, 20x22 today), - which is right in a titlebar row and reads as a dent on a lone floating - chip — so height comes off the width instead. */ - aspect-ratio: 1 / 1; - height: auto; - background: var(--dt-card) !important; - border: 1px solid var(--ui-stroke-secondary) !important; - border-radius: 0.5rem; - box-shadow: 0 2px 2px -1px rgb(0 0 0 / 0.12); - color: var(--ui-text-primary) !important; - /* Gone until you reach for the HUD. A permanent chip is a fragment of Hermes - parked on top of whatever you are really working in; reaching for the bar - is the motion that means "I want the app", so that is what brings the way - out with it. Hover, not focus — the gate #81893 warned about made the - escape hatch depend on the caret landing in the composer, broken exactly - when you most want out, whereas pointing at the bar needs nothing to be - working. - - `visibility`, not opacity alone: an always-on-top window eats clicks - wherever the page hands the hit test something real, so a 0-opacity chip - with `pointer-events: auto` would be an invisible button in the corner - that drops you out of HUD mode when you click the app behind it. - `visibility: hidden` takes it out of `elementFromPoint` as well as off the - screen, and it transitions discretely, so it flips in step with the fade - rather than needing a second mechanism. */ - opacity: 0; - visibility: hidden; - /* Leaving holds first. The chip sits 0.375rem clear of the bar, so the - cursor crosses shell dead space on its way up from the bar to the chip and - both hover triggers are false for that moment — without the hold it would - fade out from under a hand that is on its way to press it. */ - transition: - opacity var(--hud-fade) var(--hud-ease-exit) var(--hud-exit-hold), - visibility 0s linear calc(var(--hud-exit-hold) + var(--hud-fade)), - background-color var(--hud-reveal) var(--hud-ease-enter); - pointer-events: auto; -} - -/* Flipped, "above the composer" is the reserved strip at the top of the - window rather than a gap the band has to leave. */ -[data-hud-shell][data-hud-edge='top'] [data-hud-exit] { - bottom: auto; - top: 0; -} - -/* What counts as reaching for it: the bar, the transcript band, or the chip - itself. The band only answers `:hover` while it is on screen (faded out it - is `pointer-events: none`), so this can't reveal the chip over a HUD that - isn't there. The chip's own hover is in the set so arriving re-asserts the - reveal that the hold above was covering for. - - Hovering the shell at large is deliberately NOT a trigger — most of the - HUD's rectangle is empty window over someone else's app, and it is - `pointer-events: none` precisely so the cursor passing through means - nothing. */ -[data-hud-shell]:has([data-slot='composer-dock']:hover) [data-hud-exit], -[data-hud-shell]:has([data-slot='composer-bounds']:hover) [data-hud-exit], -[data-hud-shell] [data-hud-exit]:hover, -[data-hud-shell] [data-hud-exit]:focus-visible { - opacity: 1; - visibility: visible; - transition-duration: var(--hud-reveal), 0s, var(--hud-reveal); - transition-delay: 0s; -} - -/* Hover and focus-visible: the app's own control-hover tint, so pointing at it - confirms it is a button with the same feedback every other icon button in - Hermes gives. Layered OVER the card rather than replacing it — - --ui-control-hover-background is a translucent color-mix meant to sit on an - opaque titlebar, and here the button IS the opaque surface, so assigning it - directly would make the chip see-through on hover. */ -[data-hud-shell] [data-hud-exit]:hover, -[data-hud-shell] [data-hud-exit]:focus-visible { - background: - linear-gradient(var(--ui-control-hover-background), var(--ui-control-hover-background)), var(--dt-card) !important; -} + It used to float above the bar in a reserved 26px strip, hidden until you + hovered. That strip was transparent window, which the glass band then + rendered as a slab of bare untinted material across the top of the HUD, and + the chip needed its own opaque card to be legible over an unknown desktop. + Both problems were the placement, not the button: on the bar it is already + on our surface. */ /* The corner resize handle — a hot corner, not a button. The window is created non-resizable (the transparent-frameless Windows drag-growth bug), so this From ad889540f20ba49157976487a29becb762ce7933 Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:25:17 +0000 Subject: [PATCH 05/12] fmt(js): `npm run fix` on merge (#90384) Co-authored-by: github-actions[bot] --- apps/desktop/electron/main.ts | 1 + apps/desktop/src/app/chat/composer/controls.tsx | 12 ++---------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 4afca3a9c2..af28a90bc8 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -12148,6 +12148,7 @@ const hudIpc = registerHudIpc({ hudSessionId = value } }) + ipcMain.handle('hermes:bootstrap:reset', async () => { // Renderer's "Reload and retry" path. Clear the latched failure and // reset connection state so the next startHermes() call restarts the diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index baf71028ec..1682f8d9c2 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -307,11 +307,7 @@ function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disa