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.
This commit is contained in:
Brooklyn Nicholson 2026-08-14 03:01:09 -05:00 committed by brooklyn!
parent b9e7bead13
commit e10e344204
1 changed files with 97 additions and 24 deletions

View File

@ -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