feat(desktop): the HUD surface — Spotlight bar with a fading chat band

The renderer half. HUD mode reuses the app's own chat surface and only changes
the frame around it: no titlebar, no statusbar, no pane tree. The band and the
bar tile the window between them with no dead margins, and the band runs the
window's full height with the opaque bar sitting on its bottom edge, so there
is no seam to compute and none to drift as the composer grows.

Visibility is a WoW chat frame: the transcript shows while a turn is recent or
the composer has focus, then holds and fades. A bottom-anchored gradient mask
carries the fade on both the sheet and the text, and flattens away on focus so
nothing is dimmed while you are reading. Only the composer never fades — it is
the interface.

The window is mouse-transparent everywhere it isn't really there, so clicks over
the faded band reach the app behind it; `pointer-events` can't do this, since
the click never reaches the page at all.
This commit is contained in:
Brooklyn Nicholson
2026-08-08 01:39:06 -05:00
parent 7b0dbd2242
commit e8b83f37c8
9 changed files with 1021 additions and 21 deletions
+3 -3
View File
@@ -43,7 +43,7 @@ import {
sessionPinId,
shouldMigrateComposerScope
} from '@/store/session'
import { isSecondaryWindow, isWatchWindow } from '@/store/windows'
import { isAuxiliaryWindow, isWatchWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes'
import { primaryRouteSelectedSessionId, routeSessionId } from '../routes'
@@ -135,7 +135,7 @@ function ChatHeader({
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
// are compact side panels — they drop the session-actions header + border
// entirely. A brand-new draft has nothing to pin/delete/rename either.
if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
if (isAuxiliaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
return null
}
@@ -397,7 +397,7 @@ export const ChatView = memo(function ChatView({
// scratch window, not the full-height empty state.
const showIntro =
isPrimary &&
!isSecondaryWindow() &&
!isAuxiliaryWindow() &&
freshDraftReady &&
!isRoutedSessionView &&
!selectedSessionId &&
@@ -58,6 +58,7 @@ import { $reviewOpen, closeReview, openReview, REVIEW_PANE_ID } from '@/store/re
import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session'
import { watchSessionPins } from '@/store/session-pin-sync'
import { $statusbarVisible } from '@/store/statusbar-prefs'
import { isHudWindow } from '@/store/windows'
import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchPreviewTiles } from '../chat/preview-tile'
@@ -69,6 +70,7 @@ import {
watchSessionTiles,
WorkspaceTabMenu
} from '../chat/session-tile'
import { HudShell } from '../hud/hud-shell'
import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store'
import { $workspaceIsPage } from '../routes'
@@ -688,6 +690,18 @@ export function ContribController() {
const sidebarOpen = useStore($sidebarOpen)
const statusbarVisible = useStore($statusbarVisible)
// HUD mode is the SAME app with its frame removed: the wiring (gateway,
// sessions, streams, submit) mounts identically, and only the shell around
// the chat surface differs. Branching here rather than at the window entry
// is what keeps the HUD's composer the real composer.
if (isHudWindow()) {
return (
<ContribWiring>
<HudShell />
</ContribWiring>
)
}
return (
<SidebarProvider
className="h-screen min-h-0 flex-col bg-background"
@@ -5,7 +5,7 @@ import { setPetScale } from '@/store/pet-gallery'
import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '@/store/pet-overlay'
import { $sessions } from '@/store/session'
import { $attentionSessionIds } from '@/store/session-states'
import { isSecondaryWindow } from '@/store/windows'
import { isAuxiliaryWindow } from '@/store/windows'
import type { GatewayRequester } from '../types'
@@ -31,7 +31,7 @@ export function usePetBridge({ requestGateway, resumeSession, submitText }: PetB
requestGatewayRef.current = requestGateway
useEffect(() => {
if (isSecondaryWindow()) {
if (isAuxiliaryWindow()) {
return
}
@@ -9,7 +9,7 @@ import {
} from '@/store/quick-entry'
import { $gatewayState, $sessions } from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import { isSecondaryWindow } from '@/store/windows'
import { isAuxiliaryWindow } from '@/store/windows'
interface QuickEntryBridgeParams {
startFreshSessionDraft: () => void
@@ -57,7 +57,7 @@ export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: Quic
startFreshRef.current = startFreshSessionDraft
useEffect(() => {
if (isSecondaryWindow()) {
if (isAuxiliaryWindow()) {
return
}
@@ -101,7 +101,7 @@ export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: Quic
// Push gateway truth into the quick window whenever it changes: connection
// state gates its input; the recent-session list feeds its target picker.
useEffect(() => {
if (isSecondaryWindow()) {
if (isAuxiliaryWindow()) {
return
}
+24 -13
View File
@@ -67,7 +67,7 @@ import {
} from '@/store/session'
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
import { armWakeWord, stopClientCapture } from '@/store/wake-word'
import { isSecondaryWindow } from '@/store/windows'
import { isAuxiliaryWindow, isHudWindow } from '@/store/windows'
import { useSkinCommand } from '@/themes/use-skin-command'
import { closeWorkspaceTab } from '../chat/close-tab'
@@ -77,6 +77,7 @@ import { CommandPalette } from '../command-palette'
import { useGatewayBoot } from '../gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from '../gateway/hooks/use-gateway-request'
import { useKeybinds } from '../hooks/use-keybinds'
import { useHudHandoff } from '../hud/handoff'
import { ModelPickerOverlay } from '../model-picker-overlay'
import { ModelVisibilityOverlay } from '../model-visibility-overlay'
import { mainChatOccupied, openSession } from '../open-session'
@@ -624,6 +625,9 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// session / new session), and it hears gateway truth from this window.
useQuickEntryBridge({ startFreshSessionDraft, submitText })
// Leaving HUD mode hands this window the session back (see hud/handoff).
useHudHandoff({ navigate, resumeSession })
// Clear a failed turn's red error banner. Errors are renderer-local (never
// persisted): a bare error placeholder is dropped entirely; a partial-output
// failure keeps its content and sheds the error. Both the runtime cache AND
@@ -983,7 +987,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// Pane-registered tools (preview's monitor/devtools cluster) anchor flush
// against the static system cluster — in the tree layout the titlebar band
// sits ABOVE the grid, so AppShell's pane-width anchoring doesn't apply.
const SYSTEM_TOOL_COUNT = 4
const SYSTEM_TOOL_COUNT = 5
const paneToolCount = rightTitlebarTools.filter(tool => !tool.hidden).length
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))`
@@ -1006,18 +1010,23 @@ export function ContribWiring({ children }: { children: ReactNode }) {
} as CSSProperties
}
>
<TitlebarControls
leftTools={leftTitlebarTools}
onOpenSettings={() => navigate(SETTINGS_ROUTE)}
tools={rightTitlebarTools}
/>
{/* HUD mode has no titlebar to hang these off — the clusters are
`fixed`, so without this they'd float over the chat as orphaned
buttons. Exits are the ⌘⇧H toggle and ⌘W. */}
{!isHudWindow() && (
<TitlebarControls
leftTools={leftTitlebarTools}
onOpenSettings={() => navigate(SETTINGS_ROUTE)}
tools={rightTitlebarTools}
/>
)}
{children}
</div>
{/* The full real overlay set (mirrors DesktopController's `overlays`). */}
<RemoteDisplayBanner />
{!isSecondaryWindow() && <DesktopInstallOverlay />}
{!isSecondaryWindow() && (
{!isAuxiliaryWindow() && <DesktopInstallOverlay />}
{!isAuxiliaryWindow() && (
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
onCompleted={() => {
@@ -1113,11 +1122,13 @@ export function ContribWiring({ children }: { children: ReactNode }) {
{/* Toasts above everything. */}
<NotificationStack />
{/* Petdex floating mascot — renders nothing unless installed + enabled. */}
<FloatingPet />
{/* Petdex floating mascot — renders nothing unless installed + enabled.
Never in the HUD: that window is the chat bar and nothing else. */}
{!isHudWindow() && <FloatingPet />}
{/* Single persistent xterm host chasing the terminal pane's slot rect. */}
<PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
{/* Single persistent xterm host chasing the terminal pane's slot rect.
The HUD has no terminal pane, so it has nothing to chase. */}
{!isHudWindow() && <PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} />}
</ContribWiringContext.Provider>
)
}
+75
View File
@@ -0,0 +1,75 @@
import { type RefObject, useEffect } from 'react'
/**
* Let clicks fall through the HUD everywhere it isn't really there.
*
* The one thing about HUD mode that CSS cannot express, because it is a
* property of the OS WINDOW rather than of the page. It reads the engaged state
* off the DOM (`:focus-within`) rather than keeping a second copy, so there is
* one answer to "is the HUD in use" and the stylesheet owns it.
*
* An always-on-top window eats every click inside its rectangle, visible or
* not — and most of the HUD's rectangle is a faded-out band over whatever the
* user is actually working in. `pointer-events: none` doesn't help: that is a
* page-level property, and the click never reaches the page.
*
* So the window itself is made mouse-transparent except where it is genuinely
* interactive: the bar, always, and everything else only while the composer
* holds focus — the same line the band and its exit chip draw with
* `pointer-events`. `forward: true` keeps mousemove flowing while ignoring,
* which is what lets it re-arm when the cursor comes back to the bar.
*/
export function useHudClickThrough(rootRef: RefObject<HTMLElement | null>): void {
useEffect(() => {
const root = rootRef.current
const setIgnoreMouse = window.hermesDesktop?.hud?.setIgnoreMouse
if (!root || !setIgnoreMouse) {
return
}
let ignoring: boolean | null = null
// Where the cursor was last seen, so a focus change can re-decide without
// waiting for the next move (blurring with the cursor parked on the bar
// must not make the bar untouchable until you jiggle the mouse).
let point: { x: number; y: number } | null = null
const overBar = () => {
const bar = root.querySelector('[data-slot="composer-dock"]')
if (!bar || !point) {
return false
}
const rect = bar.getBoundingClientRect()
return point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom
}
const apply = () => {
const next = !root.matches(':focus-within') && !overBar()
if (ignoring !== next) {
ignoring = next
setIgnoreMouse(next)
}
}
const onMove = (event: MouseEvent) => {
point = { x: event.clientX, y: event.clientY }
apply()
}
apply()
window.addEventListener('mousemove', onMove)
root.addEventListener('focusin', apply)
root.addEventListener('focusout', apply)
return () => {
setIgnoreMouse(false)
window.removeEventListener('mousemove', onMove)
root.removeEventListener('focusin', apply)
root.removeEventListener('focusout', apply)
}
}, [rootRef])
}
+253
View File
@@ -0,0 +1,253 @@
import { type CSSProperties, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { closeHud } from '@/store/hud'
import { $busy, $messages } from '@/store/session'
import { WiredPane } from '../contrib/wiring'
import { titlebarButtonClass } from '../shell/titlebar'
import { useHudClickThrough } from './click-through'
import { useReportHudSession } from './handoff'
/** How long the thread stays visible after the last activity before it starts
* fading (WoW chat frame behavior). Focus holds it open past this. */
const HUD_RECENT_HOLD_MS = 6_000
/** Band visibility timings, published to CSS as custom properties so this
* module and the stylesheet cannot drift apart. Reveal is quick — it is an
* answer to the user; the fade lingers, then goes slowly. */
const HUD_REVEAL_MS = 150
const HUD_FADE_DELAY_MS = 3_000
const HUD_FADE_MS = 1_200
/**
* True for a hold window after any conversation activity (a message landing,
* a stream flushing, a turn starting or ending). The CSS uses it — alongside
* :focus-within — to decide whether the thread is visible; idle HUD mode is
* just the Spotlight bar.
*
* $messages replaces ~30×/s mid-stream, so activity RESTARTS the timer on
* every flush — the thread stays up while a reply is writing and for the hold
* window after it finishes, without a per-flush re-render (state only changes
* on the false↔true edges).
*/
function useRecentActivity(): boolean {
const [recent, setRecent] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// eslint-disable-next-line no-restricted-syntax -- timer handle, not an atom mirror
useEffect(() => {
const bump = () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
setRecent(true)
timerRef.current = setTimeout(() => setRecent(false), HUD_RECENT_HOLD_MS)
}
// subscribe() fires immediately, so a HUD opened onto an existing
// conversation starts with the thread showing, then fades.
const offMessages = $messages.subscribe(bump)
const offBusy = $busy.subscribe(busy => busy && bump())
return () => {
offMessages()
offBusy()
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
}, [])
return recent
}
/**
* HUD mode's shell — the chrome-free floating chat.
*
* Deliberately almost nothing: it mounts the SAME wired chat surface the
* workspace pane does, so the composer here IS the app's composer (slash
* commands, `@` refs, attachments, queue, voice, model pill) and the transcript
* is the app's transcript, rendered by the app's renderer. Only the frame
* changes — no titlebar, no statusbar, no pane tree, no sidebars.
*
* The shape is macOS Spotlight: at rest, the centered composer bar is the
* whole interface. The thread renders as bare text above it and is
* visibility-gated like a game chat frame — shown while a turn is recent or the
* composer has focus, faded out otherwise (see the `[data-hud-shell]` CSS and
* `useRecentActivity`).
*/
export function HudShell() {
const { t } = useI18n()
const recent = useRecentActivity()
// Main holds the session id on this window's behalf, so leaving HUD mode can
// hand the app window back whatever conversation ended up here.
useReportHudSession()
// Which screen EDGE the window is parked against. Parked tight to the top,
// the composer flips to the window's top edge and the thread grows DOWN
// (data-hud-edge). Computed here from window.screenY — no IPC: the renderer
// always knows where its window is. Polled because the DOM has no
// window-move event; 300ms is imperceptible for a layout flip.
//
// EDGE-tight, not a midpoint rule: the first cut compared topGap<bottomGap,
// which flips the layout the moment the window crosses the vertical center
// of the screen — reported (correctly) as "flips way too early". Now it
// flips to 'top' only when the window is actually parked against the top
// (within FLIP_ON px of the usable area, i.e. below the menu bar), and back
// once it clearly leaves (FLIP_OFF) — the gap between the two is hysteresis
// so the layout can't flutter while the window is dragged along the line.
const [edge, setEdge] = useState<'bottom' | 'top'>('bottom')
useEffect(() => {
// ZERO tolerance by explicit request: top-mode only when the window is
// flush against the usable top (gap 0 — macOS won't let it overlap the
// menu bar, so flush IS availTop). Tiny FLIP_OFF so the 300ms poll can't
// flutter on sub-pixel jitter while parked.
const FLIP_ON = 0
const FLIP_OFF = 4
const measure = () => {
// availTop ≈ menu bar / notch inset on macOS; screenY is in full-screen
// coordinates, so "parked at the top" means screenY ≈ availTop, not 0.
const availTop = (window.screen as { availTop?: number }).availTop ?? 0
const topGap = window.screenY - availTop
setEdge(prev => (topGap <= FLIP_ON ? 'top' : topGap >= FLIP_OFF ? 'bottom' : prev))
}
measure()
const timer = setInterval(measure, 300)
window.addEventListener('resize', measure)
return () => {
clearInterval(timer)
window.removeEventListener('resize', measure)
}
}, [])
// Whether the thread actually overflows its band. Gates the band's no-drag
// carve-out (styles.css): a band with nothing to scroll stays part of the
// window's drag region, so a short conversation never blocks moving the HUD.
const [scrollable, setScrollable] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const root = rootRef.current
if (!root) {
return
}
let viewport: HTMLElement | null = null
const ro = new ResizeObserver(() => measure())
const measure = () => {
const el = viewport ?? root.querySelector<HTMLElement>('[data-slot="aui_thread-viewport"]')
if (el !== viewport) {
viewport = el
if (el) {
ro.observe(el)
if (el.firstElementChild) {
ro.observe(el.firstElementChild)
}
}
}
setScrollable(Boolean(el && el.scrollHeight > el.clientHeight + 4))
}
// The viewport mounts async (lazy chat surface); poll briefly until it
// exists, then let the ResizeObserver own it.
measure()
const probe = setInterval(measure, 500)
return () => {
clearInterval(probe)
ro.disconnect()
}
}, [])
useHudClickThrough(rootRef)
// Force the HOST layers transparent. index.html's pre-paint script writes an
// opaque themed background onto <html> as an INLINE style (the anti-white-
// flash trick), and an inline style beats any stylesheet rule — so without
// this the window is a solid slab and every translucent panel below is just
// glass over a white wall. A style tag with `!important` is what the pet
// overlay and quick entry already do; they get it at mount because they are
// bespoke roots, and the HUD needs the same because it is not.
useEffect(() => {
const style = document.createElement('style')
style.textContent = 'html,body,#root{background:transparent !important;}'
document.head.appendChild(style)
return () => style.remove()
}, [])
return (
<div
className="relative flex h-screen w-screen flex-col overflow-hidden"
data-hud-edge={edge}
data-hud-recent={recent ? '' : undefined}
data-hud-scrollable={scrollable ? '' : undefined}
data-hud-shell
ref={rootRef}
style={
{
'--hud-fade-delay': `${HUD_FADE_DELAY_MS}ms`,
'--hud-fade': `${HUD_FADE_MS}ms`,
'--hud-reveal': `${HUD_REVEAL_MS}ms`
} as CSSProperties
}
>
{/* The band's sheet, on a layer of its own so it can carry the fade
without the app's chat surface having to know about it. FIRST child so
it paints behind the transcript. */}
<div aria-hidden data-hud-glass />
<WiredPane part="chatRoutes" />
{/* The top fade band, as a drag handle. Its text is masked to nothing up
there, so handing the band's mouse input to the window manager costs
no readable content — and it gives the HUD a grab area that isn't the
composer.
LAST child on purpose. Electron collects draggable regions by walking
the layout tree in order, uniting `drag` rects and subtracting
`no-drag` ones, so later elements win. Above `WiredPane` this strip
was silently subtracted away by the scrollback's full-height `no-drag`
rect (z-index does not enter into it — the region math is rect-based,
not paint-order-based). */}
<div aria-hidden data-hud-drag-strip />
{/* The way back. HUD mode has no titlebar, so without this the only
exits are ⌘⇧H and ⌘W — both invisible. Floats over the scrollback
(which is short and top-fades, so it rarely collides with text) and
carves itself out of the drag region so the click lands. */}
<Tip label={t.titlebar.exitHud}>
<Button
aria-label={t.titlebar.exitHud}
className={`${titlebarButtonClass} absolute right-1.5 top-1.5 z-20 bg-transparent [-webkit-app-region:no-drag]`}
data-hud-exit=""
onClick={closeHud}
size="icon-titlebar"
type="button"
variant="ghost"
>
<Codicon name="screen-normal" />
</Button>
</Tip>
</div>
)
}
@@ -19,6 +19,7 @@ import { useI18n } from '@/i18n'
import { messageRenderWeight } from '@/lib/render-weight'
import { cn } from '@/lib/utils'
import {
$threadScrolledUp,
onScrollToBottomRequest,
onThreadEditClose,
onThreadEditOpen,
@@ -383,6 +384,23 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
// Floating jump button (outside this subtree) → return to the bottom.
useEffect(() => onScrollToBottomRequest(() => void scrollToBottom()), [scrollToBottom])
// Waking from display: hidden (HUD mode hides the main window; OS hide does
// the same to any window): rAF and ResizeObserver were frozen the whole
// time, so the virtualizer's measurements — and scrollTop itself — are
// stale. If the user was following the bottom, re-anchor once visible;
// leave a scrolled-up reader exactly where they were.
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible' && !$threadScrolledUp.get()) {
requestAnimationFrame(() => void scrollToBottom())
}
}
document.addEventListener('visibilitychange', onVisible)
return () => document.removeEventListener('visibilitychange', onVisible)
}, [scrollToBottom])
const endEditHold = useCallback(() => {
scrollRef.current?.removeAttribute('data-editing')
}, [scrollRef])
+629
View File
@@ -2264,3 +2264,632 @@ button[data-slot='aui_msg-reactions'] svg {
opacity: 0.4;
}
}
/* ── HUD mode ────────────────────────────────────────────────────────────────
The chrome-free floating chat (`?win=hud`). Same renderer, same components,
same transcript as any other window — this block only removes the frame and
makes the window see-through. It deliberately does NOT restyle how messages
render: the transcript is the app's transcript, and a HUD that reinvents
bubbles is a second design to keep in sync. Layout and surface only. */
/* The HOST LAYERS are made transparent at mount, not here: index.html's
pre-paint script sets an opaque themed background on <html> as an INLINE
style (the anti-white-flash trick), and an inline style beats a stylesheet
rule. Against that opaque wall every translucent panel below is just glass
over white — which is exactly how this shipped: a plain opaque slab. The
pet overlay and quick entry inject a style tag at mount for the same
reason; HudShell does it too. See `hud-shell.tsx`. */
[data-hud-shell] {
background: transparent;
/* Empty space drags the window; anything that needs the mouse carves itself
back out below. */
-webkit-app-region: drag;
/* The band's fade, shared by the glass layer and the transcript so the two
can never ramp differently.
Bottom-anchored and deliberately NOT linear — the WoW chat frame's shape.
A straight ramp starts eating the newest reply immediately, which is the
one thing that must stay legible, and it leaves the whole band looking
half-dissolved. Instead: most of it is FULLY solid, then it collapses in
one short stretch, and the top fifth is nothing at all. That empty cap is
what removes the edge — any alpha still alive at the window boundary reads
as a line, and with the frost riding this same mask it would be a line of
blur, which is worse. */
--hud-thread-mask-stops: #000 0%, #000 58%, rgb(0 0 0 / 0.42) 71%, transparent 82%;
--hud-thread-mask: linear-gradient(to top, var(--hud-thread-mask-stops));
}
[data-hud-shell][data-hud-edge='top'] {
--hud-thread-mask: linear-gradient(to bottom, var(--hud-thread-mask-stops));
}
/* Chat surface carries nothing in HUD mode — the visual is the BAND below. */
[data-hud-shell] [data-chat-surface] {
background: transparent !important;
overflow: hidden !important;
}
/* The scrollback — the chat BAND, straight from the nous-viz2d chat (which is
the reference for this whole surface): a fixed-height smoked strip hugging
the input, tinted from the theme, with the text simply sitting in it.
- viz2d: `bg-black/20` at rest → `bg-black/60` on hover / focus-within /
pending. Same mechanism here, theme-aware: the tint is the theme's own
background color, so it smokes dark on dark themes and frosts light on
light ones, and text keeps its normal contrast against it.
- VISIBILITY keeps the WoW fade this HUD already had: shown while a turn
is recent/streaming (`data-hud-recent`), on band hover, or while the
composer is focused; melted away otherwise. viz2d's band never hides —
ours does, because idle HUD mode is just the Spotlight bar.
- Focus steps the tint to fully solid: typing means reading conditions.
Reveal is fast, fade-out is slow — the reveal is an answer to the user, the
fade is furniture leaving the room. */
[data-hud-shell] [data-slot='composer-bounds'] {
position: absolute !important;
left: 0 !important;
right: 0 !important;
/* The band and the bar tile the window between them, with no dead margins —
the band runs edge to edge and the bar covers the bottom of it. There is
then no seam to get wrong: the band does not stop at the bar, it runs the
window's FULL height and the opaque bar sits on top of its bottom edge.
Every earlier attempt computed that edge from
--composer-surface-measured-height, which is the composer's published
height, rounded; the real box is fractional, so the math left a hairline
that appeared and vanished as the composer grew ("the gap is 0px until two
lines"). Nothing to compute, nothing to drift. What keeps the TEXT off the
bar is the app's own composer clearance, below. */
inset: 0 !important;
height: auto !important;
width: 100% !important;
max-width: none !important;
flex: none !important;
border: 0 !important;
/* Rounded on top to match the window's own corners (roundedCorners clips the
frost to the same silhouette); square at the bottom, where the bar covers it. */
border-radius: 0.75rem 0.75rem 0 0;
/* No fill and no blur of its own — the glass is its own layer behind this
one ([data-hud-glass]). Chromium will not honour `mask-image` and
`backdrop-filter` on the SAME element: the mask ends up applying to the
backdrop rather than the content, which shows up as the sheet fading on
the gradient while the text stays stubbornly solid. Two elements, two
masks, no interaction. */
background: transparent !important;
opacity: 0;
/* WoW linger: reveal is quick, but on losing focus the band HOLDS, then fades
out slow. The timings come from hud-shell.tsx, which uses the same numbers
to decide when to drop the vibrancy layer. Reveal rules zero the delay. */
transition:
opacity var(--hud-fade) ease var(--hud-fade-delay),
background-color 350ms ease;
}
/* Shown: recent turn / streaming, or a focused composer.
POINTER EVENTS ARE THE GATE: unfocused, the band is a ghost you can't touch
— clicks in that region fall through to the shell's drag region (so the
whole upper window is a drag handle at rest) and the band can't eat a
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.
There is no desktop blur under it, and that is a hard limit rather than a
gap. macOS vibrancy is composited by WindowServer BELOW the web contents,
after this process has finished drawing, so nothing in the DOM can shape it —
mask, clip-path, stacking, none of it reaches. Leaving vibrancy on under a
masked tint is worse than no blur: the frost stays a flat slab, so the top of
the band goes pale exactly where it should be disappearing. And CSS
backdrop-filter is not a substitute; verified on the real window rather than
assumed, it produces no blur whatsoever, because a transparent window's
backdrop root is the document and the desktop was never in it.
The one real way out is native — NSVisualEffectView.maskImage takes an alpha
mask for precisely this — which needs a small addon over
getNativeWindowHandle() and buys a cross-dissolve between frosted and sharp
rather than a true blur-radius ramp. Its own change, not this one. */
[data-hud-shell] [data-hud-glass] {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
border-radius: 0.75rem 0.75rem 0 0;
background: color-mix(in srgb, var(--dt-background) 62%, transparent);
opacity: 0;
transition: opacity var(--hud-fade) ease var(--hud-fade-delay);
-webkit-mask-image: var(--hud-thread-mask);
mask-image: var(--hud-thread-mask);
-webkit-mask-position: bottom;
mask-position: bottom;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
[data-hud-shell][data-hud-recent] [data-hud-glass],
[data-hud-shell]:focus-within [data-hud-glass] {
opacity: 1;
transition-duration: var(--hud-reveal);
transition-delay: 0s;
}
/* Engaged, the ramp gets out of the way: you are reading the transcript, so
nothing in it should be dimmed. Done by SCALING the mask rather than swapping
it — `mask-image` animates discretely and would snap, while `mask-size`
interpolates. Blown up and pinned to the bottom, the band only ever shows the
gradient's solid end. Reveal-speed both ways, so letting go of the composer
brings the ramp straight back instead of waiting out the fade's hold. */
[data-hud-shell]:focus-within [data-slot='aui_thread-viewport'] {
-webkit-mask-size: 100% 600%;
mask-size: 100% 600%;
}
/* Engaged: the band goes solid — typing means reading conditions. */
[data-hud-shell]:focus-within [data-hud-glass] {
background: var(--dt-background);
}
[data-hud-shell][data-hud-edge='top'] [data-hud-glass] {
border-radius: 0 0 0.75rem 0.75rem;
}
[data-hud-shell] [data-slot='composer-bounds'] {
pointer-events: none;
}
[data-hud-shell]:focus-within [data-slot='composer-bounds'] {
pointer-events: auto;
}
[data-hud-shell][data-hud-recent] [data-slot='composer-bounds'],
[data-hud-shell]:focus-within [data-slot='composer-bounds'] {
opacity: 1;
transition-duration: var(--hud-reveal);
transition-delay: 0s;
}
/* Typing: the band goes fully solid — viz2d's focus step, taken to its end. */
[data-hud-shell]:focus-within [data-slot='composer-bounds'] {
background: var(--dt-background) !important;
}
/* User messages ride the band like every other line. In the app each sticky
user row paints an OPAQUE chat-surface slab behind it (so a stuck bubble can
slide over scrolled text) and the bubble is solid --dt-user-bubble — inside
the smoked band both read as fully opaque cards that ignore every fade. The
slab goes unconditionally; the bubble smokes at rest and returns solid when
engaged. ONE variable carries the fill so there is no specificity fight
between rest/engaged rules — the states just move the var. */
[data-hud-shell] {
--hud-bubble-fill: color-mix(in srgb, var(--dt-user-bubble) 65%, transparent);
}
/* ONE opacity law for the whole band: solid on focus, material otherwise.
Bubbles going solid on `recent` too made them opaque cards floating on the
62% band mid-stream — the band only steps solid on focus, so nothing inside
it may step earlier. */
[data-hud-shell]:focus-within {
--hud-bubble-fill: var(--dt-user-bubble);
}
[data-hud-shell] [data-slot='aui_user-message-root'] {
background: transparent !important;
/* Not sticky in HUD mode: the band is a short log, and a pinned bubble
spends a third of it on a line you already read. Static rows scroll away
like everything else (the app keeps its sticky behavior — this is the
HUD's call, not a global one). */
position: static !important;
}
/* User bubbles: the ONE thing in the band that paints its own opaque fill
(bg-(--dt-user-bubble) on USER_BUBBLE_BASE_CLASS), so it survives every
fade the band applies to its own background and reads as a solid card
floating on glass. Two things are needed, and only doing one of them is
why this kept coming back:
- the FILL follows the band's state (--hud-bubble-fill), and
- the BORDER does too; an opaque hairline outlines the card even when
the fill is right.
Fill and border both ride the same variable, so they cannot desync. */
[data-hud-shell] .composer-human-message {
background: var(--hud-bubble-fill) !important;
border-color: color-mix(in srgb, var(--ui-stroke-secondary) 45%, transparent) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
transition:
background-color 350ms ease,
border-color 350ms ease;
}
/* The bubble's own container also paints the chat surface behind it. */
[data-hud-shell] .composer-human-message-container {
background: transparent !important;
}
/* ONE glass element in HUD mode: [data-hud-glass]. Everything else drops its
backdrop-filter — the composer tree ships several Tailwind backdrop-blur
utilities (surface glass, fallback surface, attachment spinners, voice
pills) that are invisible inside the opaque app but paint as smeared halos
around the pill's rounded corners on a transparent window.
Keep this exemption in step with whatever carries the glass: pointing it at
the wrong element silently deletes the blur, which looks exactly like
backdrop-filter not working at all. */
[data-hud-shell] *:not([data-hud-glass], [data-hud-glass] *) {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* No session header, no jump-to-bottom pill, no timeline rail. */
[data-hud-shell] header,
[data-hud-shell] [data-slot='thread-timeline'],
[data-hud-shell] .thread-jump-button {
display: none !important;
}
/* SCROLLING vs DRAGGING — pick one, per element, per STATE.
`-webkit-app-region: drag` hands an element's mouse input to the window
manager, wheel included — and the region math is rect-based, so a `no-drag`
band subtracts its rectangle even when it's pointer-transparent. The band is
most of the window, so giving its rect away permanently made the HUD nearly
immovable in practice (the composer almost always has focus while in use).
So the carve-out needs BOTH: composer focused AND actual overflow to scroll
(data-hud-scrollable, set by HudShell). A short conversation leaves the
whole window grabbable; the band only becomes a scroll surface when
scrolling is a real thing you could do with it. */
[data-hud-shell][data-hud-scrollable]:focus-within [data-slot='composer-bounds'] {
-webkit-app-region: no-drag;
}
/* ── Edge flip ───────────────────────────────────────────────────────────────
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, text melts at the bottom, and
the drag strip moves to the bottom dead zone. Same four surfaces, same
variables — only the anchors swap. */
[data-hud-shell][data-hud-edge='top'] [data-slot='composer-dock'] {
top: 0 !important;
bottom: auto !important;
}
[data-hud-shell][data-hud-edge='top'] [data-slot='composer-bounds'] {
border-radius: 0 0 0.75rem 0.75rem;
}
/* Flipped, the bar is at the TOP, so the clearance has to be too — the app's
clearance element only pads the end of the thread. */
[data-hud-shell][data-hud-edge='top'] [data-chat-surface] {
--thread-last-message-clearance: 0.25rem;
}
[data-hud-shell][data-hud-edge='top'] [data-slot='aui_thread-content'] {
padding-block-start: calc(var(--composer-measured-height) + 0.25rem) !important;
}
[data-hud-shell][data-hud-edge='top'] [data-slot='aui_thread-viewport'] {
-webkit-mask-position: top;
mask-position: top;
}
[data-hud-shell][data-hud-edge='top'] [data-hud-drag-strip] {
top: auto;
bottom: 0;
}
/* Depth by age — the WoW chat frame read, as a MASK over the whole band.
Anchored at the bottom: the newest turn is fully solid against the bar and
everything above it thins out, so older lines read as ghosts and the band's
top edge melts into the desktop instead of stopping at a line.
(https://polypane.app/blog/my-take-on-fading-content-using-transparent-gradients-in-css/
— a mask, not an overlaid gradient, because the HUD has no solid background
to fake the fade against.)
On the BAND, not the thread viewport inside it, so the glass tint fades on
the same ramp as the text it is behind. Masking only the text leaves the
sheet ending in a hard rectangle with ghost words floating on it.
The ramp is the DISENGAGED look; focus scales it away (see the glass layer's
focus rule, which does the same thing to the sheet).
On the scroll VIEWPORT, not the band that contains it. A mask on the band
stopped reaching the transcript once the glass moved to its own
backdrop-filter layer — the text kept painting at full strength while the
sheet ramped. The viewport is the box the text actually lives in, and masking
it there survives whatever the compositor does with the layer above. */
[data-hud-shell] [data-slot='aui_thread-viewport'] {
-webkit-mask-image: var(--hud-thread-mask);
mask-image: var(--hud-thread-mask);
-webkit-mask-position: bottom;
mask-position: bottom;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
transition:
-webkit-mask-size var(--hud-reveal) ease,
mask-size var(--hud-reveal) ease;
}
/* The drag handle is the top strip of the WINDOW, above the band. The band
itself scrolls (a drag region eats wheel events, so it can never be the
handle); this strip is dead space either way, so it drags. */
[data-hud-shell] [data-hud-drag-strip] {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2rem;
z-index: 10;
-webkit-app-region: drag;
}
/* Tighten the thread on every side. The docked chat's gutters (px-6 py-8 plus
a 1.5rem inline pad) are sized for a full window column; in a bar a few
lines tall they're most of the surface. Bottom pad drops to a hairline —
the band already ends at the bar, so any block-end padding here reads as a
gap between the last message and the composer. */
[data-hud-shell] [data-slot='aui_thread-content'] {
padding-inline: 0.6rem !important;
padding-block: 0.8rem 0.25rem !important;
/* A chat frame fills from the bottom. The docked thread hangs from the top
because it is always taller than its content is short; the band is a few
lines tall, so a two-message conversation left a dead gap between the last
reply and the composer. Filling the viewport and packing to the end puts
that slack ABOVE the text, where it's just more transparent window. */
min-height: 100%;
justify-content: flex-end;
}
/* Parked at the top the band hangs below the bar, so it fills downward. */
[data-hud-shell][data-hud-edge='top'] [data-slot='aui_thread-content'] {
justify-content: flex-start;
}
/* Space under the last message — the app's own measured-dock clearance, minus
the 2rem of breathing room a full window can afford. This is what holds the
text off the bar now that the band runs the whole window height, and it is
measured from the live dock, so it tracks the composer growing to two, three,
ten rows with no hairline and no hole. */
[data-hud-shell] [data-chat-surface] {
--thread-last-message-clearance: var(--composer-measured-height);
}
/* Same for the dock's own bottom pad — this var also feeds
--composer-measured-height, so the thread and the composer stay in
agreement from one value. */
[data-hud-shell] {
/* Conversation rhythm, tightened for a bar a few lines tall. The docked
thread's spacing is sized for a full-height column; in the HUD that air is
most of the band, and the ramp eats the older turns before you can read
them. Same knobs the app uses, just smaller here. */
--conversation-turn-gap: 0.1875rem;
--turn-block-gap: 0.375rem;
--paragraph-gap: 0.4rem;
--composer-shell-pad-block-end: 0px;
/* The viewport fills the band, period. The app's calc shortens the viewport
to leave the dock's strips visible below it; the HUD hides those strips, so
applying it here just strands a dead zone at the end of the thread that
grows as you type. Room for the bar comes from the clearance instead. */
--thread-viewport-height: 100%;
}
/* The composer BAR is the drag handle: it's the one surface that isn't a
scroll container, so it's the only one that can be. Its frame drags; the
controls carve themselves back out (a drag region eats their clicks too). */
[data-hud-shell] [data-slot='composer-dock'] {
-webkit-app-region: drag;
}
[data-hud-shell] [data-slot='composer-rich-input'],
[data-hud-shell] [data-slot='composer-dock'] button,
[data-hud-shell] [data-slot='composer-dock'] input,
[data-hud-shell] [data-slot='composer-dock'] textarea,
[data-hud-shell] [data-slot='composer-dock'] [contenteditable],
[data-hud-shell] [data-slot='composer-dock'] [role='button'] {
-webkit-app-region: no-drag;
}
/* HUD mode is the input and the log — nothing else. The dock stacks
[micro-action pills] · [status stack] · [composer] · [underside slot], and
only the composer belongs here: the status stack (todos, subagents,
background tasks, queue, the coding/cwd/git-branch row) and the strips
around it are ambient chrome for a full window, and in a bar a few lines
tall they'd shove the input off screen.
Written as "keep the composer, hide its siblings" rather than a list of
selectors, because those siblings carry no data-slot of their own — a list
would silently miss whatever gets added to the dock next. */
[data-hud-shell] [data-slot='composer-dock'] > *:not([data-slot='composer-root']) {
display: none !important;
}
/* The coding/cwd/git-branch strip is NOT a dock sibling — it renders inside
`composer-surface` (it's meant to inherit the composer's width and top
radius), so the rule above never reaches it. Same intent, own selector. */
[data-hud-shell] .coding-status-bar {
display: none !important;
}
/* The composer is the Spotlight bar: centered, capped width, the ONE
permanent object on screen. When the thread is faded out, this is all HUD
mode is.
The dock is `absolute bottom-0 left-1/2 -translate-x-1/2` against the chat
surface. Two traps live in that, and both silently ate earlier attempts:
- `margin-bottom` does NOTHING to an absolutely-positioned box anchored to
an edge; the lift has to come from `bottom`.
- Tailwind centers it with `left-1/2` + a translate, so BOTH `transform`
and `translate` (the standalone property) must be cancelled — absolute
centering here is left/right 0 + capped width + margin-inline auto.
Do NOT restyle `position`: the controls row (model pill, mic, send) resolves
against the nearest positioned ancestor, so making the dock static re-anchors
that row and the composer tears in half — input in flow at the top, controls
stranded at the bottom. */
[data-hud-shell] [data-slot='composer-dock'],
[data-hud-shell] [data-slot='composer-dock'][data-popped-out] {
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
top: auto !important;
width: 100% !important;
max-width: none !important;
transform: none !important;
translate: none !important;
padding: 0 !important;
}
/* The bar is never "away". In the docked app the composer dims to 30% while
you read back through the thread, and comes back on hover or focus — sensible
when it's a panel at the bottom of a big window you're scrolling past. Here
it IS the interface: the one permanent, fully-present object, whatever the
transcript above it is doing. */
[data-hud-shell] [data-slot='composer-fade'] {
opacity: 1 !important;
}
[data-hud-shell] [data-slot='composer-root'] {
/* Opaque, so anything docked to the composer (status stack, queue panel)
paints the same solid fill the bar does — they share this var. */
--composer-fill: var(--dt-card);
/* The base rule adds 5px of transparent grab margin for the peel-out drag,
and the dock compensates with +10px of width. Neither applies here (the
HUD composer doesn't peel out), and left in they inset the card unevenly
inside its own float. */
padding: 0 !important;
width: 100% !important;
}
/* The composer surface paints TWO layers: the surface itself, plus an
`absolute inset-0 -z-10` fill+glass div behind it (composerFill /
composerSurfaceGlass). Two stacked fills read as a box inside a box, so the
backing layer goes and the surface carries the single fill. */
[data-hud-shell] [data-slot='composer-surface'] > .pointer-events-none.absolute.inset-0 {
display: none !important;
}
/* Opaque, pill-rounded — the Spotlight bar. Same surface tokens the composer
already uses; only the radius steps up so a lone floating bar reads as an
object rather than a snippet of app chrome.
ALWAYS fully opaque. It's the input — the one thing that must never be
compromised by whatever's behind the window, in any state. The idle/engaged
breathing belongs to the thread and the chat surface; the bar just sits
there, solid, like Spotlight does. */
[data-hud-shell] [data-slot='composer-surface'] {
border: 1px solid var(--ui-stroke-secondary) !important;
/* Same radius as the band's top and the window's own corners, so the HUD
reads as one object however the band is behaving. Its top corners let a
sliver of band through, which is the point — the glass wraps the bar's
shoulders instead of stopping in a straight line above it. */
border-radius: 0.75rem !important;
background: var(--dt-card) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
box-shadow: none !important;
}
/* The exit button: a real control, visible at rest, on a card chip so it
reads against any backdrop.
Square (aspect-ratio 1/1 on the app's titlebar button size) and INSET from
the band's corner — tucked flush it read as a mistake; a chip floating in
the corner with its own margin reads as intentional. anchor() to the band:
calc()-based attempts drifted because the composer's measured vars are
inline-scoped and stale outside [data-chat-surface]. */
[data-hud-shell] [data-slot='composer-bounds'] {
anchor-name: --hud-band;
}
[data-hud-shell] [data-hud-exit] {
position-anchor: --hud-band;
left: auto;
bottom: auto;
right: calc(anchor(right) + 0.5rem);
top: calc(anchor(top) + 0.5rem);
aspect-ratio: 1;
height: auto;
background: var(--dt-card) !important;
border: 1px solid var(--ui-stroke-tertiary);
border-radius: 0.5rem;
/* Part of the band, so it lives and dies with it — same states, same timing.
Left visible at rest it is a lone chip hovering in empty space a hundred
pixels above the bar; revealed on hovering the window it pops up whenever
the cursor crosses the HUD on its way somewhere else. */
opacity: 0;
transition: opacity var(--hud-fade) ease var(--hud-fade-delay);
/* And it is untouchable on exactly the same terms as the band it sits in —
engaged or nothing. A faded-out control that still takes clicks is just an
invisible button floating over the app behind. */
pointer-events: none;
}
[data-hud-shell]:focus-within [data-hud-exit] {
pointer-events: auto;
}
[data-hud-shell][data-hud-recent] [data-hud-exit],
[data-hud-shell]:focus-within [data-hud-exit] {
opacity: 0.75;
transition-duration: var(--hud-reveal);
transition-delay: 0s;
}
[data-hud-shell][data-hud-recent] [data-hud-exit]:hover,
[data-hud-shell]:focus-within [data-hud-exit]:hover {
opacity: 1;
}
/* The dock's fade-to-surface gradient assumes a chat column behind it; over a
transparent HUD it's a grey smear. */
[data-hud-shell] [data-slot='composer-root'] > .pointer-events-none {
display: none !important;
}
/* Portaled overlays (model picker, menus, dialogs, tooltips, the command
palette) mount into <body>, OUTSIDE [data-hud-shell] — so they never inherit
its `no-drag` carve-outs. And Electron's draggable region is RECT math, not
paint order: the shell's window-sized `drag` rect covers wherever the menu
opens, and anything that doesn't explicitly subtract itself is handed to the
window manager. The menu rendered, sat correctly, and swallowed every click
("I can't even select a model"). Carve every portal back out. */
html:has([data-hud-shell]) body > *:not(#root) {
-webkit-app-region: no-drag;
}
/* Popovers in a ~320px-tall window. Two separate problems:
1. Radix clamps the popover to --radix-*-available-height, but fixed
max-heights INSIDE the panels (the model catalog's max-h-[max(150px,
30dvh)] list) don't shrink with it, so the list keeps its own floor and
the menu overflows the window.
2. The window IS the collision boundary, so even a correctly-clamped menu
has only ~320px to live in.
(1) is ours to fix in CSS; inner scrollers yield to the window instead of
their own floor. (2) is why the menus are compact here rather than roomy —
a HUD is a small window and a 400px menu cannot fit in it. */
html:has([data-hud-shell]) [data-slot='dropdown-menu-content'] [class*='max-h-'],
html:has([data-hud-shell]) [data-slot='popover-content'] [class*='max-h-'] {
max-height: max(5rem, calc(100dvh - 9rem)) !important;
}
/* The menu panels themselves: never taller than the window minus the bar. */
html:has([data-hud-shell]) [data-slot='dropdown-menu-content'],
html:has([data-hud-shell]) [data-slot='popover-content'] {
max-height: calc(100dvh - 4.5rem) !important;
overflow-y: auto;
}
/* Hide the scrollbar — a HUD with a visible track stops reading as an overlay. */
[data-hud-shell] [data-slot='composer-bounds'] * {
scrollbar-width: none;
}
[data-hud-shell] [data-slot='composer-bounds'] *::-webkit-scrollbar {
display: none;
}