diff --git a/FEATURES.md b/FEATURES.md index 8780f203..ca2fbcb4 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -177,7 +177,7 @@ Spec: `spec/commandbar.md` | CB-10 | Keyboard navigation inside the bar | P0 | ✅ | ✅ e2e | ✅ | ↑/↓ (⌃N/⌃P on macOS), Enter, hover, aria-activedescendant; ⌘⌥⌫ / Shift+Delete removes history rows | | CB-11 | Site search with Tab (search a site directly) | P0 | ✅ | ✅ e2e+unit | ✅ | keyword or site-name prefix + Tab → chip; Backspace/Esc leaves; starter list in settings.siteSearch (editing UI is ST-06) | | CB-12 | Paste and go | P1 | ✅ | ✅ e2e | ✅ | Paste and go row first in the empty state; ⌘⌥V / Ctrl+Alt+V is the tabs lane's tab.pasteAsNewTab | -| CB-13 | Actions (browser commands by name) | P0 | 🟡 partial | ✅ e2e+unit | – | every registered action by name/synonym/initials, with the key the live keymap gives it (KS-18); Move to / Pin to [Space]; Open [Folder]; tab-only actions hidden on empty Spaces; a unit test checks each catalogue name Orbit has finds its action first, including the added Archive Tabs Below (TD-05), Close Split (SV-19; the whole split, Close Split Pane stays SV-17's) and the names Copy URLs, Change Theme and Zoom Reset. Not built, with their lanes: Show/Hide Toolbar (SB-17, look), New Blank Window (TB-17) and Manage Spaces (SP-13, finish-tabs), Enter/Exit Full Screen (NV-20), Save Page As (NV-21), Capture Full Page (DT-09) and Reader Mode (NV-23) (pages), Developer Mode for this site (DT-06, finish-downloads), Getting started (OB-10, finish-settings), Manage extensions and Extension: [name] (EX-06, extensions), New Note (notes), New Canvas and Capture (canvases), New Tweak and View Tweaks (BO-21, tweaks); Share is a later phase; Translate (NV-22, D45) and Manage passwords (D52) are out of scope | +| CB-13 | Actions (browser commands by name) | P0 | 🟡 partial | ✅ e2e+unit | – | every registered action by name/synonym/initials, with the key the live keymap gives it (KS-18); Move to / Pin to [Space]; Open [Folder]; tab-only actions hidden on empty Spaces; a unit test checks each catalogue name Orbit has finds its action first, including the added Archive Tabs Below (TD-05), Close Split (SV-19; the whole split, Close Split Pane stays SV-17's), New Blank Window (TB-17) and the names Copy URLs, Change Theme and Zoom Reset. Not built, with their lanes: Show/Hide Toolbar (SB-17, look), Manage Spaces (SP-13's Spaces overview, which finish-tabs left unbuilt), Enter/Exit Full Screen (NV-20), Save Page As (NV-21), Capture Full Page (DT-09) and Reader Mode (NV-23) (pages), Developer Mode for this site (DT-06, finish-downloads), Getting started (OB-10, finish-settings), Manage extensions and Extension: [name] (EX-06, extensions), New Note (notes), New Canvas and Capture (canvases), New Tweak and View Tweaks (BO-21, tweaks); Share is a later phase; Translate (NV-22, D45) and Manage passwords (D52) are out of scope | | CB-14 | Actions-only mode (Tab on an empty field) | P1 | ✅ | ✅ e2e+unit | ✅ | Tab on an empty field or '/ ' at the start → Actions chip; Esc/Backspace leaves | | CB-15 | Default search engine fallback | P0 | ✅ | ✅ e2e+unit | ✅ | default engine search row and destination (q=a+b); engine suggestions opt-in (settings.searchSuggestions, off by default), non-blocking with a 1.5 s timeout | | CB-16 | Open the top web result directly (Shift+Enter) | P2 | ✅ | ✅ e2e+unit | ✅ | ⇧Enter opens the first result (DuckDuckGo backslash prefix) when settings.searchTopResult is on (default off); otherwise like Enter | @@ -186,7 +186,7 @@ Spec: `spec/commandbar.md` | CB-19 | Space and profile awareness | P0 | ✅ | ✅ e2e+unit | ✅ | results use the window's current Space/profile and re-run on Space switch; new tabs go to the current Space | | CB-20 | Sidebar stays interactive while the bar is open | P1 | ✅ | ✅ e2e | ✅ | a left click over the docked or the hover-revealed sidebar closes the bar and reaches the sidebar where it is drawn (one click selects a tab); right and middle clicks only close it; ⌘T with the revealed sidebar out opens the bar over it and the sidebar stays until the bar closes; the window's edge doesn't bring the sidebar in while the bar is open (SB-12), so its appearance never dismisses the bar | | CB-21 | Closing the bar and what happens to typed text | P0 | ✅ | ✅ e2e | ✅ | Esc, ⌘T/⌘L again, click outside, window blur; text discarded; focus returns to the page, also when the window comes back after a background close | -| CB-22 | Feel: open/close animation, latency and live updates | P0 | ✅ | ✅ e2e+unit | – | spring scale-fade entrance, quicker exit, reduced-motion fade; type-ahead and Enter buffered; IME-safe; per-keystroke latency measured | +| CB-22 | Feel: open/close animation, latency and live updates | P0 | ✅ | ✅ e2e+unit | – | spring scale-fade entrance, quicker exit, reduced-motion fade; type-ahead and Enter buffered; IME-safe; per-keystroke latency measured; the overlay opens the bar a few times out of sight at start-up, at the window's size, so the first open after a launch does the work of later ones, with no resize; once a resize of the window ends, the hidden overlay is laid out again out of sight at its new size, so the next open doesn't resize it; it still shows the bar in the second frame in about two launches in five: 13 of 31 fresh launches so far (5 of this lane's 12 at load 2.0–3.7, 4.2–22.9 ms, median 14.6; 7 of its review's 12; 1 of its fix check's 7), as FL-29's note says | | CB-23 | The bar in other contexts (split pane, private window, Mini Window) | P1 | ✅ | ✅ e2e | ✅ | split pane: the bar that fills a new pane (⌃⇧=, Add Split View) takes ⌘W, ⌘T, ⌘L and Esc first: each closes it and the empty pane goes; a URL or an open tab fills the pane; ⌘L on a pane edits that pane (SV-02, SV-15). Private (non-persistent) profiles: no history or archive rows, and their tabs, folders and Spaces never show in normal windows. Mini Window: the blank window's lookup bar and the address field over its page (⌘L or a click on the host) are the bar's compact variant: the same ranking, inline completion, site search and engine suggestions, from its profile's history and the typed text, with no open tabs, archive, Spaces, folders, actions or Settings sections (they act on the browser); a choice loads in that Mini Window. Over a page the list covers a still image of the page (the page's own view steps aside until the bar closes); ⌘W and ⌘L close the bar first, Esc too, and Enter with nothing to load gives the page the keyboard; ⌘T opens the main window's bar. ⌘L on an empty Space acts like ⌘T | ## Split View (prefix SV) @@ -196,7 +196,7 @@ Spec: `spec/split-view.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| | SV-01 | What a split view is | P0 | ✅ | ✅ e2e+unit | ✅ | 2-4 live panes shown together; the split is one sidebar item | -| SV-02 | Create with the keyboard shortcut | P0 | ✅ | ✅ e2e+unit | – | Ctrl+Shift+= adds an empty pane and opens the command bar; a URL or open tab fills it, Escape removes it; refused in an empty window | +| SV-02 | Create with the keyboard shortcut | P0 | ✅ | ✅ e2e+unit | – | Ctrl+Shift+= adds an empty pane and opens the command bar; a URL or open tab fills it (an open tab goes into the pane in the update that closes the bar, never shown alone), Escape removes it; refused in an empty window | | SV-03 | Create from the command bar | P0 | ✅ | ✅ e2e | – | Add Split Right/Left/Above/Below and New Split View actions; the command bar stays open for the new pane | | SV-04 | Create by dragging a tab onto the page | P0 | ✅ | ✅ e2e+unit | ✅ | drag a tab row over the page: theme-coloured drop indicator, the page makes room, release adds the pane | | SV-05 | Create by dropping a tab onto another tab row | P0 | ✅ | ✅ e2e+unit | ✅ | resting a dragged tab on a tab or split row's middle band (~400 ms) highlights that half; dropping makes or extends a split there, undoable with Cmd/Ctrl+Z; quick drags reorder as usual | @@ -220,7 +220,7 @@ Spec: `spec/split-view.md` | SV-23 | Pinned and Favorite splits | P1 | ✅ | ✅ e2e+unit | ✅ | pinning keeps panes, sizes and orientation; drops onto pinned tabs stay pinned; each pane keeps its own pinned URL, and the split's menu (Reset to Pinned URLs) or a double-click on its Favorite tile brings every pane back (Reset Tab on one pane still resets that pane); a link to another site in either pane of a pinned or Favorite split opens a Peek over the split from that pane; Favorite splits show a combined tile (with the away dot when a pane wandered off), or the split's custom icon, and drag like tiles; a Favorite split keeps its focused pane across Spaces | | SV-24 | Splits persist | P0 | ✅ | ✅ e2e+unit | ✅ | panes, order, orientation, sizes and focused pane survive relaunch; a Today split closed or auto-archived as one unit restores from the Archive as one unit, with its layout and sizes | | SV-25 | Limits and edge cases | P1 | ✅ | ✅ e2e+unit | ✅ | fifth pane refused with a toast on every route; no split from an empty window; HTML full screen (core) fills the window, hides the strip and returns to the split | -| SV-26 | Feel: create, resize, close | P1 | ✅ | ✅ e2e+unit | – | springs for create/close/flip, closing card fades, 1:1 resize, drop indicator fades in and moves between halves at once (FL-18), frame colour until loaded, split row entrance; a pane added while a pane closes, or while a neighbour still grows back, grows in from its edge beside the neighbour (PR #19 review F10); in the sidebar, rows of a list that become one split row there are replaced by it in place, the ones above closing up into it, and on separate the first row is drawn in its place while the others slide out from under it, with the reorder spring (TB-08); no haptic tick on macOS: Electron has no haptic API (a platform limit) | +| SV-26 | Feel: create, resize, close | P1 | ✅ | ✅ e2e+unit | – | springs for create/close/flip that stop in place, without overshoot; a closing pane's card fades and collapses with a still of its page on it (main keeps one of each pane once the panes are at rest); 1:1 resize; the drop indicator shows in the frame after the move that finds it, fades in and moves between halves at once (FL-18); frame colour until loaded, split row entrance; a pane added while a pane closes, or while a neighbour still grows back, grows in from its edge beside the neighbour (PR #19 review F10); in the sidebar, rows of a list that become one split row there are replaced by it in place, the ones above closing up into it, and on separate the first row is drawn in its place while the others slide out from under it, with the reorder spring (TB-08); no haptic tick on macOS: Electron has no haptic API (a platform limit) | ## Page navigation, windows, links and page-level controls (prefix NV) @@ -233,7 +233,7 @@ Spec: `spec/navigation.md` | NV-03 | Reload and hard reload | P0 | ✅ | ✅ e2e+unit | ✅ | Cmd/Ctrl+R, F5; hard reload Cmd+Shift+R, Ctrl+Shift+R, Shift+F5, Ctrl+F5; Cmd/middle-click Reload duplicates with history; keyboard focus goes to the page | | NV-04 | Stop loading | P1 | ✅ | ✅ e2e+unit | ✅ | Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes. Known limit (keys fix check K6): a page's answer about an Esc that comes after Orbit stopped waiting for it (250 ms) answers the next Esc pressed while that page loads | | NV-05 | Find in page | P0 | ✅ | ✅ e2e+unit | ✅ | floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character on the keyboard's own layout); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; with a Peek open the bar finds in the Peek, over its card (PK-11); a Mini Window finds with a field in its top bar (MW-03). Known limits (chrome fix check 4, N3, by reading): keys held while a replay of held keys runs past 5 s (each held Backspace, Enter or Escape waits up to 1 s for the bar) are dropped, and keys typed after that can reach the bar ahead of the rest of the replay; keys held after a held Escape are dropped, where a ready bar would have let them reach the page once it closed; and a held character an auto-type tool maps onto the keyboard on the fly, which Chromium names "\u0000", is lost (chrome fix check 4) | -| NV-06 | Page zoom | P0 | ✅ | ✅ e2e+unit | – | Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too | +| NV-06 | Page zoom | P0 | ✅ | ✅ e2e+unit | – | Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered), its number changing in place at each step while it shows; shown again, neither it nor the find bar first shows what it showed last time (their view draws itself empty before it leaves the window); pinch is visual zoom and Cmd+0 undoes it too | | NV-07 | Print | P1 | 🟡 partial | ✅ e2e+unit | – | ⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page's script goes on (Chromium's waits for the dialog), but what would take the page away waits for the dialog: its window.close() (a receipt written into a pop-up or a new tab, printed and closed at once) and a link, location or reload it starts go on once the dialog closes, as they would have (the same address and kind of history entry); a print() while a dialog that ⌘P or a menu opened shows returns at once and holds nothing, as Chromium's does (D76); missing: a form the page posts, or a move through history (history.back(), go()), right after print() goes at once and takes the dialog with it (the engine doesn't let a page's history move be stopped, and posting a form again would run the page's submit handlers twice); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); missing: paper size, margins, scale, pages per sheet and two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog | | NV-08 | Clicking links: plain and modified clicks | P0 | 🟡 partial | ✅ e2e+unit | – | Cmd/middle-click background tab (while the sidebar is hidden a toast over the page, Opened in a new tab with Show, SB-13) and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; the page itself opens these (a link click in an isolated world, with the gesture), so they are requested as its click is: Referer, Sec-Fetch-Site, no SameSite=Strict cookie across sites; Option/Alt-click split view instead of the engine download; a local file opens in a new tab, window, Mini Window or split view only for a local file page whose frames are all local too, whatever the engine lets through: Orbit can't tell which frame asked, so a frame of any other origin (a web page's, or an opaque one: sandboxed, data:) refuses Cmd-, Shift- and Cmd+Option-clicks, target=_blank and window.open, and a toast names the file; a plain click without a target still opens it (D49); Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; still requested as if typed (Sec-Fetch-Site none, Strict cookies sent), with the page as Referer: the page menu Open Link in New Tab and New Window, Option-click split view, a blocked pop-up Open; without a Referer too: Peek, Mini Window and routing-rule links (peek lane), a pop-up window own links, Open Link in Private Window (on purpose); a link in a frame from another origin is opened from the top page, without a Referer; a link (or text) dragged from a page onto the sidebar opens as a tab where it is dropped, as a tab row dragged there would (TB-02): the rows make room, a closed folder takes it in, the Favorites grid opens a place, a Space's icon takes it there; only a web (http, https) address opens or is saved there: other text is a search on the list or in a folder and nothing on the Favorites, and any other address (data:, view-source:, javascript:, a local file…) opens nothing (D87); left: a link dragged onto the page opens in a split (SV-08) | | NV-09 | Links with target=_blank and script-opened windows | P0 | ✅ | ✅ e2e+unit | – | target=_blank and window.open without size: selected tab at the top of Today with its opener; with size: a small window without sidebar titled with the host (Not secure marked), the opener can close it; pop-ups without a click blocked, shown in the address display with Open and Always Allow for the site; a click or a key in any frame of a page counts, one from another site's too (D77), and a pop-up window's own opens become tabs by the same rule; a web page, or a local page with a frame of another origin (web or opaque), gets no tab, window or pop-up of a local file, nor one offered there (D49); a toast names the file only where Orbit itself refuses it, for a local page with a frame of another origin: the engine refuses a web page's before Orbit's handler runs, with no toast; a tab that only downloads closes and reselects its opener | @@ -265,7 +265,7 @@ Spec: `spec/peek.md` | PK-03 | Shift-click opens any link in Peek | P1 | ✅ | ✅ e2e+unit | ✅ | Shift-click in any tab; link menu 'Open Link in Peek' | | PK-04 | Exceptions: meeting links and link-routing rules | P1 | ✅ | ✅ e2e+unit | ✅ | meeting links open a tab; routing rules (settings.linkRules) win; rules UI is the settings lane's | | PK-05 | The Peek card's look | P0 | ✅ | ✅ e2e+unit | ✅ | inset card, shadow, dimmed page area, Close / Open as Tab / Split buttons with shortcut tooltips; no toolbar (AP-07 not built) | -| PK-06 | Open and close animation, and the dismiss gesture | P1 | ✅ | ✅ e2e+unit | – | spring grow from the link, 18% dim (20% dark), 160 ms shrink to 0.96 and fade, reduced motion; drag the top handle or swipe down beside the card: follows the input, closes past 25% or 500 px/s, else springs back | +| PK-06 | Open and close animation, and the dismiss gesture | P1 | ✅ | ✅ e2e+unit | – | spring grow from the link, 18% dim (20% dark), 160 ms shrink to 0.96 and fade, reduced motion; the page is laid out once, at its size, and joins the card at rest: the card grows blank, and shows a still of the page while it moves or closes (none while the page shows a crash or a failed load: the card shows its own colour); drag the top handle or swipe down beside the card: follows the input, closes past 25% or 500 px/s, else springs back | | PK-07 | Expand a Peek into a full tab | P0 | ✅ | ✅ e2e+unit | ✅ | ⌘O / button: card morphs to the frame, live page becomes a selected Today tab (routing rules apply) | | PK-08 | Open a Peek in Split View | P1 | ✅ | ✅ e2e | ✅ | the card slides to the right half, then split.addTab makes the source and the live Peek page one split in the source's place (a pinned source gives a pinned split), the page focused; from a full split the Peek stays open and the SV-25 toast shows | | PK-09 | Close a Peek | P0 | ✅ | ✅ e2e+unit | ✅ | Esc (after fullscreen), ⌘W, ×, click outside; archived as from 'peek' | @@ -286,7 +286,7 @@ Spec: `spec/mini-window.md` | MW-01 | What the Mini Window is | P0 | ✅ | ✅ e2e | ✅ | separate small window, one floating tab, main window untouched | | MW-02 | Links from other apps open in a Mini Window by default | P0 | ✅ | ✅ e2e | ✅ | open-url / second-instance / argv links open a Mini Window; same URL resurfaces it; routing rules first | | MW-03 | Look and anatomy | P0 | ✅ | ✅ e2e | ✅ | slim top bar: controls, favicon+host (click/⌘L opens the compact command bar on the page's address, CB-23), Open in…, profile pill; inset page card; neutral theme. ⌘F opens a find field in the top bar beside the host (NV-05): finds as you type, n/m count, No matches, Enter/⌘G/F3 next and ⇧ for previous, buttons; Esc closes it, keeps the match selected and gives the page the keyboard; keys typed before the field has the keyboard are not held (as they are for the main window's bar) | -| MW-04 | Opening animation and window placement | P1 | ✅ | ✅ e2e+unit | – | a quick scale-and-fade instead of the OS's animation: the window's UI grows from 92% to full size in 200 ms (the --ease-out curve) in its own layer while the window fades in (macOS, Windows; Linux can't fade a window, so there the UI fades in too). The OS window is not resized, which would lay the page out every frame: the page's native view, loading all along, sits out the grow and joins on the card at full size when it ends. Reduced motion shows it at once. Opens at the last-used size and place, each further window 24 px down and right; on macOS it shows above a full-screen Orbit window, on the current desktop. Only a person can check the desktop and full-screen parts, on a Mac | +| MW-04 | Opening animation and window placement | P1 | ✅ | ✅ e2e+unit | – | a quick scale-and-fade instead of the OS's animation: the window's UI grows from 92% to full size in 200 ms (the --ease-out curve) in its own layer while the window fades in (macOS, Windows; Linux can't fade a window, so there the UI fades in too). The OS window is not resized, which would lay the page out every frame: the page's native view, loading all along, sits out the grow and joins on the card at full size when it ends. Reduced motion shows it at once. The ready window Orbit keeps hidden draws itself empty until it opens, because a window shows the frame it drew last first: a link opens with its page's bar, never the blank lookup bar, and a blank window first shows early in its grow, not at full size. Opens at the last-used size and place, each further window 24 px down and right; on macOS it shows above a full-screen Orbit window, on the current desktop. Only a person can check the desktop and full-screen parts, on a Mac | | MW-05 | Move the page into a Space ("Open in…") | P0 | ✅ | ✅ e2e | ✅ | ⌘O to the most recent Space, Open in… / ⌘⌥O (⌘⇧O) native Space menu; live page, no reload | | MW-06 | Open a blank Mini Window for a quick lookup | P0 | ✅ | ✅ e2e+unit | ✅ | ⌘⌥N opens a pre-rendered blank window with a focused lookup bar, the command bar's compact variant (CB-23): history and typed-text rows, inline completion, site search, engine suggestions; the choice loads here; Esc empties it, Esc on the empty bar or ⌘W closes the window. System-wide on macOS only (outside e2e); on Windows/Linux Ctrl+Alt+N works inside Orbit, so other apps (and AltGr+N) keep the key | | MW-07 | Open a link or a tab in a Mini Window on purpose | P1 | ✅ | ✅ e2e+unit | ✅ | ⌘⌥-click a link, link menu Open Link in Mini Window, or ⌘⌥-click a sidebar row: Today rows move live, pinned/Favorite rows open a copy | @@ -297,7 +297,7 @@ Spec: `spec/mini-window.md` | MW-12 | What works inside a Mini Window | P2 | 🟡 partial | ✅ e2e | – | a video's full screen happens in the Mini Window: the window fills the screen with the page, which stays there, and the card comes back on exit (tested); copy URL, devtools, downloads through the profile's session. Not built: extensions (Orbit has no extension support yet, EX-11), captures saved to a canvas (no Canvases yet) and sharing (Orbit has no share feature) | | MW-13 | Menu-bar / tray button | P2 | ✅ | ✅ e2e+unit | ✅ | off by default; Settings > General > Mini Window turns it on: Orbit's mark as a template glyph in the macOS menu bar, the app's icon in the Windows and Linux tray (the drawn mark where a build has no icon file); a click opens a blank Mini Window with its lookup bar focused (macOS: right-click for its menu); its menu has New Mini Window and Show/Hide All Mini Windows, New Mini Window first for Linux trays that show only the menu (tested through the button's click and menu). GNOME shows tray icons only with an AppIndicator extension; on Windows and Linux Orbit still quits with its last window, button or not | | MW-14 | Platforms | P1 | ✅ | ✅ e2e+unit | ✅ | all platforms with Ctrl+Alt+N / Ctrl+O / Ctrl+Alt+O, in-app outside macOS (D23), with the tray button (MW-13) as the way in from other apps; New Mini Window and Show/Hide All in the macOS dock menu, the Windows jump list, Linux launcher actions and the tray menu. Launcher actions: a packaged Linux folder build writes ~/.local/share/applications/.desktop at start-up (under $XDG_DATA_HOME when that is set; is Electron's desktop name), with no opt-out for now, and keeps it pointing at where Orbit runs from. It never touches an entry a package installed, one the user wrote, or one it can't read (checked on a packaged build: both actions reach the running Orbit). Only a person can check the Windows jump list | -| MW-15 | Feel | P1 | ✅ | ✅ e2e | – | warm spare window takes keys at once, page loads before the window shows; opens with MW-04's 200 ms grow and fade; Open in… shrinks to 92% and fades (140 ms, macOS/Windows) as the row enters Today | +| MW-15 | Feel | P1 | ✅ | ✅ e2e | – | warm spare window takes keys at once, page loads before the window shows; opens with MW-04's 200 ms grow and fade; Open in… shrinks to 92% and fades (140 ms, macOS/Windows) as the row enters Today; closing and Open in… never leave the window on screen without its page or UI: where the window fades out its card shows a still of the page as the page leaves, if one is taken within 150 ms (macOS, Windows; not run here), elsewhere the window leaves the screen first, whole | ## Picture-in-picture, sidebar media player and call controls (prefix MD) @@ -305,7 +305,7 @@ Spec: `spec/media.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| MD-01 | Automatic picture-in-picture when leaving a video tab | P0 | ✅ | ✅ e2e+unit | – | Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank frame, at any load (a frame's page does it from its own size, before its first frame back is drawn); moving between a split's panes doesn't float it. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward. Known limit (D78): while a frame's video is in the player, the frame's page can see a hidden div the size of its viewport under , which the frame's script watches, with the root element, to put the video back before the frame is drawn; when the frame's own script, not main, puts a same-site frame's video back, the page may get one "ResizeObserver loop completed with undelivered notifications" error event, and then its own observers of or get the final size a frame later, as they did before | +| MD-01 | Automatic picture-in-picture when leaving a video tab | P0 | ✅ | ✅ e2e+unit | – | Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank video frame, at any load (a frame's page does it from its own size, before its first frame back is drawn), and its view waits out of sight, at its pane's size, until the page has drawn there, then moves in. Whichever way it comes back (selected, Back to tab, ⌘W on the tab over it, or from an empty Space, alone or in a split), the player hides first (Back to tab flies a still of it back to the page, MD-16), and no frame of its player layout reaches the page area if the page draws within 250 ms; a page slower than that shows what it drew last. Until the page has drawn, the page area keeps the page it showed, which takes clicks (selected, Back to tab), or shows the pane's blank card in its Space's colour (⌘W on the tab over it, or from an empty Space, alone or in a split; FL-29 allows a placeholder in the page's background): 6–9 frames for a page 150 ms slow to lay out. Meanwhile the held view keeps one pixel inside the window, so that it draws: the window's bottom-right pixel shows the page's top-left one, for 8–11 frames on Linux (whether rounded window corners hide it on macOS is unchecked). Moving between a split's panes doesn't float it, nor does a Peek's video when its Peek goes. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward. Known limit (D78): while a frame's video is in the player, the frame's page can see a hidden div the size of its viewport under , which the frame's script watches, with the root element, to put the video back before the frame is drawn; when the frame's own script, not main, puts a same-site frame's video back, the page may get one "ResizeObserver loop completed with undelivered notifications" error event, and then its own observers of or get the final size a frame later, as they did before | | MD-02 | Open picture-in-picture manually | P1 | 🟡 partial | ✅ e2e | – | Picture in Picture in a video's page right-click menu, the Picture in Picture action and the tray's PiP button; while the tab stays selected the engine's own PiP window shows the video (Orbit's player needs the tab's view), so its controls are the engine's until the tab is left and Orbit's player takes over | | MD-03 | The floating player's look and placement | P0 | ✅ | ✅ e2e+unit | ✅ | frameless, always-on-top window, 320x180 for 16:9 (keeps the video's shape), bottom-right 16 px inside the work area; rounded corners (a window shape where the system has none) and shadow; video only until hovered, then controls over a dark gradient; the same player on every platform | | MD-04 | Floating player controls | P0 | ✅ | ✅ e2e+unit | ✅ | on hover: play/pause, +/-15 s, progress with elapsed and total time (seekable; LIVE badge for streams), Back to tab (selects it in its window and Space), x (keeps playing in the sidebar), ... menu (playback speed, turn off today / always for the site), previous/next for Media Session pages; double-click goes back to the tab | @@ -320,7 +320,7 @@ Spec: `spec/media.md` | MD-13 | Video calls: automatic call picture-in-picture | P1 | 🟡 partial | ✅ e2e | – | leaving a call tab floats the largest participant video (never the self view) in Orbit's player with microphone/camera toggles and Back to call tab; moves and resizes like any video (a pinch zooms, as for any video). Not built: leave call (the site's Media Session hang-up handler lives in the page's own script world, which Orbit reaches only through a page patch, a lead decision under D40), a chat toggle (no web API exposes a site's chat), and the site's own document picture-in-picture view (also only through the page's Media Session) | | MD-14 | Call controls in the sidebar | P1 | 🟡 partial | ✅ e2e+unit | – | a background call tab (or a closed call window) gets a sidebar call controller: microphone and camera toggles (track.enabled on the tracks the page shows) and jump back. Not built: a call is seen only when its page shows its own camera (a self view); an audio-only call isn't, because the browser process can't see what a page captures and watching getUserMedia takes a page patch (a lead decision under D40) | | MD-15 | Switch which tab is being screen-shared | P2 | – | – | – | not possible as specified: Orbit's screen chooser shares a screen or a window, never a single tab, and the engine can't change what a running share captures (switching the shared tab is the Chrome browser's own feature, not in Electron); swapping the call page's video track would take a page patch, a lead decision under D40 | -| MD-16 | Feel: entering and leaving picture-in-picture | P1 | ✅ | ✅ e2e+unit | – | enter: the video shrinks from its place in the page to the corner (~300 ms, standard curve); Back to tab/double-click: the tab is selected at once and a still of the last frame flies back into the page; release glides to the corner; Back to PiP grows from the controller; tray slides with the snappy spring; with reduced motion (FL-28) the player appears and goes without motion and a release settles with a 120 ms ease-out. Not possible: a haptic tick on macOS (Electron has no haptics; it would take native code), and fading in the engine's own PiP window, which shows only while the tab stays selected (MD-02). Only a person can check playback for stutter | +| MD-16 | Feel: entering and leaving picture-in-picture | P1 | ✅ | ✅ e2e+unit | – | enter: the video shrinks from its place in the page to the corner (~300 ms, standard curve); Back to tab/double-click: the tab is selected at once, and the player's window, showing a still of the last frame, flies to the video's place in the page (220 ms, over the window: always on top) and hides; under Xvfb, with no window manager, the selected window comes over the player, so the flight can't be seen there; release glides to the corner; Back to PiP grows from the controller; tray slides with the snappy spring; with reduced motion (FL-28) the player appears and goes without motion and a release settles with a 120 ms ease-out. Not possible: a haptic tick on macOS (Electron has no haptics; it would take native code), and fading in the engine's own PiP window, which shows only while the tab stays selected (MD-02). Only a person can check playback for stutter | ## Archive, recently closed, history and sidebar backups (prefix AR) @@ -329,7 +329,7 @@ Spec: `spec/archive.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| | AR-01 | What goes into the Archive | P0 | ✅ | ✅ e2e+unit | ✅ | close, auto-archive, Clear, archive-below, pinned/folder/Space delete, Mini Window and Peek pages; entries carry tab, Space name, section, reason; back/forward history kept outside OrbitState (userData/archive, page state in memory only); private and empty tabs never archived; state keeps the newest ~200-300, the rest lives in the Archive store | -| AR-02 | Where the Archive lives and how to open it | P0 | ✅ | ✅ e2e | – | Library view over the page area (its own view above the tabs, below the command bar and Peek) from the bottom-bar button, ⌘⇧L (Open Library), ⌘T View Archive / Open Library and the macOS Archive menu; reopens to the last section; Esc, ⌘W or selecting another tab closes it and the page gets the keys back; section registry (*.library-section.tsx) for other lanes | +| AR-02 | Where the Archive lives and how to open it | P0 | ✅ | ✅ e2e | – | Library view over the page area (its own view above the tabs, below the command bar and Peek) from the bottom-bar button, ⌘⇧L (Open Library), ⌘T View Archive / Open Library and the macOS Archive menu; reopens to the last section; Esc, ⌘W or selecting another tab closes it and the page gets the keys back; section registry (*.library-section.tsx) for other lanes; closed, or made ahead of its first open, it draws nothing, so no half-drawn Library shows before it fades in, and only the section it opens on is marked; closing, its view leaves the window once it has drawn itself faded out (500 ms at the latest) | | AR-03 | Archive list layout | P0 | ✅ | ✅ e2e+unit | ✅ | newest first under Today / Yesterday / date headings; favicon, title, host, time and origin-Space badge; filters by source (all, sidebar, Mini Window), Space and how it closed; per profile (PR-05); virtualised and paged, fast at 10k entries. A row click previews (AR-06); ⌘-click and ⇧-click select | | AR-04 | Search the Archive | P0 | ✅ | ✅ e2e+unit | ✅ | search field filters on each keystroke (title and URL, case-insensitive, partial words, every word) using the Archive store's keys (history queryTerms/urlKeyOf); typing on narrows the last answer; Enter restores and shows the top or highlighted match; ⌘F focuses it. The command bar's Archived results search the whole Archive store of the profile, not only the recent entries kept in state | | AR-05 | Restore an archived tab | P0 | ✅ | ✅ e2e+unit | ✅ | Restore (hover button, Enter, row menu, several selected) puts tabs at the top of their Space's Today in order, with their tab id and back/forward history when the profile matches (never across profiles); routing rule > origin Space > current Space; toast with Go to Space when it lands elsewhere; command bar restores without a jump. A row click previews (AR-06) instead of restoring. Undoing Delete Folder after the Space changed profile also follows the new profile (W3) | @@ -390,8 +390,8 @@ Spec: `spec/feel.md` | FL-15 | Button press micro-animations | P1 | ✅ | ✅ e2e | – | every icon button presses to 0.94 at once and springs back (pop), the Library button too; Back/Forward nudge, Reload's glyph turns once, the + scales to 0.92; restarts on repeat; none when disabled; no spring with reduced motion | | FL-16 | Command bar open and close | P0 | ✅ | ✅ e2e | ✅ | motion spring (0.97→1, 4 px drop), 100 ms exit (80 ms after choosing a row) while focus returns at once; fade only with reduced motion; centred in the window | | FL-17 | Command bar typing and result updates | P0 | ✅ | ✅ e2e+unit | ✅ | per-keystroke local results, identity-keyed memoized rows, instant highlight; engine suggestions fill free rows without moving the highlight | -| FL-18 | Split view create and resize | P1 | 🟡 partial | ✅ e2e | – | a tab dragged over the page: the zone fades in over 100 ms on the half the new pane takes and moves between halves at once; the new pane grows in from its side with the standard spring; the divider follows the pointer 1:1; flip and close travel with the spring (docs/FEEL.md); a tab dragged onto another tab row highlights the half (SV-05) but has no bubble preview or wobble on drop | -| FL-19 | Link preview open, expand, close | P1 | ✅ | ✅ e2e+unit | – | open: a spring tuned to FL-19's 280–320 ms (response 0.275 s, ratio 0.92; the standard token FL-19 names takes longer over a card's travel, D70), within 2 px in ~320 ms with no overshoot, 18% dim; expand meets the frame in ~250 ms, then the row enters; split slides to the right half; close 160 ms to 0.96 + fade, the page behind takes clicks again 173–194 ms after Escape (best of up to three rounds, at the edge of 200 ms); drag/swipe dismiss; ⌘Z/⌘⇧T replay the open | +| FL-18 | Split view create and resize | P1 | 🟡 partial | ✅ e2e+unit | – | a tab dragged over the page: the zone shows in the frame after the move that finds it, fades in over 100 ms on the half the new pane takes and moves between halves at once; the new pane grows in from its side with the standard spring and stops in place, without overshoot; the divider follows the pointer 1:1; flip and close travel with the spring (docs/FEEL.md); a tab dragged onto another tab row highlights the half (SV-05) but has no bubble preview or wobble on drop | +| FL-19 | Link preview open, expand, close | P1 | ✅ | ✅ e2e+unit | – | open: a spring tuned to FL-19's 280–320 ms (response 0.275 s, ratio 0.92; the standard token FL-19 names takes longer over a card's travel, D70), within 2 px in ~320 ms with no overshoot, 18% dim; the page view never resizes or moves with the card (PK-06); expand meets the frame in ~250 ms, then the row enters; split slides to the right half; close 160 ms to 0.96 + fade, the page behind takes clicks again 173–194 ms after Escape (best of up to three rounds, at the edge of 200 ms); drag/swipe dismiss; ⌘Z/⌘⇧T replay the open | | FL-20 | Favorites tiles | P1 | ✅ | ✅ e2e | – | a new Favorite pops in from 0.85 with the pop spring while the other tiles re-flow with the snappy spring; a removed one shrinks to 0.85 and fades where it was while the grid closes up | | FL-21 | Sidebar scrolling and large-sidebar performance | P0 | 🟡 partial | ✅ e2e | – | only the rows near the visible band are drawn, and every row tells screen readers its place and its list's size, drawn or not; a Space shown for the first time draws a stand-in at once and its list within ~160 ms; on this machine under load a 200-tab fling and a 500-tab scroll still have a few frames of two intervals (5 and 12), so p99 is two intervals, against none over one (docs/FEEL.md) | | FL-22 | Toasts | P1 | 🟡 partial | ✅ e2e | – | rise 12 px from 0.96 with the pop spring, at most three 8 px apart, a new one pushes the others up with the snappy spring, stays 3 s (4 s with a button), a hovered one with a button waits until 1 s after the pointer leaves; fade and 8 px drop in 160 ms; under reduced motion a toast fades in where it rests, at its size, and the older ones move at once. In the Space theme; while the sidebar is hidden they show over the bottom centre of the page card (the pane in use in a split), take the pointer and, with a button, are clickable as a whole (SB-13, tabs lane); with the sidebar shown they still show at its foot, not over the page card, and plain ones let clicks through | diff --git a/docs/FEEL.md b/docs/FEEL.md index 4ec7c9f7..76882fe8 100644 --- a/docs/FEEL.md +++ b/docs/FEEL.md @@ -227,19 +227,28 @@ - The rubber band returns with the spring FL-13 gives it (response 0.3 s, ratio 1.0). From a third of the width that spring needs about 390 ms to come within 0.5 px, or 478 ms with the release wait. FL-29's 350 ms is for swipes that land on a Space, so the rubber band keeps FL-13's spring (D70). - **FL-15:** the release uses the pop spring (0.30 s, damping 0.70), as FL-15 asks; the spec gives it no time. From 0.94 the button is within 0.2 % of full size 193 ms after the release. - **FL-16:** the first ⌘T after a launch showed the bar in the second frame in two runs of three (14, 22 and 22 ms). Warm opens show it in the first frame. + - The feel-fix lane (CB-22) warms the overlay up at the window's size, so the first open does the work of later ones with no resize. Over 12 fresh launches at load 2.0–3.7, the overlay's own work for it took 3.5–11.5 ms (median 5.8), against 3.6 for later opens; the test holds it to 1.5 times theirs plus 2 ms. Before, over ten launches at load 3.5–9: 8–17 ms, against 4–8. + - The first ⌘T still shows the bar in the second frame in about two launches in five (D90): 13 of 31 launches so far. Over those 12 launches it took 4.2–22.9 ms (median 14.6) and missed in 5. The review's 12 at load 2.2–8.8: 4.9–31.0 ms, median 18.6, 7 missed. The fix check's 7 at load 8.6–11.3: 6.5–20.5 ms, 1 missed. `main`'s six at load 5.4–5.7: median 21.25. + - The update reaches the overlay 5–10 ms after the key (main sends it after 3–7 ms), against about 2.5 ms for later opens; the overlay's work adds the rest. A later ⌘T misses too, about one open in eight, so the test judges later opens at their best of three. + - Two leads, tried over 12 launches each. Waiting 1.5 s after the warm-up: 11 within a frame, so start-up costs some of it, though main ran no task over 7 ms in the 400 ms before any key. Drawing the warm-up's bars at 1 % opacity, so they are rasterized before the real open: none within a frame (21.7–55.8 ms), so they stay at 0. + - The test logs the first ⌘T's frame, and judges its work and that nothing resizes. - **FL-18:** - The zone shows 3 frames after the move that finds it, for two reasons. A fade from 0 shows only from its second frame, and React renders a pointer move's state in a task of its own, which can miss the next frame. Over seven runs it took 2–4 frames. + - Since the feel-fix lane (FL-18) the move's state renders in the move's own task, and the fade starts a frame along its curve, so its first frame shows. In three runs at load 4–8 the zone showed in the frame after the move that found it, and moved to a new half in 1 frame (3 and 2 before). - The zone now moves to a new half at once, as FL-18 asks. SV-26's 120 ms ease defers to feel.md. - The page makes room while the zone shows, so on release the new pane only takes the zone's place. - Adding a pane, a flip and closing a pane use the standard spring. The panes are within 2 px in 267–300 ms, as the spec asks, but within 0.5 px only at 433–483 ms, after a 1.4 px (0.2 %) overshoot. + - Since the feel-fix lane (SV-26, FL-18) a pane that comes to its place under 600 px/s stops there, so none passes it; one turned around at speed still carries on past and comes back (FL-01). Three runs at load 4–8: an added pane in place in 300–317 ms, a flip in 283 ms, a close in 283–300 ms, none past its place. + - Closing a pane (SV-26): since the feel-fix lane its card fades and collapses to about 0.96 with a still of its page on it, gone in 200–233 ms (three runs at load 4–9). Main keeps a still of each pane once the panes are at rest, 200 ms after the last load, scroll or key, so a pane closed within about 200 ms of the split showing has none and closes blank. - Not built: the "bubble" while a tab hovers another tab's row, and the wobble on drop. - **FL-19:** the close's tween and the expand's spring now start their clocks when they are called, so their first frame already moves. This is a timing change in the peek lane's `PeekLayer.tsx`, shown by `feel-peek.spec.ts`. - The open uses a spring tuned to FL-19's 280–320 ms (response 0.275 s, ratio 0.92). The standard spring FL-19 names takes longer than 320 ms over a card's travel (D70). - The layer goes when the close's curve ends, as it did before the lane. The lane had made it go once the card and the dim were under 0.1 %; no test showed that was needed, so it came out in fix round 1 (review follow-up 3). - Six runs at load 15–19, each in up to three rounds (D46), took 173–194 ms at their best; 4 of their 10 rounds took 201–215 ms. That is at the edge of the target: the close starts in the second frame and runs 160 ms. - Two gaps remain: - - During the open, the overlay draws at 30 fps on this machine while main moves the page view every frame, so p99 is two intervals. A fix belongs in `src/main/peek/`, which is the peek lane's. + - During the open, the overlay draws at 30 fps on this machine, so p99 is two intervals. Under Xvfb's software compositing it draws at 30–60 fps whatever it shows. - Escape goes through main to the overlay view, so the close starts in the second frame (as with FL-03). + - Main used to resize the page view in every frame of the open, from the link's size (13 times in an open), so the page showed laid out narrow until about 230 ms in. Since the feel-fix lane (PK-06) the page is laid out once, at its size, out of sight, and joins the card at rest; the card grows blank and shows a still of the page while it moves. The view changes size 0 times in an open and once in an expand (8 before). The close fades that still: the view leaves the screen 2–6 ms after Escape and the still fades over 160 ms (`PEEK_CLOSE_MS`). Read from frames, its first changed frame to its last spans 117–167 ms: the ease-out's last steps are too small to show, which D79 (2) allows two frames for. Three runs at load 4–5: the open within 2 px in 300–317 ms, its longest frame 33–67 ms (50–83 before); the expand within 0.5 px in 217–250 ms. - **FL-20:** a removed Favorite used to fade at full size. It now shrinks to 0.85 where it was. - **FL-21:** only the rows near the visible band are drawn. A Space shown for the first time draws a stand-in at once (11 ms) and its own list by 161 ms. - Under load 3–4 the long lists still have frames of two intervals, so p99 is two intervals: 5 such frames per 200-tab fling, and 12 per 500-tab scroll (was 102). diff --git a/docs/feature-status.json b/docs/feature-status.json index bde5122c..c9bd01dd 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -61,7 +61,7 @@ "IM-11": {"scope": "out", "note": "Orbit has no store for addresses and cards to import into: the spec has no row for one (OT-12), and Electron has none (D52)"}, "ST-15": {"scope": "out", "note": "the settings of a password manager and an autofill store, which Orbit doesn't have (IM-08 and IM-11 are out of scope too, D52)"}, "AR-01": {"built": "yes", "feel": true, "note": "close, auto-archive, Clear, archive-below, pinned/folder/Space delete, Mini Window and Peek pages; entries carry tab, Space name, section, reason; back/forward history kept outside OrbitState (userData/archive, page state in memory only); private and empty tabs never archived; state keeps the newest ~200-300, the rest lives in the Archive store"}, - "AR-02": {"built": "yes", "note": "Library view over the page area (its own view above the tabs, below the command bar and Peek) from the bottom-bar button, ⌘⇧L (Open Library), ⌘T View Archive / Open Library and the macOS Archive menu; reopens to the last section; Esc, ⌘W or selecting another tab closes it and the page gets the keys back; section registry (*.library-section.tsx) for other lanes"}, + "AR-02": {"built": "yes", "note": "Library view over the page area (its own view above the tabs, below the command bar and Peek) from the bottom-bar button, ⌘⇧L (Open Library), ⌘T View Archive / Open Library and the macOS Archive menu; reopens to the last section; Esc, ⌘W or selecting another tab closes it and the page gets the keys back; section registry (*.library-section.tsx) for other lanes; closed, or made ahead of its first open, it draws nothing, so no half-drawn Library shows before it fades in, and only the section it opens on is marked; closing, its view leaves the window once it has drawn itself faded out (500 ms at the latest)"}, "AR-03": {"built": "yes", "feel": true, "note": "newest first under Today / Yesterday / date headings; favicon, title, host, time and origin-Space badge; filters by source (all, sidebar, Mini Window), Space and how it closed; per profile (PR-05); virtualised and paged, fast at 10k entries. A row click previews (AR-06); ⌘-click and ⇧-click select"}, "AR-04": {"built": "yes", "feel": true, "note": "search field filters on each keystroke (title and URL, case-insensitive, partial words, every word) using the Archive store's keys (history queryTerms/urlKeyOf); typing on narrows the last answer; Enter restores and shows the top or highlighted match; ⌘F focuses it. The command bar's Archived results search the whole Archive store of the profile, not only the recent entries kept in state"}, "AR-05": {"built": "yes", "feel": true, "note": "Restore (hover button, Enter, row menu, several selected) puts tabs at the top of their Space's Today in order, with their tab id and back/forward history when the profile matches (never across profiles); routing rule > origin Space > current Space; toast with Go to Space when it lands elsewhere; command bar restores without a jump. A row click previews (AR-06) instead of restoring. Undoing Delete Folder after the Space changed profile also follows the new profile (W3)"}, @@ -91,7 +91,7 @@ "FV-08": {"built": "yes", "feel": true, "note": "Favorites sleep only after 4x the sleep period"}, "FV-10": {"built": "yes", "feel": true, "note": "see TB-04"}, "SV-01": {"built": "yes", "feel": true, "note": "2-4 live panes shown together; the split is one sidebar item"}, - "SV-02": {"built": "yes", "note": "Ctrl+Shift+= adds an empty pane and opens the command bar; a URL or open tab fills it, Escape removes it; refused in an empty window"}, + "SV-02": {"built": "yes", "note": "Ctrl+Shift+= adds an empty pane and opens the command bar; a URL or open tab fills it (an open tab goes into the pane in the update that closes the bar, never shown alone), Escape removes it; refused in an empty window"}, "SV-03": {"built": "yes", "note": "Add Split Right/Left/Above/Below and New Split View actions; the command bar stays open for the new pane"}, "SV-04": {"built": "yes", "feel": true, "note": "drag a tab row over the page: theme-coloured drop indicator, the page makes room, release adds the pane"}, "SV-05": {"built": "yes", "feel": true, "note": "resting a dragged tab on a tab or split row's middle band (~400 ms) highlights that half; dropping makes or extends a split there, undoable with Cmd/Ctrl+Z; quick drags reorder as usual"}, @@ -114,7 +114,7 @@ "SV-23": {"built": "yes", "feel": true, "note": "pinning keeps panes, sizes and orientation; drops onto pinned tabs stay pinned; each pane keeps its own pinned URL, and the split's menu (Reset to Pinned URLs) or a double-click on its Favorite tile brings every pane back (Reset Tab on one pane still resets that pane); a link to another site in either pane of a pinned or Favorite split opens a Peek over the split from that pane; Favorite splits show a combined tile (with the away dot when a pane wandered off), or the split's custom icon, and drag like tiles; a Favorite split keeps its focused pane across Spaces"}, "SV-24": {"built": "yes", "feel": true, "note": "panes, order, orientation, sizes and focused pane survive relaunch; a Today split closed or auto-archived as one unit restores from the Archive as one unit, with its layout and sizes"}, "SV-25": {"built": "yes", "feel": true, "note": "fifth pane refused with a toast on every route; no split from an empty window; HTML full screen (core) fills the window, hides the strip and returns to the split"}, - "SV-26": {"built": "yes", "note": "springs for create/close/flip, closing card fades, 1:1 resize, drop indicator fades in and moves between halves at once (FL-18), frame colour until loaded, split row entrance; a pane added while a pane closes, or while a neighbour still grows back, grows in from its edge beside the neighbour (PR #19 review F10); in the sidebar, rows of a list that become one split row there are replaced by it in place, the ones above closing up into it, and on separate the first row is drawn in its place while the others slide out from under it, with the reorder spring (TB-08); no haptic tick on macOS: Electron has no haptic API (a platform limit)"}, + "SV-26": {"built": "yes", "note": "springs for create/close/flip that stop in place, without overshoot; a closing pane's card fades and collapses with a still of its page on it (main keeps one of each pane once the panes are at rest); 1:1 resize; the drop indicator shows in the frame after the move that finds it, fades in and moves between halves at once (FL-18); frame colour until loaded, split row entrance; a pane added while a pane closes, or while a neighbour still grows back, grows in from its edge beside the neighbour (PR #19 review F10); in the sidebar, rows of a list that become one split row there are replaced by it in place, the ones above closing up into it, and on separate the first row is drawn in its place while the others slide out from under it, with the reorder spring (TB-08); no haptic tick on macOS: Electron has no haptic API (a platform limit)"}, "CB-01": {"built": "yes", "feel": true, "note": "no new-tab page: ⌘T and + New Tab open the bar; a tab exists only once a row is chosen"}, "CB-02": {"built": "yes", "feel": true, "note": "⌘T/Ctrl+T, + New Tab row, macOS File menu; second ⌘T closes"}, "CB-03": {"built": "yes", "feel": true, "note": "⌘L/Ctrl+L/Alt+D: full URL selected, current-page row (Enter reloads), navigates the current tab"}, @@ -127,7 +127,7 @@ "CB-10": {"built": "yes", "feel": true, "note": "↑/↓ (⌃N/⌃P on macOS), Enter, hover, aria-activedescendant; ⌘⌥⌫ / Shift+Delete removes history rows"}, "CB-11": {"built": "yes", "feel": true, "note": "keyword or site-name prefix + Tab → chip; Backspace/Esc leaves; starter list in settings.siteSearch (editing UI is ST-06)"}, "CB-12": {"built": "yes", "feel": true, "note": "Paste and go row first in the empty state; ⌘⌥V / Ctrl+Alt+V is the tabs lane's tab.pasteAsNewTab"}, - "CB-13": {"built": "partial", "note": "every registered action by name/synonym/initials, with the key the live keymap gives it (KS-18); Move to / Pin to [Space]; Open [Folder]; tab-only actions hidden on empty Spaces; a unit test checks each catalogue name Orbit has finds its action first, including the added Archive Tabs Below (TD-05), Close Split (SV-19; the whole split, Close Split Pane stays SV-17's) and the names Copy URLs, Change Theme and Zoom Reset. Not built, with their lanes: Show/Hide Toolbar (SB-17, look), New Blank Window (TB-17) and Manage Spaces (SP-13, finish-tabs), Enter/Exit Full Screen (NV-20), Save Page As (NV-21), Capture Full Page (DT-09) and Reader Mode (NV-23) (pages), Developer Mode for this site (DT-06, finish-downloads), Getting started (OB-10, finish-settings), Manage extensions and Extension: [name] (EX-06, extensions), New Note (notes), New Canvas and Capture (canvases), New Tweak and View Tweaks (BO-21, tweaks); Share is a later phase; Translate (NV-22, D45) and Manage passwords (D52) are out of scope"}, + "CB-13": {"built": "partial", "note": "every registered action by name/synonym/initials, with the key the live keymap gives it (KS-18); Move to / Pin to [Space]; Open [Folder]; tab-only actions hidden on empty Spaces; a unit test checks each catalogue name Orbit has finds its action first, including the added Archive Tabs Below (TD-05), Close Split (SV-19; the whole split, Close Split Pane stays SV-17's), New Blank Window (TB-17) and the names Copy URLs, Change Theme and Zoom Reset. Not built, with their lanes: Show/Hide Toolbar (SB-17, look), Manage Spaces (SP-13's Spaces overview, which finish-tabs left unbuilt), Enter/Exit Full Screen (NV-20), Save Page As (NV-21), Capture Full Page (DT-09) and Reader Mode (NV-23) (pages), Developer Mode for this site (DT-06, finish-downloads), Getting started (OB-10, finish-settings), Manage extensions and Extension: [name] (EX-06, extensions), New Note (notes), New Canvas and Capture (canvases), New Tweak and View Tweaks (BO-21, tweaks); Share is a later phase; Translate (NV-22, D45) and Manage passwords (D52) are out of scope"}, "CB-14": {"built": "yes", "feel": true, "note": "Tab on an empty field or '/ ' at the start → Actions chip; Esc/Backspace leaves"}, "CB-15": {"built": "yes", "feel": true, "note": "default engine search row and destination (q=a+b); engine suggestions opt-in (settings.searchSuggestions, off by default), non-blocking with a 1.5 s timeout"}, "CB-16": {"built": "yes", "feel": true, "note": "⇧Enter opens the first result (DuckDuckGo backslash prefix) when settings.searchTopResult is on (default off); otherwise like Enter"}, @@ -136,7 +136,7 @@ "CB-19": {"built": "yes", "feel": true, "note": "results use the window's current Space/profile and re-run on Space switch; new tabs go to the current Space"}, "CB-20": {"built": "yes", "feel": true, "note": "a left click over the docked or the hover-revealed sidebar closes the bar and reaches the sidebar where it is drawn (one click selects a tab); right and middle clicks only close it; ⌘T with the revealed sidebar out opens the bar over it and the sidebar stays until the bar closes; the window's edge doesn't bring the sidebar in while the bar is open (SB-12), so its appearance never dismisses the bar"}, "CB-21": {"built": "yes", "feel": true, "note": "Esc, ⌘T/⌘L again, click outside, window blur; text discarded; focus returns to the page, also when the window comes back after a background close"}, - "CB-22": {"built": "yes", "note": "spring scale-fade entrance, quicker exit, reduced-motion fade; type-ahead and Enter buffered; IME-safe; per-keystroke latency measured"}, + "CB-22": {"built": "yes", "note": "spring scale-fade entrance, quicker exit, reduced-motion fade; type-ahead and Enter buffered; IME-safe; per-keystroke latency measured; the overlay opens the bar a few times out of sight at start-up, at the window's size, so the first open after a launch does the work of later ones, with no resize; once a resize of the window ends, the hidden overlay is laid out again out of sight at its new size, so the next open doesn't resize it; it still shows the bar in the second frame in about two launches in five: 13 of 31 fresh launches so far (5 of this lane's 12 at load 2.0–3.7, 4.2–22.9 ms, median 14.6; 7 of its review's 12; 1 of its fix check's 7), as FL-29's note says"}, "CB-23": {"built": "yes", "feel": true, "note": "split pane: the bar that fills a new pane (⌃⇧=, Add Split View) takes ⌘W, ⌘T, ⌘L and Esc first: each closes it and the empty pane goes; a URL or an open tab fills the pane; ⌘L on a pane edits that pane (SV-02, SV-15). Private (non-persistent) profiles: no history or archive rows, and their tabs, folders and Spaces never show in normal windows. Mini Window: the blank window's lookup bar and the address field over its page (⌘L or a click on the host) are the bar's compact variant: the same ranking, inline completion, site search and engine suggestions, from its profile's history and the typed text, with no open tabs, archive, Spaces, folders, actions or Settings sections (they act on the browser); a choice loads in that Mini Window. Over a page the list covers a still image of the page (the page's own view steps aside until the bar closes); ⌘W and ⌘L close the bar first, Esc too, and Enter with nothing to load gives the page the keyboard; ⌘T opens the main window's bar. ⌘L on an empty Space acts like ⌘T"}, "KS-04": {"built": "yes", "note": "⌘T/⌘L/Alt+D, Esc, ⌘T/⌘L again, ↑/↓ (⌃N/⌃P), Enter, Tab, ⌫, ⇧Enter (option), ⌘⌥⌫ / Shift+Delete, ⌘⌥V"}, "KS-07": {"built": "yes", "note": "Space shortcuts wired through the action table"}, @@ -195,7 +195,7 @@ "NV-03": {"built": "yes", "feel": true, "note": "Cmd/Ctrl+R, F5; hard reload Cmd+Shift+R, Ctrl+Shift+R, Shift+F5, Ctrl+F5; Cmd/middle-click Reload duplicates with history; keyboard focus goes to the page"}, "NV-04": {"built": "yes", "feel": true, "note": "Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes. Known limit (keys fix check K6): a page's answer about an Esc that comes after Orbit stopped waiting for it (250 ms) answers the next Esc pressed while that page loads"}, "NV-05": {"built": "yes", "feel": true, "note": "floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character on the keyboard's own layout); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; with a Peek open the bar finds in the Peek, over its card (PK-11); a Mini Window finds with a field in its top bar (MW-03). Known limits (chrome fix check 4, N3, by reading): keys held while a replay of held keys runs past 5 s (each held Backspace, Enter or Escape waits up to 1 s for the bar) are dropped, and keys typed after that can reach the bar ahead of the rest of the replay; keys held after a held Escape are dropped, where a ready bar would have let them reach the page once it closed; and a held character an auto-type tool maps onto the keyboard on the fly, which Chromium names \"\\u0000\", is lost (chrome fix check 4)"}, - "NV-06": {"built": "yes", "note": "Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too"}, + "NV-06": {"built": "yes", "note": "Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered), its number changing in place at each step while it shows; shown again, neither it nor the find bar first shows what it showed last time (their view draws itself empty before it leaves the window); pinch is visual zoom and Cmd+0 undoes it too"}, "NV-07": {"built": "partial", "note": "⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page's script goes on (Chromium's waits for the dialog), but what would take the page away waits for the dialog: its window.close() (a receipt written into a pop-up or a new tab, printed and closed at once) and a link, location or reload it starts go on once the dialog closes, as they would have (the same address and kind of history entry); a print() while a dialog that ⌘P or a menu opened shows returns at once and holds nothing, as Chromium's does (D76); missing: a form the page posts, or a move through history (history.back(), go()), right after print() goes at once and takes the dialog with it (the engine doesn't let a page's history move be stopped, and posting a form again would run the page's submit handlers twice); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); missing: paper size, margins, scale, pages per sheet and two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog"}, "NV-08": {"built": "partial", "note": "Cmd/middle-click background tab (while the sidebar is hidden a toast over the page, Opened in a new tab with Show, SB-13) and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; the page itself opens these (a link click in an isolated world, with the gesture), so they are requested as its click is: Referer, Sec-Fetch-Site, no SameSite=Strict cookie across sites; Option/Alt-click split view instead of the engine download; a local file opens in a new tab, window, Mini Window or split view only for a local file page whose frames are all local too, whatever the engine lets through: Orbit can't tell which frame asked, so a frame of any other origin (a web page's, or an opaque one: sandboxed, data:) refuses Cmd-, Shift- and Cmd+Option-clicks, target=_blank and window.open, and a toast names the file; a plain click without a target still opens it (D49); Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; still requested as if typed (Sec-Fetch-Site none, Strict cookies sent), with the page as Referer: the page menu Open Link in New Tab and New Window, Option-click split view, a blocked pop-up Open; without a Referer too: Peek, Mini Window and routing-rule links (peek lane), a pop-up window own links, Open Link in Private Window (on purpose); a link in a frame from another origin is opened from the top page, without a Referer; a link (or text) dragged from a page onto the sidebar opens as a tab where it is dropped, as a tab row dragged there would (TB-02): the rows make room, a closed folder takes it in, the Favorites grid opens a place, a Space's icon takes it there; only a web (http, https) address opens or is saved there: other text is a search on the list or in a folder and nothing on the Favorites, and any other address (data:, view-source:, javascript:, a local file…) opens nothing (D87); left: a link dragged onto the page opens in a split (SV-08)"}, "NV-09": {"built": "yes", "note": "target=_blank and window.open without size: selected tab at the top of Today with its opener; with size: a small window without sidebar titled with the host (Not secure marked), the opener can close it; pop-ups without a click blocked, shown in the address display with Open and Always Allow for the site; a click or a key in any frame of a page counts, one from another site's too (D77), and a pop-up window's own opens become tabs by the same rule; a web page, or a local page with a frame of another origin (web or opaque), gets no tab, window or pop-up of a local file, nor one offered there (D49); a toast names the file only where Orbit itself refuses it, for a local page with a frame of another origin: the engine refuses a web page's before Orbit's handler runs, with no toast; a tab that only downloads closes and reselects its opener"}, @@ -219,7 +219,7 @@ "FL-02": {"built": "yes", "note": "260 ms standard curve drives the sidebar column and the card margins together; interruptible; reduced motion snaps; starts a frame along, so the frame that draws ⌘S already moves"}, "FL-03": {"built": "yes", "note": "0 ms dwell; card in 200 ms (snappy), out 160 ms (quick); hysteresis band; re-entering while it leaves brings it back"}, "AP-05": {"built": "yes", "feel": true, "note": "private windows are near-black whatever the theme (frame, sidebar, window background) with hover and selection tuned for black; Private label with icon in the sidebar, not over the page; no Space switcher or Favorites"}, - "MD-01": {"built": "yes", "note": "Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank frame, at any load (a frame's page does it from its own size, before its first frame back is drawn); moving between a split's panes doesn't float it. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward. Known limit (D78): while a frame's video is in the player, the frame's page can see a hidden div the size of its viewport under , which the frame's script watches, with the root element, to put the video back before the frame is drawn; when the frame's own script, not main, puts a same-site frame's video back, the page may get one \"ResizeObserver loop completed with undelivered notifications\" error event, and then its own observers of or get the final size a frame later, as they did before"}, + "MD-01": {"built": "yes", "note": "Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank video frame, at any load (a frame's page does it from its own size, before its first frame back is drawn), and its view waits out of sight, at its pane's size, until the page has drawn there, then moves in. Whichever way it comes back (selected, Back to tab, ⌘W on the tab over it, or from an empty Space, alone or in a split), the player hides first (Back to tab flies a still of it back to the page, MD-16), and no frame of its player layout reaches the page area if the page draws within 250 ms; a page slower than that shows what it drew last. Until the page has drawn, the page area keeps the page it showed, which takes clicks (selected, Back to tab), or shows the pane's blank card in its Space's colour (⌘W on the tab over it, or from an empty Space, alone or in a split; FL-29 allows a placeholder in the page's background): 6–9 frames for a page 150 ms slow to lay out. Meanwhile the held view keeps one pixel inside the window, so that it draws: the window's bottom-right pixel shows the page's top-left one, for 8–11 frames on Linux (whether rounded window corners hide it on macOS is unchecked). Moving between a split's panes doesn't float it, nor does a Peek's video when its Peek goes. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward. Known limit (D78): while a frame's video is in the player, the frame's page can see a hidden div the size of its viewport under , which the frame's script watches, with the root element, to put the video back before the frame is drawn; when the frame's own script, not main, puts a same-site frame's video back, the page may get one \"ResizeObserver loop completed with undelivered notifications\" error event, and then its own observers of or get the final size a frame later, as they did before"}, "MD-02": {"built": "partial", "note": "Picture in Picture in a video's page right-click menu, the Picture in Picture action and the tray's PiP button; while the tab stays selected the engine's own PiP window shows the video (Orbit's player needs the tab's view), so its controls are the engine's until the tab is left and Orbit's player takes over"}, "MD-03": {"built": "yes", "feel": true, "note": "frameless, always-on-top window, 320x180 for 16:9 (keeps the video's shape), bottom-right 16 px inside the work area; rounded corners (a window shape where the system has none) and shadow; video only until hovered, then controls over a dark gradient; the same player on every platform"}, "MD-04": {"built": "yes", "feel": true, "note": "on hover: play/pause, +/-15 s, progress with elapsed and total time (seekable; LIVE badge for streams), Back to tab (selects it in its window and Space), x (keeps playing in the sidebar), ... menu (playback speed, turn off today / always for the site), previous/next for Media Session pages; double-click goes back to the tab"}, @@ -234,7 +234,7 @@ "MD-13": {"built": "partial", "note": "leaving a call tab floats the largest participant video (never the self view) in Orbit's player with microphone/camera toggles and Back to call tab; moves and resizes like any video (a pinch zooms, as for any video). Not built: leave call (the site's Media Session hang-up handler lives in the page's own script world, which Orbit reaches only through a page patch, a lead decision under D40), a chat toggle (no web API exposes a site's chat), and the site's own document picture-in-picture view (also only through the page's Media Session)"}, "MD-14": {"built": "partial", "note": "a background call tab (or a closed call window) gets a sidebar call controller: microphone and camera toggles (track.enabled on the tracks the page shows) and jump back. Not built: a call is seen only when its page shows its own camera (a self view); an audio-only call isn't, because the browser process can't see what a page captures and watching getUserMedia takes a page patch (a lead decision under D40)"}, "MD-15": {"built": "not started", "note": "not possible as specified: Orbit's screen chooser shares a screen or a window, never a single tab, and the engine can't change what a running share captures (switching the shared tab is the Chrome browser's own feature, not in Electron); swapping the call page's video track would take a page patch, a lead decision under D40"}, - "MD-16": {"built": "yes", "note": "enter: the video shrinks from its place in the page to the corner (~300 ms, standard curve); Back to tab/double-click: the tab is selected at once and a still of the last frame flies back into the page; release glides to the corner; Back to PiP grows from the controller; tray slides with the snappy spring; with reduced motion (FL-28) the player appears and goes without motion and a release settles with a 120 ms ease-out. Not possible: a haptic tick on macOS (Electron has no haptics; it would take native code), and fading in the engine's own PiP window, which shows only while the tab stays selected (MD-02). Only a person can check playback for stutter"}, + "MD-16": {"built": "yes", "note": "enter: the video shrinks from its place in the page to the corner (~300 ms, standard curve); Back to tab/double-click: the tab is selected at once, and the player's window, showing a still of the last frame, flies to the video's place in the page (220 ms, over the window: always on top) and hides; under Xvfb, with no window manager, the selected window comes over the player, so the flight can't be seen there; release glides to the corner; Back to PiP grows from the controller; tray slides with the snappy spring; with reduced motion (FL-28) the player appears and goes without motion and a release settles with a 120 ms ease-out. Not possible: a haptic tick on macOS (Electron has no haptics; it would take native code), and fading in the engine's own PiP window, which shows only while the tab stays selected (MD-02). Only a person can check playback for stutter"}, "FL-32": {"built": "yes", "note": "corner resize follows the pointer, resists past its limits (rubber band) and springs back on release; a fling heads for the corner in its direction and settles with the spring; tray slides up with the snappy timing; Back to tab flies to the video's place; pinch zooms (MD-06) rather than resizes"}, "KS-11": {"built": "partial", "note": "Space and arrows in the focused player, Cmd/Ctrl+scroll and pinch zoom (tested), hardware media keys via Chromium (untested), Mute Tab action assignable; the calendar meeting shortcut (P2) belongs to TB-21/FV-09"}, "KS-12": {"built": "partial", "note": "⌘Y / Ctrl+H View History (AR-10); ⌘⇧L Open Library on macOS, the command bar elsewhere; View Archive with no key, assignable; ⌘, / Ctrl+, Settings, which cannot be remapped, and again brings the same Settings window to the front; ⌘⇧⌫ / Ctrl+Shift+Delete Clear Browsing Data…; Shortcut Settings (command bar, assignable); the shortcut sheet from Help → Keyboard Shortcuts (macOS) and the command bar (OB-12); Open File… (macOS File menu, the command bar) and ⌘O / Ctrl+O wherever no Peek or Mini Window is open: the files chosen open in new tabs, the last one selected (Settings → Shortcuts says so under Open File…, which can also get keys of its own); with no Orbit window shown, the macOS menu bar takes ⌘O itself, so Open File… still opens (F6); Alt+F stays free for the Windows/Linux app menu (SB-03, SB-26, not built). Not built: Open Downloads (DL-14, downloads lane), pinned extensions (EX-06)"}, @@ -248,7 +248,7 @@ "PK-03": {"built": "yes", "feel": true, "note": "Shift-click in any tab; link menu 'Open Link in Peek'"}, "PK-04": {"built": "yes", "feel": true, "note": "meeting links open a tab; routing rules (settings.linkRules) win; rules UI is the settings lane's"}, "PK-05": {"built": "yes", "feel": true, "note": "inset card, shadow, dimmed page area, Close / Open as Tab / Split buttons with shortcut tooltips; no toolbar (AP-07 not built)"}, - "PK-06": {"built": "yes", "note": "spring grow from the link, 18% dim (20% dark), 160 ms shrink to 0.96 and fade, reduced motion; drag the top handle or swipe down beside the card: follows the input, closes past 25% or 500 px/s, else springs back"}, + "PK-06": {"built": "yes", "note": "spring grow from the link, 18% dim (20% dark), 160 ms shrink to 0.96 and fade, reduced motion; the page is laid out once, at its size, and joins the card at rest: the card grows blank, and shows a still of the page while it moves or closes (none while the page shows a crash or a failed load: the card shows its own colour); drag the top handle or swipe down beside the card: follows the input, closes past 25% or 500 px/s, else springs back"}, "PK-07": {"built": "yes", "feel": true, "note": "⌘O / button: card morphs to the frame, live page becomes a selected Today tab (routing rules apply)"}, "PK-08": {"built": "yes", "feel": true, "note": "the card slides to the right half, then split.addTab makes the source and the live Peek page one split in the source's place (a pinned source gives a pinned split), the page focused; from a full split the Peek stays open and the SV-25 toast shows"}, "PK-09": {"built": "yes", "feel": true, "note": "Esc (after fullscreen), ⌘W, ×, click outside; archived as from 'peek'"}, @@ -258,12 +258,12 @@ "PK-13": {"built": "yes", "feel": true, "note": "no sidebar row; sidebar stays usable; selecting another tab, Space or window dismisses (reopenable)"}, "PK-15": {"built": "yes", "feel": true, "note": "no hover-to-Peek trigger: hovering links or pinned rows never opens a Peek (hover cards are TB-21)"}, "PK-16": {"built": "yes", "feel": true, "note": "Mini Window links never Peek; target=_blank stays in the Mini Window"}, - "FL-19": {"built": "yes", "note": "open: a spring tuned to FL-19's 280–320 ms (response 0.275 s, ratio 0.92; the standard token FL-19 names takes longer over a card's travel, D70), within 2 px in ~320 ms with no overshoot, 18% dim; expand meets the frame in ~250 ms, then the row enters; split slides to the right half; close 160 ms to 0.96 + fade, the page behind takes clicks again 173–194 ms after Escape (best of up to three rounds, at the edge of 200 ms); drag/swipe dismiss; ⌘Z/⌘⇧T replay the open"}, + "FL-19": {"built": "yes", "note": "open: a spring tuned to FL-19's 280–320 ms (response 0.275 s, ratio 0.92; the standard token FL-19 names takes longer over a card's travel, D70), within 2 px in ~320 ms with no overshoot, 18% dim; the page view never resizes or moves with the card (PK-06); expand meets the frame in ~250 ms, then the row enters; split slides to the right half; close 160 ms to 0.96 + fade, the page behind takes clicks again 173–194 ms after Escape (best of up to three rounds, at the edge of 200 ms); drag/swipe dismiss; ⌘Z/⌘⇧T replay the open"}, "FL-24": {"built": "yes", "note": "Library opens with a 220 ms fade and 0.98→1 scale (opacity only with reduced motion) and fades out; sections cross-fade in 90 ms; search answers within 50 ms; restored rows fade and the rows below rise; long lists virtualised; a previewed entry opens and closes with the Peek's motion (FL-19)"}, "MW-01": {"built": "yes", "feel": true, "note": "separate small window, one floating tab, main window untouched"}, "MW-02": {"built": "yes", "feel": true, "note": "open-url / second-instance / argv links open a Mini Window; same URL resurfaces it; routing rules first"}, "MW-03": {"built": "yes", "feel": true, "note": "slim top bar: controls, favicon+host (click/⌘L opens the compact command bar on the page's address, CB-23), Open in…, profile pill; inset page card; neutral theme. ⌘F opens a find field in the top bar beside the host (NV-05): finds as you type, n/m count, No matches, Enter/⌘G/F3 next and ⇧ for previous, buttons; Esc closes it, keeps the match selected and gives the page the keyboard; keys typed before the field has the keyboard are not held (as they are for the main window's bar)"}, - "MW-04": {"built": "yes", "note": "a quick scale-and-fade instead of the OS's animation: the window's UI grows from 92% to full size in 200 ms (the --ease-out curve) in its own layer while the window fades in (macOS, Windows; Linux can't fade a window, so there the UI fades in too). The OS window is not resized, which would lay the page out every frame: the page's native view, loading all along, sits out the grow and joins on the card at full size when it ends. Reduced motion shows it at once. Opens at the last-used size and place, each further window 24 px down and right; on macOS it shows above a full-screen Orbit window, on the current desktop. Only a person can check the desktop and full-screen parts, on a Mac"}, + "MW-04": {"built": "yes", "note": "a quick scale-and-fade instead of the OS's animation: the window's UI grows from 92% to full size in 200 ms (the --ease-out curve) in its own layer while the window fades in (macOS, Windows; Linux can't fade a window, so there the UI fades in too). The OS window is not resized, which would lay the page out every frame: the page's native view, loading all along, sits out the grow and joins on the card at full size when it ends. Reduced motion shows it at once. The ready window Orbit keeps hidden draws itself empty until it opens, because a window shows the frame it drew last first: a link opens with its page's bar, never the blank lookup bar, and a blank window first shows early in its grow, not at full size. Opens at the last-used size and place, each further window 24 px down and right; on macOS it shows above a full-screen Orbit window, on the current desktop. Only a person can check the desktop and full-screen parts, on a Mac"}, "MW-05": {"built": "yes", "feel": true, "note": "⌘O to the most recent Space, Open in… / ⌘⌥O (⌘⇧O) native Space menu; live page, no reload"}, "MW-06": {"built": "yes", "feel": true, "note": "⌘⌥N opens a pre-rendered blank window with a focused lookup bar, the command bar's compact variant (CB-23): history and typed-text rows, inline completion, site search, engine suggestions; the choice loads here; Esc empties it, Esc on the empty bar or ⌘W closes the window. System-wide on macOS only (outside e2e); on Windows/Linux Ctrl+Alt+N works inside Orbit, so other apps (and AltGr+N) keep the key"}, "MW-07": {"built": "yes", "feel": true, "note": "⌘⌥-click a link, link menu Open Link in Mini Window, or ⌘⌥-click a sidebar row: Today rows move live, pinned/Favorite rows open a copy"}, @@ -274,7 +274,7 @@ "MW-12": {"built": "partial", "note": "a video's full screen happens in the Mini Window: the window fills the screen with the page, which stays there, and the card comes back on exit (tested); copy URL, devtools, downloads through the profile's session. Not built: extensions (Orbit has no extension support yet, EX-11), captures saved to a canvas (no Canvases yet) and sharing (Orbit has no share feature)"}, "MW-13": {"built": "yes", "feel": true, "note": "off by default; Settings > General > Mini Window turns it on: Orbit's mark as a template glyph in the macOS menu bar, the app's icon in the Windows and Linux tray (the drawn mark where a build has no icon file); a click opens a blank Mini Window with its lookup bar focused (macOS: right-click for its menu); its menu has New Mini Window and Show/Hide All Mini Windows, New Mini Window first for Linux trays that show only the menu (tested through the button's click and menu). GNOME shows tray icons only with an AppIndicator extension; on Windows and Linux Orbit still quits with its last window, button or not"}, "MW-14": {"built": "yes", "feel": true, "note": "all platforms with Ctrl+Alt+N / Ctrl+O / Ctrl+Alt+O, in-app outside macOS (D23), with the tray button (MW-13) as the way in from other apps; New Mini Window and Show/Hide All in the macOS dock menu, the Windows jump list, Linux launcher actions and the tray menu. Launcher actions: a packaged Linux folder build writes ~/.local/share/applications/.desktop at start-up (under $XDG_DATA_HOME when that is set; is Electron's desktop name), with no opt-out for now, and keeps it pointing at where Orbit runs from. It never touches an entry a package installed, one the user wrote, or one it can't read (checked on a packaged build: both actions reach the running Orbit). Only a person can check the Windows jump list"}, - "MW-15": {"built": "yes", "note": "warm spare window takes keys at once, page loads before the window shows; opens with MW-04's 200 ms grow and fade; Open in… shrinks to 92% and fades (140 ms, macOS/Windows) as the row enters Today"}, + "MW-15": {"built": "yes", "note": "warm spare window takes keys at once, page loads before the window shows; opens with MW-04's 200 ms grow and fade; Open in… shrinks to 92% and fades (140 ms, macOS/Windows) as the row enters Today; closing and Open in… never leave the window on screen without its page or UI: where the window fades out its card shows a still of the page as the page leaves, if one is taken within 150 ms (macOS, Windows; not run here), elsewhere the window leaves the screen first, whole"}, "DL-09": {"built": "partial", "note": "Library container (archive lane): section list on the left with the Archive, History (AR-10) and Backups (AR-11) sections; other lanes add theirs through the registry. Reopens to the last section; Esc/⌘W close and the previous tab comes back. Its view is made once Orbit is idle after launch, so even the first open is quick (about 70 ms at 10,000 entries; later opens 10–50 ms). Downloads lane: the Media and Downloads sections (just before Downloads, Media); right after a download, a closed Library opens on Downloads next time; their items open in a Peek over the Library (⌘/Ctrl-click: a tab). Missing, all owned by other lanes: Canvases & Notes (notes lane), Spaces (finish-tabs, SP-13) and Site customizations (tweaks, BO-15)"}, "DL-01": {"built": "yes", "feel": true, "note": "per-profile sessions save to the profile's folder (OS Downloads), name (1) on collisions, .crdownload until done; a blank tab opened for a download closes; outlives its page; quitting asks while downloads run"}, "DT-01": {"built": "yes", "feel": true, "note": "⌥⌘I / F12 (Ctrl+Shift+I / F12) open DevTools for the selected tab or focused split pane, docked inside its view, and close them, also from inside DevTools; a Peek's or Mini Window's page gets its own. ⌥⌘J (Ctrl+Shift+J) opens them on the Console; View → Developer menu (macOS) and the command bar. A key held down acts once. Copy, paste (after DevTools' own paste warning) and the device toolbar work; links DevTools open become tabs. Windows/Linux: while DevTools have the keyboard, only the DevTools keys reach Orbit"}, @@ -382,6 +382,6 @@ "BO-21": {"built": "partial", "note": "in the command bar, none bound to a key: New Tweak… (also \"customize\", \"customization\", \"site style\"), Edit Tweak (the site's one in the editor; with several, the site controls list them to pick from; with none, a note says so), Turn Off and Turn On Tweaks for This Site (with a note), View Tweaks (the Library's list, BO-15). In a Mini Window, View Tweaks opens the Library in the browser window (one that isn't private), and the others say there why its page takes no Tweak; from a Mini Window with no browser window open, they show nothing. Export Tweak… comes with BO-20"}, "FL-29": {"built": "partial", "note": "measured by the feel specs against each budget (docs/FEEL.md); press, ⌘W, ⌘S, Space switch (settles in ~290 ms), command bar and drag budgets met; still missed: p99 at two intervals in some animations under load (⌘S hide, long lists, a Peek opening), the hover sidebar and a Peek's close start in the second frame, and the first ⌘T or ⌘W after a launch can show in the second frame. The rubber band comes back in ~480 ms on FL-13's own spring: the 350 ms settle is for swipes that land on a Space (D70)"}, "FL-30": {"built": "partial", "note": "the nine points as a checklist in docs/FEEL.md: pass, gap or fixed, each with the test that shows it"}, - "FL-18": {"built": "partial", "note": "a tab dragged over the page: the zone fades in over 100 ms on the half the new pane takes and moves between halves at once; the new pane grows in from its side with the standard spring; the divider follows the pointer 1:1; flip and close travel with the spring (docs/FEEL.md); a tab dragged onto another tab row highlights the half (SV-05) but has no bubble preview or wobble on drop"}, + "FL-18": {"built": "partial", "note": "a tab dragged over the page: the zone shows in the frame after the move that finds it, fades in over 100 ms on the half the new pane takes and moves between halves at once; the new pane grows in from its side with the standard spring and stops in place, without overshoot; the divider follows the pointer 1:1; flip and close travel with the spring (docs/FEEL.md); a tab dragged onto another tab row highlights the half (SV-05) but has no bubble preview or wobble on drop"}, "FL-27": {"built": "yes", "note": "a resized window keeps the card's margin in every frame and the page view gets each width within a frame at the median (18 ms at worst), over the page's own colour; the OS full-screen transition; new windows open a step down and right (NV-10) with the command bar's entrance; an inactive window's sidebar dims over 150 ms and comes back at once on focus; no launch sheen (optional)"} } diff --git a/src/main/archive/index.ts b/src/main/archive/index.ts index 3e1ed71c..9fbfa06c 100644 --- a/src/main/archive/index.ts +++ b/src/main/archive/index.ts @@ -131,6 +131,9 @@ export function registerArchive(ctx: MainContext): ArchiveApi { } commands.register('library.open', (args, c) => openLibrary(c, args ?? {})) commands.register('library.close', (_args, c) => library.close(windowOf(c))) + commands.register('library.drawn', ({ away }, c) => { + if (typeof away === 'number') library.drawn(windowOf(c), away) + }) commands.register('library.toggle', (_args, c) => { const windowId = windowOf(c) if (library.isOpen(windowId)) library.close(windowId) diff --git a/src/main/archive/library.ts b/src/main/archive/library.ts index 471de3e0..f28d76b0 100644 --- a/src/main/archive/library.ts +++ b/src/main/archive/library.ts @@ -5,8 +5,13 @@ import type { OrbitState, SpaceId, TabId, WindowId } from '../../shared/state' import type { MainContext } from '../context' import { activeTabOf, loadRenderer, rendererEntry, uiWebPreferences } from '../windows' -/** The Library fades out over this long before its view hides (FL-24). */ -const EXIT_MS = 180 +/** + * The Library fades out when it closes (FL-24), and its view hides once its page says it has drawn + * itself at opacity 0 (`library.drawn`): a view put back in the window shows the last frame it drew + * until it draws again, so one that left mid-fade would show that frame first at the next open. A + * page that hasn't said so after this long (its renderer stuck) has its view hidden anyway. + */ +const EXIT_FALLBACK_MS = 500 const DEFAULT_RADIUS = 10 /** * A main window's Library view is made this long after the window first lays out, once Orbit is @@ -29,6 +34,8 @@ interface LibraryWindow { /** The command bar, a menu or a Peek is over the window: when it goes, the Library takes the keys back. */ overlay?: boolean hideTimer?: ReturnType + /** Closing: the view hides once its page has drawn the fade's end for this close (`away`). */ + leaving?: { away: number; done: () => void } } /** @@ -61,6 +68,7 @@ export class LibraryHost { private frames = new Map() private radii = new Map() private nonce = 0 + private awayNonce = 0 /** Windows whose Library view is made (or waits to be made) once Orbit is idle. */ private idleScheduled = new Set() /** Called when the Library opens, closes or changes section in a window. */ @@ -139,6 +147,7 @@ export class LibraryHost { const page = pageOf(view) if (!page) return false clearTimeout(w.hideTimer) + w.leaving = undefined const section = args.section || this.sections.last() if (section !== this.sections.last()) this.sections.remember(section) const entering = !w.open @@ -173,17 +182,26 @@ export class LibraryHost { w.open = false const view = w.view const page = pageOf(view) - if (view && page) { - page.send('orbit:library:hide', {}) - clearTimeout(w.hideTimer) - // The page's own views count as covered until the Library's view has hidden: a permission - // prompt it uncovers takes no click before its choices have waited again (NV-15). - w.hideTimer = setTimeout(() => { + clearTimeout(w.hideTimer) + w.leaving = undefined + if (view && page && !page.isCrashed()) { + // The view hides once its page has drawn itself faded out (EXIT_FALLBACK_MS). The page's own + // views count as covered until then: a permission prompt it uncovers takes no click before + // its choices have waited again (NV-15). + const away = ++this.awayNonce + const done = () => { + if (w.leaving?.away !== away) return + clearTimeout(w.hideTimer) + w.leaving = undefined if (w.open) return if (pageOf(view)) view.setVisible(false) this.ctx.windows.setPageCover(windowId, undefined) - }, EXIT_MS) + } + w.leaving = { away, done } + w.hideTimer = setTimeout(done, EXIT_FALLBACK_MS) + page.send('orbit:library:hide', { away }) } else { + if (view && page) view.setVisible(false) this.ctx.windows.setPageCover(windowId, undefined) } const win = this.ctx.windows.get(windowId) @@ -193,6 +211,12 @@ export class LibraryHost { this.onChange?.(windowId, false, w.show.section) } + /** The Library's page has drawn itself faded out for the close `away`: its view leaves the window (FL-24). */ + drawn(windowId: WindowId | undefined, away: number): void { + const w = windowId ? this.wins.get(windowId) : undefined + if (w?.leaving?.away === away) w.leaving.done() + } + /** The section shown changed inside the Library: it reopens there (AR-02). */ setSection(windowId: WindowId | undefined, section: string): void { this.sections.remember(section) diff --git a/src/main/commandbar/search.ts b/src/main/commandbar/search.ts index a50be050..55805b6d 100644 --- a/src/main/commandbar/search.ts +++ b/src/main/commandbar/search.ts @@ -1,4 +1,5 @@ import { clipboard, net } from 'electron' +import { actionForInput } from '../../shared/actions' import { isBlankSpace } from '../../shared/blank' import type { SearchQueryArgs, SearchResult } from '../../shared/commands/commandbar' import type { CommandName } from '../../shared/rpc' @@ -13,6 +14,12 @@ import { normKey, pastSearchOf, type RankInput, rank } from './rank' import { SourceCache } from './sources' import { parseSuggestions } from './suggest' +/** Closes a window's command bar (in a store update's draft). */ +const closeIn = (windowId: WindowId) => (d: OrbitState) => { + const win = d.windows[windowId] + if (win && win.overlay.kind === 'commandbar') win.overlay = { kind: 'none' } +} + /** * The command bar's main-process side: ranks open tabs, history, archived * tabs, Spaces, actions and the typed URL/search per keystroke (no state @@ -28,10 +35,7 @@ export function registerSearch(ctx: MainContext, history: HistoryService): void const closeBar = (windowId: WindowId | undefined) => { if (!windowId) return - store.update((d) => { - const win = d.windows[windowId] - if (win && win.overlay.kind === 'commandbar') win.overlay = { kind: 'none' } - }) + store.update(closeIn(windowId)) } /** Profile of the window's current Space, and whether it is private (non-persistent session). */ @@ -194,6 +198,20 @@ export function registerSearch(ctx: MainContext, history: HistoryService): void contents.sendInputEvent({ type: 'mouseUp', ...at, button: 'left', clickCount: 1 }) }) + // CB-22: the keymap is built on its first use, which was the first key press after a launch (⌘T's): + // it is built now, while Orbit starts, and a new window's overlay, once it has warmed the bar + // (OverlayApp), has main look a ⌘T up again, so the first real one runs warm code. Then the + // overlay, shown at the window's size but out of the way while it warmed up, hides. + const lookUpNewTab = () => { + const mod = process.platform === 'darwin' + actionForInput({ key: 't', code: 'KeyT', meta: mod, control: !mod, alt: false, shift: false }, process.platform) + } + lookUpNewTab() + commands.register('search.warm', (_args, c) => { + for (let i = 0; i < 3; i++) lookUpNewTab() + if (c.windowId) windows.overlayWarm(c.windowId) + }) + commands.register('search.siteSearches', () => (store.state.settings.siteSearch ?? []).map((s) => ({ id: s.id, name: s.name, keyword: s.keyword })), ) @@ -227,6 +245,12 @@ export function registerSearch(ctx: MainContext, history: HistoryService): void commands.register('search.open', async ({ result, mode, disposition, topResult }, c) => { const windowId = windowOf(c) if (windowId && store.state.windows[windowId]?.kind === 'mini') return openInMini(windowId, result, topResult) + // An open tab chosen for a pane goes into it in the update that closes the bar (SV-02). + const tabId = result.kind === 'tab' && disposition !== 'new-tab' ? result.tabId : undefined + if (windowId && tabId && store.state.tabs[tabId] && barOpen(windowId)) { + const close = closeIn(windowId) + if ((ctx.hooks.barChoice ?? []).some((take) => take(windowId, tabId, close))) return + } closeBar(windowId) const state = store.state const current = windowId ? activeTabOf(state, windowId) : null diff --git a/src/main/context.ts b/src/main/context.ts index 62814366..4048f6f8 100644 --- a/src/main/context.ts +++ b/src/main/context.ts @@ -6,7 +6,7 @@ import type { WebFrameMain, WindowOpenHandlerResponse, } from 'electron' -import type { TabId } from '../shared/state' +import type { OrbitState, TabId, WindowId } from '../shared/state' import type { ToastAction } from '../shared/toast' import type { Perf } from './perf' import type { ActionRegistry, CommandRegistry, EventRegistry } from './registry' @@ -32,6 +32,12 @@ export interface Hooks { * goes to the page once these have settled. */ screenShare?: ((tabId: TabId, contents: WebContents, frame: WebFrameMain) => Promise)[] + /** + * The command bar chose an open tab to switch to. A hook that takes the choice (a pane being filled, + * SV-02) carries it out in one store update that also runs `closeBar`, and returns true; the bar + * then does nothing more. Otherwise the bar closes and selects the tab. + */ + barChoice?: ((windowId: WindowId, tabId: TabId, closeBar: (d: OrbitState) => void) => boolean)[] } /** Everything a feature module needs. Passed to each module's register(). */ diff --git a/src/main/media/agent.ts b/src/main/media/agent.ts index b8a76b8c..a9f1d045 100644 --- a/src/main/media/agent.ts +++ b/src/main/media/agent.ts @@ -619,6 +619,20 @@ const agentScript = (handle: string, define: string, frame: boolean) => `(() => return true } + // Back in its window (MD-01): resolves once the page has drawn a frame with its video back in it. + // The main process shows the view only then: until the page draws at its new size, the view + // shows the last frame it drew, in the player's layout. (rAF, then a task: after that frame.) + function drawn() { + return new Promise((resolve) => { + let n = 0 + const tick = () => { + if (lift && lift.shown && ++n < 60) requestAnimationFrame(tick) + else requestAnimationFrame(() => setTimeout(() => resolve(!lift || !lift.shown), 0)) + } + requestAnimationFrame(tick) + }) + } + // A video removed from the page leaves the top layer at once: the player has to close (MD-07). function watchRemoval(v) { if (removal) removal.disconnect() @@ -765,6 +779,7 @@ const agentScript = (handle: string, define: string, frame: boolean) => `(() => size: sizeVideo, zoom: zoomVideo, drop, + drawn, lifted: () => !!lift, index, frameBox, diff --git a/src/main/media/manager.ts b/src/main/media/manager.ts index 97ba9353..0146cd66 100644 --- a/src/main/media/manager.ts +++ b/src/main/media/manager.ts @@ -589,6 +589,9 @@ export class MediaManager { const snap = t && combineSnapshots(this.reports(t)) const tab = state.tabs[tabId] if (!t || !snap || !tab?.media || t.contents.isDestroyed()) return + // A Peek's page leaves the screen as its Peek goes (closed, or replaced by another overlay), and + // the page goes with it: nothing of it floats. + if (tab.floating === 'peek') return // The engine's own window (opened on the tab) hands its video over to Orbit's player, with // Orbit's controls. The user asked for it, so the automatic-PiP choices don't apply. The // tab's state knows first (the page's report of it may still be on its way). @@ -792,6 +795,8 @@ export class MediaManager { const target = agents[agents.length - 1] if (!target) return undefined if (fn === 'lift' && chain && agents.length > 1) return (await this.liftChain(tabId, chain, args[0])) as T + // Back in its window: every document on the way has drawn with the video back in it (MD-01). + if (fn === 'drawn') return (await Promise.all(agents.map((a) => this.exec(a, 'drawn')))) as T if (fn === 'drop') { const done = await Promise.all(agents.map((a) => this.exec(a, 'drop'))) return done[done.length - 1] as T diff --git a/src/main/media/player.ts b/src/main/media/player.ts index 21662537..bde19719 100644 --- a/src/main/media/player.ts +++ b/src/main/media/player.ts @@ -6,10 +6,18 @@ * page's top layer at the player's size at the top-left of the page; the window shows just that * corner. Orbit's controls (renderer view "pip") sit in a transparent view on top. * - * Returning to the tab needs nothing from here: the window manager attaches the tab's view to its - * window again (taking it out of this one), and the page puts the video back on that very frame - * (see agent.ts). The player then just hides. Closing the player (×) hides it and leaves the view - * detached, like any background tab, so the media plays on as audio (MD-07). + * Returning to the tab: the window manager attaches the tab's view to its window again (taking it + * out of this one), and the page puts the video back on that very frame (see agent.ts). The player + * hides first (Back to tab flies a still of its last frame back to the page, MD-16). Until the page + * has drawn that layout, the view waits out of sight at its pane's size, all of it but its corner + * pixel outside the window, and then moves into its pane (arriving(), WindowManager.holdArrivals). + * Meanwhile the page area keeps the page its window showed there, which takes clicks; with none + * there (after ⌘W on the tab over it, or back from an empty Space, alone or in a split), it shows + * the pane's blank card, a placeholder FL-29 allows. The window's bottom-right pixel shows the + * view's top-left one. So no frame of the player's layout reaches the page area (MD-01), if the + * page draws within the hold's 250 ms: a page slower than that shows what it drew last. Closing + * the player (×) hides it and leaves the view detached, like any background tab, so the media plays + * on as audio (MD-07). */ import { app, @@ -218,6 +226,7 @@ export class PipPlayer { screen.on('display-removed', () => this.refit()) screen.on('display-metrics-changed', () => this.refit()) + ctx.windows.holdArrivals((tabId, view) => this.arriving(tabId, view)) } /** The tab whose video is in the player. */ @@ -372,6 +381,17 @@ export class PipPlayer { return true } + /** + * The video's view goes back into a window (its tab was selected again, or Back to tab): it stays + * out of sight there until its page has put the video back and drawn at its size (MD-01). Until + * then the view would show the last frame the page drew: black, with the video small at its top-left. + */ + private arriving(tabId: TabId, view: WebContentsView): Promise | undefined { + const s = this.session + if (s?.tabId !== tabId || s.view !== view) return undefined + return this.host.call(tabId, 'drawn') + } + private isShown(tabId: TabId): boolean { return displayedTabs(this.ctx.store.state).has(tabId) } diff --git a/src/main/miniwindow/index.ts b/src/main/miniwindow/index.ts index 0819d6bd..02aad026 100644 --- a/src/main/miniwindow/index.ts +++ b/src/main/miniwindow/index.ts @@ -62,6 +62,10 @@ interface Mini { * the page's view sits out the grow (`pageOut`) and joins at full size when it ends. */ grow?: { ready?: (ms: number | null) => void; pageOut: boolean } + /** "Open in…" is moving the page out (MW-05): a second one does nothing. */ + leaving?: boolean + /** Waiting for the card to show the still of the page that leaves (MW-15). */ + stillShown?: () => void /** Find in the page (NV-05): its field is in the top bar. */ find?: MiniFind } @@ -96,6 +100,8 @@ const canFade = isMac || process.platform === 'win32' const GROW_FROM = 0.92 /** How long main waits for the UI to take its starting size before showing the window anyway. */ const GROW_READY_WAIT_MS = 100 +/** How long "Open in…" waits for the card to show the still of its page before the page leaves anyway. */ +const STILL_WAIT_MS = 150 /** When a Linux folder build checks its desktop entry (MW-14), after launch. */ const DESKTOP_ENTRY_DELAY_MS = 3000 @@ -409,6 +415,9 @@ export function registerMiniWindow(ctx: MainContext): void { send(mini, 'mini:grow', { phase: 'prepare', scale: GROW_FROM, fade: !canFade }) } + /** Whether the window fades out as it closes (where windows can fade), rather than going at once. */ + const fadesOut = (mini: Mini) => canFade && !reducedMotion(ctx) && !mini.win.isDestroyed() && mini.win.isVisible() + /** * Closes with a quick fade (about 150 ms, MW-15) where windows can fade. After "Open in…" it * also shrinks a little, as if the page went into the main window; its page has already left, @@ -417,7 +426,7 @@ export function registerMiniWindow(ctx: MainContext): void { const closeWindow = (mini: Mini, opts: { shrink?: boolean } = {}) => { const win = mini.win if (win.isDestroyed()) return - if (!canFade || reducedMotion(ctx) || !win.isVisible()) { + if (!fadesOut(mini)) { win.close() return } @@ -516,8 +525,11 @@ export function registerMiniWindow(ctx: MainContext): void { } win.on('resize', saveBounds) win.on('move', saveBounds) - win.on('close', () => { + win.on('close', (event) => { clearTimeout(mini.archiveTimer) + // Its UI and its page go before the window does, and it would show empty meanwhile: it leaves + // the screen now, whole (MW-15). One that faded out is out of sight already. + if (!event.defaultPrevented && win.isVisible()) win.hide() if (mini.moved || !mini.tabId) return const tab = store.state.tabs[mini.tabId] if (!tab) return @@ -652,12 +664,68 @@ export function registerMiniWindow(ctx: MainContext): void { tabs.contents(tab.id)?.focus() } - /** Moves the page, live, into a Space of the main window and closes the Mini Window (MW-05). */ + /** + * Moves the page, live, into a Space of the main window and closes the Mini Window (MW-05). The + * page leaves before the window has gone, and the window must not show empty meanwhile (MW-15): + * one that goes at once leaves the screen first, whole; one that shrinks and fades out does so + * with a still of its page on its card. + */ const moveToSpace = (mini: Mini, spaceId?: SpaceId, pinned = false) => { + const tabId = mini.tabId + if (mini.leaving || !tabId || !store.state.tabs[tabId]) return + mini.leaving = true + if (fadesOut(mini)) { + void leaveWithStill(mini).then(() => movePage(mini, spaceId, pinned)) + return + } + mini.win.hide() + send(mini, 'mini:leaving') + movePage(mini, spaceId, pinned) + } + + /** + * The window keeps its bar as it was while it goes (MW-15), and its card shows a still of the + * page, under the page's view: resolves once the UI has drawn it, or a moment later without it. + * A capture that takes longer than STILL_WAIT_MS, or never ends, is not waited for: the window + * goes without a still. (Only where windows fade, macOS and Windows: fadesOut.) + */ + const leaveWithStill = async (mini: Mini): Promise => { + const contents = mini.tabId ? tabs.contents(mini.tabId) : undefined + let still: string | undefined + let late: ReturnType | undefined + try { + const capture = contents && !contents.isDestroyed() && !mini.fullscreen ? contents.capturePage() : undefined + const image = capture + ? await Promise.race([ + capture, + new Promise((done) => { + late = setTimeout(() => done(undefined), STILL_WAIT_MS) + }), + ]) + : undefined + if (image && !image.isEmpty()) still = `data:image/jpeg;base64,${image.toJPEG(85).toString('base64')}` + } catch { + // The page went: the window goes without a still. + } finally { + clearTimeout(late) + } + if (mini.win.isDestroyed()) return + await new Promise((done) => { + const timer = setTimeout(done, still ? STILL_WAIT_MS : 0) + mini.stillShown = () => { + clearTimeout(timer) + done() + } + send(mini, 'mini:leaving', { still }) + }) + mini.stillShown = undefined + } + + const movePage = (mini: Mini, spaceId?: SpaceId, pinned = false) => { const state = store.state const tabId = mini.tabId const tab = tabId ? state.tabs[tabId] : undefined - if (!tabId || !tab) return + if (!tabId || !tab || mini.win.isDestroyed()) return const target = spaceId && state.spaces[spaceId] ? spaceId @@ -668,9 +736,8 @@ export function registerMiniWindow(ctx: MainContext): void { const profileId = state.spaces[target]?.profileId ?? tab.profileId const mainId = mainWindowFor(ctx, target) ?? windows.open(undefined, target) const url = tabs.contents(tabId)?.getURL() || tab.url - // The main window takes the page's view: it leaves the Mini Window first. The page is the - // main window's from here on, while this window shrinks and fades out showing its old bar. - send(mini, 'mini:leaving') + // The main window takes the page's view. The page is the main window's from here on, while this + // window goes, showing its old bar. uncover(mini) detachPage(mini) mini.moved = true @@ -935,6 +1002,8 @@ export function registerMiniWindow(ctx: MainContext): void { const ready = minis.get(windowId ?? '')?.grow?.ready if (ready) ready(Number.isFinite(ms) && ms > 0 ? Math.min(ms, 1000) : null) }) + // The card shows the still of the page that leaves for a Space: the page goes now (MW-15). + ctx.events.on('mini.stillShown', (_args, { windowId }) => minis.get(windowId ?? '')?.stillShown?.()) actions.register('window.newMini', () => { if (Date.now() - lastShortcutOpen < 250) return diff --git a/src/main/nav/page-tools.ts b/src/main/nav/page-tools.ts index e96677f7..d9d6d0d8 100644 --- a/src/main/nav/page-tools.ts +++ b/src/main/nav/page-tools.ts @@ -73,6 +73,8 @@ interface WindowTools { hovered: boolean /** What the view was last told, so a layout pass doesn't repeat it. */ sent?: string + /** The view is leaving the window: its page draws itself empty first, and says so with `away` (NV-06). */ + leaving?: { away: number; drawn: () => void } } /** A key typed for the find bar before its field had the keyboard (F4). */ @@ -158,6 +160,7 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( const tools = new Map() let focusNonce = 0 let zoomNonce = 0 + let awayNonce = 0 const windowOf = (c: CallContext): WindowId | undefined => c.windowId ?? windows.focusedWindowId() const toolsOf = (windowId: WindowId): WindowTools => { @@ -218,7 +221,7 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( if (!(find.open || zoom) || !page || windows.inHtmlFullscreen(windowId)) { // Nothing is under the pointer once the view has gone. if (t) t.hovered = false - windows.hideTools(windowId) + leave(windowId) return 'hidden' } const contents = windows.toolsContents(windowId) @@ -237,6 +240,27 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( : 'loading' } + /** + * Takes a window's page tools out of the window. A view put back in the window shows the last + * frame it drew until it draws again, so the next find bar or zoom indicator would show the old + * one first (NV-06): their page draws itself empty first, and the view leaves once it says so. + */ + const leave = (windowId: WindowId) => { + const contents = windows.toolsInfo(windowId).placed ? windows.toolsContents(windowId) : undefined + if (!contents) { + windows.hideTools(windowId) + return + } + const state = toolsOf(windowId) + const payload = { find: CLOSED, zoom: null, away: ++awayNonce } + state.sent = JSON.stringify(payload) + contents.send('orbit:pageTools', payload) + const drawn = new Promise((resolve) => { + state.leaving = { away: payload.away, drawn: resolve } + }) + windows.hideTools(windowId, drawn) + } + // ---------- Keys typed before the find bar can take them (round 2 fix check, F4) ---------- const held = new Map() @@ -610,6 +634,14 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( clearTimeout(t.zoomTimer) t.zoomTimer = !over && t.zoom ? setTimeout(() => hideZoom(windowId), ZOOM_INDICATOR_MS) : undefined }) + // The page tools have drawn themselves empty: their view can leave the window (NV-06). + commands.register('pageTools.drawn', ({ away }, c) => { + const windowId = windowOf(c) + const t = windowId ? tools.get(windowId) : undefined + if (t?.leaving?.away !== away) return + t.leaving.drawn() + t.leaving = undefined + }) // The tools follow the active page: tab switches, resizes, splits and full screen. windows.onLayout((windowId) => { diff --git a/src/main/peek/index.ts b/src/main/peek/index.ts index a7d4ce1b..69f03d80 100644 --- a/src/main/peek/index.ts +++ b/src/main/peek/index.ts @@ -9,7 +9,7 @@ import type { import { WebContentsView } from 'electron' import { sidebarUndoFor } from '../../shared/actions' import { decideLink, type LinkHow, type LinkSettings, type LinkSource, routeFor } from '../../shared/link-routing' -import { PEEK_RADIUS, peekCard, peekStart } from '../../shared/peek-geometry' +import { PEEK_RADIUS, peekCard, splitTarget } from '../../shared/peek-geometry' import { isPrivateTab } from '../../shared/private' import type { Rect } from '../../shared/rpc' import { layoutLeaves, MAX_PANES, SPLIT_FULL_MESSAGE } from '../../shared/split' @@ -26,7 +26,12 @@ import { archiveFloating, floatingTab, keepFloatingPagesOutOfTabClose } from './ import { ClickTracker, heldModifiers, isMiniClick, PAGE_WORLD, type PageClick } from './input' import { runPageAction, setPeekPages } from './page-actions' -/** A Peek card open in a window: a floating tab whose view sits above the overlay, in the card. */ +/** + * A Peek card open in a window: a floating tab whose view sits above the overlay, on the card once + * the card is at rest. While the card moves (opening, dragged, closing, expanding), the overlay + * animates a still of the page and the view waits out of sight, at the size it shows at next, so its + * page is laid out once and never every frame (PK-06, FL-01). + */ interface Session { windowId: WindowId tabId: TabId @@ -37,13 +42,16 @@ interface Session { phase: 'open' | 'closing' | 'expanding' | 'splitting' /** The page area the overlay covers (window coordinates). */ area: Rect - /** Last card rect and radius applied to the view (window coordinates). */ - rect?: Rect - radius: number + /** The page view is on the card (the card is at rest); otherwise it is out of sight. */ + shown: boolean + /** Shows the page on its card if the overlay never says where it goes (its renderer still starting). */ + revealTimer?: NodeJS.Timeout /** The page is in HTML fullscreen (a video): its view covers the window. */ fullscreen: boolean /** Commits the running transition if the overlay never reports it finished. */ settleTimer?: NodeJS.Timeout + /** Takes the next still of the page for the overlay's card. */ + stillTimer?: NodeJS.Timeout /** The page's background colour, for the card the overlay fades out on close. */ background?: string } @@ -71,9 +79,28 @@ export function openLinkInPeek(tabId: TabId, url: string): void { const UNDO_WINDOW_MS = 10_000 /** How long a transition may run before main commits it anyway (overlay gone, reduced motion…). */ const SETTLE_MS = 600 +/** + * Stills of the page for the overlay's card (PK-06), taken only while the page shows on the card at + * rest (a capture can cost the overlay a frame, so never while the card moves): the first just after + * the page joins the card, then again a moment after anything changed it (a load, a scroll, a key). + */ +const STILL_FIRST_MS = 40 +const STILL_AFTER_MS = 200 +/** Input that may change what a page shows (pointer moves don't count). */ +const STILL_INPUTS = new Set(['mouseUp', 'mouseWheel', 'keyUp', 'gestureScrollEnd', 'gestureFlingStart', 'touchEnd']) let nonceCounter = 0 +/** + * The page of the Peek's view while it is alive. Once the page has gone (it closed itself, or its tab + * went to sleep), `view.webContents` reads undefined, even before 'destroyed' fires: it is checked + * before anything is called on it (D28). + */ +function pageOf(view: WebContentsView | undefined): WebContents | undefined { + const contents = view?.webContents as WebContents | undefined + return contents && !contents.isDestroyed() ? contents : undefined +} + export function registerPeek(ctx: MainContext): void { const { store, commands, actions, windows, tabs } = ctx const sessions = new Map() @@ -157,7 +184,7 @@ export function registerPeek(ctx: MainContext): void { nonce, phase: 'open', area, - radius: PEEK_RADIUS, + shown: false, fullscreen: false, } sessions.set(windowId, session) @@ -184,43 +211,94 @@ export function registerPeek(ctx: MainContext): void { } catch (err) { console.error('[peek] could not create the page', err) } - if (!view) { + if (!pageOf(view)) { discard(session) return null } - place(session, view, origin) + place(session, view as WebContentsView) return tab.id } - /** Puts the Peek's page view above the overlay, at the start of the open animation. */ - const place = (s: Session, view: WebContentsView, origin?: { x: number; y: number }) => { + /** + * Puts the Peek's page view above the overlay, out of sight at the card's size while the overlay + * grows the card: the page loads, lays out and draws there once, and shows when the card is at rest. + */ + const place = (s: Session, view: WebContentsView) => { const win = windows.get(s.windowId) - if (!win || win.isDestroyed()) return + const page = pageOf(view) + if (!win || win.isDestroyed() || !page) return s.view = view - const card = peekCard(s.area) - const start = peekStart(card, s.area, origin) - s.rect = toWindow(s, start) - view.setBounds(roundRect(s.rect)) view.setBorderRadius(PEEK_RADIUS) - view.setVisible(true) + placeView(s) win.contentView.addChildView(view) - view.webContents.focus() - // Whatever the overlay does, the card ends at its place. - s.settleTimer = setTimeout(() => { - if (sessions.get(s.windowId) !== s || s.phase !== 'open' || s.fullscreen) return - applyRect(s, toWindow(s, peekCard(s.area)), PEEK_RADIUS, true) + page.focus() + // Whatever the overlay does, the page ends on its card. + s.revealTimer = setTimeout(() => { + if (sessions.get(s.windowId) !== s || s.phase !== 'open' || s.shown) return + s.shown = true + placeView(s) + stillSoon(s, STILL_FIRST_MS) }, SETTLE_MS) } - const applyRect = (s: Session, rect: Rect, radius: number, visible: boolean) => { + /** + * Puts the page view where the session's phase wants it: on the card at rest, or out of sight. + * Out of sight, all of it but its corner pixel is outside the window, so its page still lays out + * and draws, at the size it shows at next (a view wholly outside, or hidden, is given no size and + * draws nothing). Closing, it hides: it is going. A view whose page has gone is left be: the Peek + * closes with its page. + */ + const placeView = (s: Session) => { const view = s.view - if (!view || view.webContents.isDestroyed()) return - s.rect = rect - s.radius = radius - if (s.fullscreen) return - view.setBounds(roundRect(rect)) - view.setBorderRadius(Math.round(radius)) - if (view.getVisible() !== visible) view.setVisible(visible) + const win = windows.get(s.windowId) + if (!view || !pageOf(view) || !win || win.isDestroyed() || s.fullscreen) return + if (s.phase === 'closing') { + if (view.getVisible()) view.setVisible(false) + return + } + const card = roundRect(toWindow(s, peekCard(s.area))) + if (s.phase === 'open' && s.shown) { + view.setBounds(card) + } else { + const next = + s.phase === 'expanding' ? s.area : s.phase === 'splitting' ? roundRect(toWindow(s, splitTarget(s.area))) : card + const [width = 0, height = 0] = win.getContentSize() + view.setBounds({ x: width - 1, y: height - 1, width: next.width, height: next.height }) + } + if (!view.getVisible()) view.setVisible(true) + } + + /** + * The page crashed, or its main frame failed to load (the tab's error, until it loads again): + * Orbit's screen shows over its view, and a still would be of a page that has gone, or of a blank + * one. The overlay drops the one it has (dropStill), so a card that moves or fades shows its own + * colour (PK-06), and none is taken meanwhile. + */ + const broken = (s: Session) => !!store.state.tabs[s.tabId]?.error + + /** Takes a still of the page and gives it to the overlay, for the card to show while the view is out of sight. */ + const takeStill = async (s: Session) => { + const contents = s.view?.webContents + if (!contents || contents.isDestroyed() || broken(s) || s.fullscreen || s.phase !== 'open' || !s.shown) return + try { + const image = await contents.capturePage() + if (sessions.get(s.windowId) !== s || s.phase !== 'open' || broken(s) || image.isEmpty()) return + const url = `data:image/jpeg;base64,${image.toJPEG(85).toString('base64')}` + windows.sendToOverlay(s.windowId, 'peek:still', { nonce: s.nonce, url }) + } catch { + // The page went away: the card keeps the still it has, or its colour. + } + } + + const stillSoon = (s: Session, ms: number) => { + clearTimeout(s.stillTimer) + s.stillTimer = setTimeout(() => void takeStill(s), ms) + } + + /** The page shows an error now (broken()): the card drops its still and shows its own colour. */ + const dropStill = (s: Session) => { + clearTimeout(s.stillTimer) + windows.sendToOverlay(s.windowId, 'peek:still', { nonce: s.nonce, url: '' }) } // ---- Closing, expanding, splitting ---- @@ -261,8 +339,10 @@ export function registerPeek(ctx: MainContext): void { windows.focusActiveTab(windowId) return } + // The page goes at once; the card, showing its still, shrinks and fades out on its own (FL-19). + placeView(s) setOverlay(s, { phase: 'closing' }) - // Keys go to the page underneath at once; the card fades out on its own. + // Keys go to the page underneath at once. windows.focusActiveTab(windowId) s.settleTimer = setTimeout(() => discard(s), SETTLE_MS) sampleBackground(s) @@ -271,6 +351,8 @@ export function registerPeek(ctx: MainContext): void { /** Removes the Peek: its view, its tab and the overlay. */ const discard = (s: Session) => { clearTimeout(s.settleTimer) + clearTimeout(s.revealTimer) + clearTimeout(s.stillTimer) if (sessions.get(s.windowId) === s) sessions.delete(s.windowId) const view = s.view const win = windows.get(s.windowId) @@ -295,7 +377,10 @@ export function registerPeek(ctx: MainContext): void { if (s?.phase !== 'open') return s.phase = 'expanding' clearTimeout(s.settleTimer) + clearTimeout(s.revealTimer) ctx.perf.mark('peek:expand') + // Out of sight, the page lays out at the page area's size while the card grows to it. + placeView(s) setOverlay(s, { phase: 'expanding' }) s.settleTimer = setTimeout(() => commitExpand(s), SETTLE_MS) } @@ -303,6 +388,8 @@ export function registerPeek(ctx: MainContext): void { /** The Peek's page becomes an ordinary tab, live (no reload): top of Today, selected. */ const commitExpand = (s: Session) => { clearTimeout(s.settleTimer) + clearTimeout(s.revealTimer) + clearTimeout(s.stillTimer) if (sessions.get(s.windowId) !== s) return sessions.delete(s.windowId) const state = store.state @@ -362,12 +449,17 @@ export function registerPeek(ctx: MainContext): void { } s.phase = 'splitting' clearTimeout(s.settleTimer) + clearTimeout(s.revealTimer) + // Out of sight, the page lays out at its pane's size while the card slides there. + placeView(s) setOverlay(s, { phase: 'splitting' }) s.settleTimer = setTimeout(() => commitSplit(s), SETTLE_MS) } const commitSplit = (s: Session) => { clearTimeout(s.settleTimer) + clearTimeout(s.revealTimer) + clearTimeout(s.stillTimer) if (sessions.get(s.windowId) !== s) return const state = store.state const source = state.tabs[s.sourceTabId] @@ -497,11 +589,16 @@ export function registerPeek(ctx: MainContext): void { else if (s.phase === 'splitting') commitSplit(s) }) - // The overlay animates the card; the page view follows it every frame. - ctx.events.on('peek.frame', ({ nonce, rect, radius, visible }, { windowId }) => { + // The overlay animates the card: the page shows on it once it is at rest, and waits out of sight + // while it moves (a drag, a swipe). + ctx.events.on('peek.page', ({ nonce, shown }, { windowId }) => { const s = sessions.get(windowId ?? '') if (!s || s.nonce !== nonce) return - applyRect(s, toWindow(s, rect), radius, visible) + clearTimeout(s.revealTimer) + if (s.phase !== 'open' || s.shown === shown) return + s.shown = shown + placeView(s) + if (shown) stillSoon(s, STILL_FIRST_MS) }) // The page area moved or resized (sidebar toggled, window resized): the card follows. ctx.events.on('layout.panes', (_payload, { windowId }) => { @@ -511,14 +608,15 @@ export function registerPeek(ctx: MainContext): void { if (!area || sameRect(area, s.area)) return s.area = area setOverlay(s, { area: { width: area.width, height: area.height } }) - if (s.phase === 'open') applyRect(s, toWindow(s, peekCard(area)), PEEK_RADIUS, true) + placeView(s) + if (s.phase === 'open' && s.shown) stillSoon(s, STILL_AFTER_MS) }) // Find in page acts on the open Peek's page, over the top-right corner of its card (PK-11). setPeekPages((windowId) => { const s = sessions.get(windowId) const area = windows.pageArea(windowId) ?? s?.area - if (s?.phase !== 'open' || s.fullscreen || !s.view || !area) return undefined + if (s?.phase !== 'open' || s.fullscreen || !pageOf(s.view) || !area) return undefined const card = peekCard(area) return { tabId: s.tabId, card: { ...card, x: area.x + card.x, y: area.y + card.y } } }) @@ -587,6 +685,8 @@ export function registerPeek(ctx: MainContext): void { if (s.phase === 'open' && activeTabOf(next, s.windowId) !== s.sourceTabId) { queueMicrotask(() => closePeek(s.windowId, { animate: false })) } + const error = next.tabs[s.tabId]?.error + if (error && error !== prev.tabs[s.tabId]?.error) dropStill(s) } }) @@ -778,10 +878,21 @@ export function registerPeek(ctx: MainContext): void { ctx.hooks.tabCreated.push((tabId, contents) => { clicks.watch(contents) - // A Peek page that closes itself (window.close()) takes its card with it. + // The overlay's still of a Peek's page is taken again once the page has drawn a change (PK-06). + const changed = () => { + const s = sessionOfTab(tabId) + if (s?.phase === 'open' && s.shown && s.view?.webContents === contents) stillSoon(s, STILL_AFTER_MS) + } + contents.on('did-stop-loading', changed) + contents.on('did-navigate-in-page', changed) + contents.on('input-event', (_e, input) => { + if (STILL_INPUTS.has(input.type)) changed() + }) + // A Peek page that closes itself (window.close()), or goes some other way (its tab put to sleep), + // takes its card with it. By now its view may read no page at all: that counts as this one gone. contents.once('destroyed', () => { const s = sessionOfTab(tabId) - if (s && s.view?.webContents === contents && sessions.get(s.windowId) === s) discard(s) + if (s?.view && (s.view.webContents === contents || !pageOf(s.view)) && sessions.get(s.windowId) === s) discard(s) }) contents.on('will-navigate', (event) => onWillNavigate(tabId, contents, event)) contents.on('before-input-event', (event, input) => onKey(tabId, event, input)) @@ -789,7 +900,7 @@ export function registerPeek(ctx: MainContext): void { contents.on('enter-html-full-screen', () => { const s = sessionOfTab(tabId) const win = s && windows.get(s.windowId) - if (!s?.view || !win) return + if (!s?.view || !pageOf(s.view) || !win) return s.fullscreen = true const [width = 0, height = 0] = win.getContentSize() s.view.setBounds({ x: 0, y: 0, width, height }) @@ -799,7 +910,8 @@ export function registerPeek(ctx: MainContext): void { const s = sessionOfTab(tabId) if (!s?.view || !s.fullscreen) return s.fullscreen = false - if (s.rect) applyRect(s, s.rect, s.radius, true) + s.view.setBorderRadius(PEEK_RADIUS) + placeView(s) }) }) } diff --git a/src/main/split/index.ts b/src/main/split/index.ts index f2994f79..fcfff671 100644 --- a/src/main/split/index.ts +++ b/src/main/split/index.ts @@ -1,6 +1,7 @@ import { clipboard, Menu, type MenuItemConstructorOptions, nativeTheme } from 'electron' import { FAVORITES_FULL_MESSAGE } from '../../shared/defaults' import { newId } from '../../shared/ids' +import { windowMayShow } from '../../shared/private' import { equalizeLayout, flipLayout, @@ -51,6 +52,7 @@ import { swapIntoPaneInDraft, windowsShowing, } from './model' +import { installPaneStills } from './stills' import { PaneStrip } from './strip' /** @@ -82,6 +84,21 @@ interface PaneSession { /** How long an empty pane waits for the command bar's choice before it is removed. */ const SETTLE_GRACE_MS = 120 +/** An open tab the command bar chose, with the change that closes the bar. */ +interface BarChoice { + tabId: TabId + close: (d: OrbitState) => void +} + +/** Selecting `tabId` keeps the window in its Space (as `activateInDraft` decides), and the window may show it. */ +function selectsInPlace(state: OrbitState, windowId: WindowId, tabId: TabId): boolean { + const win = state.windows[windowId] + const tab = state.tabs[tabId] + if (!win || !tab || !windowMayShow(state, windowId, tabId) || hiddenByImport(state, tabId)) return false + if (tab.spaceId === win.spaceId) return true + return sectionOf(state, tabId) === 'favorite' && state.spaces[win.spaceId]?.profileId === tab.profileId +} + /** Split feature (main process). Owned by the split lane; see docs/ARCHITECTURE.md. */ export function registerSplit(ctx: MainContext): void { const { store, commands, actions, windows } = ctx @@ -89,6 +106,7 @@ export function registerSplit(ctx: MainContext): void { const sessions = new Map() const strip = new PaneStrip(ctx) installLinkDrop(ctx) + installPaneStills(ctx) // An empty pane whose command bar was open when Orbit quit has nothing to show. store.update((d) => { dropBlankPanes(d) @@ -261,8 +279,12 @@ export function registerSplit(ctx: MainContext): void { else settle(session, { cancel: true }) } - /** Ends a pane session once its command bar closed: keep, fill, swap in or remove the pane. */ - const settle = (session: PaneSession, opts: { cancel?: boolean } = {}) => { + /** + * Ends a pane session once its command bar closed: keep, fill, swap in or remove the pane. With a + * `choice`, the bar is still open and has just chosen that open tab: the bar closes in the same + * store update that carries the choice out, so no frame shows the tab outside the split (SV-02). + */ + const settle = (session: PaneSession, opts: { cancel?: boolean; choice?: BarChoice } = {}) => { const windowId = session.windowId if (sessions.get(windowId) !== session) return sessions.delete(windowId) @@ -271,17 +293,23 @@ export function registerSplit(ctx: MainContext): void { const tab = state.tabs[session.tabId] const splitId = splitOfTab(state, session.tabId) if (!tab || !splitId) return - const chosen = opts.cancel ? null : (session.chosen ?? null) + const close = opts.choice?.close ?? (() => {}) + const chosen = opts.cancel ? null : (opts.choice?.tabId ?? session.chosen ?? null) // A tab that existed when the bar closed was picked from its results; a tab the choice // created (e.g. "open in new tab") was not, and is left alone. - const picked = !!chosen && !!session.tabsAtClose?.[chosen] + const picked = !!chosen && (!!opts.choice || !!session.tabsAtClose?.[chosen]) const chosenIsToday = picked && sectionOf(state, chosen) === 'today' && !splitOfTab(state, chosen) if (!session.blank) { // ⌘L on a pane, then an open Today tab was chosen: it takes the pane's place (SV-15). if (!chosen || !chosenIsToday) return updateMoving([chosen], (d) => { - if (!swapIntoPaneInDraft(d, session.tabId, chosen)) return + close(d) + if (!swapIntoPaneInDraft(d, session.tabId, chosen)) { + // The bar left the choice to this session: it is still made. + if (opts.choice) focusInDraft(d, windowId, chosen) + return + } restoreOtherSpaces(d, session, chosen) focusInDraft(d, windowId, chosen) }) @@ -291,6 +319,7 @@ export function registerSplit(ctx: MainContext): void { if (!opts.cancel && !isBlankPane(tab)) { // Filled with a URL or search: a pinned split remembers it as the pane's pinned URL. store.update((d) => { + close(d) const split = d.splits[splitId] if (split?.pending === session.tabId) delete split.pending adoptPlacement(d, [session.tabId], locate(d, splitId)?.container) @@ -301,6 +330,7 @@ export function registerSplit(ctx: MainContext): void { if (chosen && chosenIsToday) { // An open tab was chosen: it moves into the new pane (SV-02). updateMoving([chosen], (d) => { + close(d) detachTab(d, chosen) const split = d.splits[splitId] if (!split) return @@ -319,6 +349,7 @@ export function registerSplit(ctx: MainContext): void { const url = state.tabs[chosen]?.url if (url && !isBlankPane({ url })) { store.update((d) => { + close(d) const blank = d.tabs[session.tabId] const split = d.splits[splitId] if (split?.pending === session.tabId) delete split.pending @@ -337,6 +368,7 @@ export function registerSplit(ctx: MainContext): void { // Nothing chosen (Escape, click outside) or a new tab was opened instead: the empty pane // goes away. Focus returns to the previous pane unless something else was selected since. store.update((d) => { + close(d) const stillOnBlank = activeTabOf(d, windowId) === session.tabId detachTab(d, session.tabId) delete d.tabs[session.tabId] @@ -357,6 +389,23 @@ export function registerSplit(ctx: MainContext): void { } } + // The bar chose an open tab while it fills a pane (SV-02, SV-03, SV-07) or changes one (SV-15): the + // session carries the choice out itself. A choice that takes the window to another Space, or that + // the window can't show, goes the usual way: the bar closes, selects it, and the session settles. + ctx.hooks.barChoice ??= [] + ctx.hooks.barChoice.push((windowId, chosen, close) => { + const session = sessions.get(windowId) + const state = store.state + const overlay = state.windows[windowId]?.overlay + if (!session || session.settling || overlay?.kind !== 'commandbar' || overlay.nonce !== session.nonce) return false + const tab = state.tabs[session.tabId] + if (!tab || !splitOfTab(state, session.tabId) || !selectsInPlace(state, windowId, chosen)) return false + const today = sectionOf(state, chosen) === 'today' && !splitOfTab(state, chosen) + if (session.blank ? !isBlankPane(tab) : !today) return false + settle(session, { choice: { tabId: chosen, close } }) + return true + }) + // Watch command bar sessions: open on a pane → track; closed → settle right after the choice ran. store.subscribe((next) => { for (const session of [...sessions.values()]) { diff --git a/src/main/split/stills.ts b/src/main/split/stills.ts new file mode 100644 index 00000000..45378d06 --- /dev/null +++ b/src/main/split/stills.ts @@ -0,0 +1,106 @@ +import type { TabId, WindowId } from '../../shared/state' +import type { MainContext } from '../context' + +/** + * Stills of a split's pages (SV-26). A closing pane's card fades and collapses with its page on it, + * but the page's view leaves the window the moment its tab closes. So each pane of a split on screen + * keeps a still of its page in its card, under the page's view (the shell's pane engine): taken once + * the panes are at rest, and again a moment after anything changed the page (a load, a scroll, a + * key). A capture can cost a frame, so none is taken while the panes move. + */ +const STILL_AFTER_MS = 200 +const STILL_INPUTS = new Set(['mouseUp', 'mouseWheel', 'keyUp', 'gestureScrollEnd', 'gestureFlingStart', 'touchEnd']) + +interface Shown { + /** The panes the shell last reported, with the size of each one's view. */ + panes: Map + /** Panes whose still is out of date. */ + stale: Set + timer?: ReturnType + capturing: boolean +} + +/** A rectangle's size as main lays its view out (whole pixels, edges rounded). */ +const sizeOf = (r: { x: number; y: number; width: number; height: number }) => + `${Math.round(r.x + r.width) - Math.round(r.x)}x${Math.round(r.y + r.height) - Math.round(r.y)}` + +export function installPaneStills(ctx: MainContext): void { + const { windows, tabs } = ctx + const shown = new Map() + /** The size of each pane's still, while it is up to date. */ + const taken = new Map() + + const soon = (windowId: WindowId, s: Shown) => { + clearTimeout(s.timer) + s.timer = setTimeout(() => void capture(windowId, s), STILL_AFTER_MS) + } + + const capture = async (windowId: WindowId, s: Shown) => { + s.timer = undefined + if (s.capturing) return + s.capturing = true + try { + for (const tabId of [...s.stale]) { + // The panes moved again, or the split left the window: later, or never. + if (shown.get(windowId) !== s || s.timer) return + s.stale.delete(tabId) + const size = s.panes.get(tabId) + const bounds = windows.tabViewBounds(tabId) + const contents = tabs.contents(tabId) + if (!size || !bounds || !contents || contents.isDestroyed() || sizeOf(bounds) !== size) continue + const image = await contents.capturePage() + const win = windows.get(windowId) + if (image.isEmpty() || !win || win.isDestroyed() || s.panes.get(tabId) !== size) continue + taken.set(tabId, size) + const url = `data:image/jpeg;base64,${image.toJPEG(85).toString('base64')}` + win.webContents.send('orbit:split:still', { tabId, url, width: bounds.width, height: bounds.height }) + } + } catch { + // The page went while it was being captured: its pane has gone too. + } finally { + s.capturing = false + if (shown.get(windowId) === s && s.stale.size > 0 && !s.timer) soon(windowId, s) + } + } + + // The panes the shell lays out: a split's panes get stills once they are at rest. + ctx.events.on('layout.panes', ({ panes }, { windowId }) => { + if (!windowId) return + const before = shown.get(windowId) + if (panes.length < 2) { + if (before) clearTimeout(before.timer) + shown.delete(windowId) + return + } + const s = before ?? { panes: new Map(), stale: new Set(), capturing: false } + s.panes = new Map(panes.map((p) => [p.tabId, sizeOf(p.rect)])) + for (const [tabId, size] of s.panes) if (taken.get(tabId) !== size) s.stale.add(tabId) + shown.set(windowId, s) + soon(windowId, s) + }) + + // Anything that changes a page on screen: its still is taken again a moment later. + ctx.hooks.tabCreated.push((tabId, contents) => { + const changed = () => { + taken.delete(tabId) + for (const [windowId, s] of shown) { + if (!s.panes.has(tabId)) continue + s.stale.add(tabId) + soon(windowId, s) + } + } + contents.on('did-stop-loading', changed) + contents.on('did-navigate-in-page', changed) + contents.on('input-event', (_e, input) => { + if (STILL_INPUTS.has(input.type)) changed() + }) + contents.once('destroyed', () => taken.delete(tabId)) + }) + + windows.onWindowCreated((windowId, win) => { + win.once('closed', () => { + clearTimeout(shown.get(windowId)?.timer) + shown.delete(windowId) + }) + }) +} diff --git a/src/main/windows.ts b/src/main/windows.ts index 97250fd6..6f10d76a 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -48,6 +48,12 @@ interface WindowRecord { /** Ignore renderer pane reports older than this state sequence (after a main-side fast switch). */ minPaneSeq: number overlayShown: boolean + /** The overlay is shown outside the window while its renderer warms the bar up (CB-22). */ + overlayWarming?: boolean + /** Hides the overlay if its renderer never says it is warm. */ + overlayWarmTimer?: ReturnType + /** Warms the hidden overlay again at the window's new size once a resize has ended (CB-22). */ + overlayRewarmTimer?: ReturnType /** Pending hide after the overlay's exit animation. */ overlayHideTimer?: ReturnType /** The overlay closed while the window was in the background: give the page focus when it returns. */ @@ -82,6 +88,8 @@ interface WindowRecord { */ toolsReady: boolean toolsPrewarm?: ReturnType + /** The page tools are leaving the window once their page has drawn itself empty (hideTools). */ + toolsLeaving?: { timer?: ReturnType } /** * A permission prompt over the active page's top-left corner while the sidebar is hidden (NV-15; * with the sidebar showing, the prompt is part of it). Started when first needed. @@ -109,6 +117,11 @@ interface WindowRecord { * the prompt and the small views above stay under it, with the page (setPageCover). */ cover?: WebContentsView + /** + * Pages no longer wanted that stay shown, where they were, while a view is held out of sight + * until its page has drawn (holdArrivals); they hide once it shows. + */ + lingering: Set } /** A tab view placed in a window. */ @@ -121,8 +134,16 @@ interface Attachment { /** How long the overlay view stays up after closing so its exit animation can play (FL-16). */ const OVERLAY_EXIT_MS = 110 +/** The longest the overlay waits, shown outside the window, for its renderer to warm up (CB-22). */ +const OVERLAY_WARM_MAX_MS = 3000 +/** A resize has ended once the window has kept its size this long: the hidden overlay warms again (CB-22). */ +const OVERLAY_REWARM_MS = 300 +/** The longest a view coming back into a window stays out of sight, waiting for its page to draw (holdArrivals). */ +const HOLD_MS = 250 /** The page tools' renderer starts this long after the window shows (NV-05: ⌘F finds the bar ready). */ const TOOLS_PREWARM_MS = 1500 +/** The longest the page tools stay in the window after they hide, waiting for their page to draw itself empty (NV-06). */ +const TOOLS_LEAVE_MS = 250 /** The floating sidebar's card sits this far from the window's edges (SB-12, SB-19). */ export const FLOAT_INSET = 6 @@ -232,6 +253,9 @@ export class WindowManager { /** Tabs whose page closed its own web contents (window.close()): not reopened until selected again. */ private selfClosed = new Set() private floatingOwners: ((tabId: TabId) => WindowId | undefined)[] = [] + private arrivalHolds: ((tabId: TabId, view: WebContentsView) => Promise | undefined)[] = [] + /** Views held out of sight at their window's corner (holdArrivals). */ + private held = new Map }>() /** * Views that stay above the page tools wherever either goes: a page's own dialog or print dialog * over its page, in a tab or a Peek (pages lane, NV-24, NV-07; keepAboveTools()). @@ -302,6 +326,22 @@ export class WindowManager { this.floatingOwners.push(owner) } + /** + * A feature that lends a tab's view to a window of its own (the floating player) can hold it out + * of sight when it comes back into a window: until the promise it returns settles, or HOLD_MS. + * A view whose size changed shows the last frame its page drew until the page draws at the new + * size, so meanwhile it waits at its pane's size with all of it but its corner pixel outside the + * window (outOfSight()), where its page lays out and draws; then it moves into its pane, at the + * same size. Until then the pages the window showed stay, and take clicks. Where it showed none + * in that pane (after ⌘W on the tab over it, or from an empty Space, alone or in a split), the + * pane shows its blank card. The corner pixel shows at the window's bottom-right corner (MD-01: + * no frame of the player's layout in the page area, if the page draws within HOLD_MS). A view + * hidden, or wholly outside the window, would draw nothing. + */ + holdArrivals(hold: (tabId: TabId, view: WebContentsView) => Promise | undefined): void { + this.arrivalHolds.push(hold) + } + private floatingWindowOf(tabId: TabId): WindowId | undefined { for (const owner of this.floatingOwners) { const windowId = owner(tabId) @@ -435,6 +475,7 @@ export class WindowManager { promptShown: false, promptReady: false, uiViews: new Set(), + lingering: new Set(), } this.records.set(id, rec) // macOS: the traffic lights live in the sidebar's top row (SB-20). @@ -476,7 +517,9 @@ export class WindowManager { if (!rec.htmlFullscreen) this.applyPanes(id, rec.panes, rec.radius) }) win.on('resize', () => { - if (rec.overlayShown) this.fitOverlay(rec) + if (rec.overlayShown || rec.overlayWarming) this.fitOverlay(rec) + clearTimeout(rec.overlayRewarmTimer) + rec.overlayRewarmTimer = setTimeout(() => this.rewarmOverlay(rec), OVERLAY_REWARM_MS) if (rec.floatingShown) this.fitFloating(rec) if (rec.htmlFullscreen) this.applyPanes(id, rec.panes, rec.radius) }) @@ -509,16 +552,21 @@ export class WindowManager { } win.on('closed', () => { clearTimeout(rec.overlayHideTimer) + clearTimeout(rec.overlayWarmTimer) + clearTimeout(rec.overlayRewarmTimer) clearTimeout(rec.floatingTimer) clearTimeout(rec.floatingPrewarm) clearTimeout(rec.floatingDispose) clearTimeout(rec.buttonsTimer) clearTimeout(rec.toolsPrewarm) + clearTimeout(rec.toolsLeaving?.timer) + rec.toolsLeaving = undefined viewContents(rec.floating)?.close() viewContents(rec.tools)?.close() viewContents(rec.prompt)?.close() for (const view of rec.uiViews) viewContents(view)?.close() rec.uiViews.clear() + for (const [tabId, hold] of [...this.held]) if (hold.windowId === id) this.dropHold(tabId) this.records.delete(id) for (const [tabId, entry] of [...this.attached]) { if (entry.windowId === id) { @@ -603,6 +651,11 @@ export class WindowManager { viewContents(rec.overlay)?.send(`orbit:${channel}`, payload) } + /** Sends to the window's overlay only: what only it shows, such as a Peek's still (PK-06). */ + sendToOverlay(windowId: WindowId, channel: string, payload: unknown): void { + viewContents(this.records.get(windowId)?.overlay)?.send(`orbit:${channel}`, payload) + } + /** The corner radius a shown page was last given (SB-19), for tests. */ cornerOf(tabId: TabId): number | undefined { const entry = this.attached.get(tabId) @@ -633,8 +686,11 @@ export class WindowManager { */ tabViewBounds(tabId: TabId): Rect | undefined { const entry = this.attached.get(tabId) - if (!entry || !this.records.get(entry.windowId)?.visible.has(tabId)) return undefined - const view = entry.view.getBounds() + const rec = entry && this.records.get(entry.windowId) + if (!entry || !rec?.visible.has(tabId)) return undefined + // A view held out of sight (holdArrivals) is laid out at its pane, where it shows next. + const pane = this.held.get(tabId)?.view === entry.view ? rec.panes.find((p) => p.tabId === tabId) : undefined + const view = pane ? roundRect(pane.rect) : entry.view.getBounds() const page = this.devtoolsPages.get(tabId) return page ? pageInView(view, page) : view } @@ -688,12 +744,7 @@ export class WindowManager { // Native full screen has no frame: square corners (SB-19). const corner = this.ctx.store.state.windows[windowId]?.fullscreen ? 0 : radius const wanted = new Set(panes.map((p) => p.tabId)) - for (const tabId of rec.visible) { - if (wanted.has(tabId)) continue - // Only hide what this window holds: the view may have moved to another window since. - const entry = this.attached.get(tabId) - if (entry?.windowId === rec.id) entry.view.setVisible(false) - } + for (const [tabId, hold] of [...this.held]) if (hold.windowId === rec.id && !wanted.has(tabId)) this.dropHold(tabId) const shownBefore = rec.visible for (const pane of panes) { // A page that closed itself stays closed until the tab is selected again (onState). @@ -701,13 +752,25 @@ export class WindowManager { const view = this.ensureView(pane.tabId) if (!view) continue this.attach(rec, pane.tabId, view) - view.setBounds(roundRect(pane.rect)) + const rect = roundRect(pane.rect) + view.setBounds(this.held.get(pane.tabId)?.view === view ? this.outOfSight(rec, rect) : rect) view.setBorderRadius(corner) rec.corners.set(pane.tabId, corner) view.setVisible(true) // A tab whose page crashed while in the background shows its crash screen when it is shown // again, until the user reloads it (NV-14: the pages lane's screen over the page). } + // While a view coming back is held out of sight (holdArrivals), the pages shown before, if any, stay. + const holding = [...this.held.values()].some((h) => h.windowId === rec.id) + for (const tabId of new Set([...rec.visible, ...rec.lingering])) { + rec.lingering.delete(tabId) + if (wanted.has(tabId)) continue + // Only hide what this window holds: the view may have moved to another window since. + const entry = this.attached.get(tabId) + if (entry?.windowId !== rec.id) continue + if (holding) rec.lingering.add(tabId) + else entry.view.setVisible(false) + } rec.visible = wanted if (panes.length === 1) rec.lastSingleRect = panes[0]?.rect if (rec.htmlFullscreen) this.layoutHtmlFullscreen(rec, wanted) @@ -759,6 +822,9 @@ export class WindowManager { const view = this.ensureTools(rec) view.setBounds(roundRect(bounds)) rec.toolsShown = true + // Shown again while leaving: they stay, and draw what they show now. + clearTimeout(rec.toolsLeaving?.timer) + rec.toolsLeaving = undefined if (!rec.toolsReady) return false const parent = rec.win.contentView const children = parent.children @@ -782,10 +848,12 @@ export class WindowManager { } /** - * Takes the page tools out of the window. The keyboard goes back to the page when they had it, - * or when nothing has it in the focused window (round 2 fix check, M3). + * Takes the page tools out of the window. The keyboard goes back to the page at once when they + * had it, or when nothing has it in the focused window (round 2 fix check, M3). With `drawn`, the + * view leaves once that settles (their page has drawn itself empty), TOOLS_LEAVE_MS at most: a view + * put back in the window shows the last frame it drew until it draws again (NV-06). */ - hideTools(windowId: WindowId): void { + hideTools(windowId: WindowId, drawn?: Promise): void { const rec = this.records.get(windowId) const view = rec?.tools if (!rec || !view || !rec.toolsShown) return @@ -793,15 +861,33 @@ export class WindowManager { const contents = viewContents(view) const focused = webContents.getFocusedWebContents() if ((!focused || focused === contents) && !rec.overlayShown && rec.win.isFocused()) this.focusActiveTab(rec.id) - this.removeChild(rec, view) + clearTimeout(rec.toolsLeaving?.timer) + rec.toolsLeaving = undefined + if (!drawn) { + this.removeChild(rec, view) + return + } + const leaving: { timer?: ReturnType } = {} + const leave = () => { + if (rec.toolsLeaving !== leaving) return + clearTimeout(leaving.timer) + rec.toolsLeaving = undefined + if (rec.tools === view && !rec.toolsShown) this.removeChild(rec, view) + } + leaving.timer = setTimeout(leave, TOOLS_LEAVE_MS) + rec.toolsLeaving = leaving + void drawn.then(leave, leave) } - /** Test/debug view of a window's page tools; `ready`: their page has loaded. */ + /** + * Test/debug view of a window's page tools; `ready`: their page has loaded; `placed`: they are in + * the window, shown (not on their way out). + */ toolsInfo(windowId: WindowId): { exists: boolean; ready: boolean; placed: boolean; bounds?: Rect } { const rec = this.records.get(windowId) const view = rec?.tools if (!rec || !view || !viewContents(view)) return { exists: false, ready: false, placed: false } - const placed = rec.win.contentView.children.includes(view) + const placed = rec.toolsShown && rec.win.contentView.children.includes(view) return { exists: true, ready: rec.toolsReady, placed, bounds: view.getBounds() } } @@ -1128,6 +1214,7 @@ export class WindowManager { /** Removes a (sleeping, closing or replaced) tab view from whatever window holds it. Safe for destroyed views. */ detachView(tabId: TabId, view: WebContentsView): void { + if (this.held.get(tabId)?.view === view) this.dropHold(tabId) const entry = this.attached.get(tabId) if (entry?.view === view) { this.attached.delete(tabId) @@ -1226,6 +1313,7 @@ export class WindowManager { const current = this.attached.get(tabId) const children = rec.win.contentView.children if (current?.view === view && current.windowId === rec.id && children.includes(view)) return + const arriving = !children.includes(view) let loser: WindowRecord | undefined if (current && current.view !== view) { this.removeEverywhere(current.view) @@ -1239,6 +1327,7 @@ export class WindowManager { this.placeOnTop(rec, view) this.attached.set(tabId, { view, windowId: rec.id, contentsId: view.webContents.id }) this.selfClosed.delete(tabId) + if (arriving) this.holdIfAsked(rec, tabId, view) if (loser && !loser.win.isDestroyed()) { this.applyPanes( loser.id, @@ -1305,6 +1394,43 @@ export class WindowManager { } } + /** A view just came into the window: held out of sight if a feature asks (holdArrivals). */ + private holdIfAsked(rec: WindowRecord, tabId: TabId, view: WebContentsView): void { + for (const ask of this.arrivalHolds) { + const ready = ask(tabId, view) + if (!ready) continue + this.dropHold(tabId) + const shown = () => this.unhold(tabId, view) + this.held.set(tabId, { view, windowId: rec.id, timer: setTimeout(shown, HOLD_MS) }) + ready.then(shown, shown) + return + } + } + + /** The held view's page has drawn (or HOLD_MS passed): its window lays out again and shows it. */ + private unhold(tabId: TabId, view: WebContentsView): void { + const hold = this.held.get(tabId) + if (hold?.view !== view) return + this.dropHold(tabId) + const rec = this.records.get(hold.windowId) + if (rec && !rec.win.isDestroyed()) this.applyPanes(rec.id, rec.panes, rec.radius) + } + + private dropHold(tabId: TabId): void { + clearTimeout(this.held.get(tabId)?.timer) + this.held.delete(tabId) + } + + /** + * Where a held view waits (holdArrivals): at its pane's size, with all of it but its top-left + * pixel outside the window's bottom-right corner, as the overlay warms up (CB-22) and a Peek's + * page waits (PK-06). Its page lays out and draws there, and the hold's end only moves it. + */ + private outOfSight(rec: WindowRecord, rect: Rect): Rect { + const [width = 0, height = 0] = rec.win.getContentSize() + return { x: width - 1, y: height - 1, width: rect.width, height: rect.height } + } + /** Follows a tab's live web contents: fullscreen requests, and pages that close themselves. */ private watchTab(tabId: TabId, contents: WebContents): void { const contentsId = contents.id @@ -1626,7 +1752,12 @@ export class WindowManager { if (rec.overlay) return rec.overlay const overlay = new WebContentsView({ webPreferences: uiWebPreferences(rec.id, 'overlay') }) overlay.setBackgroundColor('#00000000') - overlay.setVisible(false) + // CB-22: shown at first, at the window's size but all but one corner pixel outside it, so its + // renderer lays out and draws the bar's warm-up at that size (a hidden view's renderer, or one + // wholly outside the window, gets no size, and the first open laid everything out again). It + // hides once warm (overlayWarm). + rec.overlayWarming = true + rec.overlayWarmTimer = setTimeout(() => this.overlayWarm(rec.id), OVERLAY_WARM_MAX_MS) rec.win.contentView.addChildView(overlay) this.registerUi(overlay.webContents, rec.id) this.reloadUiOnCrash(overlay.webContents, 'overlay') @@ -1634,11 +1765,49 @@ export class WindowManager { this.hardenUi(overlay.webContents) loadRenderer(overlay.webContents, 'overlay') rec.overlay = overlay + this.fitOverlay(rec) return overlay } + /** The overlay's renderer has warmed the bar up (CB-22): the overlay hides, in its place. */ + overlayWarm(windowId: WindowId): void { + const rec = this.records.get(windowId) + if (!rec?.overlayWarming || rec.win.isDestroyed() || !viewContents(rec.overlay)) return + rec.overlayWarming = false + clearTimeout(rec.overlayWarmTimer) + if (rec.overlayShown) return + rec.overlay?.setVisible(false) + this.fitOverlay(rec) + } + + /** + * A resize has ended: the hidden overlay, still at the size it had, warms again (CB-22). It shows + * at the window's new size with all but its corner pixel outside the window, where its renderer + * lays out at that size, so the next ⌘T doesn't; it hides once its renderer has drawn there + * (`search.warm`, overlayWarm), or after OVERLAY_WARM_MAX_MS. + */ + private rewarmOverlay(rec: WindowRecord): void { + rec.overlayRewarmTimer = undefined + const overlay = rec.overlay + if (!overlay || !viewContents(overlay) || rec.overlayShown || rec.overlayWarming || rec.win.isDestroyed()) return + // Still playing its exit (OVERLAY_EXIT_MS): after that. + if (overlay.getVisible()) { + rec.overlayRewarmTimer = setTimeout(() => this.rewarmOverlay(rec), OVERLAY_REWARM_MS) + return + } + const [width = 0, height = 0] = rec.win.getContentSize() + const laidOut = overlay.getBounds() + if (laidOut.width === width && laidOut.height === height) return + rec.overlayWarming = true + clearTimeout(rec.overlayWarmTimer) + rec.overlayWarmTimer = setTimeout(() => this.overlayWarm(rec.id), OVERLAY_WARM_MAX_MS) + this.fitOverlay(rec) + overlay.setVisible(true) + } + private showOverlay(rec: WindowRecord): void { clearTimeout(rec.overlayHideTimer) + rec.overlayWarming = false rec.refocusPage = false const overlay = this.ensureOverlay(rec) this.fitOverlay(rec) @@ -1674,9 +1843,11 @@ export class WindowManager { private fitOverlay(rec: WindowRecord): void { const [width = 0, height = 0] = rec.win.getContentSize() + // Warming up, it stays at the window's size with all but its corner pixel outside the window (CB-22). + const warming = rec.overlayWarming ? { x: width - 1, y: height - 1, width, height } : undefined // A Peek's overlay covers only the page area, so the sidebar stays usable beside it (PK-13). const area = this.ctx.store.state.windows[rec.id]?.overlay.kind === 'peek' ? this.pageArea(rec.id) : undefined - rec.overlay?.setBounds(area ?? { x: 0, y: 0, width, height }) + rec.overlay?.setBounds(warming ?? area ?? { x: 0, y: 0, width, height }) } /** The native background shows at the window's edges while resizing: keep it the frame's colour. */ diff --git a/src/renderer/src/library/LibraryApp.tsx b/src/renderer/src/library/LibraryApp.tsx index 29abb44e..cbdcb313 100644 --- a/src/renderer/src/library/LibraryApp.tsx +++ b/src/renderer/src/library/LibraryApp.tsx @@ -27,6 +27,8 @@ export function LibraryApp() { const [sectionId, setSectionId] = useState() const rootRef = useRef(null) const exit = useRef(undefined) + /** The close main is waiting on (`library:hide`): it hides this view once the fade's end is drawn. */ + const away = useRef(undefined) useEffect(() => { let heard = false @@ -42,8 +44,9 @@ export function LibraryApp() { heard = true apply(payload as LibraryShow) }) - const offHide = window.orbit.on('library:hide', () => { + const offHide = window.orbit.on('library:hide', (payload) => { heard = true + away.current = (payload as { away?: number } | undefined)?.away setOpen(false) }) // The first open can arrive before this page has loaded: ask what to show. A view made ahead of @@ -85,11 +88,26 @@ export function LibraryApp() { requestAnimationFrame(() => perfMark('library:shown')) } else if (show.nonce > 0) { // (A Library made ahead of its first open was never shown: nothing to fade.) - exit.current = el.animate([{ opacity: 1 }, { opacity: 0, transform: 'scale(0.99)' }], { + const fade = el.animate([{ opacity: 1 }, { opacity: 0, transform: 'scale(0.99)' }], { duration: EXIT_MS, easing: 'ease-in', fill: 'forwards', }) + exit.current = fade + // Main hides this view once its last frame, at opacity 0, is drawn: a view put back shows + // its last frame first (FL-24). + const closing = away.current + fade.finished.then( + () => { + if (exit.current !== fade || closing === undefined) return + requestAnimationFrame(() => + requestAnimationFrame(() => { + if (exit.current === fade) void invoke('library.drawn', { away: closing }) + }), + ) + }, + () => {}, + ) } }, [open, show?.nonce]) @@ -141,7 +159,8 @@ export function LibraryApp() {
    {LIBRARY_SECTIONS.map((s) => { const Icon = s.icon - const active = s.id === section?.id + // Only the open Library marks its section: an open on another one would fade it from the old (DL-10). + const active = open && s.id === section?.id return (