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.
This commit is contained in:
jelveh
2026-07-22 21:23:06 -07:00
parent c754a60742
commit 25b0e377e8
2 changed files with 213 additions and 149 deletions
+40 -35
View File
@@ -4257,16 +4257,19 @@ window.addEventListener('popstate', () => {
});
/**
* The control drawer for headless dashboard app windows: a slim glass tray
* 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)
* carrying the head's surviving controls — app identity, minimize, and
* close — with a tongue-shaped handle hanging from its bottom center like a
* drawer pull. It opens expanded so first-time and deep-link users see the
* controls, then the tray retracts off the top edge leaving only the tongue
* peeking in; hovering the tongue (mouse), tapping it, or keyboard-focusing
* the drawer pulls it back down. 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.
* 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 settles into a slim strip along the
* tray's bottom edge as the dismiss handle. 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 drawer is a CHILD of the window element, so it shows/hides/scales with
* the window for free (minimize morphs, display:none, fullscreen requests
@@ -4277,28 +4280,30 @@ function attach_dashboard_app_drawer (el_window, options) {
const icon = options.icon || window.icons['app.svg'];
const title = options.title || app_name;
// The handle comes FIRST in the DOM so Tab reaches the toggle before
// the tray's buttons; flex column-reverse (see dashboard.css) puts it
// back below the tray visually.
// 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-handle" aria-expanded="false" title="App controls" aria-label="App controls">
<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-tray">
<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 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 drawer = $drawer.get(0);
const handle = $drawer.find('.dashboard-app-drawer-handle').get(0);
const toggle = $drawer.find('.dashboard-app-drawer-toggle').get(0);
let collapse_timer = null;
let opened_at = 0;
@@ -4306,12 +4311,12 @@ function attach_dashboard_app_drawer (el_window, options) {
clearTimeout(collapse_timer);
if ( drawer.classList.contains('collapsed') ) opened_at = Date.now();
drawer.classList.remove('collapsed');
handle.setAttribute('aria-expanded', 'true');
toggle.setAttribute('aria-expanded', 'true');
};
const collapse = () => {
clearTimeout(collapse_timer);
drawer.classList.add('collapsed');
handle.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-expanded', 'false');
};
const schedule_collapse = (ms) => {
clearTimeout(collapse_timer);
@@ -4327,9 +4332,9 @@ function attach_dashboard_app_drawer (el_window, options) {
// 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 handle's 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.
// 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();
});
@@ -4349,11 +4354,11 @@ function attach_dashboard_app_drawer (el_window, options) {
setTimeout(() => $(el_window).focusWindow(), 0);
});
// The tongue is the drawer's one toggle: pulls it open when shut,
// pushes it shut 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.
$(handle).on('click', function (e) {
// 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();
@@ -4387,8 +4392,8 @@ function attach_dashboard_app_drawer (el_window, options) {
el_window._dashboard_drawer_collapse = collapse;
$(el_window).append($drawer);
// Two frames so the collapsed state paints first and the intro slides
// in from the top edge instead of popping in fully open.
// 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));
}
+173 -114
View File
@@ -4223,98 +4223,152 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
}
/* ==========================================================================
Dashboard app drawer controls for headless in-page app windows. A slim
glass tray flush with the top edge (icon + title + minimize + close) with
a tongue-shaped handle hanging from its bottom center, like a drawer
pull. Collapsed, the tray slides up off-screen and only the tongue peeks
in from the top edge; hover (mouse), tap, or keyboard focus pulls it
back down (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.
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, the grabber settling into a slim strip along the
tray's bottom edge as the dismiss handle. 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-drawer {
/* --tray-h is both the tray's height and the collapse travel the
retracted transform below must hide exactly the tray. --safe-top
keeps the tray's contents below a notch in standalone/fullscreen
display modes while the drawer stays flush with the physical edge. */
--tray-h: 40px;
/* 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;
--strip-h: 14px; /* grabber strip along the open bottom edge */
--open-w: calc(12px + var(--icon) + var(--i2t) + var(--title-w) + var(--t2b) + var(--btn) + 2px + var(--btn) + 10px);
--open-h: 47px;
--shut-w: 56px;
--shut-h: 16px;
position: absolute;
top: 0;
left: 50%;
z-index: 500;
display: flex;
/* Handle first in the DOM (Tab reaches the toggle before the tray's
buttons) but drawn below the tray. */
flex-direction: column-reverse;
align-items: center;
transform: translateX(-50%);
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
color: #fff;
-webkit-user-select: none;
user-select: none;
/* The wrapper is layout-only it must never eat clicks meant for the
app. The tray and handle opt back in themselves. */
pointer-events: none;
}
.dashboard-app-drawer.collapsed {
transform: translateX(-50%)
translateY(calc(-1 * (var(--tray-h) + var(--safe-top))));
}
.dashboard-app-drawer-tray {
display: flex;
align-items: center;
gap: 2px;
box-sizing: border-box;
height: calc(var(--tray-h) + var(--safe-top));
padding: var(--safe-top) 8px 0 10px;
border-radius: 0 0 14px 14px;
z-index: 500;
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);
pointer-events: auto;
color: #fff;
-webkit-user-select: none;
user-select: none;
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;
}
/* Retracted, the tray sits above the viewport visibility (flipped AFTER
the slide finishes, hence the transition-delay; removing the class
restores it instantly) additionally drops its buttons out of the Tab
order and the accessibility tree. */
.dashboard-app-drawer.collapsed .dashboard-app-drawer-tray {
visibility: hidden;
transition: visibility 0s linear 0.3s;
.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.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;
}
/* 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;
}
/* The tongue. No top border and a -1px overlap fuse it with the tray's
bottom edge into one drawer-pull silhouette; collapsed, the same missing
top border makes it read as emerging from the screen edge itself. */
.dashboard-app-drawer-handle {
position: relative;
/* 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 the same handle, ridden
through the morph. 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;
width: 56px;
height: 17px;
margin: -1px 0 0;
background: none;
border: none;
margin: 0;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.22);
border-top: none;
border-radius: 0 0 12px 12px;
background: rgba(24, 24, 28, 0.62);
-webkit-backdrop-filter: blur(14px) saturate(140%);
backdrop-filter: blur(14px) saturate(140%);
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.22);
cursor: pointer;
pointer-events: auto;
transition: opacity 0.18s ease;
transition: inset 0.45s cubic-bezier(0.34, 1.2, 0.4, 1);
}
.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
@@ -4322,41 +4376,42 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
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-handle::after {
.dashboard-app-drawer-toggle::after {
content: '';
position: absolute;
inset: 0 -14px -14px;
}
/* Subtle but findable over both light and dark app content the white
hairline border carries the dark-on-dark case. */
.dashboard-app-drawer.collapsed .dashboard-app-drawer-handle {
opacity: 0.7;
}
@media (hover: hover) {
.dashboard-app-drawer.collapsed .dashboard-app-drawer-handle:hover {
opacity: 1;
}
}
.dashboard-app-drawer-grabber {
width: 20px;
width: 24px;
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.6);
transition: width 0.3s ease, background-color 0.15s ease;
}
.dashboard-app-drawer.collapsed .dashboard-app-drawer-grabber {
width: 20px;
background: rgba(255, 255, 255, 0.78);
}
@media (hover: hover) {
.dashboard-app-drawer-toggle:hover .dashboard-app-drawer-grabber {
background: rgba(255, 255, 255, 0.95);
}
}
.dashboard-app-drawer-icon {
/* 20px balances the 26px buttons and the 40px tray; radius stays 25%
of the icon for the app-icon look. Square icons only cover guards
/* 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: 20px;
height: 20px;
width: var(--icon);
height: var(--icon);
border-radius: 5px;
object-fit: cover;
display: block;
flex: none;
margin-right: var(--i2t);
}
.dashboard-app-drawer-title {
@@ -4378,17 +4433,17 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
misaligned against the centered tongue below. Icon + title cluster
as the identity on the left, controls cluster on the right. */
flex: none;
width: 150px;
width: var(--title-w);
text-align: left;
margin: 0 6px 0 8px;
margin-right: var(--t2b);
}
.dashboard-app-drawer-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
width: var(--btn);
height: var(--btn);
flex: none;
background: none;
border: none;
@@ -4397,9 +4452,16 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
margin: 0;
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
/* 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-drawer-minimize {
margin-right: 2px;
}
.dashboard-app-drawer-btn svg {
width: 14px;
height: 14px;
@@ -4416,7 +4478,7 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
background-color: rgba(255, 255, 255, 0.26);
}
.dashboard-app-drawer-handle: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;
@@ -4426,42 +4488,39 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
brings the effective target to ~44px. */
@media (pointer: coarse) {
.dashboard-app-drawer {
--tray-h: 48px;
}
.dashboard-app-drawer-tray {
padding: var(--safe-top) 10px 0 12px;
}
.dashboard-app-drawer-btn {
width: 36px;
height: 36px;
}
.dashboard-app-drawer-handle {
width: 68px;
height: 21px;
--btn: 36px;
--controls-h: 48px;
--strip-h: 16px;
--open-h: 58px;
--shut-w: 68px;
--shut-h: 20px;
}
.dashboard-app-drawer-grabber {
width: 26px;
width: 28px;
height: 4px;
}
.dashboard-app-drawer-handle::after {
inset: 0 -16px -20px;
.dashboard-app-drawer.collapsed .dashboard-app-drawer-grabber {
width: 24px;
}
.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-drawer-title {
display: none;
.dashboard-app-drawer {
--title-w: 0px;
--t2b: 0px;
}
.dashboard-app-drawer-icon {
margin-right: 6px;
.dashboard-app-drawer-title {
display: none;
}
}