mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-25 15:37:12 +00:00
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:
+3
-1
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user