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);
+}