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:
parent
10c1530599
commit
4500b43914
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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 false↔true 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue