diff --git a/ui/system/desktop/dock.css.orig b/ui/system/desktop/dock.css.orig deleted file mode 100644 index ada3de6..0000000 --- a/ui/system/desktop/dock.css.orig +++ /dev/null @@ -1,452 +0,0 @@ -/* SPDX-License-Identifier: AGPL-3.0-or-later */ - -/* Dock: bottom-anchored organic shell with three logical - zones (pinned apps / desks+views / tray) separated by thin serifs. - Asymmetric radii + soft warm shadow + theme-color halo match the - Pebble's vocabulary. */ - -.dock { - position: fixed; - left: 50%; - bottom: var(--dock-bottom); - transform: translateX(-50%); - display: flex; - align-items: center; - gap: 6px; - padding: var(--dock-padding); - height: var(--dock-height); - z-index: 10; - border-radius: 22px 24px 22px 23px; - background: var(--bg-menu); - box-shadow: - inset 0 1px 0 oklch(99% 0.005 105 / 0.7), - var(--workshop-shadow); - /* Theme-color halo, refreshed live from the active view. */ - filter: drop-shadow(0 4px 16px var(--workshop-theme-tint)); - animation: dock-breath 5.5s ease-in-out infinite; - transition: - transform var(--workshop-morph-duration) var(--workshop-easing), - opacity 0.28s var(--workshop-easing), - filter 0.6s var(--workshop-easing); - max-width: calc(100vw - 28px); -} - -@keyframes dock-breath { - 0%, - 100% { - box-shadow: - inset 0 1px 0 oklch(99% 0.005 105 / 0.7), - var(--workshop-shadow); - border-radius: 22px 24px 22px 23px; - } - 50% { - box-shadow: - inset 0 1px 0 oklch(99% 0.005 105 / 0.85), - var(--workshop-shadow-hi); - border-radius: 24px 22px 23px 22px; - } -} - -/* Mod+. translates the dock down rather than fading */ -body[data-dock-hidden] .dock { - transform: translateX(-50%) - translateY(calc(var(--dock-height) + var(--dock-bottom) + 12px)); - opacity: 0; - pointer-events: none; -} - -.dock-divider { - width: 1px; - height: var(--dock-divider-h); - background: oklch(35% 0.03 105 / 0.18); - flex-shrink: 0; - margin: 0 3px; -} - -.dock-zone { - display: flex; - align-items: center; - gap: var(--dock-zone-gap); -} - -.dock-views { - /* Allow this zone to compress on narrow widths. Many views → scroll - horizontally; the active chip auto-scrolls into view via JS. */ - min-width: 0; - flex-shrink: 1; - max-width: 38vw; - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: none; - scroll-behavior: smooth; -} -.dock-views::-webkit-scrollbar { - display: none; -} - -/* ----- Pinned apps ----- */ - -.dock-pin { - width: var(--dock-pin-size); - height: var(--dock-pin-size); - display: inline-flex; - align-items: center; - justify-content: center; - background: transparent; - border: none; - border-radius: var(--dock-pin-radius); - color: var(--color-text-secondary); - cursor: pointer; - font-size: var(--dock-pin-fs); - transition: - background var(--transition-fast), - color var(--transition-fast), - transform var(--transition-fast); -} - -.dock-pin:hover { - background: var(--bg-hover); - color: var(--color-text); - transform: translateY(-2px); -} - -.dock-pin:active { - transform: translateY(0) scale(0.94); -} - -.dock-pin.flash { - animation: pin-flash 0.45s var(--workshop-bounce); -} -@keyframes pin-flash { - 0% { - background: var(--bg-hover); - } - 50% { - background: color-mix(in oklab, var(--color-primary) 32%, var(--bg-menu)); - color: var(--color-text); - transform: translateY(-3px) scale(1.04); - } - 100% { - background: transparent; - } -} - -/* ----- Desks (center-left) ----- */ - -.dock-desk { - display: inline-flex; - align-items: center; - gap: 7px; - padding: 6px 11px 6px 9px; - background: transparent; - border: 1px solid transparent; - border-radius: 11px 12px 11px 12px; - color: var(--color-text-secondary); - font-family: inherit; - font-size: 12px; - cursor: pointer; - transition: - background var(--transition-fast), - color var(--transition-fast), - border-color var(--transition-fast), - transform var(--transition-fast); -} - -.dock-desk:hover { - background: var(--bg-hover); - color: var(--color-text); -} - -.dock-desk-color { - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--desk-color, oklch(48% 0.12 155)); - /* Subtle ring at idle hints desks are "lit" by their tint. */ - box-shadow: - 0 0 0 1px oklch(99% 0.005 105 / 0.5), - 0 0 6px - color-mix( - in oklab, - var(--desk-color, oklch(48% 0.12 155)) 60%, - transparent - ); - flex-shrink: 0; -} - -.dock-desk[data-active] { - background: color-mix(in oklab, var(--desk-color) 12%, var(--bg-menu)); - border-color: color-mix(in oklab, var(--desk-color) 32%, transparent); - color: var(--color-text); -} - -.dock-desk[data-active] .dock-desk-color { - box-shadow: - 0 0 0 2px color-mix(in oklab, var(--desk-color) 22%, var(--bg-menu)), - 0 0 12px color-mix(in oklab, var(--desk-color) 75%, transparent); -} - -/* ----- Open views in current desk (center-right) ----- */ - -.dock-view { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 9px; - background: transparent; - border: 1px solid transparent; - border-radius: 11px 12px 11px 12px; - color: var(--color-text-secondary); - font-family: inherit; - font-size: 12px; - cursor: pointer; - max-width: 160px; - transition: - background var(--transition-fast), - border-color var(--transition-fast), - color var(--transition-fast); -} - -.dock-view:hover { - background: var(--bg-hover); - color: var(--color-text); -} - -.dock-view.active { - background: var(--bg-surface); - border-color: color-mix( - in oklab, - var(--workshop-theme-tint) 100%, - transparent - ); - color: var(--color-text); - box-shadow: 0 0 0 1px var(--workshop-theme-tint); -} - -.dock-view-fav { - width: 14px; - height: 14px; - border-radius: 3px; - background-color: var(--view-fav, oklch(48% 0.12 155)); - background-size: cover; - background-position: center; - background-repeat: no-repeat; - flex-shrink: 0; -} - -.dock-view-name { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.dock-empty { - padding: 6px 12px; - font-size: 12px; - color: var(--color-text-tertiary); - font-style: italic; -} - -/* ----- Tray ----- */ - -.dock-tray { - margin-left: auto; -} - -.tray-time { - display: inline-flex; - flex-direction: column; - align-items: flex-start; - padding: 3px 8px 4px; - background: transparent; - border: none; - border-radius: var(--dock-pin-radius); - color: var(--color-text); - cursor: pointer; - line-height: 1; - transition: background var(--transition-fast); -} -.tray-time:hover { - background: var(--bg-hover); -} - -.tray-time-hour { - font-family: "Pacifico", var(--font-family-base); - font-size: 18px; - color: oklch(28% 0.04 70); - font-variant-numeric: tabular-nums; - letter-spacing: -0.01em; - margin-bottom: 1px; -} - -.tray-time-date { - font-family: var(--font-family-base); - font-size: 9px; - color: var(--color-text-tertiary); - letter-spacing: 0.05em; -} - -.tray-glyph { - position: relative; - width: var(--tray-glyph-size); - height: var(--tray-glyph-size); - display: inline-flex; - align-items: center; - justify-content: center; - background: transparent; - border: none; - border-radius: var(--dock-pin-radius); - color: var(--color-text-secondary); - cursor: pointer; - font-size: 15px; - transition: - background var(--transition-fast), - color var(--transition-fast); -} -.tray-glyph:hover { - background: var(--bg-hover); - color: var(--color-text); -} - -.tray-badge { - position: absolute; - top: 4px; - right: 4px; - background: oklch(72% 0.08 78); - color: oklch(28% 0.04 70); - font-size: 9px; - font-weight: var(--font-weight-bold); - padding: 1px 4px; - border-radius: 6px; - line-height: 1; - font-variant-numeric: tabular-nums; -} -.tray-badge.hidden { - display: none; -} - -.tray-badge-count { - position: absolute; - top: 3px; - right: 3px; - min-width: 14px; - height: 14px; - padding: 0 4px; - border-radius: 8px; - background: oklch(66% 0.16 58); - color: oklch(99% 0.005 105); - font-size: 9px; - font-weight: var(--font-weight-bold); - font-variant-numeric: tabular-nums; - display: flex; - align-items: center; - justify-content: center; - line-height: 1; - pointer-events: none; - /* Soft pulse so a fresh notification draws the eye. */ - animation: dot-pulse 3.2s ease-out infinite; - box-shadow: 0 0 0 0 oklch(58% 0.18 25 / 0.5); -} - -@keyframes dot-pulse { - 0% { - transform: scale(1); - box-shadow: 0 0 0 0 oklch(58% 0.18 25 / 0.5); - } - 12% { - transform: scale(1.22); - box-shadow: 0 0 0 2px oklch(58% 0.18 25 / 0.45); - } - 60% { - transform: scale(1); - box-shadow: 0 0 0 8px oklch(58% 0.18 25 / 0); - } - 100% { - transform: scale(1); - box-shadow: 0 0 0 0 oklch(58% 0.18 25 / 0); - } -} - -/* vertical dock */ -body[data-dock-position="left"] .dock, -body[data-dock-position="right"] .dock { - /* Center in the space *below* the topbar so a tall dock (many views) - never collides with it. The (-50%) on translateY does the second - half of the centering math. */ - top: calc(50% + var(--topbar-height) / 2); - bottom: auto; - flex-direction: column; - width: var(--dock-height); - height: auto; - max-width: none; - max-height: calc(100vh - var(--topbar-height) - 28px); - /* Disable the corner-rotation breath in vertical orientation. */ - animation: none; -} - -body[data-dock-position="left"] .dock { - left: var(--dock-bottom); - right: auto; - transform: translateY(-50%); -} -body[data-dock-position="right"] .dock { - right: var(--dock-bottom); - left: auto; - transform: translateY(-50%); -} - -/* Hidden slide-out aimed at the screen edge, not down. */ -body[data-dock-position="left"][data-dock-hidden] .dock { - transform: translateY(-50%) - translateX(calc(-1 * (var(--dock-height) + var(--dock-bottom) + 12px))); -} -body[data-dock-position="right"][data-dock-hidden] .dock { - transform: translateY(-50%) - translateX(calc(var(--dock-height) + var(--dock-bottom) + 12px)); -} - -body[data-dock-position="left"] .dock-zone, -body[data-dock-position="right"] .dock-zone { - flex-direction: column; -} - -body[data-dock-position="left"] .dock-divider, -body[data-dock-position="right"] .dock-divider { - width: var(--dock-divider-h); - height: 1px; - margin: 3px 0; -} - -body[data-dock-position="left"] .dock-desk-name, -body[data-dock-position="right"] .dock-desk-name, -body[data-dock-position="left"] .dock-view-name, -body[data-dock-position="right"] .dock-view-name { - display: none; -} -body[data-dock-position="left"] .dock-desk, -body[data-dock-position="right"] .dock-desk, -body[data-dock-position="left"] .dock-view, -body[data-dock-position="right"] .dock-view { - padding: 7px; - max-width: none; -} - -/* View overflow direction matches the dock axis. */ -body[data-dock-position="left"] .dock-views, -body[data-dock-position="right"] .dock-views { - max-width: none; - max-height: 38vh; - overflow-x: hidden; - overflow-y: auto; -} - -/* Tray sits at the FAR end of the dock (bottom of vertical). */ -body[data-dock-position="left"] .dock-tray, -body[data-dock-position="right"] .dock-tray { - margin-left: 0; - margin-top: auto; -} - -/* Mouse-to-edge re-show: the JS listens for mouse-near-bottom; for - vertical dock we trade that for the appropriate screen edge. The - listener checks data-dock-position and tests the matching axis. */ diff --git a/ui/system/desktop/index.js b/ui/system/desktop/index.js index 13f512a..75ae989 100644 --- a/ui/system/desktop/index.js +++ b/ui/system/desktop/index.js @@ -1560,6 +1560,30 @@ const layoutManager = { return activeViewId; }, + // Shared services (notifications, media_session, channels, shortcuts) + // expect `layoutManager.webviews` to be an iterable Map keyed by + // webviewId. The legacy mobile/mediacenter layoutManagers maintain + // such a Map as their primary state; on desktop the truth lives in + // DESKS, so we expose a snapshot Map rebuilt on access. Lazy-loaded + // views without a hydrated WebView are skipped — `entry.webview.X` + // accesses in the consumers would otherwise NPE. + get webviews() { + const map = new Map(); + for (const desk of DESKS) { + for (const view of desk.views) { + if (!view.webView) continue; + map.set(view.id, { webview: view.webView }); + } + } + return map; + }, + + // No-op on desktop. Mobile uses panels; desktop uses tiled + // surfaces. setActiveWebView already handles bringing a view into + // focus, so consumers calling scrollToPanel after it can safely do + // nothing here. + scrollToPanel() {}, + // `intent` is `{ direction, anchorViewId, mode }`: // - direction: "horizontal" (split right) or "vertical" (split below). // Defaults to "horizontal". diff --git a/ui/system/desktop/index.js.orig b/ui/system/desktop/index.js.orig deleted file mode 100644 index 96b759c..0000000 --- a/ui/system/desktop/index.js.orig +++ /dev/null @@ -1,1938 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// Workshop: Beaver desktop UI -// ---------------------------------------------------------------------------- -// Three-layer surface (ambient / working / chrome) with desks, tiles, stack, -// floats, dock, command palette, and a per-desk overview drawer. Real webviews -// are managed by the `layoutManager` shim below, which exposes the same API -// the rest of the app expects (addWebView / setActiveWebView / removeWebView) -// while keeping the workshop's desk model as its internal state. - -import { WebView } from "../web_view.js"; -import { initApp, createNewView, openView } from "../app.js"; -import { - registerPanel, - handleClick as handleNotificationClick, - dismiss as dismissNotification, - clearAll as clearAllNotifications, -} from "../services/notifications.js"; -import { - SPLIT_RATIO_MIN, - SPLIT_RATIO_MAX, - makeLeaf, - findLeaf, - findLeafParent, - leafViewIds, - insertSplit, - removeLeaf, - replaceLeaf, -} from "./tree.js"; -import { init as initShortcuts } from "./shortcuts.js"; -import { - init as initOverview, - openOverview, - closeOverview, - renderOverview, -} from "./overview.js"; -import { init as initPalette, openPalette, closePalette } from "./palette.js"; -import { - init as initSurface, - renderDesks, - syncDeskSurface, - focusView, - promoteToTiled, - demoteToStack, -} from "./surface.js"; -import { DesktopStore } from "./store.js"; - -/* ========================================================================== - Workshop state - -------------------------------------------------------------------------- - Single default desk to start; user creates more via the palette. View - objects gain a `webView` reference once a real `` is attached - (which happens through `layoutManager.addWebView` from app.js). - ========================================================================== */ - -const DESKS = [ - { - id: "default", - name: "Workspace", - color: "oklch(48% 0.12 155)", - wallpaper: "josh", - layoutMode: "tiled", - tree: null, - views: [], - }, -]; - -// Peers are surfaced by the ambient layer when navigator.embedder.pairing is -// wired (polish item). -const PEERS = []; - -// Static palette action groups. The "Switch desk" group is built dynamically -// in buildResults() so newly-created desks surface immediately. -const ACTIONS = [ - { - group: "Apps", - items: [ - { - kind: "app", - id: "settings", - title: "Settings", - sub: "beaver://settings", - icon: "icon-settings", - }, - // { - // kind: "app", - // id: "files", - // title: "Files", - // sub: "beaver://files", - // icon: "icon-folder", - // }, - // { - // kind: "app", - // id: "terminal", - // title: "Terminal", - // sub: "beaver://terminal", - // icon: "icon-square-terminal", - // }, - // { - // kind: "app", - // id: "devtools", - // title: "Dev Tools", - // sub: "Open Servo dev tools", - // icon: "icon-code", - // }, - ], - }, - { - group: "System", - items: [ - { - kind: "system", - id: "new-view", - title: "New view", - sub: "Open a fresh tab on the active desk", - icon: "icon-plus", - }, - { - kind: "system", - id: "show-all-views", - title: "Show all views in this desk", - sub: "Open the overview panel", - icon: "icon-layers", - kbd: ["Mod+O"], - }, - { - kind: "system", - id: "toggle-layout", - title: "Switch desk layout: tiled ↔ single", - sub: "Change how views are arranged on the active desk", - icon: "icon-layout-grid", - }, - { - kind: "system", - id: "new-desk", - title: "New desk", - sub: "Create another workspace", - icon: "icon-square-plus", - }, - { - kind: "system", - id: "rename-desk", - title: "Rename current desk", - sub: "Rename the active desk", - icon: "icon-pencil", - }, - { - kind: "system", - id: "density", - title: "Density: cozy ↔ comfortable", - sub: "Tighter or roomier spacing throughout", - icon: "icon-rows-3", - kbd: ["Mod+\\"], - }, - { - kind: "system", - id: "dock-bottom", - title: "Dock: bottom", - sub: "Default position along the bottom edge", - icon: "icon-panel-bottom", - }, - { - kind: "system", - id: "dock-left", - title: "Dock: left", - sub: "Tuck the dock along the left edge", - icon: "icon-panel-left", - }, - { - kind: "system", - id: "dock-right", - title: "Dock: right", - sub: "Tuck the dock along the right edge", - icon: "icon-panel-right", - }, - { - kind: "system", - id: "theme", - title: "Switch theme…", - sub: "default · meadow · lcars · winamp", - icon: "icon-palette", - }, - { - kind: "system", - id: "reload-ui", - title: "Reload UI", - sub: "Restart the system surface", - icon: "icon-refresh-cw", - }, - { - kind: "system", - id: "quit", - title: "Quit Beaver", - sub: "Close all windows", - icon: "icon-power", - }, - ], - }, - // The Peers group is built dynamically from the live PEERS array in - // buildResults() so newly-discovered peers surface in the palette without - // a reload. -]; - -// App launchers. Each app, when launched, creates a regular view at the -// given beaver:// URL. Apps and views share the same chrome and the same -// window-management behaviour. The dock pin is just a shortcut. -const APPS = { - settings: { - name: "Settings", - host: "beaver://settings", - path: "/index.html", - fav: "oklch(50% 0.04 220)", - thumb: "linear-gradient(150deg, oklch(80% 0.02 220), oklch(58% 0.04 240))", - themeColor: "oklch(50% 0.04 220 / 0.4)", - }, - // files: { - // name: "Files", - // host: "beaver://files", - // path: "/index.html", - // fav: "oklch(72% 0.1 78)", - // thumb: "linear-gradient(150deg, oklch(86% 0.06 80), oklch(64% 0.1 70))", - // themeColor: "oklch(72% 0.1 78 / 0.4)", - // }, - // terminal: { - // name: "Terminal", - // host: "beaver://terminal", - // path: "/index.html", - // fav: "oklch(28% 0.04 60)", - // thumb: "linear-gradient(150deg, oklch(34% 0.04 60), oklch(18% 0.02 60))", - // themeColor: "oklch(28% 0.04 60 / 0.5)", - // }, - // devtools: { - // name: "Dev Tools", - // host: "beaver://devtools", - // path: "/index.html", - // fav: "oklch(58% 0.16 280)", - // thumb: "linear-gradient(150deg, oklch(70% 0.12 280), oklch(42% 0.14 280))", - // themeColor: "oklch(58% 0.16 280 / 0.42)", - // }, -}; - -/* ========================================================================== - Element refs - ========================================================================== */ - -const $ = (sel) => document.querySelector(sel); -const $$ = (sel) => document.querySelectorAll(sel); - -const elTrayHour = $("#tray-time-hour"); -const elTrayDate = $("#tray-time-date"); -const elAmbient = document.querySelector(".layer.ambient"); -const elWorking = $("#root"); -const elDeskRow = $("#dock-desks"); -const elViewRow = $("#dock-views"); -const elPalette = $("#palette"); -const elPaletteInput = $("#palette-input"); -const elPaletteResults = $("#palette-results"); -const elPaletteBackdrop = $("#palette-backdrop"); -const elTrayMenu = $("#tray-menu"); -const elTrayPeers = $("#tray-peers"); -const elTrayNotif = $("#tray-notif"); -const elNotificationPanel = $("#notification-panel"); -const elNotificationsBadge = $("#notifications-badge"); -// Overview refs -const elOverview = $("#overview"); -const elOverviewBackdrop = $("#overview-backdrop"); -const elOverviewSurface = $("#overview-surface"); -const elOverviewStack = $("#overview-stack"); -const elOverviewStackSection = $("#overview-stack-section"); -const elOverviewTitle = $("#overview-title"); -const elOverviewSummary = $("#overview-summary"); -const elOverviewClose = $("#overview-close"); -const elOverviewModeToggle = $("#overview-mode-toggle"); -const elOverviewNewDesk = $("#overview-new-desk"); -const elOverviewModeLabel = $("#overview-mode-label"); -const elOverviewSurfaceHint = $("#overview-surface-hint"); -const elOverviewStackHint = $("#overview-stack-hint"); -// Topbar refs -const elTopbarDeskColor = $("#topbar-desk-color"); -const elTopbarDeskName = $("#topbar-desk-name"); -const elTopbarFav = $("#topbar-fav"); -const elTopbarTitle = $("#topbar-title"); -const elTopbarHost = $("#topbar-host"); -const elTopbarBack = document.querySelector('.topbar-nav[data-nav="back"]'); -const elTopbarFwd = document.querySelector('.topbar-nav[data-nav="forward"]'); -const elTopbarBlocker = $("#topbar-blocker"); -const elTopbarBlockerBadge = elTopbarBlocker?.querySelector( - ".topbar-blocker-badge", -); -const elTopbarBlockerPanel = $("#topbar-blocker-panel"); - -/* ========================================================================== - State - ========================================================================== */ - -// Initial active state: the seed desk and no active view (because the -// default desk starts empty. `createNewView` runs in bootstrap and the -// resulting WebView becomes the active view via `addWebView`). -let activeDesk = "default"; -let activeViewId = null; - -// Shared, mutable state shim. -const state = { - get activeDesk() { - return activeDesk; - }, - set activeDesk(v) { - activeDesk = v; - }, - get activeViewId() { - return activeViewId; - }, - set activeViewId(v) { - activeViewId = v; - }, -}; -// Monotonic counter for fresh view IDs when launching apps / new tabs. -let nextViewId = 1000; - -/* ========================================================================== - Persistence - -------------------------------------------------------------------------- - Snapshot what's reproducible: desk layout tree, view URLs / names / - placement flags, active selection, UI prefs, and reconstruct it on - boot. - ========================================================================== */ - -const store = new DesktopStore(); - -function buildSnapshot() { - return { - activeDesk, - activeViewId, - desks: DESKS.map((d) => ({ - id: d.id, - name: d.name, - color: d.color, - wallpaper: d.wallpaper || DEFAULT_WALLPAPER, - layoutMode: d.layoutMode || "tiled", - tree: d.tree || null, - views: d.views.map((v) => ({ - id: v.id, - name: v.name, - host: v.host, - path: v.path, - fav: v.fav, - themeColor: v.themeColor, - appId: v.appId, - thumb: v.thumb, - // The webview's currently displayed URL is what we want to - // reload on next boot, not the original src, which may have - // since navigated away. For views still un-hydrated (their desk - // hasn't been activated since restore), keep the URL we were - // going to lazily load. - url: v.webView?.currentUrl || v.webView?.src || v.lazyUrl || "", - stacked: !!v.stacked, - floating: v.floating || null, - })), - })), - prefs: { - density: document.body.dataset.density || null, - dockHidden: !!document.body.dataset.dockHidden, - dockPosition, - }, - }; -} - -function restoreSnapshot(snapshot) { - // Mutate DESKS in place so module deps that captured the array - // reference (surface, palette, overview) keep their view of it. - DESKS.length = 0; - for (const d of snapshot.desks) { - DESKS.push({ - id: d.id, - name: d.name, - color: d.color, - wallpaper: d.wallpaper || DEFAULT_WALLPAPER, - layoutMode: d.layoutMode || "tiled", - tree: d.tree || null, - views: d.views.map((v) => { - // Don't materialize the WebView yet since that would mount an iframe - // and start a network load for every restored tab at boot. Stash - // the URL on `lazyUrl`; `hydrateDesk` creates the real WebView - // when the desk first becomes active. - const view = { - id: v.id, - name: v.name, - host: v.host, - path: v.path, - fav: v.fav, - themeColor: v.themeColor, - appId: v.appId, - thumb: v.thumb, - webView: null, - lazyUrl: v.url || "about:blank", - }; - if (v.stacked) view.stacked = true; - if (v.floating) view.floating = v.floating; - return view; - }), - }); - } - // Validate active selection against the restored shape. A stale ID - // (e.g., user manually edited storage) silently falls back. - if (snapshot.activeDesk && DESKS.some((d) => d.id === snapshot.activeDesk)) { - activeDesk = snapshot.activeDesk; - } else { - activeDesk = DESKS[0]?.id || "default"; - } - if ( - snapshot.activeViewId && - DESKS.some((d) => d.views.some((v) => v.id === snapshot.activeViewId)) - ) { - activeViewId = snapshot.activeViewId; - } else { - activeViewId = activeDeskObj()?.views[0]?.id || null; - } - if (snapshot.prefs?.density === "comfortable") { - document.body.dataset.density = "comfortable"; - } - if (snapshot.prefs?.dockHidden) { - document.body.dataset.dockHidden = "true"; - } - // Dock position survives the reload too. - if (snapshot.prefs?.dockPosition) { - const pos = snapshot.prefs.dockPosition; - dockPosition = DOCK_POSITIONS.includes(pos) ? pos : "bottom"; - if (dockPosition === "bottom") delete document.body.dataset.dockPosition; - else document.body.dataset.dockPosition = dockPosition; - } -} - -// Materialize WebViews for any views in `desk` that were lazily restored. -// Called when a desk first becomes active. Once a view's WebView exists, -// `lazyUrl` is dropped. buildSnapshot will read the live URL instead. -function hydrateDesk(desk) { - if (!desk) { - return; - } - for (const view of desk.views) { - if (view.webView) continue; - const wv = new WebView(view.lazyUrl || "about:blank", view.name || "", {}); - wv.webviewId = view.id; - view.webView = wv; - delete view.lazyUrl; - } -} - -function persist() { - store.scheduleSave(buildSnapshot); -} - -// Flush any pending debounced save so a reload / window-close doesn't -// drop the most recent mutations. -window.addEventListener("beforeunload", () => store.flush(buildSnapshot)); - -function activeDeskObj() { - return DESKS.find((d) => d.id === activeDesk); -} - -function activeViewObj() { - const desk = activeDeskObj(); - return desk?.views.find((v) => v.id === activeViewId); -} - -/* ========================================================================== - Tray clock ticks every 30s. - TODO: smarter tick algorithm. - ========================================================================== */ - -function pad(n) { - return String(n).padStart(2, "0"); -} -const MONTHS = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", -]; - -function tickClock() { - const d = new Date(); - const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`; - const dateStr = `${MONTHS[d.getMonth()]} ${d.getDate()}`; - elTrayHour.textContent = time; - elTrayDate.textContent = dateStr; -} - -tickClock(); -setInterval(tickClock, 30 * 1000); - -/* ========================================================================== - Working layer: desk surfaces with view tiles - ========================================================================== */ - -// Strip the protocol and a trailing TLD from a host, so a tile placeholder -// reads as a single word. "github.com" → "github"; "beaver://settings" → -// "settings"; "tools.local" → "tools". -function shortHost(host) { - if (!host) return ""; - const stripped = host.replace(/^[a-z]+:\/\//, ""); - const dot = stripped.indexOf("."); - return dot > 0 ? stripped.slice(0, dot) : stripped; -} - -// A view is in one of three modes: -// - "floating": has a `.floating` descriptor (free window over the surface) -// - "stacked": has `.stacked: true` (off-surface, shown only in overview) -// - "tiled": neither (placement comes from desk.tree) -// Floating wins over stacked if both are accidentally set. -// -// Tiled views live inside `desk.tree`: a binary split tree borrowed from -// the legacy LayoutManager. Leaves carry a viewId; split nodes carry a -// direction ("horizontal" = side-by-side, "vertical" = stacked) and a -// ratio (first child's fraction of the parent box, 0..1). -function viewMode(v) { - if (v.floating) return "floating"; - if (v.stacked) return "stacked"; - return "tiled"; -} -function tiledViews(desk) { - return desk.views.filter((v) => viewMode(v) === "tiled"); -} -function stackedViews(desk) { - return desk.views.filter((v) => viewMode(v) === "stacked"); -} -function floatingViews(desk) { - return desk.views.filter((v) => viewMode(v) === "floating"); -} - -function findView(id) { - for (const d of DESKS) { - const v = d.views.find((v) => v.id === id); - if (v) return v; - } - return null; -} - -// Toggle the active view between tiled and floating. Bound to Mod+F. -function toggleFloatActive() { - const view = findView(activeViewId); - if (!view) { - return; - } - if (view.floating) { - delete view.floating; - } else { - // Pop into the centre of the desk with a slight offset so the user - // sees it lift off and understand they can drag from there. - view.floating = { x: 0.4, y: 0.3, w: 280, h: 200 }; - } - rerenderAll(); -} - -/* ========================================================================== - Dock: desks row + view chips + theme tint - ========================================================================== */ - -function renderDeskRow() { - // Build with createElement so desk names and color values never round-trip - // through innerHTML. Each .dock-desk button gets click + focus wiring. - const frag = document.createDocumentFragment(); - for (let i = 0; i < DESKS.length; i++) { - const desk = DESKS[i]; - const btn = document.createElement("button"); - btn.className = "dock-desk"; - btn.dataset.desk = desk.id; - const isActive = desk.id === activeDesk; - if (isActive) btn.dataset.active = ""; - btn.style.setProperty("--desk-color", desk.color); - btn.title = isActive - ? `${desk.name} — click to edit` - : `Switch to ${desk.name} (Mod+${i + 1})`; - - const dot = document.createElement("span"); - dot.className = "dock-desk-color"; - btn.appendChild(dot); - - const name = document.createElement("span"); - name.className = "dock-desk-name"; - name.textContent = desk.name; - btn.appendChild(name); - - btn.addEventListener("click", () => { - // Click the active desk again → open its settings (the topbar - // pencil disappears in page mode, so the dock is the always-visible - // way to get back to the editor). Inactive desks just switch. - if (desk.id === activeDesk) { - if (document.body.dataset.deskSettingsOpen) closeDeskSettings(); - else openDeskSettings(btn); - } else { - switchDesk(desk.id); - } - }); - frag.appendChild(btn); - } - elDeskRow.replaceChildren(frag); -} - -function renderViewChips() { - const desk = activeDeskObj(); - if (!desk || desk.views.length === 0) { - const empty = document.createElement("span"); - empty.className = "dock-empty"; - empty.textContent = "the desk is empty"; - elViewRow.replaceChildren(empty); - return; - } - - const frag = document.createDocumentFragment(); - for (const v of desk.views) { - const btn = document.createElement("button"); - btn.className = "dock-view"; - if (v.id === activeViewId) btn.classList.add("active"); - btn.dataset.view = v.id; - btn.style.setProperty("--view-fav", v.fav); - - const fav = document.createElement("span"); - fav.className = "dock-view-fav"; - applyFavicon(fav, v); - btn.appendChild(fav); - - const name = document.createElement("span"); - name.className = "dock-view-name"; - name.textContent = v.name; - btn.appendChild(name); - - btn.addEventListener("click", () => focusView(v.id)); - frag.appendChild(btn); - } - elViewRow.replaceChildren(frag); - - // Auto-scroll the active chip into view to keep it visible no matter - // how many views the desk has stacked into the chip strip. - const active = elViewRow.querySelector(".dock-view.active"); - if (active) { - active.scrollIntoView({ block: "nearest", inline: "center" }); - } -} - -// The dock's halo + the active view chip's halo both pull from the same -// CSS custom property. Update it when the active view changes so the page -// tint is the *active workpiece*, not the desk colour. Falls back to -// transparent in ambient mode so the topbar background stays neutral. -function applyThemeTint() { - const view = activeViewObj(); - const raw = view?.themeColor; - let tint; - if (!raw || raw === "transparent") { - tint = "transparent"; - } else if (raw.includes("/") && raw.includes(")")) { - // The mock data and our presets ship with the alpha already baked into - // the color (e.g. `oklch(... / 0.4)`). Use as-is. - tint = raw; - } else { - // A real page's `` is typically opaque (`#3366cc`, - // `rgb(...)`). Wrap it with color-mix so the halo + topbar tint is not a - // wall of color. - tint = `color-mix(in oklab, ${raw} 32%, transparent)`; - } - document.documentElement.style.setProperty("--workshop-theme-tint", tint); -} - -/* ========================================================================== - Topbar: contextual chrome that swaps based on focus - ---------------------------------------------------------------------------- - Two modes share the same strip: - ambient: desk identity + quick desk actions (rename, new view) - page: favicon + title + nav cluster (back / fwd / reload / float / menu / close) - ========================================================================== */ - -// Full-frame rerender: desk surfaces, dock chips, theme tint, topbar. -// Used by any state mutation broad enough to affect the active desk's -// layout / contents. Callers that only need a single render path -// (favicon update, navigation state) should call the targeted helper. -function rerenderAll({ deskRow = false } = {}) { - renderDesks(); - if (deskRow) renderDeskRow(); - renderViewChips(); - applyThemeTint(); - applyDeskWallpaper(); - renderTopbar(); - if (document.body.dataset.overviewOpen) renderOverview(); - persist(); -} - -function renderTopbar() { - const desk = activeDeskObj(); - const view = activeViewObj(); - - // Ambient slot. - if (desk) { - elTopbarDeskName.textContent = desk.name; - elTopbarDeskColor.style.setProperty("--desk-color", desk.color); - } - - if (view) { - elTopbarFav.style.setProperty("--page-fav", view.fav); - applyFavicon(elTopbarFav, view); - elTopbarTitle.textContent = view.name; - elTopbarHost.textContent = displayHost(view); - document.body.dataset.focus = "page"; - // Reflect navigation history into the back/forward button enabled state. - // canGoBack/Forward arrive via navigation-state-changed; default to - // disabled until we hear otherwise (a fresh view has no history). - if (elTopbarBack) elTopbarBack.disabled = !view.canGoBack; - if (elTopbarFwd) elTopbarFwd.disabled = !view.canGoForward; - renderTopbarBlocker(view); - } else { - document.body.dataset.focus = "ambient"; - renderTopbarBlocker(null); - } - reflectActiveLoadStatus(); -} - -// Reflect the active view's content-blocker state into the topbar shield. -function applyFavicon(el, view) { - if (!el) { - return; - } - const url = view?.faviconUrl; - el.style.backgroundImage = url ? `url("${url}")` : ""; -} - -// Hidden when the page has no http(s) origin; dimmed when blocking is off -// for the origin; badged with the per-page blocked count when > 0. -function renderTopbarBlocker(view) { - if (!elTopbarBlocker) { - return; - } - const origin = view?.blockerOrigin; - if (!origin) { - elTopbarBlocker.hidden = true; - return; - } - elTopbarBlocker.hidden = false; - elTopbarBlocker.classList.toggle("disabled", view.blockerEnabled === false); - const badge = view.blockerBadgeCount || 0; - if (elTopbarBlockerBadge) { - if (badge > 0) { - elTopbarBlockerBadge.hidden = false; - elTopbarBlockerBadge.textContent = badge > 99 ? "99+" : String(badge); - } else { - elTopbarBlockerBadge.hidden = true; - } - } -} - -// Pretty form of the URL for the topbar's secondary host element. -// "github.com" + "/anthropics/beaver" -> "github.com /anthropics/beaver" -// "beaver://settings" + "" -> "beaver://settings" -function displayHost(view) { - if (!view.host) return ""; - if (view.path) return `${view.host}${view.path}`; - return view.host; -} - -/* ========================================================================== - App launchers - ---------------------------------------------------------------------------- - Clicking a dock pin doesn't open a special "app mode", it opens (or - focuses) a regular view that points at the app's beaver:// URL on the - active desk. Apps and views are the same thing. - ========================================================================== */ - -function openOrFocusApp(appId) { - const app = APPS[appId]; - if (!app) { - return; - } - const desk = activeDeskObj(); - if (!desk) { - return; - } - - // Focus the existing app view if one's already open on this desk. - // We match by `appId` rather than the view's URL because navigation - // events overwrite `view.host` once the page loads so a stale host - // string would never match `app.host` and we'd open duplicates. - const existing = desk.views.find((v) => v.appId === appId); - if (existing) { - // If the existing view is stacked or floating, lift it onto the - // tiled surface. A click on the app icon always means "show me this". - if (viewMode(existing) !== "tiled") promoteToTiled(existing.id); - focusView(existing.id); - return; - } - - // Create a real WebView pointing at the app's beaver:// URL and route - // through the layoutManager shim. That path runs the same cap - // enforcement, theme tint, and DOM mounting as a navigator.embedder - // adoption would. - const url = `${app.host}${app.path || ""}`; - const webView = new WebView(url, app.name, {}); - layoutManager.addWebView(webView); - // Stash app-derived display fields on the just-pushed view so the - // dock chip and topbar show the app's name/fav before its real title - // and favicon arrive. - const view = findView(webView.webviewId); - if (view) { - view.name = app.name; - view.host = app.host; - view.path = app.path || ""; - view.fav = app.fav; - view.thumb = app.thumb; - view.themeColor = app.themeColor; - view.appId = appId; - } - rerenderAll(); -} - -function closeActiveView() { - if (activeViewId) layoutManager.removeWebView(activeViewId); -} - -// Find the desk a given view lives on (linear scan; mockup has 3 desks). -function findDeskForView(viewId) { - return DESKS.find((d) => d.views.some((v) => v.id === viewId)); -} - -/* ========================================================================== - Desk switching - ========================================================================== */ - -function switchDesk(deskId) { - if (!DESKS.some((d) => d.id === deskId) || deskId === activeDesk) { - return; - } - activeDesk = deskId; - const desk = activeDeskObj(); - // First time on this desk since boot? Materialize its WebViews now. - // Restored sessions defer this so we don't pay every tab's network - // cost up-front. - hydrateDesk(desk); - activeViewId = desk?.views[0]?.id ?? null; - document.body.dataset.activeDeskEmpty = String(!desk?.views.length); - rerenderAll({ deskRow: true }); -} - -/* ========================================================================== - Pinned-app & tray clicks - ========================================================================== */ - -$$(".dock-pin").forEach((pin) => { - pin.addEventListener("click", () => { - pin.classList.remove("flash"); - // Force reflow so the animation restarts on repeat clicks. - void pin.offsetWidth; - pin.classList.add("flash"); - const action = pin.dataset.action; - if (APPS[action]) openOrFocusApp(action); - }); -}); - -elTrayMenu.addEventListener("click", () => openPalette()); - -// The tray peers / notif glyphs are decorative in the mockup but should -// still acknowledge clicks. The bell now toggles the real notification -// panel; the dot itself (`#notifications-badge`) is driven by the -// notifications service that lights up with the count when notifications -// are present. -elTrayPeers.addEventListener("click", () => { - elTrayPeers.classList.add("flash"); - setTimeout(() => elTrayPeers.classList.remove("flash"), 350); -}); -elTrayNotif.addEventListener("click", () => { - if (elNotificationPanel) elNotificationPanel.open = !elNotificationPanel.open; -}); - -/* ========================================================================== - Topbar wiring: back/forward/reload flash, float toggle, close active - ========================================================================== */ - -document.querySelectorAll(".topbar-nav").forEach((btn) => { - btn.addEventListener("click", () => { - const nav = btn.dataset.nav; - if (nav === "float") { - toggleFloatActive(); - return; - } - if (nav === "close") { - closeActiveView(); - return; - } - if (nav === "menu") { - // The topbar's `…` opens the desktop-chrome popover (declared in - // index.html as #topbar-menu). The popover lives outside any - // web-view so its position stays anchored to the trigger button. - if (document.body.dataset.topbarMenuOpen) closeTopbarMenu(); - else openTopbarMenu(btn); - return; - } - if (nav === "blocker") { - if (elTopbarBlockerPanel?.open) closeTopbarBlockerPanel(); - else openTopbarBlockerPanel(btn); - return; - } - // back / forward / reload: delegate to the active webview when there - // is one. Flash the button so the click is acknowledged either way. - btn.classList.remove("flash"); - void btn.offsetWidth; - btn.classList.add("flash"); - setTimeout(() => btn.classList.remove("flash"), 480); - const view = activeViewObj(); - const wv = view?.webView; - if (!wv) { - return; - } - if (nav === "back" && wv.goBack) wv.goBack(); - else if (nav === "forward" && wv.goForward) wv.goForward(); - else if (nav === "reload" && wv.reload) wv.reload(); - }); -}); - -/* ----- Topbar popover menu -------------------------------------------------- - A small desktop-chrome popover for view-level actions (split right, split - below). Anchored under the topbar `…` button regardless of which tile is - active to keep the menu close to its trigger. Closes on outside click, - on escape, or on item activation. */ - -const elTopbarMenu = document.getElementById("topbar-menu"); -let topbarMenuOutsideHandler = null; - -function openTopbarMenu(anchorBtn) { - if (!elTopbarMenu) { - return; - } - const rect = anchorBtn.getBoundingClientRect(); - elTopbarMenu.style.top = `${Math.round(rect.bottom + 6)}px`; - elTopbarMenu.style.right = `${Math.round(window.innerWidth - rect.right)}px`; - elTopbarMenu.hidden = false; - document.body.dataset.topbarMenuOpen = "true"; - // Defer the outside-click hookup by one frame so the click that opened - // the menu doesn't immediately close it. - requestAnimationFrame(() => { - topbarMenuOutsideHandler = (e) => { - if (!elTopbarMenu.contains(e.target) && !anchorBtn.contains(e.target)) { - closeTopbarMenu(); - } - }; - document.addEventListener("mousedown", topbarMenuOutsideHandler, true); - }); -} - -function closeTopbarMenu() { - if (!elTopbarMenu) { - return; - } - elTopbarMenu.hidden = true; - delete document.body.dataset.topbarMenuOpen; - if (topbarMenuOutsideHandler) { - document.removeEventListener("mousedown", topbarMenuOutsideHandler, true); - topbarMenuOutsideHandler = null; - } -} - -if (elTopbarMenu) { - elTopbarMenu.querySelectorAll(".topbar-menu-item").forEach((btn) => { - btn.addEventListener("click", () => { - const action = btn.dataset.action; - closeTopbarMenu(); - if (action === "split-horizontal" || action === "split-vertical") { - const direction = - action === "split-horizontal" ? "horizontal" : "vertical"; - createNewView(layoutManager, { - direction, - anchorViewId: activeViewId, - }); - return; - } - const view = activeViewObj(); - if (!view) { - return; - } - const wv = view.webView; - if (action === "resize-grow") resizeActiveTile(+0.05); - else if (action === "resize-shrink") resizeActiveTile(-0.05); - else if (action === "zoom-in" && wv?.zoomIn) wv.zoomIn(); - else if (action === "zoom-out" && wv?.zoomOut) wv.zoomOut(); - else if (action === "zoom-reset" && wv?.zoomReset) wv.zoomReset(); - }); - }); -} - -/* ----- Content blocker panel ------------------------------------------------ - The element lives in the desktop chrome (mounted - as a sibling of the topbar). Anchoring + state come from the active - view's WebView; the panel's toggle dispatches `blocker-toggled` which we - forward back to the active webview so it can refresh its own state. */ - -function openTopbarBlockerPanel(anchorBtn) { - if (!elTopbarBlockerPanel) { - return; - } - const view = activeViewObj(); - const wv = view?.webView; - if (!view || !view.blockerOrigin || !wv) { - return; - } - const rect = anchorBtn.getBoundingClientRect(); - elTopbarBlockerPanel.origin = view.blockerOrigin; - elTopbarBlockerPanel.enabled = view.blockerEnabled !== false; - elTopbarBlockerPanel.blockedCount = view.blockerBlockedCount || 0; - elTopbarBlockerPanel.allowedCount = view.blockerAllowedCount || 0; - // The panel uses `right: calc(100% - x); top: y` inside its host. Mounted - // in the document body, 100% resolves to the viewport width so x must be - // the button's right edge in viewport coords. - elTopbarBlockerPanel.x = Math.round(rect.right); - elTopbarBlockerPanel.y = Math.round(rect.bottom + 6); - elTopbarBlockerPanel.open = true; -} - -function closeTopbarBlockerPanel() { - if (elTopbarBlockerPanel) elTopbarBlockerPanel.open = false; -} - -if (elTopbarBlockerPanel) { - elTopbarBlockerPanel.addEventListener("panel-dismiss", () => { - elTopbarBlockerPanel.open = false; - }); - elTopbarBlockerPanel.addEventListener("blocker-toggled", (e) => { - const view = activeViewObj(); - const wv = view?.webView; - if (!view || !wv) { - return; - } - // Mirror the new state onto the webview + the view metadata so the - // shield re-renders without waiting for the next updateBlockerStatus. - wv.blockerEnabled = e.detail.enabled; - view.blockerEnabled = e.detail.enabled; - renderTopbarBlocker(view); - // Refetch counts asynchronously so the badge stays accurate. - wv.updateBlockerStatus?.(); - }); -} - -// Grow (positive delta) or shrink (negative) the active tile along the -// axis of its immediate parent split. Mutates `parent.ratio` in place. -// This is the same pattern as the resize-handle drag (see onResizeHandlePointerMove). -// No-op when the active leaf is the root (no parent split) or stacked. -function resizeActiveTile(delta) { - const desk = activeDeskObj(); - if (!desk || !desk.tree || !activeViewId) { - return; - } - const found = findLeafParent(desk.tree, activeViewId); - if (!found) { - return; - } - const { parent, indexInParent } = found; - // If the active leaf is the first child, growing it means a larger - // ratio; if it's the second, growing it means a smaller ratio. - const sign = indexInParent === 0 ? 1 : -1; - let next = parent.ratio + sign * delta; - if (next < SPLIT_RATIO_MIN) next = SPLIT_RATIO_MIN; - if (next > SPLIT_RATIO_MAX) next = SPLIT_RATIO_MAX; - if (Math.abs(next - parent.ratio) < 0.0005) { - return; - } - parent.ratio = next; - const surface = document.querySelector( - `.desk-surface[data-desk="${desk.id}"]`, - ); - if (surface) syncDeskSurface(surface, desk); -} - -document.querySelectorAll(".topbar-action").forEach((btn) => { - btn.addEventListener("click", () => { - const action = btn.dataset.action; - if (action === "rename-desk") { - if (document.body.dataset.deskSettingsOpen) closeDeskSettings(); - else openDeskSettings(btn); - } else if (action === "new-view") { - createNewView(layoutManager); - } - }); -}); - -// Click on the title / host / favicon -> "edit URL": opens the palette -// pre-filled with the current URL and routes any navigation back into -// the active view rather than opening a new one. -[elTopbarFav, elTopbarTitle, elTopbarHost].forEach((el) => { - if (!el) { - return; - } - el.addEventListener("click", () => { - if (activeViewObj()) openPalette({ editUrl: true }); - }); -}); - -// so the user gets visual variety without a color picker. -const DESK_COLORS = [ - "oklch(48% 0.12 155)", // brand green - "oklch(72% 0.1 78)", // amber - "oklch(58% 0.16 38)", // terracotta - "oklch(58% 0.14 250)", // dusk blue - "oklch(52% 0.18 320)", // berry - "oklch(64% 0.13 110)", // moss -]; - -/* Per-desk wallpapers. Each entry's `bg` is a complete CSS background - shorthand value. Add a new image: drop the file in ./wallpapers/ and - append an entry here with a short stable id. */ -const WALLPAPERS = [ - { - id: "josh", - name: "josh", - bg: "url(./wallpapers/josh-williams-NMEnVWulWLQ-unsplash.jpg) center/cover no-repeat", - }, - { - id: "alim", - name: "alim", - bg: "url(./wallpapers/alim-YOr9NYaGGC4-unsplash.jpg) center/cover no-repeat", - }, - { - id: "michael", - name: "michael", - bg: "url(./wallpapers/michael-worden-7kvt0N3Pud0-unsplash.jpg) center/cover no-repeat", - }, - { - id: "pawel", - name: "pawel", - bg: "url(./wallpapers/pawel-czerwinski-dYb9jBn3J9M-unsplash.jpg) center/cover no-repeat", - }, - { - id: "don", - name: "don", - bg: "url(./wallpapers/doncoombez-V7KIXbMOuJw-unsplash.jpg) center/cover no-repeat", - }, - { - id: "philipp", - name: "philipp", - bg: "url(./wallpapers/philipp-dusel-JGz-oTlQ7YM-unsplash.jpg) center/cover no-repeat", - }, - { - id: "cristian", - name: "cristian", - bg: "url(./wallpapers/cristian-palmer-XexawgzYOBc-unsplash.jpg) center/cover no-repeat", - }, - { - id: "anita", - name: "anita", - bg: "url(./wallpapers/anita-austvika-R-p7nxAna5Q-unsplash.jpg) center/cover no-repeat", - }, -]; - -const DEFAULT_WALLPAPER = WALLPAPERS[0].id; - -function wallpaperFor(id) { - return WALLPAPERS.find((w) => w.id === id) || WALLPAPERS[0]; -} - -// Paint the active desk's wallpaper onto the ambient layer. Called on -// desk switch + when a desk's wallpaper changes. -function applyDeskWallpaper() { - if (!elAmbient) { - return; - } - const desk = activeDeskObj(); - const wp = wallpaperFor(desk?.wallpaper || DEFAULT_WALLPAPER); - elAmbient.style.background = wp.bg; -} - -/* ----- Dock position ------------------------------------------------------ - Global setting; cycles bottom → left → right → bottom via Mod+Shif+D or - palette command. Persisted in snapshot.prefs.dockPosition. CSS in - tokens.css + dock.css dispatches on body[data-dock-position]. */ -const DOCK_POSITIONS = ["bottom", "left", "right"]; -let dockPosition = "bottom"; - -function setDockPosition(pos) { - if (!DOCK_POSITIONS.includes(pos)) pos = "bottom"; - dockPosition = pos; - if (pos === "bottom") delete document.body.dataset.dockPosition; - else document.body.dataset.dockPosition = pos; - persist(); -} - -function cycleDockPosition() { - const next = - DOCK_POSITIONS[ - (DOCK_POSITIONS.indexOf(dockPosition) + 1) % DOCK_POSITIONS.length - ]; - setDockPosition(next); -} - -function createDesk() { - const n = DESKS.length + 1; - const desk = { - id: `desk-${Date.now()}`, - name: `Desk ${n}`, - color: DESK_COLORS[(n - 1) % DESK_COLORS.length], - wallpaper: WALLPAPERS[(n - 1) % WALLPAPERS.length].id, - layoutMode: "tiled", - tree: null, - views: [], - }; - DESKS.push(desk); - switchDesk(desk.id); -} - -/* ----- Desk settings popover ------------------------------------------------- - Anchored under the topbar pencil button (ambient mode). Edits the - active desk's name + color live and offers deletion when more than one - desk exists. Replaces the prior `prompt()`-based rename which silently - no-op'd inside Servo embeds. */ - -const elDeskSettings = document.getElementById("desk-settings"); -const elDeskSettingsName = document.getElementById("desk-settings-name"); -const elDeskSettingsColors = document.getElementById("desk-settings-colors"); -const elDeskSettingsWallpapers = document.getElementById( - "desk-settings-wallpapers", -); -const elDeskSettingsDelete = document.getElementById("desk-settings-delete"); -let deskSettingsOutsideHandler = null; - -function openDeskSettings(anchorBtn) { - if (!elDeskSettings) { - return; - } - const desk = activeDeskObj(); - if (!desk) { - return; - } - const rect = anchorBtn?.getBoundingClientRect(); - // Anchor near the trigger. If the trigger sits in the lower half of - // the viewport (e.g., a dock-desk chip), pop the popover ABOVE it so - // it doesn't render off-screen. Otherwise pop below (topbar pencil). - // Falls back to a fixed top-left position when no visible anchor. - elDeskSettings.style.top = ""; - elDeskSettings.style.bottom = ""; - elDeskSettings.style.left = ""; - if (rect && rect.width > 0) { - const above = rect.top > window.innerHeight / 2; - if (above) { - elDeskSettings.style.bottom = `${Math.round(window.innerHeight - rect.top + 6)}px`; - elDeskSettings.dataset.position = "above"; - } else { - elDeskSettings.style.top = `${Math.round(rect.bottom + 6)}px`; - elDeskSettings.dataset.position = "below"; - } - elDeskSettings.style.left = `${Math.round(rect.left)}px`; - } else { - elDeskSettings.style.top = "calc(var(--topbar-height) + 6px)"; - elDeskSettings.style.left = "12px"; - elDeskSettings.dataset.position = "below"; - } - elDeskSettingsName.value = desk.name; - renderDeskSettingsSwatches(desk); - elDeskSettingsDelete.disabled = DESKS.length <= 1; - elDeskSettings.hidden = false; - document.body.dataset.deskSettingsOpen = "true"; - // Defer focus + outside-click bind by one frame so the click that opened - // the popover doesn't immediately close it / bounce focus. - requestAnimationFrame(() => { - elDeskSettingsName.focus(); - elDeskSettingsName.select(); - deskSettingsOutsideHandler = (e) => { - if ( - !elDeskSettings.contains(e.target) && - !(anchorBtn && anchorBtn.contains(e.target)) - ) { - closeDeskSettings(); - } - }; - document.addEventListener("mousedown", deskSettingsOutsideHandler, true); - }); -} - -function closeDeskSettings() { - if (!elDeskSettings) { - return; - } - elDeskSettings.hidden = true; - delete document.body.dataset.deskSettingsOpen; - if (deskSettingsOutsideHandler) { - document.removeEventListener("mousedown", deskSettingsOutsideHandler, true); - deskSettingsOutsideHandler = null; - } -} - -function renderDeskSettingsSwatches(desk) { - const colorFrag = document.createDocumentFragment(); - for (const color of DESK_COLORS) { - const btn = document.createElement("button"); - btn.className = "desk-settings-swatch"; - btn.type = "button"; - btn.setAttribute("role", "radio"); - btn.setAttribute("aria-label", color); - btn.setAttribute("aria-checked", String(color === desk.color)); - btn.style.setProperty("--swatch", color); - btn.addEventListener("click", () => { - desk.color = color; - // Re-tag the active swatch without rebuilding the row. - elDeskSettingsColors - .querySelectorAll(".desk-settings-swatch") - .forEach((s) => { - s.setAttribute( - "aria-checked", - String(s.style.getPropertyValue("--swatch") === color), - ); - }); - rerenderAll({ deskRow: true }); - }); - colorFrag.appendChild(btn); - } - elDeskSettingsColors.replaceChildren(colorFrag); - - // Wallpaper picker: same pattern as color, just rectangles holding a - // miniature of the actual ambient background. - const wpFrag = document.createDocumentFragment(); - for (const wp of WALLPAPERS) { - const btn = document.createElement("button"); - btn.className = "desk-settings-wallpaper"; - btn.type = "button"; - btn.dataset.wallpaperId = wp.id; - btn.setAttribute("role", "radio"); - btn.setAttribute("aria-label", wp.name); - btn.setAttribute("aria-checked", String(wp.id === desk.wallpaper)); - btn.title = wp.name; - btn.style.setProperty("--wallpaper", wp.bg); - btn.addEventListener("click", () => { - desk.wallpaper = wp.id; - elDeskSettingsWallpapers - .querySelectorAll(".desk-settings-wallpaper") - .forEach((s) => { - s.setAttribute( - "aria-checked", - String(s.dataset.wallpaperId === wp.id), - ); - }); - applyDeskWallpaper(); - persist(); - }); - wpFrag.appendChild(btn); - } - elDeskSettingsWallpapers.replaceChildren(wpFrag); -} - -if (elDeskSettingsName) { - elDeskSettingsName.addEventListener("input", () => { - const desk = activeDeskObj(); - if (!desk) { - return; - } - desk.name = elDeskSettingsName.value; - renderDeskRow(); - renderTopbar(); - if (document.body.dataset.overviewOpen) renderOverview(); - }); - elDeskSettingsName.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - closeDeskSettings(); - } - }); -} - -if (elDeskSettingsDelete) { - elDeskSettingsDelete.addEventListener("click", () => { - const desk = activeDeskObj(); - if (!desk || DESKS.length <= 1) { - return; - } - // Close any webviews on this desk so their iframes / blob URLs get - // released (mirrors the per-view cleanup in layoutManager.removeWebView). - for (const view of [...desk.views]) { - layoutManager.removeWebView(view.id); - } - const idx = DESKS.indexOf(desk); - DESKS.splice(idx, 1); - // Hand focus to the neighbor (prefer the desk to the left, else the - // new first desk). - const next = DESKS[Math.max(0, idx - 1)] || DESKS[0]; - closeDeskSettings(); - if (next) switchDesk(next.id); - }); -} - -// Cycle the active view forward (+1) or backward (-1) within the active -// desk's view list. Wraps around. Skips floating views since they have their -// own z-stack and shouldn't be in the linear cycle. -function cycleViews(direction) { - const desk = activeDeskObj(); - if (!desk) { - return; - } - const cyclable = desk.views.filter((v) => !v.floating); - if (!cyclable.length) { - return; - } - const idx = cyclable.findIndex((v) => v.id === activeViewId); - const next = cyclable[(idx + direction + cyclable.length) % cyclable.length]; - focusView(next.id); -} - -// Toggle the active desk between tiled (multiple cards) and single (one -// view at a time). When switching tiled → single, all but the active -// tiled view get stacked, so the surface immediately reflects the new mode. -function toggleDeskLayout() { - const desk = activeDeskObj(); - if (!desk) { - return; - } - desk.layoutMode = desk.layoutMode === "single" ? "tiled" : "single"; - if (desk.layoutMode === "single") { - // Force everyone except the active view to stacked (or stay floating). - desk.views.forEach((v) => { - if (v.id !== activeViewId && !v.floating) v.stacked = true; - }); - // Collapse the tree to a single leaf (the active view), if any. - desk.tree = - activeViewId && findLeaf(desk.tree, activeViewId) - ? makeLeaf(activeViewId) - : null; - } - rerenderAll({ deskRow: true }); -} - -// Density toggle: flips body[data-density] between "cozy" (default) and -// "comfortable". The CSS swaps a batch of layout tokens in one go. -// TODO: should we keep that? -function toggleDensity() { - const next = - document.body.dataset.density === "comfortable" ? "cozy" : "comfortable"; - if (next === "cozy") delete document.body.dataset.density; - else document.body.dataset.density = next; - persist(); -} - -// Mouse-to-edge re-shows the dock when it's hidden to give users a way -// out without needing to remember the shortcut. The edge tested depends -// on the current dock position so the trigger always matches where the -// dock would re-appear. -document.addEventListener("mousemove", (e) => { - if (!document.body.dataset.dockHidden) { - return; - } - const distance = - dockPosition === "left" - ? e.clientX - : dockPosition === "right" - ? window.innerWidth - e.clientX - : window.innerHeight - e.clientY; - if (distance < 12) { - delete document.body.dataset.dockHidden; - persist(); - } -}); - -/* ============================================================================ - Bootstrap - ---------------------------------------------------------------------------- - Build a thin shim that exposes the LayoutManager API the rest of the app - (services, app.js) expects, while delegating to the workshop's desk model. - The shim's `addWebView` mounts the real `` element into the - active desk and triggers a re-render so the tile grid picks it up. - ============================================================================ */ - -const layoutManager = { - homescreenWebviewId: null, - get activeWebviewId() { - return activeViewId; - }, - - // `intent` is `{ direction, anchorViewId, mode }`: - // - direction: "horizontal" (split right) or "vertical" (split below). - // Defaults to "horizontal". - // - anchorViewId: leaf to split against. Defaults to activeViewId. - // - mode: "split" (default) inserts a split at the anchor. "replace" - // swaps the anchor leaf and demotes the old view to the stack. - // "first" makes the view the sole leaf (used for the first tile of - // an empty desk). - addWebView(webView, intent = {}) { - if (!webView.webviewId) { - webView.webviewId = `wv-${Date.now()}-${Math.random().toString(36).slice(2)}`; - } - const id = webView.webviewId; - const desk = activeDeskObj(); - if (!desk) { - return; - } - const view = { - id, - webView, - name: webView.title || "Loading…", - host: webView.url || "", - path: "", - // Default favicon tint until the webview reports one: tinted toward - // the desk's brand color so a fresh view doesn't read as identity-less. - fav: desk.color, - themeColor: "transparent", - }; - desk.views.push(view); - - // Place the new view in the tree based on intent. Single-mode desks - // always replace the existing leaf; tiled desks honor the requested - // split direction. Empty desks (no tree yet) just adopt this as the - // root leaf. - const direction = intent.direction || "horizontal"; - const mode = intent.mode || "split"; - if (!desk.tree) { - desk.tree = makeLeaf(id); - } else if (desk.layoutMode === "single" || mode === "replace") { - // Replace the only/active leaf; previous occupant goes to the stack. - const anchorId = intent.anchorViewId || activeViewId; - const anchorLeaf = findLeaf(desk.tree, anchorId); - if (anchorLeaf) { - const ousted = anchorLeaf.viewId; - desk.tree = replaceLeaf(desk.tree, ousted, id); - const oustedView = findView(ousted); - if (oustedView && oustedView.id !== id) oustedView.stacked = true; - } else { - desk.tree = makeLeaf(id); - } - } else { - // Split: insert at the anchor leaf. If the anchor isn't in the tree - // (e.g., active view was stacked), split at the right-most leaf as - // a sensible default. - const anchorId = intent.anchorViewId || activeViewId; - let target = anchorId && findLeaf(desk.tree, anchorId) ? anchorId : null; - if (!target) { - const ids = leafViewIds(desk.tree); - target = ids.at(-1); - } - if (target) { - desk.tree = insertSplit(desk.tree, target, id, direction); - } else { - desk.tree = makeLeaf(id); - } - } - - activeViewId = id; - document.body.dataset.activeDeskEmpty = "false"; - rerenderAll({ deskRow: true }); - }, - - setActiveWebView(id) { - if (!findView(id)) { - return; - } - const desk = findDeskForView(id); - if (desk && desk.id !== activeDesk) { - activeDesk = desk.id; - hydrateDesk(desk); - renderDeskRow(); - } - focusView(id); - }, - - removeWebView(id) { - for (const desk of DESKS) { - const idx = desk.views.findIndex((v) => v.id === id); - if (idx < 0) continue; - const view = desk.views[idx]; - // Release blob URLs captured for overview thumbnails and the - // favicon: both came from the webview as object URLs and would - // otherwise outlive their element. - if (view.webView?.screenshotUrl) { - URL.revokeObjectURL(view.webView.screenshotUrl); - view.webView.screenshotUrl = null; - } - if (view.faviconUrl?.startsWith("blob:")) { - URL.revokeObjectURL(view.faviconUrl); - view.faviconUrl = ""; - } - if (view.webView && view.webView.parentElement) { - view.webView.parentElement.removeChild(view.webView); - } - desk.views.splice(idx, 1); - // Prune the tree so the surviving sibling collapses up into the - // freed space. removeLeaf is a no-op for views that weren't tiled. - desk.tree = removeLeaf(desk.tree, id); - // Reassign the active view from the active desk's remaining views since - // the active view must always belong to the active desk. If the - // closed view was on a different desk, leave activeViewId alone. - if (activeViewId === id) { - // Prefer a tiled survivor on the active desk; fall back to any. - const adesk = activeDeskObj(); - if (adesk) { - activeViewId = tiledViews(adesk)[0]?.id ?? adesk.views[0]?.id ?? null; - } else { - activeViewId = null; - } - } - document.body.dataset.activeDeskEmpty = String( - !activeDeskObj()?.views.length, - ); - rerenderAll(); - return; - } - }, -}; - -/* ========================================================================== - Initial render - ---------------------------------------------------------------------------- - Wire the surface module's deps now that `layoutManager` exists, then - paint the initial frame. Must precede every other init / event wiring - that might fire a focus or close on a webview. - ========================================================================== */ - -initSurface({ - state, - DESKS, - elWorking, - layoutManager, - activeDeskObj, - findView, - findDeskForView, - tiledViews, - stackedViews, - floatingViews, - viewMode, - renderViewChips, - renderTopbar, - applyThemeTint, - openOverview, - persist, -}); - -// Restore persisted state (if any) before the initial render so the -// first paint already reflects the user's last session. Returns true -// when restored. -const savedSnapshot = store.load(); -if (savedSnapshot?.desks?.length) { - restoreSnapshot(savedSnapshot); - // Only the active desk gets its WebViews mounted at boot. Background - // desks stay lazy until switchDesk() activates them. - hydrateDesk(activeDeskObj()); -} - -document.body.dataset.activeDeskEmpty = String(!activeDeskObj()?.views.length); -renderDesks(); -renderDeskRow(); -renderViewChips(); -applyThemeTint(); -applyDeskWallpaper(); -renderTopbar(); - -// Listen for bubbling events from web-view elements anywhere on the page. -document.addEventListener("webview-close", (e) => { - layoutManager.removeWebView(e.detail.webviewId); -}); -document.addEventListener("webview-focus", (e) => { - layoutManager.setActiveWebView(e.detail.webviewId); -}); -// Real favicon URLs surface on every navigation; store on the view and -// surgically update the topbar shield + every `.X-fav` element wearing -// this view's id. Skips the full chip/topbar render to keep the favicon -// stream cheap. -document.addEventListener("webview-favicon-change", (e) => { - const view = findView(e.detail.webviewId); - if (!view) { - return; - } - view.faviconUrl = e.detail.favicon || ""; - const selector = `[data-view="${view.id}"] .dock-view-fav, - [data-view="${view.id}"] .overview-slot-fav, - [data-view="${view.id}"] .overview-card-fav`; - document.querySelectorAll(selector).forEach((el) => applyFavicon(el, view)); - if (view.id === activeViewId) applyFavicon(elTopbarFav, view); -}); -// Load progress: when the active view's webview is mid-load, paint a -// thin amber shimmer along the bottom of the topbar (the webview's own -// .load-progress bar lives inside the bar element, which is hidden in -// embedded mode so without this the user has no signal). -document.addEventListener("webview-load-status-change", (e) => { - const view = findView(e.detail.webviewId); - if (!view) { - return; - } - view.loadStatus = e.detail.status; - if (view.id === activeViewId) reflectActiveLoadStatus(); -}); -function reflectActiveLoadStatus() { - const view = activeViewObj(); - const loading = view?.loadStatus === "started"; - if (loading) document.body.dataset.activeLoading = "true"; - else delete document.body.dataset.activeLoading; -} -document.addEventListener("webview-title-change", (e) => { - const view = findView(e.detail.webviewId); - if (!view) { - return; - } - view.name = e.detail.title || view.name; - // Only re-render chrome elements that show this view's name. Titles fire - // on every navigation across every desk; scope the work to what's visible. - const desk = findDeskForView(view.id); - if (desk?.id !== activeDesk) { - return; - } - renderViewChips(); - if (view.id === activeViewId) renderTopbar(); - if (document.body.dataset.overviewOpen) renderOverview(); - persist(); -}); -document.addEventListener("webview-themecolor-change", (e) => { - const view = findView(e.detail.webviewId); - if (!view) { - return; - } - view.themeColor = e.detail.themeColor || "transparent"; - // Only re-apply the global tint if this view is the active one. Other - // views' theme changes shouldn't repaint the dock halo or topbar. - if (view.id === activeViewId) applyThemeTint(); - persist(); -}); -document.addEventListener("navigation-state-changed", (e) => { - const view = findView(e.detail.webviewId); - if (!view) { - return; - } - view.canGoBack = !!e.detail.canGoBack; - view.canGoForward = !!e.detail.canGoForward; - if (e.detail.url) { - // Split the URL into host + path for the topbar's secondary host slot. - try { - const u = new URL(e.detail.url); - view.host = u.host || u.protocol.replace(":", "") + "//" + u.pathname; - view.path = u.pathname + u.search + u.hash; - // Strip the trailing "/" on bare domains. - if (view.path === "/") view.path = ""; - } catch { - view.host = e.detail.url; - view.path = ""; - } - } - if (view.id === activeViewId) renderTopbar(); - persist(); -}); -document.addEventListener("webview-blocker-status-change", (e) => { - const view = findView(e.detail.webviewId); - if (!view) { - return; - } - view.blockerOrigin = e.detail.origin || ""; - view.blockerEnabled = e.detail.enabled !== false; - view.blockerBlockedCount = e.detail.blockedCount || 0; - view.blockerAllowedCount = e.detail.allowedCount || 0; - view.blockerBadgeCount = e.detail.badgeCount || 0; - if (view.id === activeViewId) renderTopbarBlocker(view); -}); -document.addEventListener("webview-split", (e) => { - // The web-view's menu dispatches { webviewId, direction }. Wire it - // through to addWebView's split intent so the new tile lands on the - // requested side of the splitter source view (not just the active one). - const direction = e.detail?.direction || "horizontal"; - const anchorViewId = e.detail?.webviewId || activeViewId; - createNewView(layoutManager, { direction, anchorViewId }); -}); - -// Register the notification panel with the service so service.add() pushes -// updates to it. Done before initApp so any startup notifications (pairing -// requests, etc.) reach the panel from the first frame. -if (elNotificationPanel) registerPanel(elNotificationPanel); - -// Bootstrap services + open the initial view (or honor ?open=URL). -const { pairingHandler } = initApp(layoutManager, false, () => ({})); -// Wire panel events now that initApp has built pairingHandler. Without -// these, clicking, dismissing, or clearing notifications were silent no-ops. -if (elNotificationPanel) { - elNotificationPanel.addEventListener("notification-click", (e) => { - handleNotificationClick(e.detail.notification, { - layoutManager, - panel: elNotificationPanel, - pairingHandler, - }); - }); - elNotificationPanel.addEventListener("notification-dismiss", (e) => { - dismissNotification(e.detail.notification); - }); - elNotificationPanel.addEventListener("notification-clear-all", () => { - clearAllNotifications(); - }); - elNotificationPanel.addEventListener("panel-closed", () => { - elNotificationPanel.open = false; - }); -} -// Mousetrap shortcuts must register *after* initApp so they override the -// conflicting bindings from services/shortcuts.js (mod+f / mod+] / mod+[ -// / mod+e / escape) -initOverview({ - state, - elOverview, - elOverviewBackdrop, - elOverviewClose, - elOverviewModeToggle, - elOverviewNewDesk, - elOverviewTitle, - elOverviewModeLabel, - elOverviewSurface, - elOverviewSummary, - elOverviewStack, - elOverviewSurfaceHint, - elOverviewStackHint, - activeDeskObj, - findView, - tiledViews, - stackedViews, - floatingViews, - viewMode, - promoteToTiled, - focusView, - rerenderAll, - toggleDeskLayout, - createDesk, - applyFavicon, - shortHost, -}); -initPalette({ - DESKS, - PEERS, - ACTIONS, - APPS, - layoutManager, - elPalette, - elPaletteInput, - elPaletteResults, - elPaletteBackdrop, - activeViewObj, - switchDesk, - focusView, - openOverview, - openOrFocusApp, - createNewView, - createDesk, - openDeskSettings, - toggleDensity, - toggleDeskLayout, - openView, - setDockPosition, -}); -initShortcuts({ - DESKS, - activeViewObj, - openPalette, - closePalette, - openOverview, - closeOverview, - closeTopbarMenu, - closeDeskSettings, - switchDesk, - toggleFloatActive, - toggleDensity, - cycleViews, - createDesk, - cycleDockPosition, - persist, -}); -const params = new URLSearchParams(window.location.search); -// Open the start URL only when there's nothing else to show. Restored -// snapshot or `?open=URL` both suppress the default tab. -const hasAnyView = DESKS.some((d) => d.views.length > 0); -if (!params.get("open") && !hasAnyView) { - createNewView(layoutManager); -} - -/* ============================================================================ - Peers: wire ambient stage chips + tray badge to the live pairing API - ---------------------------------------------------------------------------- - When pairing is enabled, listens for peer events and refreshes the - ambient peer chips + tray peer count. - ============================================================================ */ - -// Round-robin palette so peer chip dots aren't all the same color. The tints -// echo the desk colors. -const PEER_DOT_PALETTE = [ - "oklch(48% 0.12 155)", - "oklch(72% 0.1 78)", - "oklch(58% 0.16 38)", - "oklch(58% 0.14 250)", - "oklch(52% 0.18 320)", -]; - -async function refreshPeers() { - const pairing = navigator.embedder?.pairing; - if (!pairing) { - return; - } - let present = []; - try { - const all = await pairing.peers(); - // Statuses worth surfacing as "they're here" peer chips: a paired - // peer that's currently connected, or a guest peer that's connected. - // "discovered" means visible on the network but not paired. - present = all.filter( - (p) => p.status === "paired-connected" || p.status === "guest-connected", - ); - } catch { - // Pairing disabled or local() rejected. Clear and bail quietly. - present = []; - } - PEERS.length = 0; - present.forEach((p, i) => { - PEERS.push({ - id: p.id, - name: p.displayName || p.id, - color: PEER_DOT_PALETTE[i % PEER_DOT_PALETTE.length], - // Stagger the pulse phase so multiple peers don't blink in unison. - delay: `${(i * 0.9).toFixed(1)}s`, - }); - }); - // The ambient peer chips are gone; only the tray badge surfaces peer - // presence now (and the palette's Peers group still picks up PEERS). - updateTrayPeersBadge(); -} - -function updateTrayPeersBadge() { - const badge = document.getElementById("tray-peers-badge"); - if (!badge) { - return; - } - if (PEERS.length === 0) { - badge.classList.add("hidden"); - badge.textContent = ""; - } else { - badge.classList.remove("hidden"); - badge.textContent = String(PEERS.length); - } -} - -const pairing = navigator.embedder?.pairing; -if (pairing) { - pairing.addEventListener("peerdiscovered", refreshPeers); - pairing.addEventListener("peerjoined", refreshPeers); - pairing.addEventListener("peerleft", refreshPeers); - refreshPeers(); -} diff --git a/ui/system/desktop/shortcuts.js.orig b/ui/system/desktop/shortcuts.js.orig deleted file mode 100644 index 0bc7531..0000000 --- a/ui/system/desktop/shortcuts.js.orig +++ /dev/null @@ -1,108 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -/* Global desktop shortcuts using Mousetrap bindings. - Modifier-prefixed bindings use `bindGlobal` so they still fire while - typing in the palette. - */ - -export function init(deps) { - const { - DESKS, - activeViewObj, - openPalette, - closePalette, - openOverview, - closeOverview, - closeTopbarMenu, - closeDeskSettings, - switchDesk, - toggleFloatActive, - toggleDensity, - cycleViews, - createDesk, - cycleDockPosition, - persist, - } = deps; - - function bindKey(modKey, fn) { - if (!modKey) { - return; - } - Mousetrap.bindGlobal(modKey, () => { - fn(); - return false; - }); - } - - // Esc: close overlays / hide debug. Only fires outside inputs; the - // palette input has its own escape handler that closes the palette. - Mousetrap.bind("escape", () => { - if (document.body.dataset.deskSettingsOpen) closeDeskSettings(); - else if (document.body.dataset.topbarMenuOpen) closeTopbarMenu(); - else if (document.body.dataset.paletteOpen) closePalette(); - else if (document.body.dataset.overviewOpen) closeOverview(); - else if (document.body.dataset.debug) delete document.body.dataset.debug; - return false; - }); - - // Mod+K : toggle palette. - bindKey("mod+k", () => { - if (document.body.dataset.paletteOpen) closePalette(); - else openPalette(); - }); - - // Mod+O : toggle the desk overview panel. - bindKey("mod+o", () => { - if (document.body.dataset.overviewOpen) closeOverview(); - else openOverview(); - }); - - // Mod+L : focus the URL bar (palette in edit-url mode for the active view). - // Overrides shortcuts.js's mod+l (which targets the legacy webview map). - bindKey("mod+l", () => { - if (activeViewObj()) openPalette({ editUrl: true }); - }); - - // Mod+1 / Mod+2 / Mod+3 : switch desk. - for (let i = 1; i <= 9; i++) { - const idx = i - 1; - bindKey(`mod+${i}`, () => { - if (idx < DESKS.length) switchDesk(DESKS[idx].id); - }); - } - - // Mod+Shif+N : new desk. Lands on the freshly-created (empty) desk. - bindKey("mod+shift+n", createDesk); - - // Mod+. : toggle dock auto-hide. - bindKey("mod+.", () => { - if (document.body.dataset.dockHidden) - delete document.body.dataset.dockHidden; - else document.body.dataset.dockHidden = "true"; - persist(); - }); - - // Mod+Shif+D : cycle dock position (bottom -> left -> right -> bottom). - bindKey("mod+shift+d", cycleDockPosition); - - // Mod+F : float <-> tile the active view. Overrides shortcuts.js's mod+f - // (which opens a separate search OS window). - // TODO: remove legacy behavior - bindKey("mod+f", toggleFloatActive); - - // Mod+\ : toggle density. - bindKey("mod+\\", toggleDensity); - - // Mod+[ / Mod+] : cycle through views in the active desk. Overrides - // shortcuts.js's mod+]/mod+[ (which call nextPanel/prevPanel, methods - // the new desktop layoutManager shim doesn't implement). - bindKey("mod+]", () => cycleViews(1)); - bindKey("mod+[", () => cycleViews(-1)); - - // Mod+D : toggle layer outlines. - // TODO: remove? - bindKey("mod+d", () => { - if (document.body.dataset.debug) delete document.body.dataset.debug; - else document.body.dataset.debug = "true"; - }); -}