diff --git a/src/gui/src/IPC.js b/src/gui/src/IPC.js index 685451f4d..bd682f1a0 100644 --- a/src/gui/src/IPC.js +++ b/src/gui/src/IPC.js @@ -614,8 +614,10 @@ const ipc_listener = async (event, handled) => { return; } - // set window title + // set window title (headless dashboard windows show it in the + // floating control pill 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); // send confirmation to requester window target_iframe.contentWindow.postMessage({ original_msg_id: msg_id, diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index 090dfe512..2b9020c2e 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -339,6 +339,13 @@ const TabApps = { const self = this; + // Tiles double as the app switcher for headless in-page apps: a + // dot marks tiles whose app has an open (or minimized) window. + // UIWindow fires this event on every window open/close. + document.addEventListener('dashboard-app-windows-changed', () => { + self.updateRunningDots($el_window); + }); + $el_window.on('input', '.myapps-search', function () { self.updateSearchIcons($el_window); self.renderApps($el_window); @@ -439,6 +446,7 @@ const TabApps = { const appUid = $(this).attr('data-app-uid'); const targetLink = $(this).attr('data-target-link'); const noUninstall = APP_NAMES_NO_UNINSTALL.has((appName || '').toLowerCase()); + const isRunning = !! appName && $(`.window[data-app="${html_encode(appName)}"]`).length > 0; // Every app opens in a new browser tab the way tiles did before // in-page windows: external tiles via their site link, everything @@ -455,6 +463,20 @@ const TabApps = { }, }, ]; + // The tile doubles as the app switcher (headless apps have no + // titlebar): a running app — the tile shows its dot — can be + // quit from here without entering it. Closing consumes the + // app's URL entry only if it owns the URL (it doesn't, here on + // the dashboard), and the running dot clears via the + // dashboard-app-windows-changed event once the window is gone. + if ( isRunning ) { + items.push({ + html: 'Quit', + onClick: () => { + $(`.window[data-app="${html_encode(appName)}"]`).close(); + }, + }); + } if ( ! noUninstall ) { items.push('-', { html: 'Uninstall', @@ -685,6 +707,7 @@ const TabApps = { $container.html(buildPagerHtml(list, layout, instant)); revealWhenLoaded($container); + this.updateRunningDots($el_window); const scroller = $container.find('.myapps-pager-scroller')[0]; if ( this._page > 0 ) { @@ -711,6 +734,17 @@ const TabApps = { }, { passive: true }); }, + // Mark tiles whose app has a live window (visible OR minimized — both + // are running) with the macOS-dock-style dot. Cheap enough to re-run + // wholesale on every open/close/render. + updateRunningDots ($el_window) { + for ( const tile of $el_window.find('.myapps-tile').toArray() ) { + const name = tile.getAttribute('data-app-name'); + const running = !! name && $(`.window[data-app="${html_encode(name)}"]`).length > 0; + tile.classList.toggle('myapps-tile-running', running); + } + }, + updatePagerUI ($el_window) { const $container = $el_window.find('.myapps-container'); const page = this._page; diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js index 439a62372..d90abdb13 100644 --- a/src/gui/src/UI/UIWindow.js +++ b/src/gui/src/UI/UIWindow.js @@ -233,7 +233,30 @@ async function UIWindow (options) { options.is_visible = false; } - h += `
{ } }); +/** + * 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 pill 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) { + const app_name = options.app; + const icon = options.icon || window.icons['app.svg']; + const title = options.title || app_name; + + const $pill = $(` +
+ + ${html_encode(title)} + + +
+ `); + const pill = $pill.get(0); + const toggle = $pill.find('.dashboard-app-pill-toggle').get(0); + + let collapse_timer = null; + const expand = () => { + clearTimeout(collapse_timer); + pill.classList.remove('collapsed'); + toggle.setAttribute('aria-expanded', 'true'); + }; + const collapse = () => { + clearTimeout(collapse_timer); + pill.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. + 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)); + + // 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(); + expand(); + schedule_collapse(3500); + } + }); + $(toggle).on('click', function (e) { + if ( ! pill.classList.contains('collapsed') ) { + e.stopPropagation(); + collapse(); + } + }); + + // The head's controls, rerouted through the same code paths the head + // 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) { + e.stopPropagation(); + collapse(); + if ( pop_dashboard_app_url(app_name) ) return; + $(el_window).hideWindow(); + }); + $pill.find('.dashboard-app-pill-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).append($pill); + flash(); +} + /** * Tiles whose click feedback has played (or is playing) for the launch * currently in flight. A fresh app launch has a server round-trip between diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index fca9695f6..249e9dddb 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -4206,8 +4206,210 @@ body.dashboard-mode .window-minimize-btn { } /* Fullpage mode sizes .window-body-app to 100% because fullpage windows have - no titlebar — but dashboard-mode app windows keep their 29px head, so - without this the bottom of every app is clipped by that much. */ -body.dashboard-mode .window-body-app { + 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. */ +body.dashboard-mode .window:not(.window-dashboard-headless) .window-body-app { height: calc(100% - 29px) !important; } + +body.dashboard-mode .window-dashboard-headless .window-body-app { + height: 100% !important; +} + +/* ========================================================================== + 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-pill { + position: absolute; + top: calc(env(safe-area-inset-top, 0px) + 10px); + left: 50%; + transform: translateX(-50%); + z-index: 500; + display: flex; + align-items: center; + gap: 2px; + padding: 4px 6px; + border-radius: 999px; + 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.16); + 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; +} + +/* 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.) */ +.dashboard-app-pill::after { + content: ''; + position: absolute; + inset: -10px; + border-radius: inherit; +} + +.dashboard-app-pill.collapsed { + opacity: 0.55; + 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; + cursor: pointer; + border-radius: 50%; +} + +.dashboard-app-pill-icon { + width: 16px; + height: 16px; + border-radius: 4px; + display: block; + transition: width 0.18s ease, height 0.18s ease; +} + +.dashboard-app-pill.collapsed .dashboard-app-pill-icon { + width: 12px; + height: 12px; +} + +.dashboard-app-pill-title { + font-size: 12px; + font-weight: 500; + line-height: 1; + color: rgba(255, 255, 255, 0.92); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 168px; + margin: 0 6px 0 4px; + transition: max-width 0.22s ease, opacity 0.15s ease, margin 0.22s ease; +} + +.dashboard-app-pill-btn { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + max-width: 26px; + flex: none; + background: none; + border: none; + border-radius: 50%; + padding: 0; + 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; +} + +.dashboard-app-pill-btn svg { + width: 14px; + height: 14px; + flex: none; +} + +@media (hover: hover) { + .dashboard-app-pill-btn:hover { + background-color: rgba(255, 255, 255, 0.16); + } +} + +.dashboard-app-pill-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 { + 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. */ +@media (pointer: coarse) { + .dashboard-app-pill { + padding: 6px 8px; + } + + .dashboard-app-pill.collapsed { + padding: 5px 14px; + } + + .dashboard-app-pill-btn { + width: 32px; + height: 32px; + max-width: 32px; + } + + .dashboard-app-pill::after { + inset: -12px; + } +} + +/* 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 { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .dashboard-app-pill, + .dashboard-app-pill * { + transition: none !important; + } +} + +/* Running dot: marks Apps-tab tiles whose app has a live window (visible or + minimized), macOS-dock style — sits centered in the gap between the 56px + icon and the label. Kept/removed by updateRunningDots in TabApps.js. */ +.myapps-tile { + position: relative; +} + +.myapps-tile-running::after { + content: ''; + position: absolute; + top: 58px; + left: 50%; + transform: translateX(-50%); + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--dashboard-text-tertiary, #71717a); +}