Dashboard: redesign app control pill as a top-edge drawer (#3427)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

* Dashboard: redesign app control pill as a top-edge drawer

The floating control capsule over headless dashboard apps consumed
noticeable space, especially on mobile. Replace it with a drawer: a
slim glass tray flush with the top edge (icon + title + minimize +
close) whose tongue-shaped handle hangs from its bottom center. At
rest only the tongue peeks in from the edge; hovering it (mouse),
tapping it, or keyboard-focusing the drawer slides the tray down.
The collapse is a single GPU transform instead of the pill's
max-width squeezes, and the open-then-retract intro now visibly
retracts into the tongue, teaching where the controls live.

Hover-expansion is gated on pointerType === 'mouse': touch taps
synthesize a pointerenter right before click, which made the old
pill's toggle see an already-open state and shut it again.

Also fixes two window-stacking bugs surfaced while testing:

- showWindow demoted stay_on_top windows (every fullpage/dashboard
  app window, created in the 99999999+ z band) to a plain counter
  z-index on restore. focusWindow deliberately never raises
  stay_on_top windows, so after minimize/restore a single dashboard
  focus buried the app permanently. Restore now re-raises into the
  stay-on-top band.

- Pressing the drawer now focuses its window, as the titlebar it
  replaces did. The document-level activation handler works off
  mouseover_window, which only mousemove refreshes — a tap with no
  intervening mousemove (touch, restored windows) would re-raise
  the dashboard over the app.

* Dashboard: restored windows come back with the drawer collapsed

The restore re-played the open intro (expand + auto-retract). A restore
should bring back the app, not the chrome — the user already met the
controls on open, and they just USED them to minimize. showWindow now
forces the drawer shut instead, which also covers the Back-button
minimize path, where the window hides without touching drawer state.

* Dashboard drawer: left-align the title against the icon

The tray is asymmetric — one icon on the left, two buttons on the
right — so a title centered in its fixed box landed off the tray's
true center and read as misaligned against the centered tongue below.
Left-aligned, icon + title cluster as the identity on the left and
the controls cluster on the right.

* Dashboard drawer: morph the tongue into the tray instead of sliding

The drawer is now ONE glass surface that changes shape, rather than a
tray that slides in from off-screen with a handle below it. At rest
it's the tongue; opened, the same surface swells into the tray while
the grabber bar rides the morph and settles into a slim strip along
the tray's bottom edge as the dismiss handle — same handle, both
states. Content is revealed from the center outward through an inner
clipping layer (the surface itself can't be overflow:hidden without
clipping the toggle's touch halo) and condenses in/out of focus with
an opacity + blur + scale crossfade. Opening gets a soft overshoot
and settles; closing is quicker and lands without bounce.

The open width is DERIVED in CSS from the same part variables that
lay out the controls row, so the surface always fits its content
exactly and breakpoints (touch sizes, hidden title under 500px) only
override parts.

* Refine dashboard drawer open-state handle

Update the headless dashboard app drawer so the grabber bar disappears when the drawer is open and only the bottom dismiss strip remains interactive. This adjusts tray/strip sizing on desktop and mobile, adds opacity timing for smooth dissolve/reappear transitions during open/close, and aligns in-code docs/comments with the new behavior.
This commit is contained in:
Nariman Jelveh
2026-07-22 22:11:39 -07:00
committed by GitHub
parent 5c3fab5579
commit 73a7a38ea8
3 changed files with 336 additions and 170 deletions
+2 -2
View File
@@ -615,9 +615,9 @@ const ipc_listener = async (event, handled) => {
}
// set window title (headless dashboard windows show it in the
// floating control pill instead of a head)
// control drawer's tray instead of a head)
$(el_window).find('.window-head-title').html(html_encode(event.data.new_title));
$(el_window).find('.dashboard-app-pill-title').text(event.data.new_title);
$(el_window).find('.dashboard-app-drawer-title').text(event.data.new_title);
// In dashboard mode the app's title is also the browser-tab title,
// and data-name is what a restore re-applies (see showWindow's
// push) — keep both fresh. The tab title only changes while this
+108 -61
View File
@@ -238,8 +238,8 @@ async function UIWindow (options) {
// --------------------------------------------------------
// In dashboard mode a maximized app window renders HEADLESS — the app
// covers the full tab, and the titlebar's controls live elsewhere: Back
// minimizes (URL ownership), the floating pill overlay carries
// minimize/close (attach_dashboard_app_pill), and the app's tile shows
// minimizes (URL ownership), the top-edge control drawer carries
// minimize/close (attach_dashboard_app_drawer), and the app's tile shows
// a running dot with a Quit item. Everything else keeps its head:
// dialogs and the dashboard window itself (update_window_url is false
// for both), explorer, and non-maximized windows (apps launching child
@@ -689,10 +689,10 @@ async function UIWindow (options) {
push_dashboard_app_url(options.app, options.title);
}
// Headless dashboard app windows get the floating control pill in
// Headless dashboard app windows get the top-edge control drawer in
// place of the head (see the Dashboard app chrome section above).
if ( is_dashboard_app_chrome ) {
attach_dashboard_app_pill(el_window, options);
attach_dashboard_app_drawer(el_window, options);
}
// Let the Apps tab refresh its tiles' running dots (see TabApps).
@@ -3904,6 +3904,16 @@ $.fn.showWindow = async function (options) {
// show window
const el_window = this;
// Stay-on-top windows (every fullpage/dashboard app window) are
// CREATED in the 99999999+ z band; restore must re-raise them
// into that same band. A plain counter z would demote the window
// below whatever gets focused next — and focusWindow deliberately
// never raises stay_on_top windows, so the demotion would stick
// (a dashboard-mode app would sit buried under the dashboard).
const raised_zindex = () => ($(el_window).attr('data-stay_on_top') === 'true'
? 99999999 + (++window.last_window_zindex)
: ++window.last_window_zindex);
// A window minimized with no taskbar to animate toward (dashboard
// mode) was hidden in place by hideWindow — its geometry was
// never disturbed. If the app's tile is visible on the Apps tab's
@@ -3917,7 +3927,7 @@ $.fn.showWindow = async function (options) {
'data-is_minimized': false,
'data-minimized_in_place': '0',
});
$(el_window).css('z-index', ++window.last_window_zindex);
$(el_window).css('z-index', raised_zindex());
const reduce_motion = window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Skip the morph while another morph still owns the window's
@@ -3946,9 +3956,10 @@ $.fn.showWindow = async function (options) {
if ( ! morphed && was_hidden ) $(el_window).hide();
}
if ( ! morphed ) $(el_window).fadeIn(150);
// Re-introduce the control pill on restore (headless
// dashboard windows only; no-op otherwise).
el_window._dashboard_pill_flash?.();
// Restores come back with the control drawer collapsed —
// whatever state it was minimized in (headless dashboard
// windows only; no-op otherwise).
el_window._dashboard_drawer_collapse?.();
// A restore re-claims the URL for this app — except when
// the restore was DRIVEN by a history traversal (popstate
// passes no_history: the entry is already current).
@@ -3970,7 +3981,7 @@ $.fn.showWindow = async function (options) {
width: `${$(el_window).attr('data-orig-width') }px`,
height: `${$(el_window).attr('data-orig-height') }px`,
});
$(el_window).css('z-index', ++window.last_window_zindex);
$(el_window).css('z-index', raised_zindex());
$(el_window).attr({
'data-is_minimized': false,
@@ -4246,85 +4257,116 @@ window.addEventListener('popstate', () => {
});
/**
* The floating control pill for headless dashboard app windows: a small
* translucent capsule top-center of the app (parent DOM, above the app's
* iframe) carrying the head's surviving controls — app identity, minimize,
* and close. It opens expanded so first-time and deep-link users see it,
* then collapses to a subtle handle; hovering, tapping, or keyboard-focusing
* it expands it again. Timers rather than hover alone drive the collapse
* because mousemove inside the app's iframe is invisible to the parent —
* the pill itself is the only hover surface there is.
* The control drawer for headless dashboard app windows: one glass surface
* flush with the top edge of the app (parent DOM, above the app's iframe)
* that MORPHS between two shapes. At rest it's a small tongue — a grabber
* bar peeking in from the edge; opened, the same surface swells into a
* tray carrying the head's surviving controls — app identity, minimize,
* and close — while the grabber bar fades away, leaving a bare strip
* along the tray's bottom edge as the dismiss hit area (the bar
* reappears as the drawer shuts). It opens expanded so
* first-time and deep-link users see the controls, then shrinks back into
* the tongue; hovering the tongue (mouse), tapping it, or keyboard-
* focusing the drawer opens it again. Timers rather than hover alone
* drive the collapse because mousemove inside the app's iframe is
* invisible to the parent — the drawer itself is the only hover surface
* there is.
*
* The pill is a CHILD of the window element, so it shows/hides/scales with
* The drawer is a CHILD of the window element, so it shows/hides/scales with
* the window for free (minimize morphs, display:none, fullscreen requests
* from the iframe hide it via the browser's own fullscreen stacking).
*/
function attach_dashboard_app_pill (el_window, options) {
function attach_dashboard_app_drawer (el_window, options) {
const app_name = options.app;
const icon = options.icon || window.icons['app.svg'];
const title = options.title || app_name;
const $pill = $(`
<div class="dashboard-app-pill">
<button type="button" class="dashboard-app-pill-toggle" aria-expanded="true" title="App controls" aria-label="App controls">
<img class="dashboard-app-pill-icon" src="${html_encode(icon)}" alt="" draggable="false">
</button>
<span class="dashboard-app-pill-title">${html_encode(title)}</span>
<button type="button" class="dashboard-app-pill-btn dashboard-app-pill-minimize" title="Minimize to Dashboard" aria-label="Minimize to Dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button type="button" class="dashboard-app-pill-btn dashboard-app-pill-close" title="Close App" aria-label="Close App">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
// The toggle comes FIRST in the DOM so Tab reaches it before the
// controls' buttons; both layers are absolutely positioned (see
// dashboard.css), so DOM order doesn't affect the visuals.
const $drawer = $(`
<div class="dashboard-app-drawer collapsed">
<button type="button" class="dashboard-app-drawer-toggle" aria-expanded="false" title="App controls" aria-label="App controls">
<span class="dashboard-app-drawer-grabber" aria-hidden="true"></span>
</button>
<div class="dashboard-app-drawer-clip">
<div class="dashboard-app-drawer-controls">
<img class="dashboard-app-drawer-icon" src="${html_encode(icon)}" alt="" draggable="false">
<span class="dashboard-app-drawer-title">${html_encode(title)}</span>
<button type="button" class="dashboard-app-drawer-btn dashboard-app-drawer-minimize" title="Minimize to Dashboard" aria-label="Minimize to Dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button type="button" class="dashboard-app-drawer-btn dashboard-app-drawer-close" title="Close App" aria-label="Close App">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</button>
</div>
</div>
</div>
`);
const pill = $pill.get(0);
const toggle = $pill.find('.dashboard-app-pill-toggle').get(0);
const drawer = $drawer.get(0);
const toggle = $drawer.find('.dashboard-app-drawer-toggle').get(0);
let collapse_timer = null;
let opened_at = 0;
const expand = () => {
clearTimeout(collapse_timer);
pill.classList.remove('collapsed');
if ( drawer.classList.contains('collapsed') ) opened_at = Date.now();
drawer.classList.remove('collapsed');
toggle.setAttribute('aria-expanded', 'true');
};
const collapse = () => {
clearTimeout(collapse_timer);
pill.classList.add('collapsed');
drawer.classList.add('collapsed');
toggle.setAttribute('aria-expanded', 'false');
};
const schedule_collapse = (ms) => {
clearTimeout(collapse_timer);
collapse_timer = setTimeout(collapse, ms);
};
// Expand + auto-collapse: played on open and again on restore, so the
// controls introduce themselves without permanently costing pixels.
// Expand + auto-collapse: played once on open, so the controls
// introduce themselves — and retract INTO the tongue, teaching where
// they live — without permanently costing pixels.
const flash = () => {
expand();
schedule_collapse(2600);
};
// Hover keeps it open (pointer devices); leaving lets it settle. Touch
// devices never fire mouseleave, so every expansion also self-schedules
// a collapse — the mouseenter clear undoes it for real hovers.
$pill.on('mouseenter', () => expand());
$pill.on('mouseleave', () => schedule_collapse(1100));
$pill.on('focusin', () => expand());
$pill.on('focusout', () => schedule_collapse(1100));
// Hover pulls the drawer open and leaving lets it settle — mouse only:
// a touch tap synthesizes a pointerenter right before its click, which
// would make the toggle below see an already-open drawer and shut it
// again. Touch devices open by tap instead, and since they never fire
// pointerleave, that path self-schedules its collapse.
$drawer.on('pointerenter', (e) => {
if ( e.pointerType === 'mouse' ) expand();
});
$drawer.on('pointerleave', (e) => {
if ( e.pointerType === 'mouse' ) schedule_collapse(900);
});
$drawer.on('focusin', () => expand());
$drawer.on('focusout', () => schedule_collapse(1100));
// A click anywhere on the collapsed pill (incl. its invisible touch
// halo) expands it; the toggle also collapses an expanded pill for an
// explicit dismiss. Buttons are pointer-events:none while collapsed,
// so a collapsed tap can't accidentally minimize or close.
$pill.on('click', function (e) {
if ( pill.classList.contains('collapsed') ) {
e.stopPropagation();
// Pressing the drawer activates its window, as pressing a titlebar
// would — the drawer took over the head's job. Deferred a tick because
// the document-level activation handler (initgui's mousedown →
// mouseover_window) runs after this one on hover bookkeeping that only
// mousemove refreshes; a tap with no mousemove since a dashboard click
// (touch, restored windows) would re-raise the dashboard OVER the app.
$drawer.on('mousedown', () => {
setTimeout(() => $(el_window).focusWindow(), 0);
});
// The grabber is the drawer's one toggle: opens it when shut, shuts
// it when open. A click landing within a beat of the open is the
// SAME gesture that opened it (hover-then-click mice, tap) — hold
// the drawer open instead of instantly re-shutting it.
$(toggle).on('click', function (e) {
e.stopPropagation();
if ( drawer.classList.contains('collapsed') ) {
expand();
schedule_collapse(3500);
}
});
$(toggle).on('click', function (e) {
if ( ! pill.classList.contains('collapsed') ) {
e.stopPropagation();
} else if ( Date.now() - opened_at < 500 ) {
schedule_collapse(3500);
} else {
collapse();
}
});
@@ -4333,22 +4375,27 @@ function attach_dashboard_app_pill (el_window, options) {
// used: minimize consumes the app's URL entry when it owns it (the
// popstate handler then plays the minimize morph — one path with the
// browser's Back button), and close tears the window down normally.
$pill.find('.dashboard-app-pill-minimize').on('click', function (e) {
$drawer.find('.dashboard-app-drawer-minimize').on('click', function (e) {
e.stopPropagation();
collapse();
if ( pop_dashboard_app_url(app_name) ) return;
$(el_window).hideWindow();
});
$pill.find('.dashboard-app-pill-close').on('click', function (e) {
$drawer.find('.dashboard-app-drawer-close').on('click', function (e) {
e.stopPropagation();
$(el_window).close();
});
// showWindow re-plays the intro when the window is restored.
el_window._dashboard_pill_flash = flash;
// showWindow forces the drawer shut when the window is restored — the
// intro already ran on open, and a restore should bring back the app,
// not the chrome. (Minimize collapses too, but the Back-button path
// hides the window without touching the drawer.)
el_window._dashboard_drawer_collapse = collapse;
$(el_window).append($pill);
flash();
$(el_window).append($drawer);
// Two frames so the collapsed state paints first and the intro
// morphs out of the tongue instead of popping in fully open.
requestAnimationFrame(() => requestAnimationFrame(flash));
}
/**
+226 -107
View File
@@ -4213,7 +4213,7 @@ body.dashboard-mode .window-minimize-btn {
no titlebar but dashboard-mode app windows WITH a head still lose 29px to
it, so compensate for those. Headless dashboard app windows
(.window-dashboard-headless the maximized in-page apps, whose controls
live in the floating pill) get the full height. */
live in the top-edge control drawer) get the full height. */
body.dashboard-mode .window:not(.window-dashboard-headless) .window-body-app {
height: calc(100% - 29px) !important;
}
@@ -4223,105 +4223,217 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
}
/* ==========================================================================
Dashboard app pill floating controls for headless in-page app windows.
A child of the window element (so it shows/hides/scales with it), painted
above the app's iframe. Expanded it shows icon + title + minimize + close;
collapsed it shrinks to a subtle icon capsule (see
attach_dashboard_app_pill in UIWindow.js for the expand/collapse logic).
Dark translucent glass on every theme: it overlays arbitrary app content,
not the dashboard's surfaces.
Dashboard app drawer controls for headless in-page app windows. One
glass surface flush with the top edge that MORPHS between two shapes:
at rest a small tongue (a grabber bar peeking in from the edge);
opened, the same surface swells into a tray carrying icon + title +
minimize + close while the grabber bar dissolves the open tray is
clean glass, and a bare strip along its bottom edge stays behind as
the dismiss hit area; shutting condenses the bar back into the
settling tongue. Hover (mouse), tap, or keyboard focus opens it (see
attach_dashboard_app_drawer in UIWindow.js). A child of the window element (so it shows/hides/scales
with it), painted above the app's iframe. Dark translucent glass on
every theme: it overlays arbitrary app content, not the dashboard's
surfaces.
The base rules below describe the OPEN shape; .collapsed overrides to
the tongue. Each state carries its own transition, so opening gets a
soft overshoot (the tongue swells past the tray and settles) while
closing is quicker and lands without bounce.
========================================================================== */
.dashboard-app-pill {
.dashboard-app-drawer {
/* The morph's two shapes. --open-w is DERIVED from the part sizes
the controls row is laid out with the same variables, so the open
surface always fits its content exactly and breakpoints only
override parts. --safe-top keeps content below a notch in
standalone/fullscreen display modes while the surface stays flush
with the physical edge. */
--safe-top: env(safe-area-inset-top, 0px);
--icon: 20px;
--btn: 26px;
--title-w: 150px;
--i2t: 8px; /* icon → title gap */
--t2b: 6px; /* title → buttons gap */
--controls-h: 40px;
/* With no visible handle when open, the tray hugs the controls row:
--open-h leaves just enough glass below the buttons to balance the
padding above them (plus ~2px the bottom corner radius eats
optically). The bare dismiss strip fills exactly that leftover
it must never rise past the buttons' bottom edge at
(controls-h + btn)/2, because the toggle stacks OVER the controls
layer and would steal their clicks. */
--strip-h: 9px;
--open-w: calc(12px + var(--icon) + var(--i2t) + var(--title-w) + var(--t2b) + var(--btn) + 2px + var(--btn) + 10px);
--open-h: 42px;
--shut-w: 56px;
--shut-h: 16px;
position: absolute;
top: calc(env(safe-area-inset-top, 0px) + 10px);
top: 0;
left: 50%;
transform: translateX(-50%);
z-index: 500;
display: flex;
align-items: center;
gap: 2px;
padding: 4px 6px;
border-radius: 999px;
width: var(--open-w);
height: calc(var(--open-h) + var(--safe-top));
border-radius: 0 0 16px 16px;
background: rgba(24, 24, 28, 0.62);
-webkit-backdrop-filter: blur(14px) saturate(140%);
backdrop-filter: blur(14px) saturate(140%);
border: 1px solid rgba(255, 255, 255, 0.22);
border-top: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28), 0 1px 3px rgba(0, 0, 0, 0.2);
color: #fff;
-webkit-user-select: none;
user-select: none;
transition: opacity 0.18s ease, padding 0.18s ease;
transition:
width 0.45s cubic-bezier(0.34, 1.2, 0.4, 1),
height 0.45s cubic-bezier(0.34, 1.2, 0.4, 1),
border-radius 0.45s ease,
opacity 0.2s ease;
}
/* Invisible halo: keeps the collapsed capsule comfortably hittable and
hoverable without costing more visual space. (Clicks it absorbs would
have hit the app within ~10px of the pill acceptable dead zone.)
z-index -1 keeps it BELOW the pill's own content: absolutely-positioned
pseudo-elements otherwise paint above static siblings, and the halo
would swallow every real click on the buttons (element.click() works,
hit-testing doesn't). The pill is its own stacking context, so even at
-1 the halo still sits above the app's iframe for the ring outside
the capsule. */
.dashboard-app-pill::after {
content: '';
position: absolute;
inset: -10px;
border-radius: inherit;
z-index: -1;
}
/* Belt and braces for the same trap: the interactive children stack
explicitly above the halo. */
.dashboard-app-pill-toggle,
.dashboard-app-pill-btn {
position: relative;
z-index: 1;
}
.dashboard-app-pill.collapsed {
.dashboard-app-drawer.collapsed {
width: var(--shut-w);
height: calc(var(--shut-h) + var(--safe-top));
border-radius: 0 0 12px 12px;
/* Subtle but findable over both light and dark app content the
white hairline border carries the dark-on-dark case. */
opacity: 0.65;
padding: 3px 12px;
gap: 0;
opacity: 0.7;
transition:
width 0.3s cubic-bezier(0.5, 0.05, 0.25, 1),
height 0.3s cubic-bezier(0.5, 0.05, 0.25, 1),
border-radius 0.3s ease,
opacity 0.2s ease 0.08s;
}
.dashboard-app-pill.collapsed:hover {
opacity: 0.9;
/* Clipping window for the controls. The surface itself can't be
overflow:hidden that would clip the toggle's invisible touch halo
so an inner layer inherits the rounded shape and does the clipping:
mid-morph, the growing surface reveals the controls from the center
outward instead of letting them spill past the glass. */
.dashboard-app-drawer-clip {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: inherit;
pointer-events: none;
}
.dashboard-app-pill-toggle {
/* The controls row is a FIXED-size layer centered on the surface: it
never reflows during the morph, the glass just grows around it. */
.dashboard-app-drawer-controls {
position: absolute;
top: var(--safe-top);
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
width: var(--open-w);
height: var(--controls-h);
display: flex;
align-items: center;
padding: 0 10px 0 12px;
transition:
opacity 0.25s ease 0.1s,
filter 0.25s ease 0.1s,
transform 0.45s cubic-bezier(0.34, 1.2, 0.4, 1);
}
/* Shut, the controls condense out of focus fade + blur + slight
shrink and visibility (flipped AFTER the morph, hence the delay;
removing the class restores it instantly) drops the buttons out of
the Tab order and the accessibility tree. */
.dashboard-app-drawer.collapsed .dashboard-app-drawer-controls {
opacity: 0;
filter: blur(5px);
transform: translateX(-50%) scale(0.82);
visibility: hidden;
transition:
opacity 0.12s ease,
filter 0.12s ease,
transform 0.3s cubic-bezier(0.5, 0.05, 0.25, 1),
visibility 0s linear 0.3s;
}
/* The grabber toggle: fills the tongue when shut; open, it settles into
a slim strip along the tray's bottom edge same dismiss hit area,
but the bar inside it hides (below), so the open tray shows no
handle. Absolutely positioned, so its DOM position (first, for Tab
order) doesn't affect the visuals. */
.dashboard-app-drawer-toggle {
position: absolute;
inset: calc(var(--safe-top) + var(--open-h) - var(--strip-h)) 0 0 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
flex: none;
background: none;
border: none;
padding: 2px;
margin: 0;
padding: 0;
cursor: pointer;
border-radius: 50%;
transition: inset 0.45s cubic-bezier(0.34, 1.2, 0.4, 1);
}
.dashboard-app-pill-icon {
/* 20px balances the 26px buttons and the 36px capsule (16px read as
undersized); radius stays 25% of the icon for the app-icon look.
Square icons only cover guards against a non-square one. */
.dashboard-app-drawer.collapsed .dashboard-app-drawer-toggle {
inset: var(--safe-top) 0 0 0;
transition: inset 0.3s cubic-bezier(0.5, 0.05, 0.25, 1);
}
/* Invisible halo: keeps the tongue comfortably hittable and hoverable
without costing more visual space. A pseudo-element OF the button, so
hits on it are hits on the button no stacking games needed. (Clicks
it absorbs would have hit the app within ~14px of the tongue
acceptable dead zone.) */
.dashboard-app-drawer-toggle::after {
content: '';
position: absolute;
inset: 0 -14px -14px;
}
/* Open, the bar is GONE (base = open shape): it dissolves in the first
beat of the swell stretching a touch as it goes, absorbed into the
widening glass before the controls bloom at 0.1s, one handing off
to the other. On collapse it condenses back in: the delay below skips
the controls' 0.12s fade-out so the bar never materializes mid-tray,
then it fades up while riding the shrinking toggle home, landing with
the tongue (0.14s + 0.18s the 0.3s shut morph). */
.dashboard-app-drawer-grabber {
width: 24px;
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.6);
opacity: 0;
transition: width 0.3s ease, background-color 0.15s ease, opacity 0.12s ease;
}
.dashboard-app-drawer.collapsed .dashboard-app-drawer-grabber {
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.78);
opacity: 1;
transition: width 0.3s ease, background-color 0.15s ease, opacity 0.18s ease 0.14s;
}
@media (hover: hover) {
.dashboard-app-drawer-toggle:hover .dashboard-app-drawer-grabber {
background: rgba(255, 255, 255, 0.95);
}
}
.dashboard-app-drawer-icon {
/* Sized to balance the buttons and the tray; radius stays 25% of the
icon for the app-icon look. Square icons only cover guards
against a non-square one. */
width: var(--icon);
height: var(--icon);
border-radius: 5px;
object-fit: cover;
display: block;
transition: width 0.18s ease, height 0.18s ease;
flex: none;
margin-right: var(--i2t);
}
.dashboard-app-pill.collapsed .dashboard-app-pill-icon {
width: 12px;
height: 12px;
}
.dashboard-app-pill-title {
.dashboard-app-drawer-title {
font-size: 12px;
font-weight: 500;
/* Roomy enough for descenders: overflow:hidden (needed for the
@@ -4332,26 +4444,25 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* FIXED title box (not a cap): every app's pill is the same width, so
/* FIXED title box (not a cap): every app's tray is the same width, so
the minimize/close buttons sit at the same screen position in every
app they're operated by muscle memory. Short titles center in
the box like a classic titlebar. The collapse animation still
drives max-width to 0. */
app they're operated by muscle memory. Left-aligned against the
icon: the tray is asymmetric (one icon left, two buttons right), so
a centered title lands off the tray's true center and reads as
misaligned against the centered tongue below. Icon + title cluster
as the identity on the left, controls cluster on the right. */
flex: none;
width: 150px;
max-width: 150px;
text-align: center;
margin: 0 6px 0 4px;
transition: max-width 0.22s ease, opacity 0.15s ease, margin 0.22s ease;
width: var(--title-w);
text-align: left;
margin-right: var(--t2b);
}
.dashboard-app-pill-btn {
.dashboard-app-drawer-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
max-width: 26px;
width: var(--btn);
height: var(--btn);
flex: none;
background: none;
border: none;
@@ -4360,73 +4471,81 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
margin: 0;
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
overflow: hidden;
transition: max-width 0.22s ease, opacity 0.15s ease, background-color 0.12s ease;
/* The clip layer is pointer-events:none so stray clicks fall through
to the surface; the buttons opt back in. */
pointer-events: auto;
transition: background-color 0.12s ease;
}
.dashboard-app-pill-btn svg {
.dashboard-app-drawer-minimize {
margin-right: 2px;
}
.dashboard-app-drawer-btn svg {
width: 14px;
height: 14px;
flex: none;
}
@media (hover: hover) {
.dashboard-app-pill-btn:hover {
.dashboard-app-drawer-btn:hover {
background-color: rgba(255, 255, 255, 0.16);
}
}
.dashboard-app-pill-btn:active {
.dashboard-app-drawer-btn:active {
background-color: rgba(255, 255, 255, 0.26);
}
.dashboard-app-pill.collapsed .dashboard-app-pill-title,
.dashboard-app-pill.collapsed .dashboard-app-pill-btn {
max-width: 0;
opacity: 0;
margin: 0;
pointer-events: none;
}
.dashboard-app-pill-toggle:focus-visible,
.dashboard-app-pill-btn:focus-visible {
.dashboard-app-drawer-toggle:focus-visible,
.dashboard-app-drawer-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.9);
outline-offset: 1px;
}
/* Touch: bigger targets (32px buttons + halo 44px effective), and the
collapsed capsule keeps a generous tap zone via the halo. */
/* Touch: a taller tray with 36px buttons, and a bigger tongue whose halo
brings the effective target to ~44px. */
@media (pointer: coarse) {
.dashboard-app-pill {
padding: 6px 8px;
.dashboard-app-drawer {
--btn: 36px;
--controls-h: 48px;
--strip-h: 8px;
--open-h: 50px;
--shut-w: 68px;
--shut-h: 20px;
}
.dashboard-app-pill.collapsed {
padding: 5px 14px;
.dashboard-app-drawer-grabber {
width: 28px;
height: 4px;
}
.dashboard-app-pill-btn {
width: 32px;
height: 32px;
max-width: 32px;
.dashboard-app-drawer.collapsed .dashboard-app-drawer-grabber {
width: 24px;
}
.dashboard-app-pill::after {
inset: -12px;
.dashboard-app-drawer-toggle::after {
inset: 0 -16px -18px;
}
}
/* Tight screens: identity shrinks to the icon; the controls keep their
size they're the part that must stay usable. */
size they're the part that must stay usable. Zeroing the title
variables shrinks --open-w to match. */
@media (max-width: 500px) {
.dashboard-app-pill-title {
.dashboard-app-drawer {
--title-w: 0px;
--t2b: 0px;
}
.dashboard-app-drawer-title {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.dashboard-app-pill,
.dashboard-app-pill * {
.dashboard-app-drawer,
.dashboard-app-drawer * {
transition: none !important;
}
}