diff --git a/ui/homescreen/index.css b/ui/homescreen/index.css index 6b65546..2bdd3af 100644 --- a/ui/homescreen/index.css +++ b/ui/homescreen/index.css @@ -48,6 +48,7 @@ body { display: flex; flex-direction: column; justify-content: flex-end; + margin-bottom: 3.5em; } /* Bookmarks grid */ @@ -119,154 +120,3 @@ body { color: var(--color-text-secondary); margin-top: 2px; } - -/* Results area - overlays bookmarks when searching */ -.results-area { - display: none; - flex: 1; - overflow-y: auto; - padding: var(--spacing-lg); - background: var(--bg-main); -} - -body.searching .bookmarks-area { - display: none; -} - -body.searching .widgets-area { - display: none; -} - -body.searching .results-area { - display: block; -} - -/* Search bar fixed at bottom */ -.search-bar { - padding: var(--spacing-sm) var(--spacing-md) var(--spacing-md); -} - -.search-input-container { - display: flex; - align-items: center; - background: var(--bg-menu); - border-radius: var(--radius-lg); - padding: var(--spacing-sm) var(--spacing-sm); - max-width: 560px; - margin: 0 auto; - box-shadow: 0 2px 8px var(--color-shadow-menu); - transition: - box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1), - background 0.25s cubic-bezier(0.16, 1, 0.3, 1); -} - -.search-input-container:focus-within { - background: var(--bg-webview); - box-shadow: - 0 2px 12px var(--color-shadow), - 0 0 0 2px var(--color-focus-ring); -} - -.search-input-container lucide-icon { - color: var(--color-text-tertiary); - margin-right: var(--spacing-sm); - flex-shrink: 0; - transition: color var(--transition-fast); - cursor: pointer; -} - -.search-input-container:focus-within lucide-icon { - color: var(--color-primary); -} - -.search-input { - flex: 1; - border: none; - background: transparent; - font-size: 16px; - font-family: var(--font-family-base); - color: var(--color-text); - outline: none; - min-width: 0; -} - -.search-input::placeholder { - color: var(--color-text-tertiary); -} - -/* Results list styling */ -.results-list { - list-style: none; - max-width: 560px; - margin: 0 auto; -} - -.result-group { - display: flex; - margin-bottom: var(--spacing-xs); -} - -.result-group-icon { - width: 2em; - display: flex; - align-items: flex-start; - padding-top: var(--spacing-sm); - flex-shrink: 0; -} - -.result-group-icon lucide-icon { - font-size: 1em; - opacity: var(--opacity-muted); -} - -.result-group-items { - flex: 1; -} - -.result-item { - padding: var(--spacing-sm) var(--spacing-md); - background: var(--bg-webview); - transition: background var(--transition-fast); -} - -.result-group-items .result-item:first-child { - border-radius: var(--radius-sm) var(--radius-sm) 0 0; -} - -.result-group-items .result-item:last-child { - border-radius: 0 0 var(--radius-sm) var(--radius-sm); -} - -.result-group-items .result-item:only-child { - border-radius: var(--radius-sm); -} - -.result-item[data-kind="link"], -.result-item[data-kind="webview"] { - cursor: pointer; -} - -.result-item[data-kind="link"]:hover, -.result-item[data-kind="webview"]:hover { - background: var(--bg-header); -} - -.result-link { - color: var(--color-primary); - text-decoration: none; - font-weight: var(--font-weight-bold); - display: block; -} - -.result-item[data-kind="link"]:hover .result-link, -.result-item[data-kind="webview"]:hover .result-link { - color: var(--color-text-on-header); -} - -.result-item[data-kind="text"] { - cursor: default; -} - -.result-text { - color: var(--color-text); -} diff --git a/ui/homescreen/index.html b/ui/homescreen/index.html index f45d3f8..68ff8fc 100644 --- a/ui/homescreen/index.html +++ b/ui/homescreen/index.html @@ -24,24 +24,6 @@
- -
-
-
- - diff --git a/ui/homescreen/index.js b/ui/homescreen/index.js index 2925c93..105ee08 100644 --- a/ui/homescreen/index.js +++ b/ui/homescreen/index.js @@ -2,9 +2,6 @@ // DOM elements const bookmarksGrid = document.getElementById("bookmarks-grid"); -const resultsArea = document.getElementById("results-area"); -const resultsList = document.getElementById("results-list"); -const searchInput = document.getElementById("search-input"); const widgetsArea = document.getElementById("widgets-area"); // Load widgets from JSON @@ -58,143 +55,12 @@ function renderBookmarks(bookmarks) { `; // TODO: replace with item.addEventListener("click", () => { - navigate(bookmark.url); + window.open(bookmark.url, "_blank"); }); bookmarksGrid.appendChild(item); }); } -// Reset search UI to initial state -function resetSearchState() { - searchInput.value = ""; - searchInput.blur(); - document.body.classList.remove("searching"); - controller.clear(); -} - -// Navigate to a URL -function navigate(url) { - resetSearchState(); - window.open(url, "_blank"); -} - -class Controller { - constructor() { - this.inner = null; - } - - async ensureController() { - if (this.inner) { - return; - } - - let mod = await import("beaver://shared/search/controller.js"); - this.inner = new mod.SearchController({ - onNavigate: navigate, - onResultsChanged: renderResults, - debounceDelay: 150, - }); - } - - async handleResultClick(result) { - await this.ensureController(); - this.inner.handleResultClick(result); - } - - async handleSubmit(data) { - await this.ensureController(); - this.inner.handleSubmit(data); - } - - async query(query) { - await this.ensureController(); - this.inner.query(query); - } - - async clear() { - await this.ensureController(); - this.inner.clear(); - } -} - -// Initialize search controller -const controller = new Controller(); - -// Render search results (adapted from new_view.js) -function renderResults(results, groups) { - resultsList.innerHTML = ""; - - if (results.length === 0) { - return; - } - - for (const group of groups) { - const groupDiv = document.createElement("div"); - groupDiv.className = "result-group"; - - // Icon column - const iconDiv = document.createElement("div"); - iconDiv.className = "result-group-icon"; - if (group.providerIcon) { - const icon = document.createElement("lucide-icon"); - icon.setAttribute("name", group.providerIcon); - iconDiv.appendChild(icon); - } - groupDiv.appendChild(iconDiv); - - // Items column - const itemsDiv = document.createElement("div"); - itemsDiv.className = "result-group-items"; - - for (const result of group.items) { - const itemDiv = document.createElement("div"); - itemDiv.className = "result-item"; - itemDiv.dataset.kind = result.kind; - - if (result.kind === "link" || result.kind === "webview") { - const link = document.createElement("a"); - link.href = result.kind === "link" ? result.value.url : "#"; - link.className = "result-link"; - link.textContent = result.value.title; - link.addEventListener("click", (e) => { - e.preventDefault(); - controller.handleResultClick(result); - }); - itemDiv.appendChild(link); - } else if (result.kind === "text") { - const text = document.createElement("span"); - text.className = "result-text"; - text.textContent = result.value; - itemDiv.appendChild(text); - } - - itemsDiv.appendChild(itemDiv); - } - - groupDiv.appendChild(itemsDiv); - resultsList.appendChild(groupDiv); - } -} - -// Event listeners -searchInput.addEventListener("input", () => { - const query = searchInput.value.trim(); - if (query) { - document.body.classList.add("searching"); - controller.query(query); - } else { - document.body.classList.remove("searching"); - controller.clear(); - } -}); - -searchInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - controller.handleSubmit(searchInput.value); - } -}); - // Port for communicating with system app splash screen let splashPort = null; let homescreenReady = false; @@ -242,14 +108,6 @@ function signalReady() { // Initialize and signal when ready async function initialize() { - document.getElementById("clear-input").addEventListener("click", () => { - searchInput.value = ""; - document.body.classList.remove("searching"); - controller.clear(); - // TODO: figure out a way to not lose searchInput focus when tapping - // the icon to prevent the hide/show again keyboard flash. - searchInput.focus(); - }); await Promise.all([loadWidgets(), loadBookmarks()]); signalReady(); } diff --git a/ui/system/mobile/index.css b/ui/system/mobile/index.css index 0c830e6..fb76529 100644 --- a/ui/system/mobile/index.css +++ b/ui/system/mobile/index.css @@ -9,7 +9,7 @@ body.mobile-mode #header { display: none; } -/* Root becomes full-screen container for carousel */ +/* Root becomes full-screen container for the webview carousel */ body.mobile-mode #root { flex: 1; display: block; @@ -50,7 +50,7 @@ body.mobile-mode web-view::part(bar) { display: none; } -/* Footer (keyboard) adjustments for mobile */ +/* Footer (virtual keyboard) on mobile */ body.mobile-mode #footer { position: fixed; bottom: 0; @@ -59,258 +59,6 @@ body.mobile-mode #footer { z-index: var(--z-chrome); } -/* Mobile action bar adjustments when keyboard is open */ -body.mobile-mode mobile-action-bar { - transition: bottom var(--transition-fast); -} - -body.mobile-mode.keyboard-open mobile-action-bar { - bottom: var(--keyboard-height); -} - -/* ============================================================================ - Peek Preview Styles - ============================================================================ */ - -.mobile-peek-preview { - position: fixed; - top: 0; - width: 100%; - height: 100%; - background: var(--bg-menu); - z-index: var(--z-gesture); - display: flex; - flex-direction: column; - box-shadow: 0 0 20px var(--color-shadow); - opacity: 0; - visibility: hidden; -} - -.mobile-peek-preview.visible { - opacity: 1; - visibility: visible; -} - -/* Animate transform on release (commit or cancel) */ -.mobile-peek-preview.animating { - transition: transform var(--transition-fast); -} - -.mobile-peek-preview.from-left { - left: 0; - transform: translateX(-100%); -} - -.mobile-peek-preview.from-right { - right: 0; - left: auto; - transform: translateX(100%); -} - -.peek-header { - display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-md); - background: var(--bg-header); -} - -.peek-favicon { - width: 20px; - height: 20px; - object-fit: contain; - border-radius: var(--radius-sm); -} - -.peek-favicon[src=""] { - display: none; -} - -.peek-title { - flex: 1; - font-size: var(--font-size-sm); - font-weight: var(--font-weight-bold); - color: var(--color-text-menu); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.peek-screenshot { - flex: 1; - width: 100%; - object-fit: cover; - object-position: top; -} - -/* ============================================================================ - Tab Count Indicator (shown during edge swipe) - ============================================================================ */ - -.mobile-tab-indicator { - position: fixed; - bottom: 60px; - left: 50%; - transform: translateX(-50%); - display: flex; - gap: 6px; - padding: var(--spacing-sm) var(--spacing-md); - background: var(--color-backdrop); - border-radius: var(--radius-lg); - z-index: var(--z-gesture); - opacity: 0; - visibility: hidden; - transition: - opacity var(--transition-fast), - visibility var(--transition-fast); -} - -.mobile-tab-indicator.visible { - opacity: 1; - visibility: visible; -} - -.tab-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--color-text-on-header); - opacity: 0.3; - transition: - opacity var(--transition-fast), - transform var(--transition-fast); -} - -.tab-dot.active { - background: var(--color-focus-ring); - opacity: 1; - transform: scale(1.2); -} - -/* ============================================================================ - Transition Animations - ============================================================================ */ - -/* Slide transition between webviews */ -@keyframes slideInFromLeft { - from { - transform: translateX(-100%); - } - to { - transform: translateX(0); - } -} - -@keyframes slideInFromRight { - from { - transform: translateX(100%); - } - to { - transform: translateX(0); - } -} - -@keyframes slideOutToLeft { - from { - transform: translateX(0); - } - to { - transform: translateX(-100%); - } -} - -@keyframes slideOutToRight { - from { - transform: translateX(0); - } - to { - transform: translateX(100%); - } -} - -body.mobile-mode .mobile-webview-container.slide-in-left { - animation: slideInFromLeft 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; -} - -body.mobile-mode .mobile-webview-container.slide-in-right { - animation: slideInFromRight 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; -} - -body.mobile-mode .mobile-webview-container.slide-out-left { - animation: slideOutToLeft 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; -} - -body.mobile-mode .mobile-webview-container.slide-out-right { - animation: slideOutToRight 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; -} - -/* ============================================================================ - Edge Zone Visual Feedback (subtle) - ============================================================================ */ - -.edge-feedback { - position: fixed; - z-index: var(--z-gesture); - pointer-events: none; - opacity: 0; - transition: opacity var(--transition-fast); -} - -.edge-feedback.active { - opacity: 1; -} - -.edge-feedback.left { - left: 0; - top: 0; - width: 4px; - height: 100%; - background: linear-gradient(to right, var(--color-focus-ring), transparent); -} - -.edge-feedback.right { - right: 0; - top: 0; - width: 4px; - height: 100%; - background: linear-gradient(to left, var(--color-focus-ring), transparent); -} - -.edge-feedback.bottom { - bottom: 0; - left: 0; - width: 100%; - height: 4px; - background: linear-gradient(to top, var(--color-focus-ring), transparent); -} - -.edge-feedback.top { - top: 0; - left: 0; - width: 100%; - height: 4px; - background: linear-gradient(to bottom, var(--color-focus-ring), transparent); -} - -.gesture-edge-overlay { - position: fixed; - z-index: calc(var(--z-gesture) + 1); - touch-action: none; - pointer-events: auto; - background: transparent; -} - -/* Expand edge overlay to full screen during active gesture, - above the peek preview so we keep receiving touch events */ -.gesture-edge-overlay.fullscreen-overlay { - left: 0 !important; - top: 0 !important; - right: auto !important; - bottom: auto !important; - width: 100% !important; - height: 100% !important; -} - /* ============================================================================ Mobile Splash Screen ============================================================================ */ diff --git a/ui/system/mobile/index.js b/ui/system/mobile/index.js index ae7f224..6ca05dd 100644 --- a/ui/system/mobile/index.js +++ b/ui/system/mobile/index.js @@ -1,9 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import "./action_bar.js"; -import "./notification_sheet.js"; -import "./overview.js"; -import "./radial_menu.js"; +import "./pebble/index.js"; import { createNewView, diff --git a/ui/system/mobile/init.js b/ui/system/mobile/init.js index bfdae79..b3349e1 100644 --- a/ui/system/mobile/init.js +++ b/ui/system/mobile/init.js @@ -19,165 +19,75 @@ export function initMobile( layoutManager.setHomescreen(homescreen.webviewId); splash.waitForHomescreen(homescreen); - const actionBar = document.createElement("mobile-action-bar"); - document.body.appendChild(actionBar); - actionBar.setLayoutManager(layoutManager); - layoutManager.setActionBar(actionBar); - - actionBar.addEventListener("action-new-tab", () => createNewView()); - actionBar.addEventListener("action-home", () => switchToHomescreen()); - - const notificationSheet = document.createElement("mobile-notification-sheet"); - document.body.appendChild(notificationSheet); - notificationSheet.tabCount = layoutManager.getTabCount(); - notifications.registerPanel(notificationSheet); + const pebble = document.createElement("mobile-pebble"); + document.body.appendChild(pebble); + pebble.setLayoutManager(layoutManager); + pebble.updateState(); + notifications.registerPanel(pebble); + + // Action dispatches from the pebble + pebble.addEventListener("action-new-tab", () => { + createNewView(); + pebble.updateState(); + }); + pebble.addEventListener("action-home", () => { + switchToHomescreen(); + pebble.updateState(); + }); + pebble.addEventListener("action-nav", (e) => { + const section = e.detail?.section; + if (section === "settings") { + openView("beaver://settings/index.html", "Settings"); + } + }); + pebble.addEventListener("action-open", (e) => { + const url = e.detail?.url; + if (url) openView(url, ""); + }); + pebble.addEventListener("action-share", () => { + // TODO: real share sheet + }); + pebble.addEventListener("action-bookmark", () => { + // TODO: real bookmark add + }); + pebble.addEventListener("action-more", () => { + // TODO: real overflow menu + }); - notificationSheet.addEventListener("notification-click", (e) => { + // Notifications routing + pebble.addEventListener("notification-click", (e) => { notifications.handleClick(e.detail.notification, { layoutManager, - panel: notificationSheet, + panel: pebble, pairingHandler, }); }); - - notificationSheet.addEventListener("notification-dismiss", (e) => { + pebble.addEventListener("notification-dismiss", (e) => { notifications.dismiss(e.detail.notification); }); - - notificationSheet.addEventListener("notification-clear-all", () => { + pebble.addEventListener("notification-clear-all", () => { notifications.clearAll(); }); - notificationSheet.addEventListener("sheet-closed", () => { - notificationSheet.open = false; - }); - - notificationSheet.addEventListener( - "media-action", - services.media.handleAction, - ); - - const overview = document.createElement("mobile-overview"); - document.body.appendChild(overview); + // Media controls embedded in Command state forward via media-action + pebble.addEventListener("media-action", services.media.handleAction); - overview.addEventListener("tab-select", (e) => { - layoutManager.setActiveWebView(e.detail.tabId); + // Refresh pebble state when the carousel changes from outside + document.addEventListener("webview-focus", () => { + pebble.updateState(); }); - overview.addEventListener("tab-close", (e) => { - layoutManager.removeWebView(e.detail.tabId); - updateTabOverviewData(); + document.addEventListener("webview-close", () => { + pebble.updateState(); }); - overview.addEventListener("tab-new", () => { - createNewView(); - overview.open = false; - }); - overview.addEventListener("tab-home", () => { - switchToHomescreen(); - overview.open = false; + document.addEventListener("webview-favicon-change", () => { + pebble.updateState(); }); - overview.addEventListener("overview-close", () => { - overview.open = false; - }); - - const radialMenu = document.createElement("mobile-radial-menu"); - document.body.appendChild(radialMenu); - - radialMenu.addEventListener("radial-action", (e) => { - handleRadialMenuAction(e.detail, layoutManager, { - createNewView, - switchToHomescreen, - openView, - }); + document.addEventListener("webview-themecolor-change", () => { + pebble.updateState(); }); - radialMenu.addEventListener("radial-dismiss", () => { - const entry = layoutManager.getActiveEntry(); - entry?.webview?.dismissPendingContextMenu?.(); + document.addEventListener("navigation-state-changed", () => { + pebble.updateState(); }); - // Override so overview uses the mobile component - layoutManager.showOverview = function () { - updateTabOverviewData(); - overview.open = true; - }; - layoutManager.hideOverview = function () { - overview.open = false; - }; - - // Radial menu trigger from webviews - document - .getElementById("root") - .addEventListener("webview-show-radial-menu", (e) => { - radialMenu.canGoBack = e.detail.canGoBack; - radialMenu.canGoForward = e.detail.canGoForward; - radialMenu.isHomescreen = - layoutManager.activeWebviewId === layoutManager.homescreenWebviewId; - radialMenu.show(e.detail.x, e.detail.y, e.detail.contextMenu); - }); - - // Notification show (edge gesture) - document.addEventListener("mobile-show-notifications", () => { - notificationSheet.notifications = notifications.getAll(); - notificationSheet.tabCount = layoutManager.getTabCount(); - notificationSheet.open = true; - }); - - function updateTabOverviewData() { - let tabs; - if (layoutManager.getOverviewTabs) { - tabs = layoutManager.getOverviewTabs(); - } else { - tabs = []; - for (const [id, entry] of layoutManager.webviews) { - tabs.push({ - id, - title: entry.webview.title || "Untitled", - favicon: entry.webview.favicon || "", - screenshotUrl: entry.webview.screenshotUrl || null, - }); - } - } - overview.tabs = tabs; - overview.activeTabId = layoutManager.activeWebviewId; - notificationSheet.tabCount = tabs.length; - } - - return { actionBar, notificationSheet, overview, radialMenu }; -} - -function handleRadialMenuAction( - detail, - layoutManager, - { createNewView, switchToHomescreen, openView }, -) { - switch (detail.action) { - case "new-view": - createNewView(); - break; - case "home": - switchToHomescreen(); - break; - case "back": - layoutManager.goBack(); - break; - case "forward": - layoutManager.goForward(); - break; - case "reload": - layoutManager.reload(); - break; - case "close-view": - if (layoutManager.activeWebviewId) { - layoutManager.removeWebView(layoutManager.activeWebviewId); - } - break; - case "overview": - layoutManager.showOverview(); - break; - case "settings": - openView("beaver://settings/index.html", "Settings"); - break; - case "context-menu": - layoutManager.getActiveEntry()?.webview?.showPendingContextMenu?.(); - break; - } + return { pebble }; } diff --git a/ui/system/mobile/layout_manager.js b/ui/system/mobile/layout_manager.js index 61b45aa..d6386c4 100644 --- a/ui/system/mobile/layout_manager.js +++ b/ui/system/mobile/layout_manager.js @@ -1,86 +1,31 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import { EdgeGestureHandler } from "./edge_gesture_handler.js"; - export class MobileLayoutManager { constructor(rootElement, webViewBuilder) { this.root = rootElement; this.webViewBuilder = webViewBuilder; - // Linear array of webviews (carousel model) + // Linear array of webviews (carousel model). The homescreen is just + // another tab in the carousel; the only special behaviour is that it + // cannot be closed (see removeWebView). this.webviews = new Map(); // webviewId -> { webview, index } this.webviewOrder = []; // Array of webviewIds in order this.activeIndex = -1; this.activeWebviewId = null; this.nextId = 1; - // Homescreen webview (special, cannot be closed, excluded from overview/swipe) this.homescreenWebviewId = null; - // Overview mode state - this.overviewMode = false; - this.overviewElement = null; - - // Gesture handler - this.gestureHandler = new EdgeGestureHandler(this); - - // Action bar component - this.actionBar = null; - - // Setup event listeners this.setupEventListeners(); } setupEventListeners() { - document.addEventListener("webview-close", (e) => { - this.removeWebView(e.detail.webviewId); - }); - - document.addEventListener("webview-focus", (e) => { - this.setActiveWebView(e.detail.webviewId); + document.addEventListener("webview-close", (event) => { + this.removeWebView(event.detail.webviewId); }); - // Listen for navigation state changes from webviews - document.addEventListener("navigation-state-changed", (e) => { - // Only update if it's from the active webview - if (e.detail.webviewId === this.activeWebviewId && this.actionBar) { - this.actionBar.updateState(); - } - }); - - // Disable all edges when action bar or notification sheet opens - document.addEventListener("action-bar-opened", () => { - this.disableAllEdges(); - }); - document.addEventListener("action-bar-closed", () => { - this.restoreEdges(); - }); - document.addEventListener("sheet-opened", () => { - this.disableAllEdges(); - }); - document.addEventListener("sheet-closed", () => { - this.restoreEdges(); - }); - } - - // Disable all edge gestures (when overlay UI is open) - disableAllEdges() { - this.gestureHandler.setEdgesEnabled({ - left: false, - right: false, - bottom: false, - top: false, - }); - } - - // Restore edge gestures based on current state - restoreEdges() { - const onHomescreen = this.isHomescreen(this.activeWebviewId); - this.gestureHandler.setEdgesEnabled({ - left: !onHomescreen, - right: !onHomescreen, - bottom: !onHomescreen, - top: true, + document.addEventListener("webview-focus", (event) => { + this.setActiveWebView(event.detail.webviewId); }); } @@ -88,21 +33,15 @@ export class MobileLayoutManager { return `wv-${this.nextId++}`; } - // Set the homescreen webview + // Designate the homescreen webview (un-closable; otherwise a normal tab) setHomescreen(webviewId) { this.homescreenWebviewId = webviewId; - // Update edge gestures if homescreen is currently active - if (this.activeWebviewId === webviewId) { - this.restoreEdges(); - } } - // Check if a webview is the homescreen isHomescreen(webviewId) { return webviewId === this.homescreenWebviewId; } - // Get the currently active webview entry getActiveEntry() { if (this.activeWebviewId) { return this.webviews.get(this.activeWebviewId); @@ -110,37 +49,29 @@ export class MobileLayoutManager { return null; } - // Add a new webview to the carousel addWebView(webview) { const id = this.generateId(); webview.webviewId = id; - - // Mark webview as mobile mode for styling webview.classList.add("mobile-mode"); - // Add to the end of the order const index = this.webviewOrder.length; this.webviewOrder.push(id); this.webviews.set(id, { webview, index }); - // Create container for the webview const container = document.createElement("div"); container.className = "mobile-webview-container"; container.dataset.webviewId = id; container.appendChild(webview); this.root.appendChild(container); - // Make this the active webview this.setActiveWebView(id); return webview; } - // Remove a webview from the carousel removeWebView(webviewId) { - // Prevent closing the homescreen + // Homescreen is un-closable if (this.isHomescreen(webviewId)) { - console.warn("[MobileLayoutManager] Cannot close homescreen"); return; } @@ -149,32 +80,25 @@ export class MobileLayoutManager { return; } - const { webview, index } = entry; + const { index } = entry; - // Remove from DOM const container = this.root.querySelector( `.mobile-webview-container[data-webview-id="${webviewId}"]`, ); - if (container) { - container.remove(); - } + container?.remove(); - // Remove from tracking this.webviews.delete(webviewId); this.webviewOrder = this.webviewOrder.filter((id) => id !== webviewId); - // Update indices for remaining webviews this.webviewOrder.forEach((id, newIndex) => { - const e = this.webviews.get(id); - if (e) { - e.index = newIndex; + const view = this.webviews.get(id); + if (view) { + view.index = newIndex; } }); - // If we removed the active webview, activate another one if (this.activeWebviewId === webviewId) { if (this.webviewOrder.length > 0) { - // Activate the webview at the same position or the last one const newIndex = Math.min(index, this.webviewOrder.length - 1); this.setActiveWebView(this.webviewOrder[newIndex]); } else { @@ -184,28 +108,18 @@ export class MobileLayoutManager { } } - // Set the active webview by ID setActiveWebView(webviewId) { - // Remove active state from previous and capture its screenshot if (this.activeWebviewId && this.webviews.has(this.activeWebviewId)) { const prevEntry = this.webviews.get(this.activeWebviewId); prevEntry.webview.active = false; - - // Capture screenshot of the tab we're switching away from - // TODO: fix screenshot capture when not fully visible. - // prevEntry.webview.captureScreenshot(true); - const prevContainer = this.root.querySelector( `.mobile-webview-container[data-webview-id="${this.activeWebviewId}"]`, ); - if (prevContainer) { - prevContainer.classList.remove("active"); - } + prevContainer?.classList.remove("active"); } this.activeWebviewId = webviewId; - // Set active state on new if (webviewId && this.webviews.has(webviewId)) { const entry = this.webviews.get(webviewId); entry.webview.active = true; @@ -213,89 +127,63 @@ export class MobileLayoutManager { const container = this.root.querySelector( `.mobile-webview-container[data-webview-id="${webviewId}"]`, ); - if (container) { - container.classList.add("active"); - } + container?.classList.add("active"); } - - // Update edge gesture areas based on whether we're on the homescreen - this.restoreEdges(); } - // Switch to webview at given index switchTo(index) { if (index < 0 || index >= this.webviewOrder.length) { return; } - const webviewId = this.webviewOrder[index]; - this.setActiveWebView(webviewId); + this.setActiveWebView(this.webviewOrder[index]); } - // Navigate to next webview in carousel (skips homescreen) + // Carousel navigation — homescreen is a normal member of the carousel. nextWebView() { - const regularViews = this.webviewOrder.filter( - (id) => !this.isHomescreen(id), - ); - if (regularViews.length <= 1) { + if (this.webviewOrder.length <= 1) { return; } - - const currentIndex = regularViews.indexOf(this.activeWebviewId); + const currentIndex = this.webviewOrder.indexOf(this.activeWebviewId); if (currentIndex === -1) { - // Currently on homescreen, switch to first regular view - this.setActiveWebView(regularViews[0]); + this.setActiveWebView(this.webviewOrder[0]); } else { - const nextIndex = (currentIndex + 1) % regularViews.length; - this.setActiveWebView(regularViews[nextIndex]); + const next = (currentIndex + 1) % this.webviewOrder.length; + this.setActiveWebView(this.webviewOrder[next]); } } - // Navigate to previous webview in carousel (skips homescreen) prevWebView() { - const regularViews = this.webviewOrder.filter( - (id) => !this.isHomescreen(id), - ); - if (regularViews.length <= 1) { + if (this.webviewOrder.length <= 1) { return; } - - const currentIndex = regularViews.indexOf(this.activeWebviewId); + const currentIndex = this.webviewOrder.indexOf(this.activeWebviewId); if (currentIndex === -1) { - // Currently on homescreen, switch to last regular view - this.setActiveWebView(regularViews[regularViews.length - 1]); + this.setActiveWebView(this.webviewOrder[this.webviewOrder.length - 1]); } else { - const prevIndex = - (currentIndex - 1 + regularViews.length) % regularViews.length; - this.setActiveWebView(regularViews[prevIndex]); + const prev = + (currentIndex - 1 + this.webviewOrder.length) % + this.webviewOrder.length; + this.setActiveWebView(this.webviewOrder[prev]); } } - // Get adjacent webview ID for peek preview (skips homescreen) + // Adjacent webview ID for the Pebble's peek preview during horizontal swipe. getAdjacentWebViewId(direction) { - const regularViews = this.webviewOrder.filter( - (id) => !this.isHomescreen(id), - ); - if (regularViews.length <= 1) { + if (this.webviewOrder.length <= 1) { return null; } - - const currentIndex = regularViews.indexOf(this.activeWebviewId); + const currentIndex = this.webviewOrder.indexOf(this.activeWebviewId); if (currentIndex === -1) { - // On homescreen, no peek return null; } - if (direction === "next") { - const nextIndex = (currentIndex + 1) % regularViews.length; - return regularViews[nextIndex]; - } else { - const prevIndex = - (currentIndex - 1 + regularViews.length) % regularViews.length; - return regularViews[prevIndex]; + return this.webviewOrder[(currentIndex + 1) % this.webviewOrder.length]; } + return this.webviewOrder[ + (currentIndex - 1 + this.webviewOrder.length) % this.webviewOrder.length + ]; } - // Get webview info for peek preview getWebViewInfo(webviewId) { const entry = this.webviews.get(webviewId); if (!entry) { @@ -309,59 +197,19 @@ export class MobileLayoutManager { }; } - // Show action bar (bottom slide-up) - // Not allowed when on homescreen - showActionBar() { - if (this.isHomescreen(this.activeWebviewId)) { - return; - } - if (this.actionBar) { - this.actionBar.show(); - } - } - - // Hide action bar - hideActionBar() { - if (this.actionBar) { - this.actionBar.hide(); - } - } - - // Toggle action bar - toggleActionBar() { - if (this.actionBar) { - this.actionBar.toggle(); - } - } - - // Set the action bar component reference - setActionBar(actionBar) { - this.actionBar = actionBar; - } - - // Perform navigation action on active webview + // Navigation actions on active webview goBack() { - const entry = this.getActiveEntry(); - if (entry) { - entry.webview.goBack(); - } + this.getActiveEntry()?.webview.goBack(); } goForward() { - const entry = this.getActiveEntry(); - if (entry) { - entry.webview.goForward(); - } + this.getActiveEntry()?.webview.goForward(); } reload() { - const entry = this.getActiveEntry(); - if (entry) { - entry.webview.doReload(); - } + this.getActiveEntry()?.webview.doReload(); } - // Navigate to URL in active webview navigateTo(url) { const entry = this.getActiveEntry(); if (entry) { @@ -370,16 +218,10 @@ export class MobileLayoutManager { } } - // Get current URL of active webview getCurrentUrl() { - const entry = this.getActiveEntry(); - if (entry) { - return entry.webview.currentUrl || ""; - } - return ""; + return this.getActiveEntry()?.webview.currentUrl || ""; } - // Get navigation state of active webview getNavigationState() { const entry = this.getActiveEntry(); if (entry) { @@ -391,52 +233,25 @@ export class MobileLayoutManager { return { canGoBack: false, canGoForward: false }; } - // Get tab count (excludes homescreen) + // Tab count includes the homescreen. getTabCount() { - return this.webviewOrder.filter((id) => !this.isHomescreen(id)).length; - } - - // Get tabs for overview (excludes homescreen) - getOverviewTabs() { - return this.webviewOrder - .filter((id) => !this.isHomescreen(id)) - .map((id) => { - const entry = this.webviews.get(id); - return { - id, - title: entry.webview.title || "Untitled", - favicon: entry.webview.favicon || "", - screenshotUrl: entry.webview.screenshotUrl || null, - }; - }); - } - - // ============================================================================ - // Overview Mode (for compatibility, minimal implementation for MVP) - // ============================================================================ - - toggleOverview() { - if (this.overviewMode) { - this.hideOverview(); - } else { - this.showOverview(); - } + return this.webviewOrder.length; } - showOverview() { - this.overviewMode = true; - // TODO: Implement mobile tab overview grid - console.log( - "[MobileLayoutManager] Overview mode enabled (not yet implemented)", - ); - } - - hideOverview() { - this.overviewMode = false; - console.log("[MobileLayoutManager] Overview mode disabled"); + // All tabs for the Pebble's view strip and grid. + getOverviewViews() { + return this.webviewOrder.map((id) => { + const entry = this.webviews.get(id); + return { + id, + title: entry.webview.title || "Untitled", + favicon: entry.webview.favicon || "", + screenshotUrl: entry.webview.screenshotUrl || null, + }; + }); } - // Navigation methods for compatibility with desktop shortcuts + // Compatibility with desktop keyboard shortcuts nextPanel() { this.nextWebView(); } diff --git a/ui/system/mobile/pebble/index.css b/ui/system/mobile/pebble/index.css new file mode 100644 index 0000000..0b7923c --- /dev/null +++ b/ui/system/mobile/pebble/index.css @@ -0,0 +1,1142 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +/* ============================================================================ + Mobile Pebble — three-state morphic control inside a shadow DOM + States : data-state = ambient | glance | command + Docks : data-dock = left | center | right (ambient only) + Hints : data-pressing, data-dragging + ============================================================================ */ + +:host { + --pebble-shadow: + 0 1px 2px oklch(35% 0.03 105 / 0.18), 0 6px 18px oklch(35% 0.03 105 / 0.18), + 0 18px 48px oklch(35% 0.03 105 / 0.12); + --pebble-easing: cubic-bezier(0.16, 1, 0.3, 1); + --pebble-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); + --pebble-morph-duration: 0.42s; + --pebble-side-margin: 12px; + --pebble-drag-x: 0px; + /* Touch target for the drag handle at the top of the glance / command + card. Tall enough to hit easily with a real finger; the visible bar + inside is small (see .handle::before). */ + --pebble-handle-height: 40px; + /* Halo color picked up from the active page's . + Set inline by the JS controller; transparent means "no tint". */ + --pebble-theme-tint: transparent; + + position: fixed; + inset: 0; + z-index: var(--z-sheet); + pointer-events: none; + display: block; + font-family: var(--font-family-base); + color: var(--color-text); +} + +/* Fullscreen gesture catcher — transparent overlay that captures pointer + events outside the pebble during an active gesture. Servo doesn't honour + setPointerCapture reliably, so we keep the touch on a single element by + enlarging the receiving surface to cover the whole viewport. */ +.gesture-catcher { + position: absolute; + inset: 0; + background: transparent; + pointer-events: none; +} + +:host([data-pressing]) .gesture-catcher { + pointer-events: auto; +} + +/* The morphing pebble shell */ +.pebble { + position: absolute; + left: 50%; + bottom: calc(24px + var(--keyboard-offset, 0px)); + width: 92px; + height: 44px; + /* Slight asymmetric radius — a hand-cut stone, not a machine-pressed pill. */ + border-radius: 22px 24px 22px 23px; + background: var(--bg-menu); + box-shadow: var(--pebble-shadow); + /* Theme-color halo, picked up from the active page's . + Lives on `filter` rather than the box-shadow stack so it's never trapped + inside an active CSS animation: the keyframes only touch box-shadow, and + the filter re-evaluates --pebble-theme-tint on every frame. */ + filter: drop-shadow(0 3px 12px var(--pebble-theme-tint)); + transform: translateX(-50%) translateX(var(--pebble-drag-x)); + overflow: hidden; + cursor: pointer; + pointer-events: auto; + user-select: none; + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; + transition: + width var(--pebble-morph-duration) var(--pebble-easing), + height var(--pebble-morph-duration) var(--pebble-easing), + left var(--pebble-morph-duration) var(--pebble-easing), + bottom var(--pebble-morph-duration) var(--pebble-easing), + border-radius var(--pebble-morph-duration) var(--pebble-easing), + transform var(--pebble-morph-duration) var(--pebble-easing), + box-shadow var(--pebble-morph-duration) var(--pebble-easing); +} + +:host([data-state="ambient"]) .pebble { + touch-action: none; + animation: pebble-breath 5.5s ease-in-out infinite; +} + +@keyframes pebble-breath { + 0%, + 100% { + box-shadow: var(--pebble-shadow); + border-radius: 22px 24px 22px 23px; + } + 50% { + box-shadow: + 0 1px 2px oklch(35% 0.03 105 / 0.22), + 0 8px 22px oklch(35% 0.03 105 / 0.22), + 0 22px 56px oklch(35% 0.03 105 / 0.16); + /* Subtle shape settling — the pebble shifts its asymmetry like a + slow breath. Replaces a literal scale (which would fight the + dock-aware translateX on .pebble's transform). */ + border-radius: 24px 22px 23px 22px; + } +} + +:host([data-pressing]) .pebble { + transform: translateX(-50%) translateX(var(--pebble-drag-x)) scale(0.96); +} + +:host([data-state="ambient"][data-dock="left"]) .pebble { + left: var(--pebble-side-margin); + transform: translateX(0) translateX(var(--pebble-drag-x)); +} +:host([data-state="ambient"][data-dock="left"][data-pressing]) .pebble { + transform: translateX(0) translateX(var(--pebble-drag-x)) scale(0.96); +} + +:host([data-state="ambient"][data-dock="right"]) .pebble { + left: calc(100vw - 92px - var(--pebble-side-margin)); + transform: translateX(0) translateX(var(--pebble-drag-x)); +} +:host([data-state="ambient"][data-dock="right"][data-pressing]) .pebble { + transform: translateX(0) translateX(var(--pebble-drag-x)) scale(0.96); +} + +:host([data-state="glance"]) .pebble { + left: var(--pebble-side-margin); + bottom: calc(12px + var(--keyboard-offset, 0px)); + width: calc(100vw - 2 * var(--pebble-side-margin)); + height: 30vh; + min-height: 220px; + border-radius: 28px; + transform: translateX(0); + cursor: default; + /* The page owns all touches inside the sheet — inner scrollers opt back in */ + touch-action: none; + animation: none; +} + +:host([data-state="command"]) .pebble { + left: 0; + bottom: var(--keyboard-offset, 0px); + width: 100vw; + height: calc(80vh - var(--keyboard-offset, 0px)); + border-radius: 28px 28px 0 0; + transform: translateX(0); + cursor: default; + touch-action: none; + animation: none; + box-shadow: + 0 -2px 6px oklch(35% 0.03 105 / 0.16), + 0 -16px 48px oklch(35% 0.03 105 / 0.18); +} + +/* Search mode — when the search input is focused, expand the card to full + size and hide everything except handle + search row + results. Returns + to the underlying state (glance / command) when search clears. */ +:host([data-searching]) .pebble { + left: 0; + bottom: var(--keyboard-offset, 0px); + width: 100vw; + height: calc(95vh - var(--keyboard-offset, 0px)); + min-height: 0; + border-radius: 28px 28px 0 0; + box-shadow: + 0 -2px 6px oklch(35% 0.03 105 / 0.16), + 0 -16px 48px oklch(35% 0.03 105 / 0.18); +} + +:host([data-searching][data-state="glance"]) .pins-row, +:host([data-searching][data-state="glance"]) .tab-strip { + display: none; +} + +:host([data-searching][data-state="command"]) .command-nav, +:host([data-searching][data-state="command"]) .command-section, +:host([data-searching][data-state="command"]) media-control { + display: none; +} + +:host([data-searching]) .results-area { + max-height: none; + flex: 1; +} + +:host([data-dragging]) .pebble { + transition: transform 0s; +} + +/* ============================================================================ + Content layers — crossfade between states + ============================================================================ */ + +.pebble-content { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + opacity: 0; + visibility: hidden; + transition: + opacity 0.18s var(--pebble-easing), + visibility 0.18s var(--pebble-easing); + pointer-events: none; +} + +.pebble-content.active { + opacity: 1; + visibility: visible; + pointer-events: auto; + transition-delay: 0.18s; +} + +/* ----- Ambient ----- */ + +.pebble-ambient { + align-items: center; + justify-content: center; + flex-direction: row; + gap: 6px; + padding: 0 12px; +} + +.ambient-favicon { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 1; + object-fit: contain; + border-radius: 4px; + filter: drop-shadow(0 1px 1px oklch(20% 0.02 105 / 0.15)); +} + +.meta-tabs { + font-size: 10px; + font-weight: var(--font-weight-bold); + color: var(--color-text-secondary); + background: var(--bg-icon); + padding: 2px 6px; + border-radius: 8px; + line-height: 1; + letter-spacing: 0.02em; + min-width: 10px; + text-align: center; +} + +.meta-notif { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--color-primary); + flex-shrink: 0; + display: none; +} + +.meta-notif.active { + display: block; + /* Slightly slower base period; the keyframes carry the rhythm. */ + animation: dot-pulse 3.2s ease-out infinite; + box-shadow: 0 0 0 0 oklch(48% 0.12 155 / 0.5); +} + +@keyframes dot-pulse { + /* Quick rise, slow decay — closer to a firefly's blink than a metronome. + The dot snaps up, then the surrounding ring expands and fades over + most of the cycle. */ + 0% { + transform: scale(1); + box-shadow: 0 0 0 0 oklch(48% 0.12 155 / 0.5); + } + 12% { + transform: scale(1.22); + box-shadow: 0 0 0 2px oklch(48% 0.12 155 / 0.45); + } + 60% { + transform: scale(1); + box-shadow: 0 0 0 8px oklch(48% 0.12 155 / 0); + } + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 oklch(48% 0.12 155 / 0); + } +} + +/* ----- Glance ----- */ + +.pebble-glance { + padding: 0 16px 16px; + gap: 12px; +} + +/* The handle is a full-width grippable stripe at the top of the card, + sized via --pebble-handle-height. No visible bar — the muscle memory + for "drag down from the top of the sheet" is already universal, and + the iOS-modal-handle visual is the most generic possible affordance. + touch-action:none keeps Servo's touch handler aligned with our + pointer-event gestures. */ +.handle { + align-self: stretch; + height: var(--pebble-handle-height); + cursor: grab; + touch-action: none; + flex-shrink: 0; +} + +.search-row { + display: flex; + align-items: center; + gap: 6px; + padding: 12px 14px; + background: var(--bg-surface); + border-radius: var(--radius-lg); + box-shadow: inset 0 0 0 1px var(--color-border); + transition: box-shadow var(--transition-fast); +} + +.search-row:focus-within { + box-shadow: + inset 0 0 0 1px transparent, + 0 0 0 2px var(--color-focus-ring); + background: var(--bg-webview); +} + +.search-icon { + color: var(--color-text-tertiary); + font-size: 18px; +} + +.search-input { + flex: 1; + border: none; + background: transparent; + outline: none; + font-family: inherit; + font-size: 16px; + color: var(--color-text); + min-width: 0; +} + +.search-input::placeholder { + color: var(--color-text-tertiary); +} + +.pins-row { + display: flex; + align-items: stretch; + justify-content: space-between; + gap: 6px; +} + +.pin-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 4px; + background: transparent; + border: none; + border-radius: var(--radius-md); + color: var(--color-text-secondary); + cursor: pointer; + font-family: inherit; + font-size: 11px; + font-weight: var(--font-weight-normal); + min-height: 56px; + transition: + background var(--transition-fast), + color var(--transition-fast), + transform var(--transition-fast); +} + +.pin-btn:hover, +.pin-btn:active { + background: var(--bg-hover); + color: var(--color-text); +} + +.pin-btn:active { + transform: scale(0.94); +} + +.pin-btn:disabled { + opacity: 0.32; + cursor: not-allowed; +} + +.pin-glyph { + font-size: 22px; +} + +.pin-label { + opacity: 0.85; +} + +.pin-more .pin-glyph { + font-size: 18px; +} + +.tab-strip { + display: flex; + gap: 8px; + overflow-x: auto; + scrollbar-width: none; + padding-bottom: 2px; + touch-action: pan-x; + -webkit-overflow-scrolling: touch; +} +.tab-strip::-webkit-scrollbar { + display: none; +} + +.tab-chip { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + background: var(--bg-surface); + border: 1px solid var(--color-border); + border-radius: 14px; + color: var(--color-text-secondary); + cursor: pointer; + font-family: inherit; + font-size: var(--font-size-sm); + max-width: 160px; + transition: + background var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast); +} + +.tab-chip:hover { + background: var(--bg-hover); +} + +.tab-chip.active { + background: var(--bg-webview); + border-color: var(--color-primary); + color: var(--color-text); +} + +.tab-chip .tab-fav { + width: 14px; + height: 14px; + object-fit: contain; + border-radius: 3px; + flex-shrink: 0; +} + +.tab-chip .tab-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tab-chip.tab-add { + padding: 7px 10px; + color: var(--color-text-tertiary); +} + +/* ----- Search results (Glance + Command) ----- */ + +.results-area { + max-height: 30vh; + overflow-y: auto; + border-bottom: 1px solid var(--color-border); + padding-bottom: 8px; + touch-action: pan-y; +} + +.result-group { + display: flex; + margin-bottom: var(--spacing-xs); +} + +.result-group-icon { + width: 2em; + display: flex; + align-items: flex-start; + padding-top: var(--spacing-sm); + flex-shrink: 0; +} + +.result-group-icon lucide-icon { + font-size: 1em; + color: var(--color-text-tertiary); +} + +.result-group-items { + flex: 1; +} + +.result-item { + padding: var(--spacing-sm) var(--spacing-md); + background: var(--bg-surface); + transition: background var(--transition-fast); +} + +.result-group-items .result-item:first-child { + border-radius: var(--radius-sm) var(--radius-sm) 0 0; +} +.result-group-items .result-item:last-child { + border-radius: 0 0 var(--radius-sm) var(--radius-sm); +} +.result-group-items .result-item:only-child { + border-radius: var(--radius-sm); +} + +.result-item[data-kind="link"], +.result-item[data-kind="webview"] { + cursor: pointer; +} + +.result-item[data-kind="link"]:hover, +.result-item[data-kind="webview"]:hover { + background: var(--bg-hover); +} + +.result-link { + color: var(--color-primary); + text-decoration: none; + font-weight: var(--font-weight-bold); + display: block; +} + +.result-text { + color: var(--color-text); +} + +/* ----- Command ----- */ + +.pebble-command { + padding: 0 0 0; +} + +.command-scroll { + flex: 1; + overflow-y: auto; + padding: 0 16px 32px; + display: flex; + flex-direction: column; + gap: 18px; + /* Allow vertical scrolling inside the otherwise-touch-action:none sheet */ + touch-action: pan-y; + -webkit-overflow-scrolling: touch; +} + +.command-nav { + display: flex; + align-items: center; + gap: 8px; + padding-top: 6px; +} + +.nav-link { + display: inline-flex; + align-items: center; + gap: 6px; + background: transparent; + border: none; + font-family: inherit; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + color: var(--color-text-secondary); + cursor: pointer; + padding: 6px 10px; + border-radius: 10px; + transition: background var(--transition-fast); +} + +.nav-link:hover { + background: var(--bg-hover); + color: var(--color-text); +} + +.nav-link:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.section-action { + margin-left: auto; +} + +.nav-spacer { + flex: 1; +} + +.command-search { + margin-top: 2px; +} + +.command-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.section-title { + display: flex; + align-items: baseline; + /* Sentence-case in the base font — no uppercase, no letter-spacing, just + a slightly heavier weight to separate the section name from body text. */ + font-family: var(--font-family-base); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + color: var(--color-text-secondary); + margin: 0; + line-height: 1.2; +} + +.section-title em { + font-style: normal; + font-weight: var(--font-weight-normal); + margin-left: 6px; + color: var(--color-text-tertiary); + font-size: var(--font-size-xs); + font-variant-numeric: tabular-nums; +} + +.tab-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + /* Two safeguards working together: + - `align-content: start` stops the grid from distributing extra vertical + space across rows (the symptom would be empty space inside top-row + cards when the last row mixes a tab card and the new-card). + - `minmax(132px, max-content)` for auto rows guarantees each row is at + least the new-card's intended height (matches .tab-card-new's + min-height), since Servo's `max-content` sizing doesn't include + `min-height` when computing a row's size — without this floor, a + row containing only the new-card collapses to ~70px and the card + overflows into the next section. */ + grid-auto-rows: minmax(132px, max-content); + align-content: start; +} + +.tab-card { + position: relative; + display: flex; + flex-direction: column; + background: var(--bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; + cursor: pointer; + font-family: inherit; + text-align: left; + padding: 0; + /* Hairline top highlight + soft inset — gives a pressed-onto-paper feel + rather than a floating SaaS card. */ + box-shadow: + inset 0 1px 0 oklch(99% 0.005 105 / 0.6), + 0 1px 2px oklch(35% 0.03 105 / 0.06); + transition: + transform var(--transition-fast), + box-shadow var(--transition-fast), + border-color var(--transition-fast); +} + +.tab-card:hover { + transform: translateY(-1px); + box-shadow: + inset 0 1px 0 oklch(99% 0.005 105 / 0.6), + 0 4px 14px var(--color-shadow); +} + +.tab-card.active { + border-color: var(--color-primary); + box-shadow: + inset 0 1px 0 oklch(99% 0.005 105 / 0.6), + 0 0 0 2px oklch(48% 0.12 155 / 0.18); +} + +.tab-card.closing { + animation: tab-card-close 0.3s var(--pebble-easing) forwards; + pointer-events: none; +} + +@keyframes tab-card-close { + to { + transform: translateY(-30px) scale(0.85); + opacity: 0; + } +} + +.tab-thumb { + width: 100%; + aspect-ratio: 4 / 3; + background: var(--bg-webview); + object-fit: cover; + object-position: top; + display: block; +} + +.tab-thumb-placeholder { + display: flex; + align-items: center; + justify-content: center; +} + +.tab-thumb-placeholder lucide-icon { + font-size: 28px; + color: var(--color-text-tertiary); + opacity: var(--opacity-muted); +} + +.tab-card footer { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + font-size: var(--font-size-xs); + color: var(--color-text); + /* Warmer than bg-menu — gives the card footer a "paper label" tint + rather than blending into the rest of the surface stack. */ + background: var(--bg-icon); + border-top: 1px solid var(--color-border); +} + +.tab-card footer .tab-fav { + width: 14px; + height: 14px; + object-fit: contain; + border-radius: 3px; + flex-shrink: 0; +} + +.tab-card footer span:nth-child(2) { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tab-close { + position: absolute; + top: 6px; + right: 6px; + width: 22px; + height: 22px; + border: none; + border-radius: 50%; + background: oklch(20% 0.02 105 / 0.55); + color: oklch(99% 0.005 105); + font-size: 16px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + /* Always visible on touch — there's no hover state to fall back on. */ + opacity: 1; + transition: opacity var(--transition-fast); +} + +.tab-card-new { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + /* Visually equivalent to a real tab card — no dashed-placeholder pattern. */ + background: var(--bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: + inset 0 1px 0 oklch(99% 0.005 105 / 0.6), + 0 1px 2px oklch(35% 0.03 105 / 0.06); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + cursor: pointer; + font-family: inherit; + min-height: 132px; + transition: + transform var(--transition-fast), + box-shadow var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast); +} + +.tab-card-new:hover { + border-color: var(--color-primary); + color: var(--color-text); + transform: translateY(-1px); + box-shadow: + inset 0 1px 0 oklch(99% 0.005 105 / 0.6), + 0 4px 14px var(--color-shadow); +} + +.new-glyph { + font-size: 28px; +} + +.pins-row-wide .pin-btn { + background: var(--bg-surface); +} + +/* ----- Notifications inside Command ----- */ + +.notif-empty { + padding: 18px 14px; + text-align: center; + font-size: var(--font-size-sm); + color: var(--color-text-tertiary); + background: var(--bg-surface); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); +} + +.notif-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.notif-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + background: var(--bg-surface); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + cursor: pointer; +} + +.notif-icon { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--bg-icon); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; + overflow: hidden; +} + +.notif-icon img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.notif-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.notif-body strong { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + color: var(--color-text); +} + +.notif-body p { + margin: 0; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.notif-time { + color: var(--color-text-tertiary) !important; +} + +.notif-dismiss { + background: transparent; + border: none; + color: var(--color-text-tertiary); + font-size: 18px; + cursor: pointer; + padding: 4px 6px; + flex-shrink: 0; +} + +.notif-dismiss:hover { + color: var(--color-text); +} + +/* ----- Content blocker section ----- */ + +.blocker-card { + background: var(--bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.blocker-origin { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.blocker-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.blocker-label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); +} + +.blocker-toggle { + position: relative; + width: 2.4em; + height: 1.4em; +} + +.blocker-toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.blocker-slider { + position: absolute; + inset: 0; + background: var(--color-border); + border-radius: 0.7em; + cursor: pointer; + transition: background var(--transition-fast); +} + +.blocker-slider::before { + content: ""; + position: absolute; + width: 1em; + height: 1em; + left: 0.2em; + bottom: 0.2em; + background: var(--color-text-on-header); + border-radius: 50%; + transition: transform var(--transition-fast); +} + +.blocker-toggle input:checked + .blocker-slider { + background: var(--color-primary); +} + +.blocker-toggle input:checked + .blocker-slider::before { + transform: translateX(1em); +} + +.blocker-stats { + font-size: var(--font-size-xs); + color: var(--color-text-tertiary); +} + +.blocker-count { + color: var(--color-text-secondary); + font-weight: var(--font-weight-bold); +} + +/* ============================================================================ + Radial menu — anchored above the Pebble + ============================================================================ */ + +.radial { + position: absolute; + inset: 0; + pointer-events: auto; +} + +.radial-overlay { + position: absolute; + inset: 0; + background: var(--color-backdrop); + animation: radial-overlay-in 0.2s var(--pebble-easing) forwards; +} + +@keyframes radial-overlay-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.radial-zones { + position: absolute; + left: var(--radial-x, 50%); + top: var(--radial-y, 100%); + width: 0; + height: 0; +} + +.radial-zone { + position: absolute; + width: 56px; + height: 56px; + margin: -28px; + border: none; + border-radius: 50%; + background: var(--bg-menu); + color: var(--color-text-menu); + font-size: 22px; + cursor: pointer; + box-shadow: 0 4px 14px var(--color-shadow-menu); + display: flex; + align-items: center; + justify-content: center; + transition: + transform 0.32s var(--pebble-bounce), + background var(--transition-fast), + opacity var(--transition-fast); +} + +.radial-zone:active { + background: var(--color-focus-ring); +} + +.radial-zone.disabled { + opacity: 0.32; + cursor: not-allowed; +} + +.radial-zone[data-zone="0"] { + transform: translate(0, -110px); + transition-delay: 0.05s; +} +.radial-zone[data-zone="1"] { + transform: translate(75px, -85px); + transition-delay: 0.04s; +} +.radial-zone[data-zone="2"] { + transform: translate(110px, -20px); + transition-delay: 0.03s; +} +.radial-zone[data-zone="3"] { + transform: translate(-110px, -20px); + transition-delay: 0.03s; +} +.radial-zone[data-zone="4"] { + transform: translate(-75px, -85px); + transition-delay: 0.04s; +} + +/* ============================================================================ + Peek preview (tab switch) + ============================================================================ */ + +.peek { + position: absolute; + inset: 0; + pointer-events: none; + display: flex; + align-items: flex-end; + justify-content: center; + padding-bottom: calc(24px + 60px); + opacity: 0; + transition: opacity 0.2s var(--pebble-easing); +} + +.peek.visible { + opacity: 1; +} + +.peek-card { + background: var(--bg-menu); + border-radius: var(--radius-lg); + box-shadow: var(--pebble-shadow); + padding: 14px; + width: 240px; + display: flex; + flex-direction: column; + gap: 10px; + transform: scale(1); + opacity: 1; + transition: + transform 0.22s var(--pebble-easing), + opacity 0.22s var(--pebble-easing); +} + +.peek-card header { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + color: var(--color-text); +} + +.peek-fav { + width: 18px; + height: 18px; + object-fit: contain; + border-radius: 4px; +} + +.peek-thumb { + width: 100%; + height: 120px; + border-radius: var(--radius-md); + background: var(--bg-webview); + object-fit: cover; + object-position: top; +} + +.peek-thumb-placeholder { + display: flex; + align-items: center; + justify-content: center; +} + +.peek-thumb-placeholder lucide-icon { + font-size: 28px; + color: var(--color-text-tertiary); +} + +/* ============================================================================ + Reduced motion + ============================================================================ */ + +@media (prefers-reduced-motion: reduce) { + .pebble, + .pebble-content, + .radial-zone, + .peek-card, + .peek { + transition-duration: 0.01s !important; + animation: none !important; + } +} diff --git a/ui/system/mobile/pebble/index.js b/ui/system/mobile/pebble/index.js new file mode 100644 index 0000000..3c1b6e6 --- /dev/null +++ b/ui/system/mobile/pebble/index.js @@ -0,0 +1,1295 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// TODO: cleanup tab -> view + +import { + LitElement, + html, + css, +} from "beaver://shared/third_party/lit/lit-all.min.js"; + +import { SearchController } from "beaver://shared/search/controller.js"; + +const STATE = { AMBIENT: "ambient", GLANCE: "glance", COMMAND: "command" }; +const DOCK = { LEFT: "left", CENTER: "center", RIGHT: "right" }; + +// Gesture thresholds (px) +const T_PAN = 8; +const T_GLANCE = 24; +const T_COMMAND = 80; +const T_TAB_COMMIT = 40; +const T_LONGPRESS_MS = 480; +const T_DOCK_HMOVE = 18; + +// Radial menu — zone set used on a regular webview (browser navigation) +const RADIAL_ZONES_PAGE = [ + { id: 0, action: "home", icon: "house" }, + { id: 1, action: "tabs", icon: "layout-grid" }, + { id: 2, action: "forward", icon: "arrow-right" }, + { id: 3, action: "back", icon: "arrow-left" }, + { id: 4, action: "search", icon: "search" }, +]; + +// Radial menu — zone set used on the homescreen (no page-nav actions; offer +// home-relevant shortcuts instead). Top-left slot intentionally left empty. +const RADIAL_ZONES_HOME = [ + { id: 0, action: "settings", icon: "settings" }, + { id: 1, action: "tabs", icon: "layout-grid" }, + { id: 2, action: "new-tab", icon: "plus" }, + { id: 4, action: "search", icon: "search" }, +]; + +// Default pinned actions (Glance + Command rows). New-tab lives only on the +// "+" chip at the end of the tab strip — keeping it out of the pins row. +const PINS = [ + { id: "back", icon: "arrow-left", label: "Back" }, + { id: "forward", icon: "arrow-right", label: "Forward" }, + { id: "reload", icon: "rotate-ccw", label: "Reload" }, + { id: "share", icon: "share", label: "Share" }, + { id: "bookmark", icon: "star", label: "Bookmark" }, + // { id: "more", icon: "ellipsis", label: "More" }, +]; + +export class MobilePebble extends LitElement { + static properties = { + // Reflected on host for CSS state selectors + state: { type: String, reflect: true, attribute: "data-state" }, + dock: { type: String, reflect: true, attribute: "data-dock" }, + pressing: { type: Boolean, reflect: true, attribute: "data-pressing" }, + dragging: { type: Boolean, reflect: true, attribute: "data-dragging" }, + searching: { type: Boolean, reflect: true, attribute: "data-searching" }, + // Live data from layout manager / services + url: { type: String }, + canGoBack: { type: Boolean }, + canGoForward: { type: Boolean }, + tabs: { type: Array }, + activeTabId: { type: String }, + tabCount: { type: Number }, + favicon: { type: String }, + themeColor: { type: String, state: true }, + notifications: { type: Array }, + // Content blocker + blockerOrigin: { type: String, state: true }, + blockerEnabled: { type: Boolean, state: true }, + blockerCount: { type: Number, state: true }, + blockerAllowed: { type: Number, state: true }, + // Search results + results: { type: Array, state: true }, + groups: { type: Array, state: true }, + // Overlays + radialOpen: { type: Boolean, state: true }, + radialX: { type: Number, state: true }, + radialY: { type: Number, state: true }, + peekVisible: { type: Boolean, state: true }, + peekTab: { type: Object, state: true }, + peekDirection: { type: Number, state: true }, + // Inline pebble offset during dock-drag (px from current dock anchor) + dragOffset: { type: Number, state: true }, + }; + + static styles = css` + @import url(beaver://system/mobile/pebble/index.css); + `; + + constructor() { + super(); + this.state = STATE.AMBIENT; + this.dock = DOCK.CENTER; + this.pressing = false; + this.dragging = false; + this.searching = false; + + this.url = ""; + this.canGoBack = false; + this.canGoForward = false; + this.tabs = []; + this.activeTabId = null; + this.tabCount = 0; + this.favicon = ""; + this.themeColor = ""; + this.notifications = []; + + this.blockerOrigin = ""; + this.blockerEnabled = true; + this.blockerCount = 0; + this.blockerAllowed = 0; + + this.results = []; + this.groups = []; + + this.radialOpen = false; + this.radialX = 0; + this.radialY = 0; + + this.peekVisible = false; + this.peekTab = null; + this.peekDirection = 0; + + this.dragOffset = 0; + + this.layoutManager = null; + this._g = null; // gesture state object + this._blockerBaselineReset = false; + this._blockerBaseline = 0; + this._lastUrl = ""; + + this._boundDocPointerDown = this._onDocumentPointerDown.bind(this); + this._boundDocKeyDown = this._onDocumentKeyDown.bind(this); + + this._initSearchController(); + } + + connectedCallback() { + super.connectedCallback(); + document.addEventListener("pointerdown", this._boundDocPointerDown, true); + document.addEventListener("keydown", this._boundDocKeyDown); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener( + "pointerdown", + this._boundDocPointerDown, + true, + ); + document.removeEventListener("keydown", this._boundDocKeyDown); + } + + updated(changedProperties) { + super.updated?.(changedProperties); + // When the Glance card opens, force the search input's value to match + // the current URL. Lit's `.value=${this.url}` binding is supposed to + // keep them in sync, but it diffs against the previously *bound* value, + // which can lie if `url` and `state` change in the same batch. + if ( + (changedProperties.has("state") || changedProperties.has("url")) && + this.state === STATE.GLANCE + ) { + const input = this.renderRoot.querySelector( + ".pebble-glance .search-input", + ); + if (input && input.value !== (this.url ?? "")) { + input.value = this.url ?? ""; + } + } + // Reflect the page's theme color onto a custom property the pebble's + // filter: drop-shadow reads from. Empty / default → transparent (no halo). + if (changedProperties.has("themeColor")) { + this.style.setProperty( + "--pebble-theme-tint", + this.themeColor || "transparent", + ); + } + } + + // ----- Public API (called by init.js / layout_manager / services) ----- + + setLayoutManager(lm) { + this.layoutManager = lm; + } + + // Refresh URL / nav state / tabs / favicon from layout manager + updateState() { + if (!this.layoutManager) { + return; + } + const newUrl = this.layoutManager.getCurrentUrl(); + if (newUrl !== this._lastUrl) { + this._lastUrl = newUrl; + this._blockerBaselineReset = true; + } + this.url = newUrl; + const nav = this.layoutManager.getNavigationState(); + this.canGoBack = nav.canGoBack; + this.canGoForward = nav.canGoForward; + this.tabCount = this.layoutManager.getTabCount(); + this.tabs = this.layoutManager.getOverviewViews?.() ?? []; + this.activeTabId = this.layoutManager.activeWebviewId; + const entry = this.layoutManager.getActiveEntry?.(); + this.favicon = entry?.webview?.favicon || ""; + // Theme color from the page (skip the WebView default of "gray"). + const tc = entry?.webview?.themeColor; + this.themeColor = tc && tc !== "gray" ? tc : ""; + this._updateBlockerState(); + } + + // Notifications service `registerPanel` contract: + // - sets `panel.notifications` directly (handled by the Lit reactive prop) + // - sets `panel.open = false` to dismiss (collapses pebble to ambient) + get open() { + return this.state !== STATE.AMBIENT; + } + + set open(value) { + if (!value && this.state !== STATE.AMBIENT) { + this._setState(STATE.AMBIENT); + } + } + + // Keyboard-shortcut friendly helpers (used by services/shortcuts.js) + toggleCommand() { + this._setState( + this.state === STATE.COMMAND ? STATE.AMBIENT : STATE.COMMAND, + ); + } + + collapse() { + if (this.radialOpen) { + this._closeRadial(); + return true; + } + if (this.state !== STATE.AMBIENT) { + this._setState(STATE.AMBIENT); + return true; + } + return false; + } + + // ----- Content blocker ----- + + async _updateBlockerState() { + if (!this.url || !this.url.startsWith("http")) { + this.blockerOrigin = ""; + this.blockerCount = 0; + this.blockerEnabled = true; + this.blockerAllowed = 0; + return; + } + try { + const origin = new URL(this.url).origin; + this.blockerOrigin = origin; + const state = + await navigator.embedder.contentBlocker.getOriginState(origin); + this.blockerCount = state.blockedCount; + this.blockerEnabled = state.enabled; + this.blockerAllowed = state.allowedCount; + if (this._blockerBaselineReset) { + this._blockerBaselineReset = false; + this._blockerBaseline = state.blockedCount; + } + } catch { + this.blockerOrigin = ""; + this.blockerCount = 0; + this.blockerEnabled = true; + this.blockerAllowed = 0; + } + } + + async _onBlockerToggle(e) { + const newEnabled = e.target.checked; + this.blockerEnabled = newEnabled; + try { + await navigator.embedder.contentBlocker.setOriginEnabled( + this.blockerOrigin, + newEnabled, + ); + } catch { + this.blockerEnabled = !newEnabled; + } + } + + _blockerHostname() { + try { + return new URL(this.blockerOrigin).hostname; + } catch { + return this.blockerOrigin; + } + } + + // ----- Search ----- + + _initSearchController() { + this.searchController = new SearchController({ + onNavigate: (url) => this._navigateTo(url), + onSelectWebView: (_windowId, webviewId) => { + this.layoutManager?.setActiveWebView?.(webviewId); + this._setState(STATE.AMBIENT); + }, + onResultsChanged: (results, groups) => { + this.results = results; + this.groups = groups; + }, + debounceDelay: 150, + }); + } + + _navigateTo(url) { + if (this._isOnHomescreen()) { + // Don't replace the homescreen — open the URL in a new view instead. + this.dispatchEvent( + new CustomEvent("action-open", { + bubbles: true, + composed: true, + detail: { url }, + }), + ); + } else { + this.layoutManager?.navigateTo(url); + } + this._setState(STATE.AMBIENT); + } + + _onSearchInput(e) { + const query = e.target.value.trim(); + if (query) { + this.searchController.query(query); + } else { + this.results = []; + this.groups = []; + this.searchController.clear(); + } + } + + _onSearchKeydown(e) { + if (e.key === "Enter") { + const input = e.currentTarget; + const value = input.value.trim(); + if (value) this.searchController.handleSubmit(value); + } else if (e.key === "Escape") { + this._setState(STATE.AMBIENT); + } + } + + _onSearchFocus(e) { + this.searching = true; + // Belt-and-braces: make sure the input shows the current URL before + // selecting it. Lit's `.value=${this.url}` binding is meant to keep this + // in sync, but if it ever drifts (e.g. the input was hidden during the + // last update), reset the value here so the user can overwrite the URL + // with a single keypress. + if (this.url && e.target.value !== this.url) { + e.target.value = this.url; + } + e.target.select(); + } + + _onSearchBlur(e) { + if (!e.target.value) { + this.searching = false; + } + } + + _onResultClick(result) { + this.searchController.handleResultClick(result); + } + + // ----- State machine ----- + + _setState(next) { + if (this.state === next) { + return; + } + this.state = next; + if (next !== STATE.AMBIENT) { + this.updateState(); + } else { + this.results = []; + this.groups = []; + this.searching = false; + this.searchController?.clear(); + } + } + + _setDock(next) { + this.dock = next; + } + + // ----- Gestures (Pointer Events) ----- + + _isInteractive(el) { + return !!el.closest( + "button, input, a, .tab-chip, .nav-link, .tab-card, .notif-item, " + + ".result-item, .blocker-toggle, label, media-control", + ); + } + + _onPebblePointerDown(e) { + if (e.button !== undefined && e.button !== 0) { + return; + } + if (this._isInteractive(e.target)) { + return; + } + // In glance/command, only engage gesture from the handle area + if (this.state !== STATE.AMBIENT && !e.target.closest(".handle")) { + return; + } + + // Servo doesn't honour CSS touch-action; preventDefault on the pointer + // event is what tells its touch handler that the page owns this touch. + e.preventDefault(); + + const shell = this.renderRoot.querySelector(".pebble"); + shell.setPointerCapture(e.pointerId); + this._g = { + pid: e.pointerId, + startX: e.clientX, + startY: e.clientY, + curX: e.clientX, + curY: e.clientY, + startState: this.state, + shellStartLeft: shell.getBoundingClientRect().left, + axis: null, + didPan: false, + didLongPress: false, + isDocking: false, + isTabSwitching: false, + peekDirection: 0, + longPressTimer: null, + }; + this.pressing = true; + if (this.state === STATE.AMBIENT) { + this._g.longPressTimer = setTimeout(() => { + if (this._g && !this._g.didPan) { + this._g.didLongPress = true; + this._openRadial(); + if (navigator.vibrate) navigator.vibrate(8); + } + }, T_LONGPRESS_MS); + } + } + + _onPebblePointerMove(e) { + if (!this._g || e.pointerId !== this._g.pid) { + return; + } + e.preventDefault(); + this._g.curX = e.clientX; + this._g.curY = e.clientY; + const dx = this._g.curX - this._g.startX; + const dy = this._g.curY - this._g.startY; + const adx = Math.abs(dx); + const ady = Math.abs(dy); + + if (!this._g.axis && (adx > T_PAN || ady > T_PAN)) { + this._g.axis = adx > ady ? "x" : "y"; + this._g.didPan = true; + if (this._g.longPressTimer && !this._g.didLongPress) { + clearTimeout(this._g.longPressTimer); + this._g.longPressTimer = null; + } + } + + // After long-press: horizontal motion → enter dock-drag, close radial + if ( + this._g.didLongPress && + !this._g.isDocking && + this.state === STATE.AMBIENT && + adx > T_DOCK_HMOVE + ) { + this._closeRadial(); + // Defensive: if a peek somehow slipped in, clean it up before + // taking over with the dock-drag. + if (this._g.isTabSwitching) { + this._hidePeek(); + this._g.isTabSwitching = false; + this._g.peekDirection = 0; + } + this._g.isDocking = true; + this.dragging = true; + } + + if (this._g.isDocking) { + this.dragOffset = dx; + return; + } + + // Horizontal pan in ambient → tab switch peek. Once a long-press has + // fired, the gesture is committed to the radial / dock-drag flow, so + // never start a peek from here. + if ( + this._g.axis === "x" && + this.state === STATE.AMBIENT && + !this._g.didLongPress + ) { + this._g.isTabSwitching = true; + const dir = dx < 0 ? 1 : -1; + if (this._g.peekDirection !== dir) { + this._hidePeek(); + this._g.peekDirection = dir; + this._showPeek(dir); + } + return; + } + } + + _onPebblePointerUp(e) { + if (!this._g || e.pointerId !== this._g.pid) { + return; + } + const shell = this.renderRoot.querySelector(".pebble"); + shell.releasePointerCapture(e.pointerId); + this.pressing = false; + this.dragging = false; + if (this._g.longPressTimer) clearTimeout(this._g.longPressTimer); + + const dx = this._g.curX - this._g.startX; + const dy = this._g.curY - this._g.startY; + + // Dock-drag finalize + if (this._g.isDocking) { + const finalCenter = this._g.shellStartLeft + dx + 46; // half pebble width + const vw = window.innerWidth; + let nearest = DOCK.CENTER; + if (finalCenter < vw / 3) nearest = DOCK.LEFT; + else if (finalCenter > (vw * 2) / 3) nearest = DOCK.RIGHT; + this.dragOffset = 0; + this._setDock(nearest); + this._g = null; + return; + } + + // Tab-switch finalize + if (this._g.isTabSwitching) { + if (Math.abs(dx) > T_TAB_COMMIT) { + const dir = dx < 0 ? 1 : -1; + this._commitTabSwitch(dir); + } + this._hidePeek(); + this._g = null; + return; + } + + // Long-press kept radial open — let zone / overlay tap finish the gesture + if (this._g.didLongPress && this.radialOpen) { + this._g = null; + return; + } + + // Vertical pan release + if (this._g.axis === "y") { + if (this._g.startState === STATE.AMBIENT) { + if (dy < -T_COMMAND) this._setState(STATE.COMMAND); + else if (dy < -T_GLANCE) this._setState(STATE.GLANCE); + } else if (this._g.startState === STATE.GLANCE) { + if (dy < -T_COMMAND) this._setState(STATE.COMMAND); + else if (dy > T_GLANCE) this._setState(STATE.AMBIENT); + } else if (this._g.startState === STATE.COMMAND) { + if (dy > T_COMMAND) this._setState(STATE.AMBIENT); + else if (dy > T_GLANCE) this._setState(STATE.GLANCE); + } + this._g = null; + return; + } + + // Tap (no pan, no long-press) + if (!this._g.didPan && !this._g.didLongPress) { + if (this.state === STATE.AMBIENT) this._setState(STATE.GLANCE); + else if (this.state === STATE.GLANCE) this._setState(STATE.AMBIENT); + else if (this.state === STATE.COMMAND) this._setState(STATE.GLANCE); + } + + this._g = null; + } + + _onPebblePointerCancel(e) { + if (!this._g || e.pointerId !== this._g.pid) { + return; + } + this.pressing = false; + this.dragging = false; + this.dragOffset = 0; + if (this._g.longPressTimer) clearTimeout(this._g.longPressTimer); + this._hidePeek(); + this._g = null; + } + + // ----- Document-level handlers ----- + + _onDocumentPointerDown(e) { + if (this.state === STATE.AMBIENT) { + return; + } + // Use composedPath so we can detect taps that fall outside the shadow root + const path = e.composedPath(); + if (path.includes(this)) { + return; + } + this._setState(STATE.AMBIENT); + } + + _onDocumentKeyDown(e) { + if (e.key !== "Escape") { + return; + } + if (this.radialOpen) this._closeRadial(); + else if (this.state !== STATE.AMBIENT) this._setState(STATE.AMBIENT); + } + + // ----- Radial ----- + + _openRadial() { + if (this.state !== STATE.AMBIENT) { + return; + } + const shell = this.renderRoot.querySelector(".pebble"); + const r = shell.getBoundingClientRect(); + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + // The radial zones reach ~110px out from center plus 28px of zone + // radius, so 138px is the minimum margin from each edge for the fan + // to stay fully visible. Add a small padding for shadow / breathing + // room. When the pebble is docked left/right the anchor sits inside + // the pebble's edge — clamp it so the menu doesn't get clipped. + const margin = 150; + this.radialX = Math.max(margin, Math.min(window.innerWidth - margin, cx)); + this.radialY = cy; + this.radialOpen = true; + } + + _closeRadial() { + this.radialOpen = false; + } + + _onRadialOverlayClick(e) { + if (e.target === e.currentTarget) this._closeRadial(); + } + + _onRadialZoneClick(zone, e) { + e.stopPropagation(); + if (this._isRadialZoneDisabled(zone)) { + return; + } + this._closeRadial(); + this._dispatchAction(zone.action); + } + + _isRadialZoneDisabled(zone) { + if (zone.action === "back") { + return !this.canGoBack; + } + if (zone.action === "forward") { + return !this.canGoForward; + } + return false; + } + + // ----- Peek (tab-switch preview) ----- + + _showPeek(direction) { + if (!this.layoutManager) { + return; + } + const nextId = this.layoutManager.getAdjacentWebViewId?.( + direction === 1 ? "next" : "prev", + ); + if (!nextId) { + return; + } + const info = this.layoutManager.getWebViewInfo?.(nextId); + if (!info) { + return; + } + this.peekTab = info; + this.peekDirection = direction; + this.peekVisible = true; + } + + _hidePeek() { + this.peekVisible = false; + } + + _commitTabSwitch(direction) { + if (!this.layoutManager) { + return; + } + if (direction === 1) this.layoutManager.nextWebView?.(); + else this.layoutManager.prevWebView?.(); + // Refresh ambient favicon / tab strip + this.updateState(); + } + + // ----- Actions (pinned + radial + nav) ----- + + _dispatchAction(action) { + switch (action) { + case "back": + if (this.canGoBack) this.layoutManager?.goBack(); + break; + case "forward": + if (this.canGoForward) this.layoutManager?.goForward(); + break; + case "reload": + this.layoutManager?.reload(); + break; + case "home": + this.dispatchEvent( + new CustomEvent("action-home", { bubbles: true, composed: true }), + ); + this._setState(STATE.AMBIENT); + break; + case "new-tab": + this.dispatchEvent( + new CustomEvent("action-new-tab", { bubbles: true, composed: true }), + ); + this._setState(STATE.AMBIENT); + break; + case "tabs": + this._setState(STATE.COMMAND); + break; + case "settings": + this.dispatchEvent( + new CustomEvent("action-nav", { + bubbles: true, + composed: true, + detail: { section: "settings" }, + }), + ); + this._setState(STATE.AMBIENT); + break; + case "search": { + this._setState(STATE.GLANCE); + // The glance content has visibility:hidden with a 0.18s transition + // delay, so focusing right after the render is a no-op. Wait for + // the transitionend on the glance container before grabbing focus, + // with a safety timeout in case the transition never fires (reduced + // motion, browser quirks, or the listener being attached too late). + this.updateComplete.then(() => { + const glance = this.renderRoot.querySelector(".pebble-glance"); + const input = glance?.querySelector(".search-input"); + if (!glance || !input) { + return; + } + let done = false; + let safety = 0; + const focusInput = () => { + if (done) { + return; + } + done = true; + glance.removeEventListener("transitionend", onEnd); + clearTimeout(safety); + // Skip focus if the user navigated away in the meantime. + if (this.state === STATE.GLANCE) input.focus(); + }; + const onEnd = (e) => { + if (e.target !== glance) { + return; + } + if (e.propertyName !== "opacity") { + return; + } + focusInput(); + }; + glance.addEventListener("transitionend", onEnd); + safety = setTimeout(focusInput, 500); + }); + break; + } + case "share": + case "bookmark": + case "more": + this.dispatchEvent( + new CustomEvent("action-" + action, { + bubbles: true, + composed: true, + }), + ); + break; + } + } + + _onPinClick(pin) { + this._dispatchAction(pin.id); + } + + _onTabChipClick(tab) { + if (tab.id === this.activeTabId) { + this._setState(STATE.AMBIENT); + return; + } + this.layoutManager?.setActiveWebView?.(tab.id); + this.updateState(); + } + + _onTabCardClick(tab) { + this.layoutManager?.setActiveWebView?.(tab.id); + this.updateState(); + this._setState(STATE.AMBIENT); + } + + _onTabCardClose(e, tab) { + e.stopPropagation(); + const card = e.currentTarget.closest(".tab-card"); + const finalize = () => { + this.layoutManager?.removeWebView?.(tab.id); + this.updateState(); + }; + if (!card) { + finalize(); + return; + } + card.classList.add("closing"); + // Match the duration of the .tab-card.closing animation in CSS (0.3s). + setTimeout(finalize, 300); + } + + _onNotifClick(notification) { + this.dispatchEvent( + new CustomEvent("notification-click", { + bubbles: true, + composed: true, + detail: { notification }, + }), + ); + } + + _onNotifDismiss(e, notification) { + e.stopPropagation(); + this.dispatchEvent( + new CustomEvent("notification-dismiss", { + bubbles: true, + composed: true, + detail: { notification }, + }), + ); + } + + _onNotifClearAll() { + this.dispatchEvent( + new CustomEvent("notification-clear-all", { + bubbles: true, + composed: true, + }), + ); + } + + _onNavLinkClick(section) { + this.dispatchEvent( + new CustomEvent("action-nav", { + bubbles: true, + composed: true, + detail: { section }, + }), + ); + } + + _formatTime(ts) { + if (!ts) { + return ""; + } + const diff = Date.now() - ts; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + if (minutes < 1) { + return "Just now"; + } + if (minutes < 60) { + return `${minutes}m ago`; + } + if (hours < 24) { + return `${hours}h ago`; + } + return new Date(ts).toLocaleDateString(); + } + + // ----- Render ----- + + render() { + const dragStyle = + this.dragging && this.dragOffset + ? `--pebble-drag-x: ${this.dragOffset}px` + : ""; + return html` +
+
+ ${this._renderAmbient()} ${this._renderGlance()} + ${this._renderCommand()} +
+ ${this.radialOpen ? this._renderRadial() : ""} + ${this.peekVisible ? this._renderPeek() : ""} + `; + } + + _renderAmbient() { + const active = this.state === STATE.AMBIENT; + const notifCount = this.notifications?.length || 0; + return html` +
+ ${this.favicon + ? html`` + : html``} + ${this.tabCount > 0 + ? html`${this.tabCount}` + : ""} + 0 ? `${notifCount} unread` : ""} + > +
+ `; + } + + _renderGlance() { + const active = this.state === STATE.GLANCE; + return html` +
+ +
+ + +
+ ${this.groups?.length ? this._renderResults() : ""} + +
+ ${this.tabs.map( + (tab) => html` + + `, + )} + +
+
+ `; + } + + _renderResults() { + return html` +
+ ${this.groups.map( + (group) => html` +
+
+ ${group.providerIcon + ? html`` + : ""} +
+
+ ${group.items.map((result) => this._renderResult(result))} +
+
+ `, + )} +
+ `; + } + + _renderResult(result) { + if (result.kind === "link" || result.kind === "webview") { + return html` +
this._onResultClick(result)} + > + e.preventDefault()}> + ${result.value.title} + +
+ `; + } + if (result.kind === "text") { + return html` +
+ ${result.value} +
+ `; + } + return html``; + } + + _renderCommand() { + const active = this.state === STATE.COMMAND; + const notifCount = this.notifications?.length || 0; + return html` +
+ +
+ + + ${this.groups?.length ? this._renderResults() : ""} +
+

Views ${this.tabCount}

+
+ ${this.tabs.map( + (tab) => html` +
this._onTabCardClick(tab)} + > + ${tab.screenshotUrl + ? html`` + : html`
+ +
`} +
+ ${tab.favicon + ? html`` + : html``} + ${tab.title || "Untitled"} +
+ ${this.layoutManager?.isHomescreen?.(tab.id) + ? "" + : html``} +
+ `, + )} + +
+
+ ${this.blockerOrigin ? this._renderBlocker() : ""} +
+

Quick actions

+
+ ${PINS.slice(0, 5).map( + (pin) => html` + + `, + )} +
+
+ +
+

+ Notifications ${notifCount} + +

+ ${notifCount === 0 + ? html`
No notifications
` + : html`
    + ${this.notifications.map( + (n) => html` +
  • this._onNotifClick(n)} + > + + ${n.iconUrl + ? html`` + : html``} + +
    + ${n.title || "Notification"} + ${n.body ? html`

    ${n.body}

    ` : ""} +

    + ${this._formatTime(n.timestamp)} +

    +
    + +
  • + `, + )} +
`} +
+
+
+ `; + } + + _renderBlocker() { + const total = this.blockerCount + this.blockerAllowed; + const pct = total > 0 ? Math.round((this.blockerCount / total) * 100) : 0; + return html` +
+

Content blocker

+
+
${this._blockerHostname()}
+
+ Blocking + +
+
+ ${this.blockerCount} + blocked / ${total} total (${pct}%) +
+
+
+ `; + } + + _renderRadial() { + const style = `--radial-x: ${this.radialX}px; --radial-y: ${this.radialY}px;`; + const zones = this._isOnHomescreen() + ? RADIAL_ZONES_HOME + : RADIAL_ZONES_PAGE; + return html` +
+
+
+ ${zones.map( + (zone) => html` + + `, + )} +
+
+ `; + } + + _isOnHomescreen() { + return !!this.layoutManager?.isHomescreen?.(this.activeTabId); + } + + _renderPeek() { + const tab = this.peekTab; + if (!tab) { + return html``; + } + return html` +
+
+
+ ${tab.favicon + ? html`` + : html``} + ${t.title || "Untitled"} +
+ ${tab.screenshotUrl + ? html`` + : html`
+ +
`} +
+
+ `; + } +} + +customElements.define("mobile-pebble", MobilePebble); diff --git a/ui/system/mobile/action_bar.css b/ui/system/mobile_legacy/action_bar.css similarity index 100% rename from ui/system/mobile/action_bar.css rename to ui/system/mobile_legacy/action_bar.css diff --git a/ui/system/mobile/action_bar.js b/ui/system/mobile_legacy/action_bar.js similarity index 100% rename from ui/system/mobile/action_bar.js rename to ui/system/mobile_legacy/action_bar.js diff --git a/ui/system/mobile/edge_gesture_handler.js b/ui/system/mobile_legacy/edge_gesture_handler.js similarity index 100% rename from ui/system/mobile/edge_gesture_handler.js rename to ui/system/mobile_legacy/edge_gesture_handler.js diff --git a/ui/system/mobile_legacy/index.css b/ui/system/mobile_legacy/index.css new file mode 100644 index 0000000..0c830e6 --- /dev/null +++ b/ui/system/mobile_legacy/index.css @@ -0,0 +1,385 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +/* Hide desktop-only elements */ +body.mobile-mode #sidebar { + display: none; +} + +body.mobile-mode #header { + display: none; +} + +/* Root becomes full-screen container for carousel */ +body.mobile-mode #root { + flex: 1; + display: block; + overflow: hidden; + position: relative; +} + +/* Each webview container is full-screen, stacked */ +body.mobile-mode .mobile-webview-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: var(--keyboard-offset); + opacity: 0; + visibility: hidden; + transition: + opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1), + visibility 0.3s cubic-bezier(0.16, 1, 0.3, 1), + bottom 0.3s cubic-bezier(0.16, 1, 0.3, 1); + z-index: var(--z-base); +} + +body.mobile-mode .mobile-webview-container.active { + opacity: 1; + visibility: visible; + z-index: var(--z-base-active); +} + +/* WebView fills container completely */ +body.mobile-mode .mobile-webview-container web-view { + width: 100%; + height: 100%; +} + +/* Override WebView to hide title bar on mobile */ +body.mobile-mode web-view::part(bar) { + display: none; +} + +/* Footer (keyboard) adjustments for mobile */ +body.mobile-mode #footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: var(--z-chrome); +} + +/* Mobile action bar adjustments when keyboard is open */ +body.mobile-mode mobile-action-bar { + transition: bottom var(--transition-fast); +} + +body.mobile-mode.keyboard-open mobile-action-bar { + bottom: var(--keyboard-height); +} + +/* ============================================================================ + Peek Preview Styles + ============================================================================ */ + +.mobile-peek-preview { + position: fixed; + top: 0; + width: 100%; + height: 100%; + background: var(--bg-menu); + z-index: var(--z-gesture); + display: flex; + flex-direction: column; + box-shadow: 0 0 20px var(--color-shadow); + opacity: 0; + visibility: hidden; +} + +.mobile-peek-preview.visible { + opacity: 1; + visibility: visible; +} + +/* Animate transform on release (commit or cancel) */ +.mobile-peek-preview.animating { + transition: transform var(--transition-fast); +} + +.mobile-peek-preview.from-left { + left: 0; + transform: translateX(-100%); +} + +.mobile-peek-preview.from-right { + right: 0; + left: auto; + transform: translateX(100%); +} + +.peek-header { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-md); + background: var(--bg-header); +} + +.peek-favicon { + width: 20px; + height: 20px; + object-fit: contain; + border-radius: var(--radius-sm); +} + +.peek-favicon[src=""] { + display: none; +} + +.peek-title { + flex: 1; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + color: var(--color-text-menu); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.peek-screenshot { + flex: 1; + width: 100%; + object-fit: cover; + object-position: top; +} + +/* ============================================================================ + Tab Count Indicator (shown during edge swipe) + ============================================================================ */ + +.mobile-tab-indicator { + position: fixed; + bottom: 60px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 6px; + padding: var(--spacing-sm) var(--spacing-md); + background: var(--color-backdrop); + border-radius: var(--radius-lg); + z-index: var(--z-gesture); + opacity: 0; + visibility: hidden; + transition: + opacity var(--transition-fast), + visibility var(--transition-fast); +} + +.mobile-tab-indicator.visible { + opacity: 1; + visibility: visible; +} + +.tab-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-text-on-header); + opacity: 0.3; + transition: + opacity var(--transition-fast), + transform var(--transition-fast); +} + +.tab-dot.active { + background: var(--color-focus-ring); + opacity: 1; + transform: scale(1.2); +} + +/* ============================================================================ + Transition Animations + ============================================================================ */ + +/* Slide transition between webviews */ +@keyframes slideInFromLeft { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +@keyframes slideInFromRight { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +@keyframes slideOutToLeft { + from { + transform: translateX(0); + } + to { + transform: translateX(-100%); + } +} + +@keyframes slideOutToRight { + from { + transform: translateX(0); + } + to { + transform: translateX(100%); + } +} + +body.mobile-mode .mobile-webview-container.slide-in-left { + animation: slideInFromLeft 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +body.mobile-mode .mobile-webview-container.slide-in-right { + animation: slideInFromRight 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +body.mobile-mode .mobile-webview-container.slide-out-left { + animation: slideOutToLeft 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +body.mobile-mode .mobile-webview-container.slide-out-right { + animation: slideOutToRight 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +/* ============================================================================ + Edge Zone Visual Feedback (subtle) + ============================================================================ */ + +.edge-feedback { + position: fixed; + z-index: var(--z-gesture); + pointer-events: none; + opacity: 0; + transition: opacity var(--transition-fast); +} + +.edge-feedback.active { + opacity: 1; +} + +.edge-feedback.left { + left: 0; + top: 0; + width: 4px; + height: 100%; + background: linear-gradient(to right, var(--color-focus-ring), transparent); +} + +.edge-feedback.right { + right: 0; + top: 0; + width: 4px; + height: 100%; + background: linear-gradient(to left, var(--color-focus-ring), transparent); +} + +.edge-feedback.bottom { + bottom: 0; + left: 0; + width: 100%; + height: 4px; + background: linear-gradient(to top, var(--color-focus-ring), transparent); +} + +.edge-feedback.top { + top: 0; + left: 0; + width: 100%; + height: 4px; + background: linear-gradient(to bottom, var(--color-focus-ring), transparent); +} + +.gesture-edge-overlay { + position: fixed; + z-index: calc(var(--z-gesture) + 1); + touch-action: none; + pointer-events: auto; + background: transparent; +} + +/* Expand edge overlay to full screen during active gesture, + above the peek preview so we keep receiving touch events */ +.gesture-edge-overlay.fullscreen-overlay { + left: 0 !important; + top: 0 !important; + right: auto !important; + bottom: auto !important; + width: 100% !important; + height: 100% !important; +} + +/* ============================================================================ + Mobile Splash Screen + ============================================================================ */ + +.mobile-splash { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: var(--z-modal); + background: oklch(12% 0.02 105); + display: none; + align-items: center; + justify-content: center; + opacity: 1; + transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1); +} + +body.mobile-mode .mobile-splash.active { + display: flex; +} + +body.mobile-mode .mobile-splash.fade-out { + opacity: 0; + pointer-events: none; +} + +.splash-content { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-lg); +} + +.splash-logo { + font-size: 2.5em; + color: oklch(85% 0.06 85); +} + +.splash-spinner { + font-size: 2em; + color: oklch(85% 0.06 85); + animation: splash-spin 1.5s linear infinite; +} + +@keyframes splash-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +#powered-by { + display: flex; + flex-direction: column; + align-items: center; + font-family: var(--font-family-base); + color: oklch(70% 0.01 105); + gap: var(--spacing-sm); +} + +#powered-by-logos { + display: flex; + gap: var(--spacing-md); +} + +#powered-by-logos img { + height: 16px; +} diff --git a/ui/system/mobile_legacy/index.html b/ui/system/mobile_legacy/index.html new file mode 100644 index 0000000..06ee435 --- /dev/null +++ b/ui/system/mobile_legacy/index.html @@ -0,0 +1,59 @@ + + + + + Beaver + + + + + + + + + + + + + + + + + + +
+
+ + Beaver Logo +
+ +
+
+ Powered By +
+ servo + iroh +
+
+
+
+
+
+
+ + + + + diff --git a/ui/system/mobile_legacy/index.js b/ui/system/mobile_legacy/index.js new file mode 100644 index 0000000..ae7f224 --- /dev/null +++ b/ui/system/mobile_legacy/index.js @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import "./action_bar.js"; +import "./notification_sheet.js"; +import "./overview.js"; +import "./radial_menu.js"; + +import { + createNewView, + switchToHomescreen, + openView, + newWebViewBuilder, + initApp, +} from "../app.js"; +import { MobileLayoutManager } from "./layout_manager.js"; +import { initMobile } from "./init.js"; + +document.addEventListener("DOMContentLoaded", () => { + document.body.classList.add("mobile-mode"); + navigator.servo.setBoolPreference( + "beaver.ime_virtual_keyboard_enabled", + true, + ); + + const root = document.getElementById("root"); + const layoutManager = new MobileLayoutManager(root, newWebViewBuilder); + + let modeComponents = {}; + const { pairingHandler, services } = initApp( + layoutManager, + true, + () => modeComponents, + ); + + modeComponents = initMobile(layoutManager, { + createNewView: () => createNewView(layoutManager), + switchToHomescreen: () => switchToHomescreen(layoutManager), + openView: (url, title) => openView(layoutManager, url, title), + pairingHandler, + services, + }); +}); diff --git a/ui/system/mobile_legacy/init.js b/ui/system/mobile_legacy/init.js new file mode 100644 index 0000000..bfdae79 --- /dev/null +++ b/ui/system/mobile_legacy/init.js @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { WebView } from "../web_view.js"; +import { MobileSplashScreen } from "./splash_screen.js"; +import { notifications } from "../services/index.js"; + +export function initMobile( + layoutManager, + { createNewView, switchToHomescreen, openView, pairingHandler, services }, +) { + const splash = new MobileSplashScreen(); + splash.show(); + + const homescreenUrl = navigator.servo.getStringPreference( + "beaver.homescreen_url", + ); + const homescreen = new WebView(homescreenUrl, "Home", {}); + layoutManager.addWebView(homescreen); + layoutManager.setHomescreen(homescreen.webviewId); + splash.waitForHomescreen(homescreen); + + const actionBar = document.createElement("mobile-action-bar"); + document.body.appendChild(actionBar); + actionBar.setLayoutManager(layoutManager); + layoutManager.setActionBar(actionBar); + + actionBar.addEventListener("action-new-tab", () => createNewView()); + actionBar.addEventListener("action-home", () => switchToHomescreen()); + + const notificationSheet = document.createElement("mobile-notification-sheet"); + document.body.appendChild(notificationSheet); + notificationSheet.tabCount = layoutManager.getTabCount(); + notifications.registerPanel(notificationSheet); + + notificationSheet.addEventListener("notification-click", (e) => { + notifications.handleClick(e.detail.notification, { + layoutManager, + panel: notificationSheet, + pairingHandler, + }); + }); + + notificationSheet.addEventListener("notification-dismiss", (e) => { + notifications.dismiss(e.detail.notification); + }); + + notificationSheet.addEventListener("notification-clear-all", () => { + notifications.clearAll(); + }); + + notificationSheet.addEventListener("sheet-closed", () => { + notificationSheet.open = false; + }); + + notificationSheet.addEventListener( + "media-action", + services.media.handleAction, + ); + + const overview = document.createElement("mobile-overview"); + document.body.appendChild(overview); + + overview.addEventListener("tab-select", (e) => { + layoutManager.setActiveWebView(e.detail.tabId); + }); + overview.addEventListener("tab-close", (e) => { + layoutManager.removeWebView(e.detail.tabId); + updateTabOverviewData(); + }); + overview.addEventListener("tab-new", () => { + createNewView(); + overview.open = false; + }); + overview.addEventListener("tab-home", () => { + switchToHomescreen(); + overview.open = false; + }); + overview.addEventListener("overview-close", () => { + overview.open = false; + }); + + const radialMenu = document.createElement("mobile-radial-menu"); + document.body.appendChild(radialMenu); + + radialMenu.addEventListener("radial-action", (e) => { + handleRadialMenuAction(e.detail, layoutManager, { + createNewView, + switchToHomescreen, + openView, + }); + }); + radialMenu.addEventListener("radial-dismiss", () => { + const entry = layoutManager.getActiveEntry(); + entry?.webview?.dismissPendingContextMenu?.(); + }); + + // Override so overview uses the mobile component + layoutManager.showOverview = function () { + updateTabOverviewData(); + overview.open = true; + }; + layoutManager.hideOverview = function () { + overview.open = false; + }; + + // Radial menu trigger from webviews + document + .getElementById("root") + .addEventListener("webview-show-radial-menu", (e) => { + radialMenu.canGoBack = e.detail.canGoBack; + radialMenu.canGoForward = e.detail.canGoForward; + radialMenu.isHomescreen = + layoutManager.activeWebviewId === layoutManager.homescreenWebviewId; + radialMenu.show(e.detail.x, e.detail.y, e.detail.contextMenu); + }); + + // Notification show (edge gesture) + document.addEventListener("mobile-show-notifications", () => { + notificationSheet.notifications = notifications.getAll(); + notificationSheet.tabCount = layoutManager.getTabCount(); + notificationSheet.open = true; + }); + + function updateTabOverviewData() { + let tabs; + if (layoutManager.getOverviewTabs) { + tabs = layoutManager.getOverviewTabs(); + } else { + tabs = []; + for (const [id, entry] of layoutManager.webviews) { + tabs.push({ + id, + title: entry.webview.title || "Untitled", + favicon: entry.webview.favicon || "", + screenshotUrl: entry.webview.screenshotUrl || null, + }); + } + } + overview.tabs = tabs; + overview.activeTabId = layoutManager.activeWebviewId; + notificationSheet.tabCount = tabs.length; + } + + return { actionBar, notificationSheet, overview, radialMenu }; +} + +function handleRadialMenuAction( + detail, + layoutManager, + { createNewView, switchToHomescreen, openView }, +) { + switch (detail.action) { + case "new-view": + createNewView(); + break; + case "home": + switchToHomescreen(); + break; + case "back": + layoutManager.goBack(); + break; + case "forward": + layoutManager.goForward(); + break; + case "reload": + layoutManager.reload(); + break; + case "close-view": + if (layoutManager.activeWebviewId) { + layoutManager.removeWebView(layoutManager.activeWebviewId); + } + break; + case "overview": + layoutManager.showOverview(); + break; + case "settings": + openView("beaver://settings/index.html", "Settings"); + break; + case "context-menu": + layoutManager.getActiveEntry()?.webview?.showPendingContextMenu?.(); + break; + } +} diff --git a/ui/system/mobile_legacy/layout_manager.js b/ui/system/mobile_legacy/layout_manager.js new file mode 100644 index 0000000..61b45aa --- /dev/null +++ b/ui/system/mobile_legacy/layout_manager.js @@ -0,0 +1,451 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { EdgeGestureHandler } from "./edge_gesture_handler.js"; + +export class MobileLayoutManager { + constructor(rootElement, webViewBuilder) { + this.root = rootElement; + this.webViewBuilder = webViewBuilder; + + // Linear array of webviews (carousel model) + this.webviews = new Map(); // webviewId -> { webview, index } + this.webviewOrder = []; // Array of webviewIds in order + this.activeIndex = -1; + this.activeWebviewId = null; + this.nextId = 1; + + // Homescreen webview (special, cannot be closed, excluded from overview/swipe) + this.homescreenWebviewId = null; + + // Overview mode state + this.overviewMode = false; + this.overviewElement = null; + + // Gesture handler + this.gestureHandler = new EdgeGestureHandler(this); + + // Action bar component + this.actionBar = null; + + // Setup event listeners + this.setupEventListeners(); + } + + setupEventListeners() { + document.addEventListener("webview-close", (e) => { + this.removeWebView(e.detail.webviewId); + }); + + document.addEventListener("webview-focus", (e) => { + this.setActiveWebView(e.detail.webviewId); + }); + + // Listen for navigation state changes from webviews + document.addEventListener("navigation-state-changed", (e) => { + // Only update if it's from the active webview + if (e.detail.webviewId === this.activeWebviewId && this.actionBar) { + this.actionBar.updateState(); + } + }); + + // Disable all edges when action bar or notification sheet opens + document.addEventListener("action-bar-opened", () => { + this.disableAllEdges(); + }); + document.addEventListener("action-bar-closed", () => { + this.restoreEdges(); + }); + document.addEventListener("sheet-opened", () => { + this.disableAllEdges(); + }); + document.addEventListener("sheet-closed", () => { + this.restoreEdges(); + }); + } + + // Disable all edge gestures (when overlay UI is open) + disableAllEdges() { + this.gestureHandler.setEdgesEnabled({ + left: false, + right: false, + bottom: false, + top: false, + }); + } + + // Restore edge gestures based on current state + restoreEdges() { + const onHomescreen = this.isHomescreen(this.activeWebviewId); + this.gestureHandler.setEdgesEnabled({ + left: !onHomescreen, + right: !onHomescreen, + bottom: !onHomescreen, + top: true, + }); + } + + generateId() { + return `wv-${this.nextId++}`; + } + + // Set the homescreen webview + setHomescreen(webviewId) { + this.homescreenWebviewId = webviewId; + // Update edge gestures if homescreen is currently active + if (this.activeWebviewId === webviewId) { + this.restoreEdges(); + } + } + + // Check if a webview is the homescreen + isHomescreen(webviewId) { + return webviewId === this.homescreenWebviewId; + } + + // Get the currently active webview entry + getActiveEntry() { + if (this.activeWebviewId) { + return this.webviews.get(this.activeWebviewId); + } + return null; + } + + // Add a new webview to the carousel + addWebView(webview) { + const id = this.generateId(); + webview.webviewId = id; + + // Mark webview as mobile mode for styling + webview.classList.add("mobile-mode"); + + // Add to the end of the order + const index = this.webviewOrder.length; + this.webviewOrder.push(id); + this.webviews.set(id, { webview, index }); + + // Create container for the webview + const container = document.createElement("div"); + container.className = "mobile-webview-container"; + container.dataset.webviewId = id; + container.appendChild(webview); + this.root.appendChild(container); + + // Make this the active webview + this.setActiveWebView(id); + + return webview; + } + + // Remove a webview from the carousel + removeWebView(webviewId) { + // Prevent closing the homescreen + if (this.isHomescreen(webviewId)) { + console.warn("[MobileLayoutManager] Cannot close homescreen"); + return; + } + + const entry = this.webviews.get(webviewId); + if (!entry) { + return; + } + + const { webview, index } = entry; + + // Remove from DOM + const container = this.root.querySelector( + `.mobile-webview-container[data-webview-id="${webviewId}"]`, + ); + if (container) { + container.remove(); + } + + // Remove from tracking + this.webviews.delete(webviewId); + this.webviewOrder = this.webviewOrder.filter((id) => id !== webviewId); + + // Update indices for remaining webviews + this.webviewOrder.forEach((id, newIndex) => { + const e = this.webviews.get(id); + if (e) { + e.index = newIndex; + } + }); + + // If we removed the active webview, activate another one + if (this.activeWebviewId === webviewId) { + if (this.webviewOrder.length > 0) { + // Activate the webview at the same position or the last one + const newIndex = Math.min(index, this.webviewOrder.length - 1); + this.setActiveWebView(this.webviewOrder[newIndex]); + } else { + this.activeWebviewId = null; + this.activeIndex = -1; + } + } + } + + // Set the active webview by ID + setActiveWebView(webviewId) { + // Remove active state from previous and capture its screenshot + if (this.activeWebviewId && this.webviews.has(this.activeWebviewId)) { + const prevEntry = this.webviews.get(this.activeWebviewId); + prevEntry.webview.active = false; + + // Capture screenshot of the tab we're switching away from + // TODO: fix screenshot capture when not fully visible. + // prevEntry.webview.captureScreenshot(true); + + const prevContainer = this.root.querySelector( + `.mobile-webview-container[data-webview-id="${this.activeWebviewId}"]`, + ); + if (prevContainer) { + prevContainer.classList.remove("active"); + } + } + + this.activeWebviewId = webviewId; + + // Set active state on new + if (webviewId && this.webviews.has(webviewId)) { + const entry = this.webviews.get(webviewId); + entry.webview.active = true; + this.activeIndex = entry.index; + const container = this.root.querySelector( + `.mobile-webview-container[data-webview-id="${webviewId}"]`, + ); + if (container) { + container.classList.add("active"); + } + } + + // Update edge gesture areas based on whether we're on the homescreen + this.restoreEdges(); + } + + // Switch to webview at given index + switchTo(index) { + if (index < 0 || index >= this.webviewOrder.length) { + return; + } + const webviewId = this.webviewOrder[index]; + this.setActiveWebView(webviewId); + } + + // Navigate to next webview in carousel (skips homescreen) + nextWebView() { + const regularViews = this.webviewOrder.filter( + (id) => !this.isHomescreen(id), + ); + if (regularViews.length <= 1) { + return; + } + + const currentIndex = regularViews.indexOf(this.activeWebviewId); + if (currentIndex === -1) { + // Currently on homescreen, switch to first regular view + this.setActiveWebView(regularViews[0]); + } else { + const nextIndex = (currentIndex + 1) % regularViews.length; + this.setActiveWebView(regularViews[nextIndex]); + } + } + + // Navigate to previous webview in carousel (skips homescreen) + prevWebView() { + const regularViews = this.webviewOrder.filter( + (id) => !this.isHomescreen(id), + ); + if (regularViews.length <= 1) { + return; + } + + const currentIndex = regularViews.indexOf(this.activeWebviewId); + if (currentIndex === -1) { + // Currently on homescreen, switch to last regular view + this.setActiveWebView(regularViews[regularViews.length - 1]); + } else { + const prevIndex = + (currentIndex - 1 + regularViews.length) % regularViews.length; + this.setActiveWebView(regularViews[prevIndex]); + } + } + + // Get adjacent webview ID for peek preview (skips homescreen) + getAdjacentWebViewId(direction) { + const regularViews = this.webviewOrder.filter( + (id) => !this.isHomescreen(id), + ); + if (regularViews.length <= 1) { + return null; + } + + const currentIndex = regularViews.indexOf(this.activeWebviewId); + if (currentIndex === -1) { + // On homescreen, no peek + return null; + } + + if (direction === "next") { + const nextIndex = (currentIndex + 1) % regularViews.length; + return regularViews[nextIndex]; + } else { + const prevIndex = + (currentIndex - 1 + regularViews.length) % regularViews.length; + return regularViews[prevIndex]; + } + } + + // Get webview info for peek preview + getWebViewInfo(webviewId) { + const entry = this.webviews.get(webviewId); + if (!entry) { + return null; + } + return { + id: webviewId, + title: entry.webview.title || "Untitled", + favicon: entry.webview.favicon || "", + screenshotUrl: entry.webview.screenshotUrl || null, + }; + } + + // Show action bar (bottom slide-up) + // Not allowed when on homescreen + showActionBar() { + if (this.isHomescreen(this.activeWebviewId)) { + return; + } + if (this.actionBar) { + this.actionBar.show(); + } + } + + // Hide action bar + hideActionBar() { + if (this.actionBar) { + this.actionBar.hide(); + } + } + + // Toggle action bar + toggleActionBar() { + if (this.actionBar) { + this.actionBar.toggle(); + } + } + + // Set the action bar component reference + setActionBar(actionBar) { + this.actionBar = actionBar; + } + + // Perform navigation action on active webview + goBack() { + const entry = this.getActiveEntry(); + if (entry) { + entry.webview.goBack(); + } + } + + goForward() { + const entry = this.getActiveEntry(); + if (entry) { + entry.webview.goForward(); + } + } + + reload() { + const entry = this.getActiveEntry(); + if (entry) { + entry.webview.doReload(); + } + } + + // Navigate to URL in active webview + navigateTo(url) { + const entry = this.getActiveEntry(); + if (entry) { + entry.webview.ensureIframe(); + entry.webview.iframe.load(url); + } + } + + // Get current URL of active webview + getCurrentUrl() { + const entry = this.getActiveEntry(); + if (entry) { + return entry.webview.currentUrl || ""; + } + return ""; + } + + // Get navigation state of active webview + getNavigationState() { + const entry = this.getActiveEntry(); + if (entry) { + return { + canGoBack: entry.webview.canGoBack || false, + canGoForward: entry.webview.canGoForward || false, + }; + } + return { canGoBack: false, canGoForward: false }; + } + + // Get tab count (excludes homescreen) + getTabCount() { + return this.webviewOrder.filter((id) => !this.isHomescreen(id)).length; + } + + // Get tabs for overview (excludes homescreen) + getOverviewTabs() { + return this.webviewOrder + .filter((id) => !this.isHomescreen(id)) + .map((id) => { + const entry = this.webviews.get(id); + return { + id, + title: entry.webview.title || "Untitled", + favicon: entry.webview.favicon || "", + screenshotUrl: entry.webview.screenshotUrl || null, + }; + }); + } + + // ============================================================================ + // Overview Mode (for compatibility, minimal implementation for MVP) + // ============================================================================ + + toggleOverview() { + if (this.overviewMode) { + this.hideOverview(); + } else { + this.showOverview(); + } + } + + showOverview() { + this.overviewMode = true; + // TODO: Implement mobile tab overview grid + console.log( + "[MobileLayoutManager] Overview mode enabled (not yet implemented)", + ); + } + + hideOverview() { + this.overviewMode = false; + console.log("[MobileLayoutManager] Overview mode disabled"); + } + + // Navigation methods for compatibility with desktop shortcuts + nextPanel() { + this.nextWebView(); + } + + prevPanel() { + this.prevWebView(); + } + + scrollToPanel(index) { + this.switchTo(index); + } +} diff --git a/ui/system/mobile/notification_sheet.css b/ui/system/mobile_legacy/notification_sheet.css similarity index 100% rename from ui/system/mobile/notification_sheet.css rename to ui/system/mobile_legacy/notification_sheet.css diff --git a/ui/system/mobile/notification_sheet.js b/ui/system/mobile_legacy/notification_sheet.js similarity index 100% rename from ui/system/mobile/notification_sheet.js rename to ui/system/mobile_legacy/notification_sheet.js diff --git a/ui/system/mobile/overview.css b/ui/system/mobile_legacy/overview.css similarity index 100% rename from ui/system/mobile/overview.css rename to ui/system/mobile_legacy/overview.css diff --git a/ui/system/mobile/overview.js b/ui/system/mobile_legacy/overview.js similarity index 100% rename from ui/system/mobile/overview.js rename to ui/system/mobile_legacy/overview.js diff --git a/ui/system/mobile/radial_menu.css b/ui/system/mobile_legacy/radial_menu.css similarity index 100% rename from ui/system/mobile/radial_menu.css rename to ui/system/mobile_legacy/radial_menu.css diff --git a/ui/system/mobile/radial_menu.js b/ui/system/mobile_legacy/radial_menu.js similarity index 100% rename from ui/system/mobile/radial_menu.js rename to ui/system/mobile_legacy/radial_menu.js diff --git a/ui/system/mobile_legacy/splash_screen.js b/ui/system/mobile_legacy/splash_screen.js new file mode 100644 index 0000000..cafb90a --- /dev/null +++ b/ui/system/mobile_legacy/splash_screen.js @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export class MobileSplashScreen { + static TIMEOUT_MS = 5000; + static FADE_DURATION_MS = 400; + + constructor() { + this.element = document.getElementById("mobile-splash"); + this.timeoutId = null; + this.channel = null; + } + + show() { + if (this.element) { + this.element.classList.add("active"); + } + } + + hide() { + if (!this.element || !this.element.classList.contains("active")) { + return; + } + + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + + this.element.classList.add("fade-out"); + setTimeout(() => { + this.element.classList.remove("active", "fade-out"); + }, MobileSplashScreen.FADE_DURATION_MS); + + document.querySelector("main").classList.remove("before-splash"); + } + + /** + * Wait for the homescreen webview to be ready. + * Sends a MessagePort to the homescreen and waits for "ready" message. + * Falls back to timeout if no response received. + */ + async waitForHomescreen(homescreenWebView) { + // Set up timeout fallback + const timeoutPromise = new Promise((resolve) => { + this.timeoutId = setTimeout(() => { + console.warn("[Splash] Timeout reached, hiding splash screen"); + resolve(); + }, MobileSplashScreen.TIMEOUT_MS); + }); + + // Set up MessageChannel for homescreen communication + const readyPromise = new Promise(async (resolve) => { + // Wait for WebView to render so iframe exists in shadow DOM + await homescreenWebView.updateComplete; + + const iframe = homescreenWebView.shadowRoot?.querySelector("iframe"); + if (!iframe) { + console.error("[Splash] No iframe found in WebView shadow DOM"); + return; + } + + let channelSetup = false; + + // Listen for embedloadstatuschange on the iframe + const onLoadStatus = (event) => { + console.log("[Splash] embedloadstatuschange event:", event.detail); + if (channelSetup) { + return; + } + + // Wait for "complete" status like the keyboard does + if (event.detail === "complete") { + channelSetup = true; + iframe.removeEventListener("embedloadstatuschange", onLoadStatus); + this.setupChannel(iframe, resolve); + } + }; + + iframe.addEventListener("embedloadstatuschange", onLoadStatus); + }); + + // Race: either homescreen signals ready or timeout fires + await Promise.race([readyPromise, timeoutPromise]); + this.hide(); + } + + setupChannel(iframe, onReady) { + this.channel = new MessageChannel(); + + this.channel.port1.onmessage = (event) => { + console.log("[Splash] Received message on port1:", event.data); + if (event.data?.type === "homescreen-ready") { + console.log("[Splash] Homescreen ready signal received"); + onReady(); + } + }; + + this.channel.port1.onmessageerror = (event) => { + console.error("[Splash] Message error on port1:", event); + }; + + // Send port2 to homescreen + console.log("[Splash] Sending port to homescreen iframe"); + try { + iframe.contentWindow.postMessage({ type: "splash-port" }, "*", [ + this.channel.port2, + ]); + console.log("[Splash] postMessage sent successfully"); + } catch (e) { + console.error("[Splash] Failed to send postMessage:", e); + } + } +} diff --git a/ui/system/services/shortcuts.js b/ui/system/services/shortcuts.js index 3dbd5e3..005bbe8 100644 --- a/ui/system/services/shortcuts.js +++ b/ui/system/services/shortcuts.js @@ -55,26 +55,18 @@ export function init( }); Mousetrap.bindGlobal("mod+e", () => { - layoutManager.toggleOverview(); + const mc = mobileComponents(); + if (mc.pebble) { + mc.pebble.toggleCommand(); + } else if (layoutManager.toggleOverview) { + layoutManager.toggleOverview(); + } return false; }); Mousetrap.bindGlobal("escape", () => { const mc = mobileComponents(); - if (mc.radialMenu?.open) { - mc.radialMenu.hide(); - return false; - } - if (mc.overview?.open) { - mc.overview.open = false; - return false; - } - if (mc.notificationSheet?.open) { - mc.notificationSheet.open = false; - return false; - } - if (mc.actionBar?.open) { - mc.actionBar.hide(); + if (mc.pebble?.collapse?.()) { return false; } if (mc.notificationPanel?.open) { diff --git a/ui/system/web_view.js b/ui/system/web_view.js index 7ffdb5c..2b1625e 100644 --- a/ui/system/web_view.js +++ b/ui/system/web_view.js @@ -17,6 +17,10 @@ export class WebView extends LitElement { this.src = src; this.title = title; + // True once the page itself has provided a title via embedtitlechange. + // Until then, treat the title as a placeholder that may be replaced by + // the URL on navigation. + this._titleFromPage = false; this.favicon = ""; this.canGoBack = false; this.canGoForward = false; @@ -184,6 +188,7 @@ export class WebView extends LitElement { if (event.detail) { console.log(`ontitlechange: ${event.detail}`); this.title = event.detail; + this._titleFromPage = true; } } @@ -208,6 +213,13 @@ export class WebView extends LitElement { onthemecolorchange(event) { this.themeColor = event.detail; + this.dispatchEvent( + new CustomEvent("webview-themecolor-change", { + bubbles: true, + composed: true, + detail: { webviewId: this.webviewId, themeColor: event.detail }, + }), + ); } onloadstatuschange(event) { @@ -556,53 +568,6 @@ export class WebView extends LitElement { // Not logged in — skip. } - // In mobile mode, show the radial menu instead of the regular context menu - if (document.body.classList.contains("mobile-mode")) { - // Extract navigation state from context menu items - const navState = { - canGoBack: params.items.some( - (item) => item.id === "GoBack" && !item.disabled, - ), - canGoForward: params.items.some( - (item) => item.id === "GoForward" && !item.disabled, - ), - }; - - // Filter items to remove actions that are part of radial menu - const filteredItems = items.filter( - (item) => - item.id !== "GoBack" && - item.id !== "GoForward" && - item.id !== "Reload", - ); - - // Store pending context menu for later - this.pendingContextMenu = { - controlId: detail.controlId, - items: filteredItems, - x: detail.position?.x || 0, - y: detail.position?.y || 0, - }; - - // Dispatch event to show radial menu at the touch position - this.dispatchEvent( - new CustomEvent("webview-show-radial-menu", { - bubbles: true, - composed: true, - detail: { - x: detail.position?.x || 0, - y: detail.position?.y || 0, - canGoBack: navState.canGoBack, - canGoForward: navState.canGoForward, - contextMenu: this.pendingContextMenu, - }, - }), - ); - - // Don't respond yet - radial menu will handle it - return; - } - // Show the context menu this.contextMenu = { controlId: detail.controlId, @@ -680,14 +645,6 @@ export class WebView extends LitElement { this.contextMenu = null; } - // Show the pending context menu (called from radial menu's context-menu action) - showPendingContextMenu() { - if (this.pendingContextMenu) { - this.contextMenu = this.pendingContextMenu; - this.pendingContextMenu = null; - } - } - // Show an inline task provider overlay within this webview. showInlineProvider(url, title) { this.inlineProvider = { url, title }; @@ -698,15 +655,6 @@ export class WebView extends LitElement { this.inlineProvider = null; } - // Dismiss the pending context menu without showing it - dismissPendingContextMenu() { - if (this.pendingContextMenu) { - this.ensureIframe(); - this.iframe.respondToContextMenu(this.pendingContextMenu.controlId, null); - this.pendingContextMenu = null; - } - } - handleSelectOption(e) { const { controlId, index } = e.detail; console.log( @@ -972,8 +920,16 @@ export class WebView extends LitElement { this.ensureIframe(); this.canGoBack = this.iframe.canGoBack(); this.canGoForward = this.iframe.canGoForward(); - this.currentUrl = event.detail; - this.title = event.detail; + const newUrl = event.detail; + const isNavigation = this.currentUrl !== newUrl; + this.currentUrl = newUrl; + // Only fall back to URL-as-title on a genuine navigation (the URL is + // changing). The constructor-supplied title persists when the URL does + // not change between repeated embedurlchange fires. + if (isNavigation && (this._titleFromPage || !this.title)) { + this.title = newUrl; + this._titleFromPage = false; + } // Dispatch navigation state change event for action bar updates this.dispatchEvent(