feat(desktop): frost the HUD band and fade it in three states

The band is real macOS vibrancy now rather than backdrop-filter, which
reaches nothing in a transparent window — its backdrop root is the document,
and the desktop was never in it. Vibrancy composites below the web contents,
so it can see the desktop, and it can't be masked or clipped from the page.
That rules out the gradient the band used to carry and settles it as a flat
panel: uniform tint, uniform frost.

The fade does the work the gradient was doing. A landing turn brings the
transcript half way up to be glanced at, focus promotes it to properly
readable, and the hold takes it back down and then away — the panel sliding
behind the bar as the last of the text goes.

It only fades from an idle transcript. A running turn or a question waiting
on you holds it open, because a prompt that fades out is one you can neither
read nor answer, and the hold timer alone would expire through a long tool
call that prints nothing.
This commit is contained in:
Brooklyn Nicholson 2026-08-08 03:52:17 -05:00
parent 10c1530599
commit 4500b43914
4 changed files with 291 additions and 153 deletions

View File

@ -9191,8 +9191,13 @@ function spawnHudWindow(sessionId) {
hasShadow: false,
alwaysOnTop: true,
type: IS_MAC ? 'panel' : undefined,
// Clips the window to a rounded silhouette rather than a hard rectangle.
// Clips the vibrancy layer to the HUD's silhouette rather than a hard
// rectangle — the frost stops where the window's corners do.
roundedCorners: true,
// Vibrancy must keep rendering while the window is BLURRED: streaming under
// another app is the whole feature, and the default 'followWindow' kills
// the frost the moment something else takes focus.
visualEffectState: 'active',
hiddenInMissionControl: IS_MAC,
show: false,
backgroundColor: '#00000000',
@ -9988,6 +9993,20 @@ ipcMain.handle('hermes:hud:open', async (_event, request) => {
return { ok: true }
})
// Real frosted glass behind the band — the thing CSS backdrop-filter cannot do,
// because Chromium composites a transparent window's page against nothing and
// the desktop is not in its backdrop root. Vibrancy IS the window's content
// view, so it frosts the whole rectangle; the HUD's layout leaves no dead
// margins for that reason, and the renderer only turns it on while the band is
// showing (idle HUD mode must be the bar and nothing else).
ipcMain.handle('hermes:hud:vibrancy', (_event, on) => {
if (hudWindow && !hudWindow.isDestroyed() && IS_MAC) {
hudWindow.setVibrancy(on ? 'hud' : null)
}
return { ok: true }
})
// Let clicks fall through the HUD wherever it isn't really there. An
// always-on-top window eats every click inside its rectangle, and most of that
// rectangle is a faded-out band over whatever the user is actually working in.

View File

@ -0,0 +1,64 @@
import { type RefObject, useEffect } from 'react'
/** The caret is in the composer — see the `:has()` rules in styles.css. */
const TYPING_SELECTOR = '[data-slot="composer-rich-input"]:focus'
/**
* Native frost behind the band.
*
* macOS vibrancy, not CSS `backdrop-filter` reaches nothing here, because a
* transparent window's backdrop root is the document and the desktop was never
* in it. Vibrancy is composited by WindowServer BELOW the web contents, which
* is what lets it see the desktop and also what makes it untouchable from the
* page: no mask, clip or stacking order can shape it.
*
* That is survivable because the band is a flat panel. It was NOT survivable
* while the band carried a vertical gradient the frost stayed a slab under a
* fading tint and the top went pale exactly where it should have been
* disappearing, which is what sent the whole thing round in circles. Uniform
* panel, uniform frost.
*
* It still cannot animate, so it is switched while the tint is at full strength
* and can hide the change: on the moment the band is engaged, off the moment the
* hold ends and the opacity fade begins. Letting it outlive the fade leaves bare
* untinted frost on screen a grey blurred rectangle that pops out at the end
* instead of a band fading away.
*
* Engaged means the caret is in the composer, matching the stylesheet. Merely
* holding window focus does not count: activating a window restores focus to
* whatever had it last, so grabbing the bar to drag the HUD would otherwise
* read as sitting down to use it. Queried live rather than tracked from
* document.activeElement, which stays put when the window is blurred and would
* latch the frost on forever once the user had ever typed here.
*/
export function useHudGlass(rootRef: RefObject<HTMLElement | null>, engaged: boolean): void {
useEffect(() => {
const root = rootRef.current
const setVibrancy = window.hermesDesktop?.hud?.setVibrancy
if (!root || !setVibrancy) {
return
}
let on: boolean | null = null
const apply = () => {
const next = engaged || root.querySelector(TYPING_SELECTOR) !== null
if (on !== next) {
on = next
void setVibrancy(next)
}
}
apply()
root.addEventListener('focusin', apply)
root.addEventListener('focusout', apply)
return () => {
void setVibrancy(false)
root.removeEventListener('focusin', apply)
root.removeEventListener('focusout', apply)
}
}, [engaged, rootRef])
}

View File

@ -1,34 +1,54 @@
import { useStore } from '@nanostores/react'
import { type CSSProperties, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { closeHud } from '@/store/hud'
import { $activeSessionAwaitingInput } from '@/store/prompts'
import { $busy, $messages } from '@/store/session'
import { WiredPane } from '../contrib/wiring'
import { titlebarButtonClass } from '../shell/titlebar'
import { useHudClickThrough } from './click-through'
import { useReportHudSession } from './handoff'
import { useHudGlass } from './glass'
import { useHudGoto, 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
/** How long the transcript lingers at its glanceable opacity after a turn
* lands, or after you let go of the composer before it goes. This is the ONLY
* hold: the CSS carries no transition-delay, because two stacked holds read as
* a third fade state that nobody asked for. Focus keeps it open past this. */
const HUD_RECENT_HOLD_MS = 700
/** 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
const HUD_REVEAL_MS = 110
const HUD_FADE_MS = 180
/** The step DOWN to the glanceable opacity when you let go. Deliberately slower
* than the fade that follows it easing off is a softer gesture than leaving,
* and matching them made the two read as one long dissolve. */
const HUD_DIM_MS = Math.round(HUD_FADE_MS * 1.5)
/** The sheet rolling shut. Shorter than the fade so the panel is already gone
* while the last of the text is still going it reads as the transcript being
* drawn down into the bar rather than the two dissolving in lockstep. */
const HUD_COLLAPSE_MS = Math.round(HUD_FADE_MS * 0.66)
/** Breathing room the sheet keeps above the first row, so the fade has
* somewhere to land. Published to CSS, and used here to work out how much of
* the window the HUD actually occupies. */
const HUD_SHEET_OVERHANG_PX = 12
/** Composer on top, transcript always hanging below it Spotlight's shape,
* rather than flipping to follow the screen edge the HUD is parked against. */
const HUD_THREAD_ALWAYS_BELOW = true
/**
* 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
@ -40,12 +60,16 @@ const HUD_SHEET_OVERHANG_PX = 12
* window after it finishes, without a per-flush re-render (state only changes
* on the falsetrue edges).
*/
function useRecentActivity(): boolean {
function useRecentActivity(): [boolean, () => void] {
const [recent, setRecent] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const bumpRef = useRef(() => {})
// eslint-disable-next-line no-restricted-syntax -- timer handle, not an atom mirror
useEffect(() => {
let signature = ''
const bump = () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
@ -55,9 +79,29 @@ function useRecentActivity(): boolean {
timerRef.current = setTimeout(() => setRecent(false), HUD_RECENT_HOLD_MS)
}
// Gated on the transcript actually CHANGING, not on the atom being written.
// $messages is republished for plenty of reasons that aren't new content
// (session sync, re-renders, relative timestamps), and re-arming the hold on
// every one of those latched the band open permanently — the fade simply
// never got to start.
const onMessages = () => {
const messages = $messages.get()
const last = messages[messages.length - 1]
const next = `${messages.length}:${last?.id ?? ''}:${last ? chatMessageText(last).length : 0}`
if (next === signature) {
return
}
signature = next
bump()
}
bumpRef.current = bump
// subscribe() fires immediately, so a HUD opened onto an existing
// conversation starts with the thread showing, then fades.
const offMessages = $messages.subscribe(bump)
const offMessages = $messages.subscribe(onMessages)
const offBusy = $busy.subscribe(busy => busy && bump())
return () => {
@ -70,7 +114,27 @@ function useRecentActivity(): boolean {
}
}, [])
return recent
return [recent, () => bumpRef.current()]
}
/**
* True while the HUD must stay up regardless of the hold timer.
*
* The fade is built for an idle transcript, and there are states where leaving
* is the wrong answer: a clarify/approval/sudo/secret prompt is a question you
* have to answer, and a running turn is progress you asked to watch. Letting
* either fade hands you a surface you cannot use the band goes to zero opacity
* and the window goes mouse-transparent under it, so the prompt is neither
* readable nor clickable.
*
* `recent` alone doesn't cover it: it re-arms on transcript changes, so a long
* tool call with no visible output would time out mid-turn.
*/
function useHudHeld(): boolean {
const awaitingInput = useStore($activeSessionAwaitingInput)
const busy = useStore($busy)
return awaitingInput || busy
}
/**
@ -90,11 +154,13 @@ function useRecentActivity(): boolean {
*/
export function HudShell() {
const { t } = useI18n()
const recent = useRecentActivity()
const [recent, holdBand] = useRecentActivity()
const held = useHudHeld()
// 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()
useHudGoto(useNavigate())
// 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
@ -108,33 +174,33 @@ export function HudShell() {
// flips to 'top' only when the HUD is actually parked against the top, and
// back once it clearly leaves — the gap between the two thresholds is
// hysteresis so the layout can't flutter while it's dragged along the line.
const [edge, setEdge] = useState<'bottom' | 'top'>('bottom')
// How much of the window the HUD actually occupies. The sheet is sized to the
// transcript, so a tall window can be mostly empty above it — the flip has to
// measure the visible panel, not a window edge that reached the screen top
// long before the bar looked anywhere near it.
const visibleHeightRef = useRef(0)
const [edge, setEdge] = useState<'bottom' | 'top'>('top')
useEffect(() => {
// ZERO tolerance by explicit request: top-mode only when the HUD 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.
// Proportional to the display, not an absolute pixel count. The HUD hugs
// its bar, so its visible top can never actually reach the screen edge —
// an exact-flush rule would simply never fire. "Parked at the top" is a
// band near the top instead, with hysteresis so dragging along the line
// can't flutter the layout.
const usableHeight = (window.screen as { availHeight?: number }).availHeight || window.screen.height || 1
const FLIP_ON = usableHeight * 0.12
const FLIP_OFF = usableHeight * 0.18
// Measured on the WINDOW, and flush-only. Flipping is what lets the bar
// reach the top of the screen at all: the window's top edge can sit against
// the menu bar, and the flip moves the composer to that edge. Keying it off
// the visible panel instead meant it could never fire — the panel hugs the
// bar at the bottom of the window — and took the top of the screen away
// with it. FLIP_OFF is just enough hysteresis that the 300ms poll can't
// flutter on sub-pixel jitter while parked.
const FLIP_ON = 0
const FLIP_OFF = 4
const measure = () => {
// TRYING IT: the bar stays on top and the transcript always hangs below,
// wherever the HUD is parked. Flip the constant to re-enable the
// edge-aware layout (the CSS for both orientations is still here).
if (HUD_THREAD_ALWAYS_BELOW) {
setEdge('top')
return
}
// 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 visibleTop = window.screenY + Math.max(0, window.innerHeight - visibleHeightRef.current)
const topGap = visibleTop - availTop
const topGap = window.screenY - availTop
setEdge(prev => (topGap <= FLIP_ON ? 'top' : topGap >= FLIP_OFF ? 'bottom' : prev))
}
@ -231,6 +297,7 @@ export function HudShell() {
}
}, [])
useHudGlass(rootRef, recent || held)
useHudClickThrough(rootRef)
// Force the HOST layers transparent. index.html's pre-paint script writes an
@ -252,14 +319,19 @@ export function HudShell() {
<div
className="relative flex h-screen w-screen flex-col overflow-hidden"
data-hud-edge={edge}
data-hud-recent={recent ? '' : undefined}
data-hud-recent={recent || held ? '' : undefined}
data-hud-scrollable={scrollable ? '' : undefined}
data-hud-shell
// Letting go of the composer re-arms the hold, so the transcript steps
// down to its glanceable opacity and lingers there instead of jumping
// straight from full to gone.
onBlur={holdBand}
ref={rootRef}
style={
{
'--hud-fade-delay': `${HUD_FADE_DELAY_MS}ms`,
'--hud-fade': `${HUD_FADE_MS}ms`,
'--hud-collapse': `${HUD_COLLAPSE_MS}ms`,
'--hud-dim': `${HUD_DIM_MS}ms`,
'--hud-reveal': `${HUD_REVEAL_MS}ms`,
'--hud-sheet-overhang': `${HUD_SHEET_OVERHANG_PX}px`
} as CSSProperties

View File

@ -2285,18 +2285,10 @@ button[data-slot='aui_msg-reactions'] svg {
/* 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. */
--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));
/* Arrive decisively and settle; leave gently and get out of the way. Linear
both directions is what made this feel mechanical. */
--hud-ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
--hud-ease-exit: cubic-bezier(0.4, 0, 0.7, 0.2);
}
/* Chat surface carries nothing in HUD mode — the visual is the BAND below. */
@ -2323,18 +2315,17 @@ button[data-slot='aui_msg-reactions'] svg {
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. */
/* Anchored to the bar and sized to the transcript, exactly like the sheet
behind it same box, same clock so the text is clipped away as the panel
rolls rather than fading in place.
Edge to edge with no dead margins, and it never computes where the bar's
top is: the bar simply covers this box's bottom. Every earlier attempt
derived that edge from --composer-surface-measured-height, which is the
composer's published height, rounded, while 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"). What keeps the TEXT off the bar is the
app's own composer clearance, below. */
inset: 0 !important;
height: auto !important;
width: 100% !important;
@ -2348,13 +2339,14 @@ button[data-slot='aui_msg-reactions'] svg {
([data-hud-glass]), so it can be sized to the transcript while this box
stays the full window for the drag region and the scroll container. */
background: transparent !important;
/* Three opacity states: gone at rest, half strength when a turn is recent or
you've just let go of the composer (there to glance at, not to demand
attention), full while you're actually reading it. */
opacity: 0;
/* WoW linger: reveal is quick, but on losing focus the band HOLDS, then fades
out slow. The timings are published by hud-shell.tsx so the two can't
drift. Reveal rules zero the delay. */
translate: 0 var(--hud-exit-shift, 2.5rem);
transition:
opacity var(--hud-fade) ease var(--hud-fade-delay),
background-color 350ms ease;
opacity var(--hud-fade) var(--hud-ease-exit),
translate var(--hud-collapse) var(--hud-ease-exit);
}
/* Shown: recent turn / streaming, or a focused composer.
@ -2387,64 +2379,54 @@ button[data-slot='aui_msg-reactions'] svg {
right: 0;
bottom: 0;
left: 0;
/* Grows up from the bar to fit the transcript, capped at the window. An empty
session measures 0 and the sheet collapses behind the bar, rather than
painting a full-height slab with nothing in it. The extra is breathing room
above the first row so the fade has somewhere to land. */
height: min(100%, calc(var(--hud-band-height, 0px) + var(--hud-sheet-overhang, 0.75rem)));
z-index: 0;
pointer-events: none;
border-radius: 0.75rem 0.75rem 0 0;
background: color-mix(in srgb, var(--dt-background) 62%, transparent);
background: color-mix(in srgb, var(--dt-card) 55%, transparent);
/* Slides down behind the bar as it fades. A TRANSFORM, not height: it is
composited, so it stays smooth where animating height re-lays-out the panel
every frame and looked coarse and it leaves the box alone, which matters
because the one time the transcript's own container was collapsed the
messages spilled out of a zero-height box. Translate rather than scale: the
panel keeps its proportions on the way out instead of being squashed.
The height below still tracks the transcript; only the offset animates on
exit, and it finishes ahead of the fade so the panel has left while the last
of the text is still going. */
height: min(100%, calc(var(--hud-band-height, 0px) + var(--hud-sheet-overhang, 0.75rem)));
translate: 0 var(--hud-exit-shift, 2.5rem);
opacity: 0;
/* Height is animated because it changes on every reply. Snapping to each new
size reads as the panel twitching; growing into it reads as the transcript
filling up. */
transition:
opacity var(--hud-fade) ease var(--hud-fade-delay),
height var(--hud-reveal) ease;
-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%;
opacity var(--hud-fade) var(--hud-ease-exit),
translate var(--hud-collapse) var(--hud-ease-exit),
height var(--hud-reveal) var(--hud-ease-enter),
background-color var(--hud-dim) var(--hud-ease-enter);
}
/* Engaged: as tall as the transcript needs, capped at the window. An empty
session measures 0, so it stays rolled up. */
[data-hud-shell][data-hud-recent] [data-hud-glass],
[data-hud-shell]:focus-within [data-hud-glass] {
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-glass] {
translate: none;
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-hud-glass],
[data-hud-shell]:focus-within [data-slot='aui_thread-viewport'] {
-webkit-mask-size: 100% 600%;
mask-size: 100% 600%;
/* Engaged means the caret is in the composer, not merely that the window holds
focus somewhere. Activating a window restores focus to whatever had it last,
so `:focus-within` counted grabbing the bar to DRAG the HUD as sitting down
to use it, and the transcript flew open every time it was moved. Hit-testing
below stays on :focus-within being permissive about what can be clicked is
harmless, being permissive about what lights up is not. */
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-glass] {
background: color-mix(in srgb, var(--dt-card) 92%, transparent);
}
/* Engaged: the band goes solid — typing means reading conditions. */
[data-hud-shell]:focus-within [data-hud-glass] {
background: var(--dt-background);
}
/* Flipped, the sheet hangs from the bar instead of standing on it. The MASK
does not flip with it: the transcript still reads oldest-to-newest downward,
so the newest turn is at the bottom either way and the fade always runs away
from it. Flipping the gradient too faded the newest reply and left the oldest
solid, which reads as the band having come apart. */
/* Flipped, the sheet hangs from the bar instead of standing on it. */
[data-hud-shell][data-hud-edge='top'] [data-hud-glass] {
top: 0;
bottom: auto;
--hud-exit-shift: -2.5rem;
border-radius: 0 0 0.75rem 0.75rem;
}
@ -2452,13 +2434,32 @@ button[data-slot='aui_msg-reactions'] svg {
pointer-events: none;
}
/* Clickable whenever it's actually on screen, not only while the caret is in
the composer a link in the transcript is worth clicking the moment you can
see it. Faded out it goes back to `none`, which is what lets clicks over the
HUD reach whatever you're really working in. */
[data-hud-shell][data-hud-recent] [data-slot='composer-bounds'],
[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'] {
/* Three states, not two. A turn landing brings the transcript up, but only part
way it is there to be glanced at over whatever you are actually doing, and
full-strength text over another app reads as the HUD demanding attention it
hasn't earned. Focus is what promotes it to properly readable; the hold and
fade then take it away from wherever it was. */
[data-hud-shell][data-hud-recent] [data-slot='composer-bounds'] {
opacity: 0.5;
translate: none;
/* Same duration AND curve as the sheet's tint step, or the two visibly ease
apart on the way down. */
transition-duration: var(--hud-dim);
transition-timing-function: var(--hud-ease-enter);
}
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-slot='composer-bounds'] {
opacity: 1;
translate: none;
transition-duration: var(--hud-reveal);
transition-delay: 0s;
}
@ -2478,7 +2479,7 @@ button[data-slot='aui_msg-reactions'] svg {
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 {
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) {
--hud-bubble-fill: var(--dt-user-bubble);
}
@ -2506,7 +2507,7 @@ button[data-slot='aui_msg-reactions'] svg {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
transition:
background-color 350ms ease,
background-color var(--hud-reveal) var(--hud-ease-enter),
border-color 350ms ease;
}
@ -2581,40 +2582,6 @@ button[data-slot='aui_msg-reactions'] svg {
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. */
@ -2685,12 +2652,26 @@ button[data-slot='aui_msg-reactions'] svg {
-webkit-app-region: drag;
}
/* Focusables, not a list of the controls that happen to be in the bar today
a drag region eats the clicks of anything it covers, so anything missed here
is a dead control. */
[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'] {
[data-hud-shell]
[data-slot='composer-dock']
:is(
a[href],
button,
input,
label,
select,
textarea,
[contenteditable],
[role='button'],
[role='combobox'],
[role='menuitem'],
[role='switch'],
[tabindex]:not([tabindex='-1'])
) {
-webkit-app-region: no-drag;
}
@ -2819,13 +2800,15 @@ button[data-slot='aui_msg-reactions'] svg {
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. */
transition: opacity var(--hud-fade) var(--hud-ease-exit);
/* And it takes clicks on exactly the same terms as the band it sits in
visible or nothing. A faded-out control that still takes clicks is an
invisible button floating over the app behind; a lit one that doesn't is
the dead chip you get from keying this to focus alone. */
pointer-events: none;
}
[data-hud-shell][data-hud-recent] [data-hud-exit],
[data-hud-shell]:focus-within [data-hud-exit] {
pointer-events: auto;
}
@ -2837,14 +2820,14 @@ button[data-slot='aui_msg-reactions'] svg {
}
[data-hud-shell][data-hud-recent] [data-hud-exit],
[data-hud-shell]:focus-within [data-hud-exit] {
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [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 {
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-exit]:hover {
opacity: 1;
}