Dashboard: headless full-tab apps with a floating control pill

Maximized app windows in dashboard mode no longer render a titlebar —
the app covers the full tab. The head's controls move to lighter-weight
affordances:

- A floating pill overlaid top-center of the app (parent DOM, above the
  iframe): expanded it shows the app icon, title, minimize, and close;
  it opens expanded so first-time and deep-link users see it, then
  collapses to a subtle capsule that re-expands on hover, tap, or
  keyboard focus. Minimize routes through the same URL-entry consumption
  as the Back button; close tears the window down normally. Dark
  translucent glass so it reads over any app content; safe-area aware;
  bigger targets on coarse pointers; title hidden under 500px; respects
  prefers-reduced-motion.
- Apps-tab tiles double as the app switcher: a macOS-dock-style running
  dot marks tiles with a live (visible or minimized) window, and the
  tile context menu gains a Quit item for running apps. UIWindow
  broadcasts window open/close so the dots stay current.
- puter.ui.setWindowTitle also updates the pill title.

Only maximized, URL-owning app windows in dashboard mode go headless:
dialogs, explorer, non-maximized child windows, and everything on the
desktop keep their titlebars. dashboard.css's 29px head-height
compensation is scoped to windows that still have heads, so headless
app bodies fill the window exactly.
This commit is contained in:
jelveh
2026-07-22 11:19:51 -07:00
parent d5e9417d2d
commit 2dbd7f62a5
4 changed files with 392 additions and 5 deletions
+3 -1
View File
@@ -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,
+34
View File
@@ -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;
+150 -1
View File
@@ -233,7 +233,30 @@ async function UIWindow (options) {
options.is_visible = false;
}
h += `<div class="window window-active
// --------------------------------------------------------
// Dashboard app chrome
// --------------------------------------------------------
// 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
// 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
// windows) — a headless floating window would have no drag handle.
const is_dashboard_app_chrome = !! (window.is_dashboard_mode
&& options.app && options.app !== 'explorer'
&& options.update_window_url
&& options.is_maximized && options.is_visible
&& (options.iframe_url || options.iframe_srcdoc));
if ( is_dashboard_app_chrome ) {
options.has_head = false;
// The class exempts this window from dashboard.css's 29px
// head-height compensation on .window-body-app.
options.window_class = `${options.window_class ?? ''} window-dashboard-headless`;
}
h += `<div class="window window-active
${options.app === 'explorer' ? 'window-explorer' : ''}
${options.cover_page ? 'window-cover-page' : ''}
${options.uid !== undefined ? `window-${options.uid}` : ''}
@@ -666,6 +689,17 @@ async function UIWindow (options) {
push_dashboard_app_url(options.app, options.title);
}
// Headless dashboard app windows get the floating control pill in
// place of the head (see the Dashboard app chrome section above).
if ( is_dashboard_app_chrome ) {
attach_dashboard_app_pill(el_window, options);
}
// Let the Apps tab refresh its tiles' running dots (see TabApps).
if ( window.is_dashboard_mode && options.app ) {
document.dispatchEvent(new CustomEvent('dashboard-app-windows-changed'));
}
// A launch from a dashboard Apps-tab tile (TabApps passes
// morph_from_dashboard_tile) morphs the tile's icon into the opening
// window — the reverse of hideWindow's minimize morph. The window is
@@ -2825,6 +2859,7 @@ function delete_window_element (el_window) {
if ( window.active_element === el_window ) {
window.active_element = null;
}
const was_app = $(el_window).attr('data-app');
// remove DOM element
$(el_window).remove();
// if no other windows open, reset window_counter
@@ -2833,6 +2868,11 @@ function delete_window_element (el_window) {
{
window.window_counter = 0;
}
// Every close path funnels through here — after the element is gone,
// let the Apps tab turn the app tile's running dot off (see TabApps).
if ( window.is_dashboard_mode && was_app ) {
document.dispatchEvent(new CustomEvent('dashboard-app-windows-changed'));
}
}
$(document).on('click', '.window-sidebar-item', async function (e) {
@@ -3906,6 +3946,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
// dashboard windows only; no-op otherwise).
el_window._dashboard_pill_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).
@@ -4188,6 +4231,112 @@ 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 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 = $(`
<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>
</button>
</div>
`);
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
+205 -3
View File
@@ -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);
}