From 33963ef8bda4ad38486cece23cfda77f6ebb592a Mon Sep 17 00:00:00 2001 From: jelveh Date: Wed, 22 Jul 2026 21:00:40 -0700 Subject: [PATCH] Dashboard: redesign app control pill as a top-edge drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/gui/src/IPC.js | 4 +- src/gui/src/UI/UIWindow.js | 157 +++++++++++++-------- src/gui/src/css/dashboard.css | 248 ++++++++++++++++++++-------------- 3 files changed, 242 insertions(+), 167 deletions(-) diff --git a/src/gui/src/IPC.js b/src/gui/src/IPC.js index 4a7dce974..fd0fff1aa 100644 --- a/src/gui/src/IPC.js +++ b/src/gui/src/IPC.js @@ -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 diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js index 6573b7df5..47eec92f6 100644 --- a/src/gui/src/UI/UIWindow.js +++ b/src/gui/src/UI/UIWindow.js @@ -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,9 @@ $.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 + // Re-introduce the control drawer on restore (headless // dashboard windows only; no-op otherwise). - el_window._dashboard_pill_flash?.(); + el_window._dashboard_drawer_flash?.(); // 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 +3980,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 +4256,110 @@ 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: a slim glass tray + * 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. * - * 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 = $(` -
- - ${html_encode(title)} - - +
+ + ${html_encode(title)} + + +
`); - const pill = $pill.get(0); - const toggle = $pill.find('.dashboard-app-pill-toggle').get(0); + const drawer = $drawer.get(0); + const handle = $drawer.find('.dashboard-app-drawer-handle').get(0); let collapse_timer = null; + let opened_at = 0; const expand = () => { clearTimeout(collapse_timer); - pill.classList.remove('collapsed'); - toggle.setAttribute('aria-expanded', 'true'); + if ( drawer.classList.contains('collapsed') ) opened_at = Date.now(); + drawer.classList.remove('collapsed'); + handle.setAttribute('aria-expanded', 'true'); }; const collapse = () => { clearTimeout(collapse_timer); - pill.classList.add('collapsed'); - toggle.setAttribute('aria-expanded', 'false'); + drawer.classList.add('collapsed'); + handle.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. + // 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 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. + $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 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) { + 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 +4368,24 @@ 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; + el_window._dashboard_drawer_flash = flash; - $(el_window).append($pill); - flash(); + $(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. + requestAnimationFrame(() => requestAnimationFrame(flash)); } /** diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index 92337448c..06906fc28 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -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,143 @@ 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. 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-pill { +.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; + --safe-top: env(safe-area-inset-top, 0px); position: absolute; - top: calc(env(safe-area-inset-top, 0px) + 10px); + top: 0; left: 50%; - transform: translateX(-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; - padding: 4px 6px; - border-radius: 999px; + 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; 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; + pointer-events: auto; } -/* 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; +/* 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; + pointer-events: none; } -/* Belt and braces for the same trap: the interactive children stack - explicitly above the halo. */ -.dashboard-app-pill-toggle, -.dashboard-app-pill-btn { +/* 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; - z-index: 1; -} - -.dashboard-app-pill.collapsed { - /* 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; -} - -.dashboard-app-pill.collapsed:hover { - opacity: 0.9; -} - -.dashboard-app-pill-toggle { display: flex; align-items: center; justify-content: center; flex: none; - background: none; - border: none; - padding: 2px; - margin: 0; + width: 56px; + height: 17px; + margin: -1px 0 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; - border-radius: 50%; + pointer-events: auto; + transition: opacity 0.18s ease; } -.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. */ +/* 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-handle::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; + height: 3px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.78); +} + +.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 + against a non-square one. */ width: 20px; height: 20px; border-radius: 5px; object-fit: cover; display: block; - transition: width 0.18s ease, height 0.18s ease; + flex: none; } -.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 +4370,22 @@ 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. */ + the box like a classic titlebar. */ 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; } -.dashboard-app-pill-btn { +.dashboard-app-drawer-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; - max-width: 26px; flex: none; background: none; border: none; @@ -4360,73 +4394,77 @@ 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; + transition: background-color 0.12s ease; } -.dashboard-app-pill-btn svg { +.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-handle: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 { + --tray-h: 48px; } - .dashboard-app-pill.collapsed { - padding: 5px 14px; + .dashboard-app-drawer-tray { + padding: var(--safe-top) 10px 0 12px; } - .dashboard-app-pill-btn { - width: 32px; - height: 32px; - max-width: 32px; + .dashboard-app-drawer-btn { + width: 36px; + height: 36px; } - .dashboard-app-pill::after { - inset: -12px; + .dashboard-app-drawer-handle { + width: 68px; + height: 21px; + } + + .dashboard-app-drawer-grabber { + width: 26px; + height: 4px; + } + + .dashboard-app-drawer-handle::after { + inset: 0 -16px -20px; } } /* Tight screens: identity shrinks to the icon; the controls keep their size — they're the part that must stay usable. */ @media (max-width: 500px) { - .dashboard-app-pill-title { + .dashboard-app-drawer-title { display: none; } + + .dashboard-app-drawer-icon { + margin-right: 6px; + } } @media (prefers-reduced-motion: reduce) { - .dashboard-app-pill, - .dashboard-app-pill * { + .dashboard-app-drawer, + .dashboard-app-drawer * { transition: none !important; } }