From e10e3442041a0ed8f2770ca26d859f01dc0c2bfb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 14 Aug 2026 03:01:09 -0500 Subject: [PATCH] fix(desktop): make the HUD exit button findable, and only when wanted The bare dimmed glyph had nothing to read against. Over a light document in a light theme it is a pale mark on white, and every rest opacity tried (0.35, then 0.45, then 0.75 behind a text halo) came back reported as the button being gone. Give the control its own substrate, which is what every shipped overlay does: Apple's HIG puts controls on a material rather than directly on content, Firefox picture-in-picture draws close/unpip as opaque chips, and Discord's overlay adds a contrast layer over the game. Deriving contrast from the backdrop is not available to us either way -- mix-blend-difference composites against the page, and behind a transparent Electron window that is nothing. The chip now wears the composer bar's own tokens (fill, hairline, radius, bottom shadow), so it inverts with the theme and with the OS appearance under mode 'system'. It rests hidden and fades in while the bar, the band, or the chip itself is hovered, with a hold on the way out so it survives the reach across the gap -- reaching for the HUD is the motion that means "I want the app". Hover rather than focus: the caret gate behind #81893 broke the escape hatch exactly when it was needed. --- apps/desktop/src/styles.css | 121 +++++++++++++++++++++++++++++------- 1 file changed, 97 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 3fc1f06996d15..6bd413648fd9a 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -2344,6 +2344,10 @@ button[data-slot='aui_msg-reactions'] svg { --hud-band-inset: 0.5rem; /* Clear space above the composer for the exit chip. */ --hud-chip-strip: 1.625rem; + /* How long the exit chip stays after you stop pointing at the HUD. Long + enough to cross the gap between the bar and the chip without it + evaporating, short enough that it isn't furniture. */ + --hud-exit-hold: 600ms; } /* Chat surface carries nothing in HUD mode — the visual is the BAND below. */ @@ -2865,11 +2869,42 @@ button[data-slot='aui_msg-reactions'] svg { } /* The exit button. HUD mode has no titlebar, so this is the only visible way - back; it lives in the open space off the bar's outer edge, at the right. + back; it rides the bar's outer edge at the right. - A bare themed glyph rather than a chip: it sits over whatever the user is - really working in, and a card floating there is a stray fragment of app on - their screen. + It wears the BAR'S material, not the desktop's. Every shipped control that + floats over content the app does not own resolves this the same way — give + the control its own substrate and let the glyph read against that, never + against the backdrop: + + - Apple HIG, Materials: "Materials help visually separate foreground + elements, such as text and controls, from background elements." Controls + sit ON a material, never directly on content; even `clear` Liquid Glass + is specced with a 35%-opacity dimming layer behind it over bright + content. + https://developer.apple.com/design/human-interface-guidelines/materials + - Firefox picture-in-picture, the closest analogue we have (a small + always-on-top window over arbitrary content): the close/unpip buttons + are `background-color: rgba(255,255,255,.8)` with a `#000` glyph — an + opaque chip, not a bare icon. + toolkit/themes/shared/pictureinpicture/player.css + - Discord's overlay redesign: "This layer provided contrast against the + game making the UI easier to see." + https://discord.com/blog/redesigning-the-discord-overlay + + Anything that instead tries to derive contrast from the backdrop is a dead + end here. mix-blend-difference composites against the PAGE behind the + element, and behind a transparent Electron window that is nothing — the + desktop is composited by WindowServer after Chromium has finished drawing + the frame (the same reason backdrop-filter can't frost the HUD). A glyph + halo has the same flaw from the other side: it guesses one backdrop + luminance and is grime over everything else. + + So: the composer bar's exact tokens — same fill, same hairline, same bottom + shadow. The bar is the HUD's solved case for "our surface, over an unknown + desktop, in either appearance", and theme plus OS light/dark (mode 'system') + come free with it, because --dt-card and --ui-text-primary are the pair the + whole app is built on. The chip inverts with the appearance instead of + betting on one. Placed from --hud-bar-height rather than CSS anchor(). Lightning CSS drops a whole rule containing an `anchor()` on the vertical axis, so the flipped-edge @@ -2879,22 +2914,42 @@ button[data-slot='aui_msg-reactions'] svg { top: auto; right: 0.375rem; bottom: calc(var(--hud-bar-height, var(--composer-fallback-height)) + 0.375rem); - aspect-ratio: 1; + /* Square. `size="icon-titlebar"` sizes width and height from two DIFFERENT + vars (--titlebar-control-size / --titlebar-control-height, 20x22 today), + which is right in a titlebar row and reads as a dent on a lone floating + chip — so height comes off the width instead. */ + aspect-ratio: 1 / 1; height: auto; - background: transparent !important; - border: 0; + background: var(--dt-card) !important; + border: 1px solid var(--ui-stroke-secondary) !important; + border-radius: 0.5rem; + box-shadow: 0 2px 2px -1px rgb(0 0 0 / 0.12); color: var(--ui-text-primary) !important; - /* Always clickable, dim at rest. Gating the CLICKS on composer `:focus` made - the only visible way out of HUD mode depend on the thing most likely to be - broken when someone wants out: if focus never lands (#81893) you can't type - AND the chip silently swallows clicks, leaving a transparent always-on-top - rectangle over the desktop with no in-app way to dismiss it. + /* Gone until you reach for the HUD. A permanent chip is a fragment of Hermes + parked on top of whatever you are really working in; reaching for the bar + is the motion that means "I want the app", so that is what brings the way + out with it. Hover, not focus — the gate #81893 warned about made the + escape hatch depend on the caret landing in the composer, broken exactly + when you most want out, whereas pointing at the bar needs nothing to be + working. - Dim rather than invisible keeps the original intent — this shouldn't be a - loud chip floating over whatever you're actually working in — without - making the escape hatch conditional on the failure it exists for. */ - opacity: 0.45; - transition: opacity var(--hud-fade) var(--hud-ease-exit); + `visibility`, not opacity alone: an always-on-top window eats clicks + wherever the page hands the hit test something real, so a 0-opacity chip + with `pointer-events: auto` would be an invisible button in the corner + that drops you out of HUD mode when you click the app behind it. + `visibility: hidden` takes it out of `elementFromPoint` as well as off the + screen, and it transitions discretely, so it flips in step with the fade + rather than needing a second mechanism. */ + opacity: 0; + visibility: hidden; + /* Leaving holds first. The chip sits 0.375rem clear of the bar, so the + cursor crosses shell dead space on its way up from the bar to the chip and + both hover triggers are false for that moment — without the hold it would + fade out from under a hand that is on its way to press it. */ + transition: + opacity var(--hud-fade) var(--hud-ease-exit) var(--hud-exit-hold), + visibility 0s linear calc(var(--hud-exit-hold) + var(--hud-fade)), + background-color var(--hud-reveal) var(--hud-ease-enter); pointer-events: auto; } @@ -2905,18 +2960,36 @@ button[data-slot='aui_msg-reactions'] svg { top: 0; } -/* Hover and focus-visible brighten it too, so pointing at the chip confirms it - is a control before you commit the click. */ -[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-exit], +/* What counts as reaching for it: the bar, the transcript band, or the chip + itself. The band only answers `:hover` while it is on screen (faded out it + is `pointer-events: none`), so this can't reveal the chip over a HUD that + isn't there. The chip's own hover is in the set so arriving re-asserts the + reveal that the hold above was covering for. + + Hovering the shell at large is deliberately NOT a trigger — most of the + HUD's rectangle is empty window over someone else's app, and it is + `pointer-events: none` precisely so the cursor passing through means + nothing. */ +[data-hud-shell]:has([data-slot='composer-dock']:hover) [data-hud-exit], +[data-hud-shell]:has([data-slot='composer-bounds']:hover) [data-hud-exit], [data-hud-shell] [data-hud-exit]:hover, [data-hud-shell] [data-hud-exit]:focus-visible { - opacity: 0.9; - transition-duration: var(--hud-reveal); + opacity: 1; + visibility: visible; + transition-duration: var(--hud-reveal), 0s, var(--hud-reveal); transition-delay: 0s; } -[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-exit]:hover { - opacity: 1; +/* Hover and focus-visible: the app's own control-hover tint, so pointing at it + confirms it is a button with the same feedback every other icon button in + Hermes gives. Layered OVER the card rather than replacing it — + --ui-control-hover-background is a translucent color-mix meant to sit on an + opaque titlebar, and here the button IS the opaque surface, so assigning it + directly would make the chip see-through on hover. */ +[data-hud-shell] [data-hud-exit]:hover, +[data-hud-shell] [data-hud-exit]:focus-visible { + background: + linear-gradient(var(--ui-control-hover-background), var(--ui-control-hover-background)), var(--dt-card) !important; } /* The corner resize handle — a hot corner, not a button. The window is created