diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index 2ed1169f4e..f8c2c86a67 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -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 &&
diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx
index c66ac36799..1bd11309a1 100644
--- a/apps/desktop/src/app/contrib/controller.tsx
+++ b/apps/desktop/src/app/contrib/controller.tsx
@@ -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 (
+
+
+
+ )
+ }
+
return (
{
- if (isSecondaryWindow()) {
+ if (isAuxiliaryWindow()) {
return
}
diff --git a/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts b/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts
index dbd886e18f..5fbfd102b1 100644
--- a/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts
+++ b/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts
@@ -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
}
diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx
index 02f95d9765..fbb6558c53 100644
--- a/apps/desktop/src/app/contrib/wiring.tsx
+++ b/apps/desktop/src/app/contrib/wiring.tsx
@@ -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
}
>
- 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() && (
+ navigate(SETTINGS_ROUTE)}
+ tools={rightTitlebarTools}
+ />
+ )}
{children}
{/* The full real overlay set (mirrors DesktopController's `overlays`). */}
- {!isSecondaryWindow() && }
- {!isSecondaryWindow() && (
+ {!isAuxiliaryWindow() && }
+ {!isAuxiliaryWindow() && (
{
@@ -1113,11 +1122,13 @@ export function ContribWiring({ children }: { children: ReactNode }) {
{/* Toasts above everything. */}
- {/* Petdex floating mascot — renders nothing unless installed + enabled. */}
-
+ {/* Petdex floating mascot — renders nothing unless installed + enabled.
+ Never in the HUD: that window is the chat bar and nothing else. */}
+ {!isHudWindow() && }
- {/* Single persistent xterm host chasing the terminal pane's slot rect. */}
-
+ {/* Single persistent xterm host chasing the terminal pane's slot rect.
+ The HUD has no terminal pane, so it has nothing to chase. */}
+ {!isHudWindow() && }
)
}
diff --git a/apps/desktop/src/app/hud/click-through.ts b/apps/desktop/src/app/hud/click-through.ts
new file mode 100644
index 0000000000..1c6eaf9ed9
--- /dev/null
+++ b/apps/desktop/src/app/hud/click-through.ts
@@ -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): 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])
+}
diff --git a/apps/desktop/src/app/hud/hud-shell.tsx b/apps/desktop/src/app/hud/hud-shell.tsx
new file mode 100644
index 0000000000..49d3309b36
--- /dev/null
+++ b/apps/desktop/src/app/hud/hud-shell.tsx
@@ -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 | 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('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(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('[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 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 (
+
+ {/* 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. */}
+
+
+
+
+ {/* 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). */}
+
+
+ {/* 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. */}
+
+
+
+
+ )
+}
diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx
index 304b3eb3ec..0caf309e85 100644
--- a/apps/desktop/src/components/assistant-ui/thread/list.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx
@@ -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 = ({
// 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])
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index 2ee38e0b65..70188469a8 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -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 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 , 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;
+}