From 89dd4e63bab76004a665ee7fb8df7f67690a358a Mon Sep 17 00:00:00 2001 From: Siya Date: Sun, 11 Oct 2026 15:58:41 +0000 Subject: [PATCH 1/3] feel: sign off 23 rows of navigation, the Archive, import and keys (D48, D79) The lead's second "feels right" sign-off. The Archive's timings were re-measured at low load (load 2.4-3.6, three rounds), and one 60 fps screen recording per area was read frame by frame. The record, with the rows held back and their numbers, is reviews/feel-signoff-2.md in the project's context folder. - docs/feature-status.json: "feel": true on 23 built rows (NV 9, AR 6, IM 7, KS-15). FEATURES.md regenerated: 97 feel right. - scripts/capture/scenarios: nv, ar and im, each printing a [mark] line per step so frames around it can be cut from the clip. - scripts/capture/record.mjs: a scenario may export env for Orbit's launch (so it is imported before the launch); Import searches no browser on this computer unless a scenario names its own (ORBIT_IMPORT_PLACES=[]); the fixture server takes ?wait= for a page still loading. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 48 +++--- docs/feature-status.json | 46 +++--- scripts/capture/record.mjs | 34 ++-- scripts/capture/scenarios/ar.mjs | 238 +++++++++++++++++++++++++++ scripts/capture/scenarios/im.mjs | 265 +++++++++++++++++++++++++++++++ scripts/capture/scenarios/nv.mjs | 246 ++++++++++++++++++++++++++++ 6 files changed, 820 insertions(+), 57 deletions(-) create mode 100644 scripts/capture/scenarios/ar.mjs create mode 100644 scripts/capture/scenarios/im.mjs create mode 100644 scripts/capture/scenarios/nv.mjs diff --git a/FEATURES.md b/FEATURES.md index bb613f15..2b42c98f 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,7 +4,7 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 472 spec’d · 276 built (77 partial) · 356 tested · 74 feel right. 13 more are out of scope and not counted. +**Totals:** 472 spec’d · 276 built (77 partial) · 356 tested · 97 feel right. 13 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| @@ -228,25 +228,25 @@ Spec: `spec/navigation.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| NV-01 | Back and forward | P0 | ✅ | ✅ e2e+unit | – | buttons and keys (Cmd+Left/Right only outside text fields), history menu with favicons that closes on tab switch, Cmd/middle-click opens the entry in a background tab with its history; mouse side buttons are Chromium's; Backspace inert | +| NV-01 | Back and forward | P0 | ✅ | ✅ e2e+unit | ✅ | buttons and keys (Cmd+Left/Right only outside text fields), history menu with favicons that closes on tab switch, Cmd/middle-click opens the entry in a background tab with its history; mouse side buttons are Chromium's; Backspace inert | | NV-02 | Trackpad two-finger swipe back/forward | P1 | ✅ | ✅ e2e+unit | – | a horizontal two-finger swipe over the page goes back or forward: an arrow slides in from that edge of the page's card, following the fingers 1:1, fills once it's fully in (112 px), and letting go then goes as Back and Forward do; let go sooner, it fades and nothing happens. Only with somewhere to go, and on macOS only with the system's Swipe between pages on. The page gets the swipe first: while what's under the pointer (or around it) can still scroll that way, or the page uses the wheel (preventDefault), the swipe is the page's until the fingers lift, so it navigates only at the scroll edge. Over the sidebar the same swipe switches Space (SP-08) and never navigates. Not offered over a Peek's, a Mini Window's or a pop-up's page, where the swipe does nothing (the spec doesn't say). Windows and Linux: wherever the touchpad scrolls smoothly (pixel deltas); a mouse wheel's notches never navigate. Recognition follows the Space swipe (FL-13): 9 px decide the axis, and the fingers have lifted after a 90 ms pause or when the OS momentum starts. Limits: Electron reports no scroll phases, so the page preload watches the page's wheel events in its own world (D40 use orbit-page:swipe); the swipe goes by the scroll's direction, so with macOS's natural scrolling off a swipe right is forward; a swipe that starts over a frame of the page stays the frame's (frames get no preload); the arrow's small view takes the wheel if the pointer is right over it. Tests use synthetic trackpad scrolls; the real gesture, with momentum, is unverified on macOS (person-check) | -| 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-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-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 (toast Opened in a new tab while the sidebar is hidden) 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; not built: dragging links | | 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 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 | | NV-10 | New window | P0 | 🟡 partial | ✅ e2e | – | Cmd/Ctrl+N (SP-24): same Spaces and pinned tabs, cascaded, command bar open, full screen follows; links opened in new windows (Shift-click, link menu) cascade too; the Ctrl+Cmd+N blank window (TB-17) is not built | -| NV-11 | Private (incognito) window | P0 | ✅ | ✅ e2e+unit | – | Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task, orbit --private-window (with links, they open in it and never in a Mini Window): in-memory session shared by open private windows, wiped when the last closes; no state, history, favicon, archive, site-choice or reopen record kept; own Space-less sidebar, dark look, Private label with a plain note; command bar sees only private tabs; extension rules are EX-10 | -| NV-12 | Page context menu | P0 | ✅ | ✅ e2e | – | native menu: page (Back, Forward, Reload, Save Page As: complete for a local page, else as a single file or its HTML alone, so no local file a web page or web frame names is copied, D49; Print, View Page Source), link (New Tab, which toasts Opened in a new tab while the sidebar is hidden; Split View, New Window, Private Window, Copy, Save Link As; a local file link or image on a page that is not a local file keeps only its Copy items, D49; a script, page source or data link has no open items), selection (Copy, Search engine, Look Up on macOS), image, video and audio (Loop and Show Controls, ticked as the element is, greyed where the engine cannot: an audio player keeps its controls; then the media lane Picture in Picture on a video), editable (suggestions, Add to Dictionary, edit roles, Paste and Match Style, Writing Direction), Inspect last; Peek and Mini Window items come from their lanes; media items in a frame inside the page act on the frame element playing the same source; P2 Translate and Share Quote not built | +| NV-11 | Private (incognito) window | P0 | ✅ | ✅ e2e+unit | ✅ | Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task, orbit --private-window (with links, they open in it and never in a Mini Window): in-memory session shared by open private windows, wiped when the last closes; no state, history, favicon, archive, site-choice or reopen record kept; own Space-less sidebar, dark look, Private label with a plain note; command bar sees only private tabs; extension rules are EX-10 | +| NV-12 | Page context menu | P0 | ✅ | ✅ e2e | ✅ | native menu: page (Back, Forward, Reload, Save Page As: complete for a local page, else as a single file or its HTML alone, so no local file a web page or web frame names is copied, D49; Print, View Page Source), link (New Tab, which toasts Opened in a new tab while the sidebar is hidden; Split View, New Window, Private Window, Copy, Save Link As; a local file link or image on a page that is not a local file keeps only its Copy items, D49; a script, page source or data link has no open items), selection (Copy, Search engine, Look Up on macOS), image, video and audio (Loop and Show Controls, ticked as the element is, greyed where the engine cannot: an audio player keeps its controls; then the media lane Picture in Picture on a video), editable (suggestions, Add to Dictionary, edit roles, Paste and Match Style, Writing Direction), Inspect last; Peek and Mini Window items come from their lanes; media items in a frame inside the page act on the frame element playing the same source; P2 Translate and Share Quote not built | | NV-13 | Load-error pages | P1 | ✅ | ✅ e2e+unit | – | a main-frame load that fails shows Orbit's screen over that page's area only (a tab, a split pane, a Peek, a Mini Window, a pop-up): an icon, a title, one plain sentence in Chromium's words, the site, the engine's error code and Try again, which has the keyboard when the page had it (never taken from the sidebar or the command bar); it is a view over the engine's own error page rather than an orbit: page (D39), so the address keeps the failed URL and ⌘R, Try again, Back and history stay the engine's; a host that doesn't resolve, a refused connection, no internet, an empty response and other net errors each say what happened; the screen stays through a retry that fails again; the row keeps its title and icon when the site that failed is the one it showed, and otherwise shows that site's name (and its icon if Orbit knows one); Orbit gives up on a server that hasn't answered within 120 s (Chromium waits until the user stops it): "took too long to respond", the address stays where it was going, Try again and ⌘R ask again, and Back shows the page before as it was, its screen included; a form being sent is never stopped (it may be uploading); certificates: "Your connection is not private" with Back to safety on the keyboard (back, or closing a tab with nowhere to go back to), Proceed only behind Advanced and never for a revoked certificate, remembered for that certificate and host per profile until Orbit quits; offline: when the network comes back while the screen shows, the page loads again, once (e2e dispatch the engine's online event, as the emulation is per page); a site that asks for HTTPS only (HSTS) still gets Proceed: Electron doesn't say which sites do; gap, as DL-12's (D54): no Safe Browsing interstitial, which needs a reputation service none is free to use; the http not-secure icon is SB-04 | | NV-14 | Crashed or unresponsive tab | P1 | ✅ | ✅ e2e+unit | – | a page that crashes or is killed shows "This page crashed" over its area, with the reason (out of memory, killed…) and Reload, which has the keyboard when the page had it; Reload, ⌘R and ⌘⇧R bring the page back, and closing the tab works as ever; the row keeps its title and icon; a page that crashed behind another shows its crash screen when its row is selected, until the user reloads it (it no longer reloads by itself when shown: the review F11 test in core.spec.ts now reloads it); the same screen shows over a Peek's, a Mini Window's or a pop-up's page (e2e cover tabs); a page that stops responding (the engine's own signal, after about 15 s of unanswered input) gets "Page unresponsive" over it with Wait (Enter, Esc) or Close page, which ends the page's process: its screen then says "This page was closed" with Reload, and other tabs that share that process end too, as in Chromium; the dialog goes by itself when the page answers again; after Wait, a page that stays hung isn't asked about again until it answers and hangs anew (the engine says it once); e2e: the engine says nothing of a hang while a debugger is attached, and Playwright attaches to every page, so the hang tests keep a page really busy and emit the engine's signal; TB-16 (busy background tabs paused) and EX-13 (crashing extensions) are their own rows | -| NV-15 | Permission prompts | P0 | ✅ | ✅ e2e+unit | – | prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden), for the shown tab or else a split view pane that waits: camera, mic, location, notifications, clipboard, MIDI, idle, windows, fonts, local network, serial/USB/HID choosers, screen share, file access; Allow while visiting / Allow this time / Never allow, or Block / Allow; a press answers only once the prompt has shown for 500 ms and 500 ms after the press before; Esc dismisses; kept per profile and site (private in memory); a frame from another site can't ask for notifications or use the page's; pages read undecided as default/prompt (a PermissionStatus change event doesn't fire for a decision made in Orbit, and the patched getters and requestPermission show their source to toString); a notification click brings its tab forward (page notifications, not service-worker ones; only from a frame allowed notifications, once per 500 ms); Peek, Mini Window and pop-ups ask in a dialog over their own window (Allow, Block, Not Now; Esc keeps nothing; the same request asks once); screen sharing asks only in the screen chooser, never first as camera and microphone (MD-11); a prompt whose view crashed leaves the window, and the keyboard with the page, until its page has loaded again, then shows again (chrome fix check 4, N4); the recording indicators are TB-15's; toolbar anchor waits on SB-17, Site settings is ST-13 | +| NV-15 | Permission prompts | P0 | ✅ | ✅ e2e+unit | ✅ | prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden), for the shown tab or else a split view pane that waits: camera, mic, location, notifications, clipboard, MIDI, idle, windows, fonts, local network, serial/USB/HID choosers, screen share, file access; Allow while visiting / Allow this time / Never allow, or Block / Allow; a press answers only once the prompt has shown for 500 ms and 500 ms after the press before; Esc dismisses; kept per profile and site (private in memory); a frame from another site can't ask for notifications or use the page's; pages read undecided as default/prompt (a PermissionStatus change event doesn't fire for a decision made in Orbit, and the patched getters and requestPermission show their source to toString); a notification click brings its tab forward (page notifications, not service-worker ones; only from a frame allowed notifications, once per 500 ms); Peek, Mini Window and pop-ups ask in a dialog over their own window (Allow, Block, Not Now; Esc keeps nothing; the same request asks once); screen sharing asks only in the screen chooser, never first as camera and microphone (MD-11); a prompt whose view crashed leaves the window, and the keyboard with the page, until its page has loaded again, then shows again (chrome fix check 4, N4); the recording indicators are TB-15's; toolbar anchor waits on SB-17, Site settings is ST-13 | | NV-16 | Site controls panel | P0 | 🟡 partial | ✅ e2e+unit | – | button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, automatic Picture in Picture for the site (MD-08), zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features | -| NV-17 | Copy the current URL | P0 | ✅ | ✅ e2e | – | copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02) | +| NV-17 | Copy the current URL | P0 | ✅ | ✅ e2e | ✅ | copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02) | | NV-18 | Links from other apps (Orbit as the default browser) | P0 | ✅ | ✅ e2e | – | summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05 | -| NV-19 | Links that open other apps (external protocols) | P1 | ✅ | ✅ e2e+unit | – | a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): "Open Mail?" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), " wants to open this application." naming the site of the frame that followed it, and "Always allow to open links of this type in the associated app" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key in any of its frames opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets "Open this link in another app?"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time, and once it has asked, whatever the answer (Open, Cancel or Esc), it asks again only after a new click or key in it, as in Chromium (D77): a link it follows before then gets nothing (a click or a key in any of its frames counts, a frame from another site's too); an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it | +| NV-19 | Links that open other apps (external protocols) | P1 | ✅ | ✅ e2e+unit | ✅ | a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): "Open Mail?" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), " wants to open this application." naming the site of the frame that followed it, and "Always allow to open links of this type in the associated app" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key in any of its frames opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets "Open this link in another app?"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time, and once it has asked, whatever the answer (Open, Cancel or Esc), it asks again only after a new click or key in it, as in Chromium (D77): a link it follows before then gets nothing (a click or a key in any of its frames counts, a frame from another site's too); an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it | | NV-20 | Full screen | P1 | ✅ | ✅ e2e+unit | – | ⌃⌘F on macOS (fn+F / Globe+F is the system's own key and does the same), F11 and Alt+Shift+F on Windows and Linux, View → Toggle Full Screen (macOS) and the command bar toggle window full screen; a held key toggles once. In full screen the sidebar hides and the page fills the window; the window's left edge brings the sidebar in over the page (SB-12), and the loading indicator stays (SB-28). Leaving docks the sidebar again, unless the user docked or hid it meanwhile (⌘S); a window closed or quit in full screen keeps its docked sidebar for next time; a window opened from one in full screen opens in full screen, its sidebar hidden (NV-10). Esc in a page, or over its error or crash screen, goes to the page first and full screen stays; pressed again within a second, or held (at its first repeat), it leaves full screen, and the page gets neither that press nor its repeats (nor, as the engine does after a key it didn't deliver, the release); presses further apart each go to the page. A single Esc never leaves, even one the page left alone: nothing asks the page (D60), and a web app's Esc is often its own without preventDefault. Exit Full Screen in Settings → Shortcuts prefers websites by default; set to prefer Orbit, one Esc leaves at once and the page gets nothing of it (KS-02, D68). Esc is no binding of it: an open overlay (command bar, Peek, find bar, a page dialog) takes Esc first (KS-19). The Esc that leaves full screen on a loading page doesn't stop the load (NV-04). A video's own full screen is Chromium's: it fills the window without the sidebar, Esc leaves only it, and the toggle keys leave both. Mini Windows don't go full screen this way. Xvfb has no window manager, so the e2e tests check the state and layout Orbit keeps; a person checks that the window really fills the screen, the macOS animation (the sidebar goes and comes back as it ends), fn+F / Globe+F, and that the View menu has one full-screen item (macOS may add its own Enter Full Screen beside Orbit's). Known limits (keys lane): with the keyboard in a frame of a loading page, the Esc the page gets stops the load after 250 ms, as only the main frame answers (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is (K7) | | NV-21 | Save page and view source | P2 | ✅ | ✅ e2e | – | Save Page As…: ⇧⌘S (Ctrl+Shift+S on Windows and Linux; ⌘S is the sidebar's, SB-11), File → Save As… (macOS menu), the command bar's Save Page As… and the page menu's, each showing its live keys (KS-18). The OS save dialog opens from the Downloads folder, named after the page: a web page saves as a single file (MHTML) or, named .html, as its HTML alone; a local page whose frames are all local files saves complete (D49). A Peek's or a Mini Window's page saves itself. View Source is DT-04. On a canvas tab the same command exports the canvas: that comes with EZ-11 (no canvases yet). | | NV-22 | Translate a page | P2 | ⛔ out of scope | – | – | Orbit's engine has no built-in translation, and the spec allows no paid API (D45). ST-14's offer to translate, the Translate command and the address field's translate button go with it | @@ -328,14 +328,14 @@ 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-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-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) | +| 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) | | AR-06 | Preview an archived item without restoring | P2 | 🟡 partial | ✅ e2e+unit | – | a row click opens its page in a Peek over the Library (the Peek component, its open and close motion); the entry stays archived. ⌘W and Esc close only the Peek and the Library takes the keys back (Enter then restores that row); Open as Tab (⌘O) opens it as a tab and keeps the entry; closing a preview, or a reopen of it, archives no second entry. Missing: a Restore button in the Peek card (peek lane). The Peek carries overlay.archiveEntry and archive.restorePreview restores and closes it. With no tab to preview over (an empty Space), a click opens the page in a new tab | -| AR-07 | Clear the Archive | P1 | ✅ | ✅ e2e+unit | – | Clear Archive… from the Archive entry’s right-click menu in the Library, Archive → Clear Archive… (macOS app menu) and the command bar; always asks in the Library first (Cancel has the focus, Esc cancels), says it can’t be undone and that browsing history is kept; clears the profile shown, and the cleared pages leave the disk at once (the log is rewritten). Delete (a row’s menu, or Delete for the selected rows) shows a toast whose Undo puts the entries back with their history for a minute | -| AR-08 | Retention of archived entries | P1 | ✅ | ✅ e2e+unit | – | settings.archiveKeepDays, default 30; 30 Days / 90 Days / 1 Year / Forever from the Archive entry’s menu in the Library and Archive → Keep Archived Tabs For (macOS) until Settings exist. A shorter time that would delete entries asks first, in the Library, saying how many (Cancel keeps them all and the time as it was). Purged once the Archive is read at launch, hourly, and at once when a shorter time is confirmed; browsing history is not touched | +| AR-07 | Clear the Archive | P1 | ✅ | ✅ e2e+unit | ✅ | Clear Archive… from the Archive entry’s right-click menu in the Library, Archive → Clear Archive… (macOS app menu) and the command bar; always asks in the Library first (Cancel has the focus, Esc cancels), says it can’t be undone and that browsing history is kept; clears the profile shown, and the cleared pages leave the disk at once (the log is rewritten). Delete (a row’s menu, or Delete for the selected rows) shows a toast whose Undo puts the entries back with their history for a minute | +| AR-08 | Retention of archived entries | P1 | ✅ | ✅ e2e+unit | ✅ | settings.archiveKeepDays, default 30; 30 Days / 90 Days / 1 Year / Forever from the Archive entry’s menu in the Library and Archive → Keep Archived Tabs For (macOS) until Settings exist. A shorter time that would delete entries asks first, in the Library, saying how many (Cancel keeps them all and the time as it was). Purged once the Archive is read at launch, hourly, and at once when a shorter time is confirmed; browsing history is not touched | | AR-09 | Reopen recently closed tabs | P0 | ✅ | ✅ e2e+unit | – | ⌘⇧T and ⌘Z walk back through closes with full history (TB-07, TB-18); ⌘Z also shows the tab again when it was the one shown; a closed Peek comes back through the same key (PK-10, peek lane). A page closed in the last 10 minutes keeps its history rank in the command bar (as the archived row). macOS File menu: Reopen Closed Tab and a Recently Closed submenu of the last 10; on every platform the Archive toolbar has a Recently Closed menu. Neither list shows a tab of a private or deleted profile | | AR-10 | Browsing history view | P1 | ✅ | ✅ e2e+unit | – | History is a page in a tab, as D39 planned once internal pages existed: orbit://history/ shows every visit of the tab's profile from the history store, newest first by day, virtualised and paged, with search (all words, title or address). ⌘Y (Ctrl+H), Archive → View History (macOS) and the command bar's View History show the History tab of the Space the window shows (the one used last), else open one; an open Library makes way; from a Mini Window it shows in the latest main window. The list loads again each time its tab shows again. A row opens its page in a new tab (⌘-click or middle-click: behind); its × or its menu (Open in New Tab, Open in Background, Copy Link, Remove from History) removes every visit to the page. The page asks main only about its own tab's profile and opens only pages it listed (D53). The Library keeps its History section, the same list. Clearing the Archive leaves history alone | | AR-11 | Sidebar backups and "Restore Data" | P2 | ✅ | ✅ e2e+unit | – | Backups of the whole sidebar (Spaces, each profile’s Favorites, pinned and Today tabs, folders, split views; no private profiles) in userData/backups, one JSON file each: taken within 5 minutes of a sidebar change (only when it differs from the newest; browsing alone never makes one), once 30 s after launch, before every restore, and with Back Up Now. Kept: the newest 10 of today, then the newest of each of the 10 days before, of each week of the month before that and of each 30 days of the year before that; a deleted profile’s Favorites, Spaces and tabs are taken out of every backup. Help → Restore Data… (macOS) and the command bar open the Backups section of the Library: a dated list (day headings, time, Spaces and tabs; a file that can’t be read is listed as such, without Restore); Restore… asks first, then puts the sidebar back: tabs in both keep their page, tabs only in the backup come back asleep (with the page and history the Archive kept for them, leaving the Archive), tabs opened since go to the Archive, Spaces added since go; private windows and their tabs are left alone; Favorites over 12 go to Today (D33). When the sidebar can’t be backed up first, nothing changes. A running import stops first: none of the tabs it hasn’t shown yet go to the Archive, and its window says why (D65). Each restore is one step on the sidebar’s undo stack (TB-18): the toast’s Undo or ⌘Z, also in the Library, puts back the sidebar it replaced, a second ⌘Z goes further back and ⌘⇧Z restores again; earlier steps stay. After an undo, a window moved off a removed Space stays where it went, and the pages come back asleep | @@ -360,7 +360,7 @@ Spec: `spec/shortcuts.md` | KS-12 | Library, history, downloads, settings, extensions, help | P1 | 🟡 partial | ✅ e2e+unit | – | ⌘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) | | KS-13 | Developer tools | P1 | 🟡 partial | ✅ e2e+unit | – | ⌥⌘I / F12 (Ctrl+Shift+I / F12) open or close DevTools (DT-01, F12 in e2e), ⌥⌘J (Ctrl+Shift+J) the console, ⌥⌘C the element picker (no default on Windows/Linux, where Ctrl+Shift+C is Copy URL), ⌥⌘U (Ctrl+U) View Source, ⌘⇧R (Ctrl+Shift+R) hard reload: each checked against the spec table per platform, and none fixed; a remapped DevTools key moves in the router and in DevTools' own page, and the picker can take ⇧⌘C from Copy URL (unit tests); ⇧⌘M / Ctrl+Shift+M stays DevTools' device toolbar key (no action has it). A remap reaches DevTools already open only once they open again (devtools lane). Not built: developer mode for a site (DT-06; its ⌃D is a plain ⌃-letter, which macOS keeps for the caret, D60) and the task manager (DT-11, Shift+Esc) | | KS-14 | Canvases, notes, capture and Tweaks | P2 | – | – | – | | -| KS-15 | Standard app and window keys | P1 | ✅ | ✅ e2e+unit | – | macOS: the menu bar keeps the system’s items and keys: Hide Orbit ⌘H, Hide Others ⌥⌘H and Quit ⌘Q in the Orbit menu, the Window menu (Minimize ⌘M, ⌘` between Orbit windows) and the Help menu with its search (⌘?). No action can take these keys, nor Alt+F4, Alt+Tab or Alt+F on Windows and Linux, and Settings → Shortcuts says what keeps them (KS-03). Windows and Linux: Alt+F4 closes the window and, on the last one, quits; F1 opens Help (OB-13) and can’t be changed there. An Exit item comes with the Windows/Linux app menu (SB-26, not built). The menu template and keymap are unit-tested; not run on a Mac | +| KS-15 | Standard app and window keys | P1 | ✅ | ✅ e2e+unit | ✅ | macOS: the menu bar keeps the system’s items and keys: Hide Orbit ⌘H, Hide Others ⌥⌘H and Quit ⌘Q in the Orbit menu, the Window menu (Minimize ⌘M, ⌘` between Orbit windows) and the Help menu with its search (⌘?). No action can take these keys, nor Alt+F4, Alt+Tab or Alt+F on Windows and Linux, and Settings → Shortcuts says what keeps them (KS-03). Windows and Linux: Alt+F4 closes the window and, on the last one, quits; F1 opens Help (OB-13) and can’t be changed there. An Exit item comes with the Windows/Linux app menu (SB-26, not built). The menu template and keymap are unit-tested; not run on a Mac | | KS-16 | Text-editing keys keep their meaning | P0 | ✅ | ✅ e2e+unit | – | typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) outside text fields go back or forward at once and the page never sees them, however busy it is (Orbit knows where the keyboard is before the press, D60); from a text field they go only when its page leaves them alone (no answer within 250 ms counts as left alone), a web component's field (in an open shadow root) included (keys fix check K1); with the keyboard in a frame, the frame's own fields and handlers decide (e2e with same-site frames; cross-site frames take the same path, but e2e keys can't reach their process); Esc in a Mini Window's address field gives the keyboard back to its page; ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router). Known limits: with the keyboard in a frame of a loading page, Esc stops the load after 250 ms, as only the main frame says whether it used Esc (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is: Back and Forward then run at once, even in a field that uses them, and a prefer-websites key acts after 250 ms (K7) | | KS-17 | Mouse, trackpad and modifier-click bindings | P0 | 🟡 partial | ✅ e2e | – | each pointer binding checked against its feature's tests: ⌘-click or middle-click, ⇧⌘-, ⇧- (Peek), ⌥- (split view) and ⌘⌥-click (Mini Window) on links; ⌘-click or middle-click on Back, Forward (keys e2e) or Reload for a new tab; long-press or right-click Back / Forward for history; double-click Reload to stop; ⌘-click a pinned tab's favicon (PN-04); ⌘- / ⇧-click rows (TB-13); double-click the empty sidebar (keys e2e), a tab row (TB-12) or a Favorite (FV-06); swipe over the sidebar (SP-08); pinch / ⌘-scroll (NV-06); mouse buttons 4/5; right-click on a video offers Picture in Picture (MD-02: its tests right-click a plain video once, in the page and in a frame from another site; the second right-click the spec describes is for a site with its own video menu, which isn't tested). Not built: ⌘-hover + New Tab to paste and go (CB-12), swipe over the page (NV-02), ⌥-click Capture full page (DT-09) | | KS-18 | Shortcut hints everywhere | P1 | ✅ | ✅ e2e+unit | – | button tooltips (address, copy link, site controls, sidebar toggle, back/forward/reload, find, zoom, Library, Clear, Space dots, Peek, Mini Window Open in…), the empty-window hint, command-bar rows, macOS menu items, the shortcut sheet (OB-12) and the right-click menus of a tab, a multi-selection, the page and a split pane (an item that does what a key does shows that key’s first binding) follow a remap without a restart; a menu shows it the next time it opens. Windows and Linux have no app menu yet (SB-03, SB-26), so there is no menu bar there to label | @@ -566,20 +566,20 @@ Spec: `spec/import.md` | IM-01 | Import entry points | P1 | 🟡 partial | ✅ e2e | – | the import window (a child of the browser window, in its Space's colours): Back / Next / Cancel, Cancel at any step imports nothing, Enter is Next and Escape is Back, ⌘W closes it. Opened from onboarding (the same flow inline, OB-03), the command bar's Import from Another Browser, Settings → General → Import…, and on macOS the app menu's Import from Another Browser… (macOS-unverified). Not built: a ☰ entry on Windows/Linux (Orbit has no ☰ menu). In the progress view, Cancel is offered only while the source is read; once adding starts it offers Hide, and the bookmarks added are one undo step. The window isn’t modal: the browser window stays usable while it’s open, as IM-14 asks | | IM-02 | Choose the source browser and profile(s) | P1 | 🟡 partial | ✅ e2e+unit | – | the browsers found on this computer (Chrome, Chromium, Brave, Edge, Vivaldi, Opera, Opera GX, Firefox; Safari on macOS), each a tile in its colour with its profile or profile count; those not installed aren't shown. Several profiles: a step to choose one or more, by name. Profiles come from Chromium's Local State (or a single Default folder, or Opera's own folder) and Firefox's profiles.ini; every file is read from a copy. Safari without Full Disk Access is shown as blocked, with the way round it (export a bookmarks file from Safari and import that). Tests search only made-up fixture folders. Letter tiles, not logos: Orbit doesn't ship other browsers' marks. Safari's reader (Bookmarks.plist, a binary property list) is tested on fixtures, macOS-unverified | | IM-03 | Choose which data types to import | P1 | 🟡 partial | ✅ e2e+unit | – | after the source and its profiles, what they hold as checkboxes, all on, with counts ("6 bookmarks, as pinned tabs", "6 pages visited"); types a source can't provide aren't shown, and a profile whose file the browser holds offers the rest; never passwords or cards (D52). Cancel (Do this later in onboarding) weighs the same as Next. Extensions are listed, off, as not importable yet (IM-09). Cookies (IM-10) aren’t offered. A file that is there but can’t be read is named with why, e.g. “Work: history couldn’t be read (the file is damaged)”, and the profile’s other types are still offered | -| IM-04 | Choose which bookmarks to import | P1 | ✅ | ✅ e2e+unit | – | the source's tree (Chromium's bookmarks bar, other and mobile bookmarks; Firefox's toolbar, menu, other and mobile bookmarks; Safari's favorites bar, menu and reading list; a file's own groups), several profiles each under its name, with a checkbox per folder and bookmark; everything chosen at first; Select all / none; "4 of 6 bookmarks selected". Addresses that can't be pinned show unticked with the reason. Folders below the top level start closed, and so does a top-level one holding over 1,000 bookmarks. The list isn’t virtualized: every row at the top level or in an open folder is drawn, so a large file shows quickly only when its bookmarks sit in such closed folders (20,000 in folders of 2,000 show in about 0.4 s). 20,000 bookmarks at the top level take about 2.9 s to show; 200,000 there, or 200 top-level folders of 1,000, about 30 s; 10,000 top-level folders about 6 s; and opening a large folder draws all its rows. Only the import window waits | +| IM-04 | Choose which bookmarks to import | P1 | ✅ | ✅ e2e+unit | ✅ | the source's tree (Chromium's bookmarks bar, other and mobile bookmarks; Firefox's toolbar, menu, other and mobile bookmarks; Safari's favorites bar, menu and reading list; a file's own groups), several profiles each under its name, with a checkbox per folder and bookmark; everything chosen at first; Select all / none; "4 of 6 bookmarks selected". Addresses that can't be pinned show unticked with the reason. Folders below the top level start closed, and so does a top-level one holding over 1,000 bookmarks. The list isn’t virtualized: every row at the top level or in an open folder is drawn, so a large file shows quickly only when its bookmarks sit in such closed folders (20,000 in folders of 2,000 show in about 0.4 s). 20,000 bookmarks at the top level take about 2.9 s to show; 200,000 there, or 200 top-level folders of 1,000, about 30 s; 10,000 top-level folders about 6 s; and opening a large folder draws all its rows. Only the import window waits | | IM-05 | Mapping bookmarks into Orbit (pinned tabs and folders) | P1 | 🟡 partial | ✅ e2e+unit | – | one pinned folder "Imported from " ("Imported bookmarks" from a file) at the end of the target Space's pinned section: the source's top-level groups and nested folders kept, in order, as closed sub-folders (the folder itself closed above 1,000 items); several profiles into one Space each get a sub-folder by name; each bookmark a pinned tab with its title and address, asleep, with no favicon or request until opened; bookmarklets, saved searches, browser-only and non-web addresses skipped and counted; Firefox tags and separators left out; no Favorites made; one undo step takes it all back. 2,000 bookmarks take about 0.3–0.4 s, made out of sight in parts and shown at once (IM-14), with the longest main-process stall about 40–50 ms. Not built: fetching a favicon when an imported row scrolls into view; its icon comes when the tab is first opened | -| IM-06 | Target Space and profile for imported data | P1 | ✅ | ✅ e2e+unit | – | the last step: the profile (it gets the history) and the Space (its pinned section gets the bookmarks): the current Space by default, the first one in onboarding, or New Space… with a name. Several source profiles: "Make a matching Orbit profile and Space for each" makes them, in order, named after the source profiles, each getting its own profile's history. Private windows' profiles aren't offered and never get history | -| IM-07 | History import | P1 | ✅ | ✅ e2e+unit | – | Chromium's History, Firefox's places.sqlite and Safari's History.db (macOS-unverified), read from copies a window of rows at a time, into the chosen profile's history: each page with its title, visit and typed counts and its newest visits (up to 20). History (⌘Y / Ctrl+H) lists them and the command bar suggests and completes them at once; no tab is made and nothing loads. Left out: browsers' own pages, hidden pages, frame loads (Chromium subframes; Firefox embeds, framed links, downloads). History keeps the 50,000 most recent pages: a larger history keeps its newest and the summary counts the rest. 100,000 pages take about 2 s, with main's longest stall about 50–110 ms and none in the windows; the profile's history is written once, as a snapshot | +| IM-06 | Target Space and profile for imported data | P1 | ✅ | ✅ e2e+unit | ✅ | the last step: the profile (it gets the history) and the Space (its pinned section gets the bookmarks): the current Space by default, the first one in onboarding, or New Space… with a name. Several source profiles: "Make a matching Orbit profile and Space for each" makes them, in order, named after the source profiles, each getting its own profile's history. Private windows' profiles aren't offered and never get history | +| IM-07 | History import | P1 | ✅ | ✅ e2e+unit | ✅ | Chromium's History, Firefox's places.sqlite and Safari's History.db (macOS-unverified), read from copies a window of rows at a time, into the chosen profile's history: each page with its title, visit and typed counts and its newest visits (up to 20). History (⌘Y / Ctrl+H) lists them and the command bar suggests and completes them at once; no tab is made and nothing loads. Left out: browsers' own pages, hidden pages, frame loads (Chromium subframes; Firefox embeds, framed links, downloads). History keeps the 50,000 most recent pages: a larger history keeps its newest and the summary counts the rest. 100,000 pages take about 2 s, with main's longest stall about 50–110 ms and none in the windows; the profile's history is written once, as a snapshot | | IM-08 | Password import | P1 | ⛔ out of scope | – | – | Orbit has no password manager to import into: the spec has no row for one (OT-11), and Electron has none. IM-03 offers no Passwords type, and IM-12 and IM-13 take no passwords CSV (D52) | | IM-09 | Extension import (Chromium-family sources) | P1 | 🟡 partial | ✅ e2e+unit | – | A Chromium-family profile's extensions are listed on the types step, from its Preferences and Secure Preferences (read from copies; a name kept only in the manifest is read from it, in its default language): pinned ones first, the browser's own left out. The row is off and says they aren't importable yet; unpacked ones never come over. Not built: installing them fresh from the store with one summary confirmation, pinning the pinned ones, and the ad-blocker de-duplication (blockers are already marked), because the extensions lane's install path isn't on main yet. Firefox add-ons aren't listed (the spec covers Chromium-family sources) | | IM-10 | Cookies / signed-in sessions import | P2 | – | – | – | | | IM-11 | Autofill addresses and payment cards import | P2 | ⛔ out of scope | – | – | 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) | -| IM-12 | Bringing data over from a browser Orbit can’t read (user-facing exports only) | P1 | ✅ | ✅ e2e+unit | – | Paste a list of links (one address per line, "Title — address" with a dash, bar or tab, Markdown links, bullets): one pinned folder named by the user, a pinned tab per link in order, asleep; lines that aren't links are counted in the summary. ⌘V / Ctrl+V on the sidebar (outside a text field) with two or more links on the clipboard opens the same import with them filled in. A list over about 1 MB or 10,000 lines, pasted or from a file, isn’t put in the text field: the import window shows its count and first eight lines, read-only, with Clear, and imports it as it is. A paste over 64 MB as UTF-8 (22.5 million CJK characters, say) says “This is too large to import (the limit is 64 MB)”, as a file does, and none of it leaves the window it was pasted in. Clear also frees main’s copy of a held list. The other routes (a bookmarks file, notes, canvases, site styles) are explained in the import window's help, which reads nothing from another browser; passwords are out of scope (D52) | -| IM-13 | Import from files (standard formats) | P1 | ✅ | ✅ e2e+unit | – | bookmarks HTML (the Netscape format; Chromium- and Firefox-style exports tested) and a .txt list of links, from From a file… or dropped on the import window or the sidebar. Bookmarks map as IM-05 into "Imported bookmarks"; links as IM-12 (a list over about 1 MB or 10,000 lines shows only its count and first lines); a file with neither says "No bookmarks found in this file" and imports nothing; a cut-off file imports what it holds. Files are read in a worker, in time linear in their size (D58): main’s longest stall is about 0.07 s for a 16 MB file or list, and about 0.2 s for a 60 MB file of 200,000 bookmarks. A file nested deeper than 64 folders keeps its deeper bookmarks in the 64th; a file gives at most 200,000 bookmarks (folders don’t count) and 20,000 folders, the bookmarks of folders past that kept in the folder that holds them; over 64 MB, every route, a paste included, says “This is too large to import (the limit is 64 MB)” without reading it; a read that fails says “Orbit couldn’t read this file”. No passwords CSV (D52) | +| IM-12 | Bringing data over from a browser Orbit can’t read (user-facing exports only) | P1 | ✅ | ✅ e2e+unit | ✅ | Paste a list of links (one address per line, "Title — address" with a dash, bar or tab, Markdown links, bullets): one pinned folder named by the user, a pinned tab per link in order, asleep; lines that aren't links are counted in the summary. ⌘V / Ctrl+V on the sidebar (outside a text field) with two or more links on the clipboard opens the same import with them filled in. A list over about 1 MB or 10,000 lines, pasted or from a file, isn’t put in the text field: the import window shows its count and first eight lines, read-only, with Clear, and imports it as it is. A paste over 64 MB as UTF-8 (22.5 million CJK characters, say) says “This is too large to import (the limit is 64 MB)”, as a file does, and none of it leaves the window it was pasted in. Clear also frees main’s copy of a held list. The other routes (a bookmarks file, notes, canvases, site styles) are explained in the import window's help, which reads nothing from another browser; passwords are out of scope (D52) | +| IM-13 | Import from files (standard formats) | P1 | ✅ | ✅ e2e+unit | ✅ | bookmarks HTML (the Netscape format; Chromium- and Firefox-style exports tested) and a .txt list of links, from From a file… or dropped on the import window or the sidebar. Bookmarks map as IM-05 into "Imported bookmarks"; links as IM-12 (a list over about 1 MB or 10,000 lines shows only its count and first lines); a file with neither says "No bookmarks found in this file" and imports nothing; a cut-off file imports what it holds. Files are read in a worker, in time linear in their size (D58): main’s longest stall is about 0.07 s for a 16 MB file or list, and about 0.2 s for a 60 MB file of 200,000 bookmarks. A file nested deeper than 64 folders keeps its deeper bookmarks in the 64th; a file gives at most 200,000 bookmarks (folders don’t count) and 20,000 folders, the bookmarks of folders past that kept in the folder that holds them; over 64 MB, every route, a paste included, says “This is too large to import (the limit is 64 MB)” without reading it; a read that fails says “Orbit couldn’t read this file”. No passwords CSV (D52) | | IM-14 | Progress, summary and errors | P1 | ✅ | ✅ e2e+unit | – | progress per data type (a spinner, then a check), then a summary with counts per type and the skipped items with reasons; Done shows the imported folder in the sidebar (its Space, the pinned section open, the folder in view). Bookmarks are made out of sight, in parts, and their folder is shown whole, at once (D62): while 20,000 are added, the browser window’s longest freeze is under 0.1 s; 100,000 take about 34 s, with the window’s longest freeze about 0.34 s and main’s longest stall about 0.57 s (saving the state). A file the source browser holds (it can't be copied) shows "Chrome is using some of its files. Quit Chrome and try again, or import the rest" with Try again; the types it could read can still be imported. History shows the pages read, then added; Done says they're in History and the command bar; what History can't keep is listed with the reason. The summary lists every item left out with its reason: skipped bookmarks (also in a folder that holds nothing else), those past 200,000 bookmarks in one file, list or profile (folders don’t count, so the summary and the selection count agree), lines that aren’t links, and a profile’s file that couldn’t be read (“Work: history couldn’t be read (the file is damaged)”). Until the folder shows, nothing lists its tabs (D65): not the command bar (by title, by address or among recent tabs), the recent-tab switcher or the Library. One the user reached anyway and placed stays where they put it, and the folder leaves it out. A quit or a failure part-way takes back exactly the tabs the import made, by id, wherever they are, and nothing of the user’s; a crash part-way leaves none either, as the next start removes tabs that no section shows. Restore Data part-way stops the import first: its tabs are removed without going to the Archive, and the import window says “The import stopped: data was restored from a backup.” Copies of the other browser’s files go in a folder under Orbit’s user data, removed at quit (a copy still being made too) and cleared at startup (D58) | -| IM-15 | Re-import and duplicates | P2 | ✅ | ✅ e2e+unit | – | the same browser or file again makes a new "Imported from (2)" folder (then (3)…), never merging into or changing the first. History de-duplicates silently: a page already imported gains only the visits it lacked, so its counts aren't doubled. Passwords aren't imported (D52) | +| IM-15 | Re-import and duplicates | P2 | ✅ | ✅ e2e+unit | ✅ | the same browser or file again makes a new "Imported from (2)" folder (then (3)…), never merging into or changing the first. History de-duplicates silently: a page already imported gains only the visits it lacked, so its counts aren't doubled. Passwords aren't imported (D52) | | IM-16 | Starter suggestions from imported history | P2 | ✅ | ✅ e2e+unit | – | after onboarding's import of a history, the Done step offers the six sites visited most (each by its most-visited page, or its home page when that's a search): the first three as Favorites, the rest as pinned tabs. One click takes one (another takes it back); Dismiss all drops them. They're added unloaded with the import when Start browsing ends onboarding, never with Skip setup (D44). Orbit can't tell web apps from other sites, so it ranks by visits; a suggested pinned tab is named after its site until first opened | -| IM-17 | Export Orbit's pinned items (anti-lock-in) | P2 | ✅ | ✅ e2e+unit | – | Export Pinned Items as Bookmarks… in the File menu (macOS-unverified: Orbit has no menu bar elsewhere), the command bar, and Settings → General → Import and export (with Include Today tabs) writes a standard bookmarks HTML file through the system's save dialog: the Favorites a folder (one per profile when there are several), each Space a folder of its pinned section with its folders nested as they are, a split's tabs in its place; Today's tabs only when chosen, as a Today folder in their Space. Orbit's own pages and private windows' profiles are left out. Copy All Links on a folder is the tabs lane's (it exists) | +| IM-17 | Export Orbit's pinned items (anti-lock-in) | P2 | ✅ | ✅ e2e+unit | ✅ | Export Pinned Items as Bookmarks… in the File menu (macOS-unverified: Orbit has no menu bar elsewhere), the command bar, and Settings → General → Import and export (with Include Today tabs) writes a standard bookmarks HTML file through the system's save dialog: the Favorites a folder (one per profile when there are several), each Space a folder of its pinned section with its folders nested as they are, a split's tabs in its place; Today's tabs only when chosen, as a Today folder in their Space. Orbit's own pages and private windows' profiles are left out. Copy All Links on a folder is the tabs lane's (it exists) | ## Onboarding (first run), tips and tutorials diff --git a/docs/feature-status.json b/docs/feature-status.json index e98ed3a7..de507924 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -58,14 +58,14 @@ "IM-08": {"scope": "out", "note": "Orbit has no password manager to import into: the spec has no row for one (OT-11), and Electron has none. IM-03 offers no Passwords type, and IM-12 and IM-13 take no passwords CSV (D52)"}, "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", "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-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-03": {"built": "yes", "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", "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", "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)"}, + "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)"}, "AR-06": {"built": "partial", "note": "a row click opens its page in a Peek over the Library (the Peek component, its open and close motion); the entry stays archived. ⌘W and Esc close only the Peek and the Library takes the keys back (Enter then restores that row); Open as Tab (⌘O) opens it as a tab and keeps the entry; closing a preview, or a reopen of it, archives no second entry. Missing: a Restore button in the Peek card (peek lane). The Peek carries overlay.archiveEntry and archive.restorePreview restores and closes it. With no tab to preview over (an empty Space), a click opens the page in a new tab"}, - "AR-07": {"built": "yes", "note": "Clear Archive… from the Archive entry’s right-click menu in the Library, Archive → Clear Archive… (macOS app menu) and the command bar; always asks in the Library first (Cancel has the focus, Esc cancels), says it can’t be undone and that browsing history is kept; clears the profile shown, and the cleared pages leave the disk at once (the log is rewritten). Delete (a row’s menu, or Delete for the selected rows) shows a toast whose Undo puts the entries back with their history for a minute"}, - "AR-08": {"built": "yes", "note": "settings.archiveKeepDays, default 30; 30 Days / 90 Days / 1 Year / Forever from the Archive entry’s menu in the Library and Archive → Keep Archived Tabs For (macOS) until Settings exist. A shorter time that would delete entries asks first, in the Library, saying how many (Cancel keeps them all and the time as it was). Purged once the Archive is read at launch, hourly, and at once when a shorter time is confirmed; browsing history is not touched"}, + "AR-07": {"built": "yes", "feel": true, "note": "Clear Archive… from the Archive entry’s right-click menu in the Library, Archive → Clear Archive… (macOS app menu) and the command bar; always asks in the Library first (Cancel has the focus, Esc cancels), says it can’t be undone and that browsing history is kept; clears the profile shown, and the cleared pages leave the disk at once (the log is rewritten). Delete (a row’s menu, or Delete for the selected rows) shows a toast whose Undo puts the entries back with their history for a minute"}, + "AR-08": {"built": "yes", "feel": true, "note": "settings.archiveKeepDays, default 30; 30 Days / 90 Days / 1 Year / Forever from the Archive entry’s menu in the Library and Archive → Keep Archived Tabs For (macOS) until Settings exist. A shorter time that would delete entries asks first, in the Library, saying how many (Cancel keeps them all and the time as it was). Purged once the Archive is read at launch, hourly, and at once when a shorter time is confirmed; browsing history is not touched"}, "AR-09": {"built": "yes", "note": "⌘⇧T and ⌘Z walk back through closes with full history (TB-07, TB-18); ⌘Z also shows the tab again when it was the one shown; a closed Peek comes back through the same key (PK-10, peek lane). A page closed in the last 10 minutes keeps its history rank in the command bar (as the archived row). macOS File menu: Reopen Closed Tab and a Recently Closed submenu of the last 10; on every platform the Archive toolbar has a Recently Closed menu. Neither list shows a tab of a private or deleted profile"}, "AR-10": {"built": "yes", "note": "History is a page in a tab, as D39 planned once internal pages existed: orbit://history/ shows every visit of the tab's profile from the history store, newest first by day, virtualised and paged, with search (all words, title or address). ⌘Y (Ctrl+H), Archive → View History (macOS) and the command bar's View History show the History tab of the Space the window shows (the one used last), else open one; an open Library makes way; from a Mini Window it shows in the latest main window. The list loads again each time its tab shows again. A row opens its page in a new tab (⌘-click or middle-click: behind); its × or its menu (Open in New Tab, Open in Background, Copy Link, Remove from History) removes every visit to the page. The page asks main only about its own tab's profile and opens only pages it listed (D53). The Library keeps its History section, the same list. Clearing the Archive leaves history alone"}, "AR-11": {"built": "yes", "note": "Backups of the whole sidebar (Spaces, each profile’s Favorites, pinned and Today tabs, folders, split views; no private profiles) in userData/backups, one JSON file each: taken within 5 minutes of a sidebar change (only when it differs from the newest; browsing alone never makes one), once 30 s after launch, before every restore, and with Back Up Now. Kept: the newest 10 of today, then the newest of each of the 10 days before, of each week of the month before that and of each 30 days of the year before that; a deleted profile’s Favorites, Spaces and tabs are taken out of every backup. Help → Restore Data… (macOS) and the command bar open the Backups section of the Library: a dated list (day headings, time, Spaces and tabs; a file that can’t be read is listed as such, without Restore); Restore… asks first, then puts the sidebar back: tabs in both keep their page, tabs only in the backup come back asleep (with the page and history the Archive kept for them, leaving the Archive), tabs opened since go to the Archive, Spaces added since go; private windows and their tabs are left alone; Favorites over 12 go to Today (D33). When the sidebar can’t be backed up first, nothing changes. A running import stops first: none of the tabs it hasn’t shown yet go to the Archive, and its window says why (D65). Each restore is one step on the sidebar’s undo stack (TB-18): the toast’s Undo or ⌘Z, also in the Library, puts back the sidebar it replaced, a second ⌘Z goes further back and ⌘⇧Z restores again; earlier steps stay. After an undo, a window moved off a removed Space stays where it went, and the pages come back asleep"}, @@ -187,27 +187,27 @@ "PR-07": {"built": "yes", "note": "hover badge on the title row, 'Space · Profile' tooltip, check mark in the Profile submenu"}, "PR-08": {"built": "partial", "note": "profiles.delete refuses Default and profiles in use, erases the profile's Favorites and clears its stored data; the partition folder stays on disk and the Settings button is the settings lane's"}, "PR-09": {"built": "partial", "note": "profiles.rename command; the Settings UI is the settings lane's"}, - "NV-01": {"built": "yes", "note": "buttons and keys (Cmd+Left/Right only outside text fields), history menu with favicons that closes on tab switch, Cmd/middle-click opens the entry in a background tab with its history; mouse side buttons are Chromium's; Backspace inert"}, + "NV-01": {"built": "yes", "feel": true, "note": "buttons and keys (Cmd+Left/Right only outside text fields), history menu with favicons that closes on tab switch, Cmd/middle-click opens the entry in a background tab with its history; mouse side buttons are Chromium's; Backspace inert"}, "NV-02": {"built": "yes", "note": "a horizontal two-finger swipe over the page goes back or forward: an arrow slides in from that edge of the page's card, following the fingers 1:1, fills once it's fully in (112 px), and letting go then goes as Back and Forward do; let go sooner, it fades and nothing happens. Only with somewhere to go, and on macOS only with the system's Swipe between pages on. The page gets the swipe first: while what's under the pointer (or around it) can still scroll that way, or the page uses the wheel (preventDefault), the swipe is the page's until the fingers lift, so it navigates only at the scroll edge. Over the sidebar the same swipe switches Space (SP-08) and never navigates. Not offered over a Peek's, a Mini Window's or a pop-up's page, where the swipe does nothing (the spec doesn't say). Windows and Linux: wherever the touchpad scrolls smoothly (pixel deltas); a mouse wheel's notches never navigate. Recognition follows the Space swipe (FL-13): 9 px decide the axis, and the fingers have lifted after a 90 ms pause or when the OS momentum starts. Limits: Electron reports no scroll phases, so the page preload watches the page's wheel events in its own world (D40 use orbit-page:swipe); the swipe goes by the scroll's direction, so with macOS's natural scrolling off a swipe right is forward; a swipe that starts over a frame of the page stays the frame's (frames get no preload); the arrow's small view takes the wheel if the pointer is right over it. Tests use synthetic trackpad scrolls; the real gesture, with momentum, is unverified on macOS (person-check)"}, - "NV-03": {"built": "yes", "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", "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", "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-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-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 (toast Opened in a new tab while the sidebar is hidden) 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; not built: dragging links"}, "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 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"}, "NV-10": {"built": "partial", "note": "Cmd/Ctrl+N (SP-24): same Spaces and pinned tabs, cascaded, command bar open, full screen follows; links opened in new windows (Shift-click, link menu) cascade too; the Ctrl+Cmd+N blank window (TB-17) is not built"}, - "NV-11": {"built": "yes", "note": "Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task, orbit --private-window (with links, they open in it and never in a Mini Window): in-memory session shared by open private windows, wiped when the last closes; no state, history, favicon, archive, site-choice or reopen record kept; own Space-less sidebar, dark look, Private label with a plain note; command bar sees only private tabs; extension rules are EX-10"}, - "NV-12": {"built": "yes", "note": "native menu: page (Back, Forward, Reload, Save Page As: complete for a local page, else as a single file or its HTML alone, so no local file a web page or web frame names is copied, D49; Print, View Page Source), link (New Tab, which toasts Opened in a new tab while the sidebar is hidden; Split View, New Window, Private Window, Copy, Save Link As; a local file link or image on a page that is not a local file keeps only its Copy items, D49; a script, page source or data link has no open items), selection (Copy, Search engine, Look Up on macOS), image, video and audio (Loop and Show Controls, ticked as the element is, greyed where the engine cannot: an audio player keeps its controls; then the media lane Picture in Picture on a video), editable (suggestions, Add to Dictionary, edit roles, Paste and Match Style, Writing Direction), Inspect last; Peek and Mini Window items come from their lanes; media items in a frame inside the page act on the frame element playing the same source; P2 Translate and Share Quote not built"}, + "NV-11": {"built": "yes", "feel": true, "note": "Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task, orbit --private-window (with links, they open in it and never in a Mini Window): in-memory session shared by open private windows, wiped when the last closes; no state, history, favicon, archive, site-choice or reopen record kept; own Space-less sidebar, dark look, Private label with a plain note; command bar sees only private tabs; extension rules are EX-10"}, + "NV-12": {"built": "yes", "feel": true, "note": "native menu: page (Back, Forward, Reload, Save Page As: complete for a local page, else as a single file or its HTML alone, so no local file a web page or web frame names is copied, D49; Print, View Page Source), link (New Tab, which toasts Opened in a new tab while the sidebar is hidden; Split View, New Window, Private Window, Copy, Save Link As; a local file link or image on a page that is not a local file keeps only its Copy items, D49; a script, page source or data link has no open items), selection (Copy, Search engine, Look Up on macOS), image, video and audio (Loop and Show Controls, ticked as the element is, greyed where the engine cannot: an audio player keeps its controls; then the media lane Picture in Picture on a video), editable (suggestions, Add to Dictionary, edit roles, Paste and Match Style, Writing Direction), Inspect last; Peek and Mini Window items come from their lanes; media items in a frame inside the page act on the frame element playing the same source; P2 Translate and Share Quote not built"}, "NV-13": {"built": "yes", "note": "a main-frame load that fails shows Orbit's screen over that page's area only (a tab, a split pane, a Peek, a Mini Window, a pop-up): an icon, a title, one plain sentence in Chromium's words, the site, the engine's error code and Try again, which has the keyboard when the page had it (never taken from the sidebar or the command bar); it is a view over the engine's own error page rather than an orbit: page (D39), so the address keeps the failed URL and ⌘R, Try again, Back and history stay the engine's; a host that doesn't resolve, a refused connection, no internet, an empty response and other net errors each say what happened; the screen stays through a retry that fails again; the row keeps its title and icon when the site that failed is the one it showed, and otherwise shows that site's name (and its icon if Orbit knows one); Orbit gives up on a server that hasn't answered within 120 s (Chromium waits until the user stops it): \"took too long to respond\", the address stays where it was going, Try again and ⌘R ask again, and Back shows the page before as it was, its screen included; a form being sent is never stopped (it may be uploading); certificates: \"Your connection is not private\" with Back to safety on the keyboard (back, or closing a tab with nowhere to go back to), Proceed only behind Advanced and never for a revoked certificate, remembered for that certificate and host per profile until Orbit quits; offline: when the network comes back while the screen shows, the page loads again, once (e2e dispatch the engine's online event, as the emulation is per page); a site that asks for HTTPS only (HSTS) still gets Proceed: Electron doesn't say which sites do; gap, as DL-12's (D54): no Safe Browsing interstitial, which needs a reputation service none is free to use; the http not-secure icon is SB-04"}, "NV-14": {"built": "yes", "note": "a page that crashes or is killed shows \"This page crashed\" over its area, with the reason (out of memory, killed…) and Reload, which has the keyboard when the page had it; Reload, ⌘R and ⌘⇧R bring the page back, and closing the tab works as ever; the row keeps its title and icon; a page that crashed behind another shows its crash screen when its row is selected, until the user reloads it (it no longer reloads by itself when shown: the review F11 test in core.spec.ts now reloads it); the same screen shows over a Peek's, a Mini Window's or a pop-up's page (e2e cover tabs); a page that stops responding (the engine's own signal, after about 15 s of unanswered input) gets \"Page unresponsive\" over it with Wait (Enter, Esc) or Close page, which ends the page's process: its screen then says \"This page was closed\" with Reload, and other tabs that share that process end too, as in Chromium; the dialog goes by itself when the page answers again; after Wait, a page that stays hung isn't asked about again until it answers and hangs anew (the engine says it once); e2e: the engine says nothing of a hang while a debugger is attached, and Playwright attaches to every page, so the hang tests keep a page really busy and emit the engine's signal; TB-16 (busy background tabs paused) and EX-13 (crashing extensions) are their own rows"}, - "NV-15": {"built": "yes", "note": "prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden), for the shown tab or else a split view pane that waits: camera, mic, location, notifications, clipboard, MIDI, idle, windows, fonts, local network, serial/USB/HID choosers, screen share, file access; Allow while visiting / Allow this time / Never allow, or Block / Allow; a press answers only once the prompt has shown for 500 ms and 500 ms after the press before; Esc dismisses; kept per profile and site (private in memory); a frame from another site can't ask for notifications or use the page's; pages read undecided as default/prompt (a PermissionStatus change event doesn't fire for a decision made in Orbit, and the patched getters and requestPermission show their source to toString); a notification click brings its tab forward (page notifications, not service-worker ones; only from a frame allowed notifications, once per 500 ms); Peek, Mini Window and pop-ups ask in a dialog over their own window (Allow, Block, Not Now; Esc keeps nothing; the same request asks once); screen sharing asks only in the screen chooser, never first as camera and microphone (MD-11); a prompt whose view crashed leaves the window, and the keyboard with the page, until its page has loaded again, then shows again (chrome fix check 4, N4); the recording indicators are TB-15's; toolbar anchor waits on SB-17, Site settings is ST-13"}, + "NV-15": {"built": "yes", "feel": true, "note": "prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden), for the shown tab or else a split view pane that waits: camera, mic, location, notifications, clipboard, MIDI, idle, windows, fonts, local network, serial/USB/HID choosers, screen share, file access; Allow while visiting / Allow this time / Never allow, or Block / Allow; a press answers only once the prompt has shown for 500 ms and 500 ms after the press before; Esc dismisses; kept per profile and site (private in memory); a frame from another site can't ask for notifications or use the page's; pages read undecided as default/prompt (a PermissionStatus change event doesn't fire for a decision made in Orbit, and the patched getters and requestPermission show their source to toString); a notification click brings its tab forward (page notifications, not service-worker ones; only from a frame allowed notifications, once per 500 ms); Peek, Mini Window and pop-ups ask in a dialog over their own window (Allow, Block, Not Now; Esc keeps nothing; the same request asks once); screen sharing asks only in the screen chooser, never first as camera and microphone (MD-11); a prompt whose view crashed leaves the window, and the keyboard with the page, until its page has loaded again, then shows again (chrome fix check 4, N4); the recording indicators are TB-15's; toolbar anchor waits on SB-17, Site settings is ST-13"}, "NV-16": {"built": "partial", "note": "button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, automatic Picture in Picture for the site (MD-08), zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features"}, "NV-18": {"built": "yes", "note": "summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05"}, - "NV-19": {"built": "yes", "note": "a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): \"Open Mail?\" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), \" wants to open this application.\" naming the site of the frame that followed it, and \"Always allow to open links of this type in the associated app\" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key in any of its frames opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets \"Open this link in another app?\"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time, and once it has asked, whatever the answer (Open, Cancel or Esc), it asks again only after a new click or key in it, as in Chromium (D77): a link it follows before then gets nothing (a click or a key in any of its frames counts, a frame from another site's too); an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it"}, + "NV-19": {"built": "yes", "feel": true, "note": "a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): \"Open Mail?\" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), \" wants to open this application.\" naming the site of the frame that followed it, and \"Always allow to open links of this type in the associated app\" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key in any of its frames opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets \"Open this link in another app?\"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time, and once it has asked, whatever the answer (Open, Cancel or Esc), it asks again only after a new click or key in it, as in Chromium (D77): a link it follows before then gets nothing (a click or a key in any of its frames counts, a frame from another site's too); an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it"}, "NV-20": {"built": "yes", "note": "⌃⌘F on macOS (fn+F / Globe+F is the system's own key and does the same), F11 and Alt+Shift+F on Windows and Linux, View → Toggle Full Screen (macOS) and the command bar toggle window full screen; a held key toggles once. In full screen the sidebar hides and the page fills the window; the window's left edge brings the sidebar in over the page (SB-12), and the loading indicator stays (SB-28). Leaving docks the sidebar again, unless the user docked or hid it meanwhile (⌘S); a window closed or quit in full screen keeps its docked sidebar for next time; a window opened from one in full screen opens in full screen, its sidebar hidden (NV-10). Esc in a page, or over its error or crash screen, goes to the page first and full screen stays; pressed again within a second, or held (at its first repeat), it leaves full screen, and the page gets neither that press nor its repeats (nor, as the engine does after a key it didn't deliver, the release); presses further apart each go to the page. A single Esc never leaves, even one the page left alone: nothing asks the page (D60), and a web app's Esc is often its own without preventDefault. Exit Full Screen in Settings → Shortcuts prefers websites by default; set to prefer Orbit, one Esc leaves at once and the page gets nothing of it (KS-02, D68). Esc is no binding of it: an open overlay (command bar, Peek, find bar, a page dialog) takes Esc first (KS-19). The Esc that leaves full screen on a loading page doesn't stop the load (NV-04). A video's own full screen is Chromium's: it fills the window without the sidebar, Esc leaves only it, and the toggle keys leave both. Mini Windows don't go full screen this way. Xvfb has no window manager, so the e2e tests check the state and layout Orbit keeps; a person checks that the window really fills the screen, the macOS animation (the sidebar goes and comes back as it ends), fn+F / Globe+F, and that the View menu has one full-screen item (macOS may add its own Enter Full Screen beside Orbit's). Known limits (keys lane): with the keyboard in a frame of a loading page, the Esc the page gets stops the load after 250 ms, as only the main frame answers (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is (K7)"}, "NV-21": {"built": "yes", "note": "Save Page As…: ⇧⌘S (Ctrl+Shift+S on Windows and Linux; ⌘S is the sidebar's, SB-11), File → Save As… (macOS menu), the command bar's Save Page As… and the page menu's, each showing its live keys (KS-18). The OS save dialog opens from the Downloads folder, named after the page: a web page saves as a single file (MHTML) or, named .html, as its HTML alone; a local page whose frames are all local files saves complete (D49). A Peek's or a Mini Window's page saves itself. View Source is DT-04. On a canvas tab the same command exports the canvas: that comes with EZ-11 (no canvases yet)."}, - "NV-17": {"built": "yes", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02)"}, + "NV-17": {"built": "yes", "feel": true, "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02)"}, "NV-24": {"built": "partial", "note": "alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, over a Peek it shows above the find bar there; it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; the title names the asking page's site (\" says\"), and an about:blank tab or pop-up that its opener writes into names the opener's, as in Chromium; \"Leave site?\" (\"Reload site?\" for a reload) when a page that asks for it, from its main frame or a frame (one from another site too), after a user gesture in any of its frames as in Chromium (a click, or a key that reaches the page: ⌘W and Orbit's other shortcuts are none, D77), goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; known limits (D77): the engine's close runs a page's beforeunload with a user activation, which a page that auto-archive or sleep keeps then holds (Orbit's pop-up rule and links to other apps go by Orbit's own count, not the engine's), and ⌘W on a page that has had a user gesture and has a beforeunload listener, while its script is busy, waits for the engine's answer, up to its 1 s limit, before the tab goes; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's)"}, "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; full screen ⌃⌘F (F11, Alt+Shift+F) with NV-20; print ⌘P (Ctrl+P) with NV-07; view source ⌥⌘U (Ctrl+U) with DT-04; save page ⇧⌘S (Ctrl+Shift+S) with NV-21. Missing: reader mode ⌥⌘R comes with NV-23 (not built), developer mode ⌃D with DT-06"}, "KS-09": {"built": "yes", "note": "⌃⇧= adds a pane the command bar fills, ⌃⇧− closes the focused pane, ⌃⇧1–4 focus a pane, ⌃⇧] / ⌃⇧[ the next / previous pane (Ctrl+Shift on Windows/Linux), ⌘L / Ctrl+L changes the focused pane's address (SV-15), Add Right/Left/Above/Below Split from the command bar; tested from a page"}, @@ -237,7 +237,7 @@ "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)"}, "KS-13": {"built": "partial", "note": "⌥⌘I / F12 (Ctrl+Shift+I / F12) open or close DevTools (DT-01, F12 in e2e), ⌥⌘J (Ctrl+Shift+J) the console, ⌥⌘C the element picker (no default on Windows/Linux, where Ctrl+Shift+C is Copy URL), ⌥⌘U (Ctrl+U) View Source, ⌘⇧R (Ctrl+Shift+R) hard reload: each checked against the spec table per platform, and none fixed; a remapped DevTools key moves in the router and in DevTools' own page, and the picker can take ⇧⌘C from Copy URL (unit tests); ⇧⌘M / Ctrl+Shift+M stays DevTools' device toolbar key (no action has it). A remap reaches DevTools already open only once they open again (devtools lane). Not built: developer mode for a site (DT-06; its ⌃D is a plain ⌃-letter, which macOS keeps for the caret, D60) and the task manager (DT-11, Shift+Esc)"}, "KS-10": {"built": "yes", "note": "⌘O / Ctrl+O expands a Peek into a selected Today tab (PK-07); Esc and ⌘W / Ctrl+W close it (PK-09, Esc fixed); ⌘⇧T and ⌘Z reopen it (PK-10); ⌘⌥N / Ctrl+Alt+N opens a Mini Window (MW-06); in a Mini Window ⌘O moves its page into the most recent Space (MW-05), ⌘⌥O (also ⌘⇧O) / Ctrl+Alt+O offers the Spaces, ⌘W closes it (MW-09). All from the one keymap and remappable but Esc; the tests press the keys (peek, mini-window and keys specs)"}, - "KS-15": {"built": "yes", "note": "macOS: the menu bar keeps the system’s items and keys: Hide Orbit ⌘H, Hide Others ⌥⌘H and Quit ⌘Q in the Orbit menu, the Window menu (Minimize ⌘M, ⌘` between Orbit windows) and the Help menu with its search (⌘?). No action can take these keys, nor Alt+F4, Alt+Tab or Alt+F on Windows and Linux, and Settings → Shortcuts says what keeps them (KS-03). Windows and Linux: Alt+F4 closes the window and, on the last one, quits; F1 opens Help (OB-13) and can’t be changed there. An Exit item comes with the Windows/Linux app menu (SB-26, not built). The menu template and keymap are unit-tested; not run on a Mac"}, + "KS-15": {"built": "yes", "feel": true, "note": "macOS: the menu bar keeps the system’s items and keys: Hide Orbit ⌘H, Hide Others ⌥⌘H and Quit ⌘Q in the Orbit menu, the Window menu (Minimize ⌘M, ⌘` between Orbit windows) and the Help menu with its search (⌘?). No action can take these keys, nor Alt+F4, Alt+Tab or Alt+F on Windows and Linux, and Settings → Shortcuts says what keeps them (KS-03). Windows and Linux: Alt+F4 closes the window and, on the last one, quits; F1 opens Help (OB-13) and can’t be changed there. An Exit item comes with the Windows/Linux app menu (SB-26, not built). The menu template and keymap are unit-tested; not run on a Mac"}, "KS-20": {"built": "partial", "note": "private window: the same keys, but the actions that need Spaces, pinned tabs, Favorites, folders or the Archive (⌘D, ⌃1–9 / Alt+1–9, ⌘⌥← / ⌘⌥→, ⌘⇧K Clear, Favorite, New Folder, View Archive, every Space action) do nothing there, from a key, a menu or the command bar, and a toast says “Not available in a private window”; its sidebar has no Clear button; tab keys work. Mini Window: ⌘W closes it, ⌘O / ⌘⌥O move or offer to move its page (KS-10), ⌘L edits its address with the keyboard in the field and Esc gives the keyboard back to its page, ⌘R, zoom and print act on its page, sidebar and Space keys do nothing; ⌘Y / Ctrl+H History, the Library and Help → Keyboard Shortcuts open in the browser window behind it. Full screen: every key works with the sidebar hidden; ⌘1–9 and ⌘⌥↑ / ⌘⌥↓ switch tabs without bringing it out. Not built: ⌘F in a Mini Window (it has no find bar, Mini Window lane), the blank window (TB-17), extension keys in private windows (EX-06, EX-10)"}, "SB-10": {"built": "partial", "note": "media tray and call controls (media lane), above the bottom bar, pushing the list up; download progress and the update banner belong to other lanes"}, "PK-01": {"built": "yes", "feel": true, "note": "floating card over the source tab; one Peek per window; links inside navigate it"}, @@ -320,16 +320,16 @@ "IM-01": {"built": "partial", "note": "the import window (a child of the browser window, in its Space's colours): Back / Next / Cancel, Cancel at any step imports nothing, Enter is Next and Escape is Back, ⌘W closes it. Opened from onboarding (the same flow inline, OB-03), the command bar's Import from Another Browser, Settings → General → Import…, and on macOS the app menu's Import from Another Browser… (macOS-unverified). Not built: a ☰ entry on Windows/Linux (Orbit has no ☰ menu). In the progress view, Cancel is offered only while the source is read; once adding starts it offers Hide, and the bookmarks added are one undo step. The window isn’t modal: the browser window stays usable while it’s open, as IM-14 asks"}, "IM-02": {"built": "partial", "note": "the browsers found on this computer (Chrome, Chromium, Brave, Edge, Vivaldi, Opera, Opera GX, Firefox; Safari on macOS), each a tile in its colour with its profile or profile count; those not installed aren't shown. Several profiles: a step to choose one or more, by name. Profiles come from Chromium's Local State (or a single Default folder, or Opera's own folder) and Firefox's profiles.ini; every file is read from a copy. Safari without Full Disk Access is shown as blocked, with the way round it (export a bookmarks file from Safari and import that). Tests search only made-up fixture folders. Letter tiles, not logos: Orbit doesn't ship other browsers' marks. Safari's reader (Bookmarks.plist, a binary property list) is tested on fixtures, macOS-unverified"}, "IM-03": {"built": "partial", "note": "after the source and its profiles, what they hold as checkboxes, all on, with counts (\"6 bookmarks, as pinned tabs\", \"6 pages visited\"); types a source can't provide aren't shown, and a profile whose file the browser holds offers the rest; never passwords or cards (D52). Cancel (Do this later in onboarding) weighs the same as Next. Extensions are listed, off, as not importable yet (IM-09). Cookies (IM-10) aren’t offered. A file that is there but can’t be read is named with why, e.g. “Work: history couldn’t be read (the file is damaged)”, and the profile’s other types are still offered"}, - "IM-06": {"built": "yes", "note": "the last step: the profile (it gets the history) and the Space (its pinned section gets the bookmarks): the current Space by default, the first one in onboarding, or New Space… with a name. Several source profiles: \"Make a matching Orbit profile and Space for each\" makes them, in order, named after the source profiles, each getting its own profile's history. Private windows' profiles aren't offered and never get history"}, - "IM-04": {"built": "yes", "note": "the source's tree (Chromium's bookmarks bar, other and mobile bookmarks; Firefox's toolbar, menu, other and mobile bookmarks; Safari's favorites bar, menu and reading list; a file's own groups), several profiles each under its name, with a checkbox per folder and bookmark; everything chosen at first; Select all / none; \"4 of 6 bookmarks selected\". Addresses that can't be pinned show unticked with the reason. Folders below the top level start closed, and so does a top-level one holding over 1,000 bookmarks. The list isn’t virtualized: every row at the top level or in an open folder is drawn, so a large file shows quickly only when its bookmarks sit in such closed folders (20,000 in folders of 2,000 show in about 0.4 s). 20,000 bookmarks at the top level take about 2.9 s to show; 200,000 there, or 200 top-level folders of 1,000, about 30 s; 10,000 top-level folders about 6 s; and opening a large folder draws all its rows. Only the import window waits"}, + "IM-06": {"built": "yes", "feel": true, "note": "the last step: the profile (it gets the history) and the Space (its pinned section gets the bookmarks): the current Space by default, the first one in onboarding, or New Space… with a name. Several source profiles: \"Make a matching Orbit profile and Space for each\" makes them, in order, named after the source profiles, each getting its own profile's history. Private windows' profiles aren't offered and never get history"}, + "IM-04": {"built": "yes", "feel": true, "note": "the source's tree (Chromium's bookmarks bar, other and mobile bookmarks; Firefox's toolbar, menu, other and mobile bookmarks; Safari's favorites bar, menu and reading list; a file's own groups), several profiles each under its name, with a checkbox per folder and bookmark; everything chosen at first; Select all / none; \"4 of 6 bookmarks selected\". Addresses that can't be pinned show unticked with the reason. Folders below the top level start closed, and so does a top-level one holding over 1,000 bookmarks. The list isn’t virtualized: every row at the top level or in an open folder is drawn, so a large file shows quickly only when its bookmarks sit in such closed folders (20,000 in folders of 2,000 show in about 0.4 s). 20,000 bookmarks at the top level take about 2.9 s to show; 200,000 there, or 200 top-level folders of 1,000, about 30 s; 10,000 top-level folders about 6 s; and opening a large folder draws all its rows. Only the import window waits"}, "IM-05": {"built": "partial", "note": "one pinned folder \"Imported from \" (\"Imported bookmarks\" from a file) at the end of the target Space's pinned section: the source's top-level groups and nested folders kept, in order, as closed sub-folders (the folder itself closed above 1,000 items); several profiles into one Space each get a sub-folder by name; each bookmark a pinned tab with its title and address, asleep, with no favicon or request until opened; bookmarklets, saved searches, browser-only and non-web addresses skipped and counted; Firefox tags and separators left out; no Favorites made; one undo step takes it all back. 2,000 bookmarks take about 0.3–0.4 s, made out of sight in parts and shown at once (IM-14), with the longest main-process stall about 40–50 ms. Not built: fetching a favicon when an imported row scrolls into view; its icon comes when the tab is first opened"}, - "IM-13": {"built": "yes", "note": "bookmarks HTML (the Netscape format; Chromium- and Firefox-style exports tested) and a .txt list of links, from From a file… or dropped on the import window or the sidebar. Bookmarks map as IM-05 into \"Imported bookmarks\"; links as IM-12 (a list over about 1 MB or 10,000 lines shows only its count and first lines); a file with neither says \"No bookmarks found in this file\" and imports nothing; a cut-off file imports what it holds. Files are read in a worker, in time linear in their size (D58): main’s longest stall is about 0.07 s for a 16 MB file or list, and about 0.2 s for a 60 MB file of 200,000 bookmarks. A file nested deeper than 64 folders keeps its deeper bookmarks in the 64th; a file gives at most 200,000 bookmarks (folders don’t count) and 20,000 folders, the bookmarks of folders past that kept in the folder that holds them; over 64 MB, every route, a paste included, says “This is too large to import (the limit is 64 MB)” without reading it; a read that fails says “Orbit couldn’t read this file”. No passwords CSV (D52)"}, - "IM-12": {"built": "yes", "note": "Paste a list of links (one address per line, \"Title — address\" with a dash, bar or tab, Markdown links, bullets): one pinned folder named by the user, a pinned tab per link in order, asleep; lines that aren't links are counted in the summary. ⌘V / Ctrl+V on the sidebar (outside a text field) with two or more links on the clipboard opens the same import with them filled in. A list over about 1 MB or 10,000 lines, pasted or from a file, isn’t put in the text field: the import window shows its count and first eight lines, read-only, with Clear, and imports it as it is. A paste over 64 MB as UTF-8 (22.5 million CJK characters, say) says “This is too large to import (the limit is 64 MB)”, as a file does, and none of it leaves the window it was pasted in. Clear also frees main’s copy of a held list. The other routes (a bookmarks file, notes, canvases, site styles) are explained in the import window's help, which reads nothing from another browser; passwords are out of scope (D52)"}, + "IM-13": {"built": "yes", "feel": true, "note": "bookmarks HTML (the Netscape format; Chromium- and Firefox-style exports tested) and a .txt list of links, from From a file… or dropped on the import window or the sidebar. Bookmarks map as IM-05 into \"Imported bookmarks\"; links as IM-12 (a list over about 1 MB or 10,000 lines shows only its count and first lines); a file with neither says \"No bookmarks found in this file\" and imports nothing; a cut-off file imports what it holds. Files are read in a worker, in time linear in their size (D58): main’s longest stall is about 0.07 s for a 16 MB file or list, and about 0.2 s for a 60 MB file of 200,000 bookmarks. A file nested deeper than 64 folders keeps its deeper bookmarks in the 64th; a file gives at most 200,000 bookmarks (folders don’t count) and 20,000 folders, the bookmarks of folders past that kept in the folder that holds them; over 64 MB, every route, a paste included, says “This is too large to import (the limit is 64 MB)” without reading it; a read that fails says “Orbit couldn’t read this file”. No passwords CSV (D52)"}, + "IM-12": {"built": "yes", "feel": true, "note": "Paste a list of links (one address per line, \"Title — address\" with a dash, bar or tab, Markdown links, bullets): one pinned folder named by the user, a pinned tab per link in order, asleep; lines that aren't links are counted in the summary. ⌘V / Ctrl+V on the sidebar (outside a text field) with two or more links on the clipboard opens the same import with them filled in. A list over about 1 MB or 10,000 lines, pasted or from a file, isn’t put in the text field: the import window shows its count and first eight lines, read-only, with Clear, and imports it as it is. A paste over 64 MB as UTF-8 (22.5 million CJK characters, say) says “This is too large to import (the limit is 64 MB)”, as a file does, and none of it leaves the window it was pasted in. Clear also frees main’s copy of a held list. The other routes (a bookmarks file, notes, canvases, site styles) are explained in the import window's help, which reads nothing from another browser; passwords are out of scope (D52)"}, "IM-14": {"built": "yes", "note": "progress per data type (a spinner, then a check), then a summary with counts per type and the skipped items with reasons; Done shows the imported folder in the sidebar (its Space, the pinned section open, the folder in view). Bookmarks are made out of sight, in parts, and their folder is shown whole, at once (D62): while 20,000 are added, the browser window’s longest freeze is under 0.1 s; 100,000 take about 34 s, with the window’s longest freeze about 0.34 s and main’s longest stall about 0.57 s (saving the state). A file the source browser holds (it can't be copied) shows \"Chrome is using some of its files. Quit Chrome and try again, or import the rest\" with Try again; the types it could read can still be imported. History shows the pages read, then added; Done says they're in History and the command bar; what History can't keep is listed with the reason. The summary lists every item left out with its reason: skipped bookmarks (also in a folder that holds nothing else), those past 200,000 bookmarks in one file, list or profile (folders don’t count, so the summary and the selection count agree), lines that aren’t links, and a profile’s file that couldn’t be read (“Work: history couldn’t be read (the file is damaged)”). Until the folder shows, nothing lists its tabs (D65): not the command bar (by title, by address or among recent tabs), the recent-tab switcher or the Library. One the user reached anyway and placed stays where they put it, and the folder leaves it out. A quit or a failure part-way takes back exactly the tabs the import made, by id, wherever they are, and nothing of the user’s; a crash part-way leaves none either, as the next start removes tabs that no section shows. Restore Data part-way stops the import first: its tabs are removed without going to the Archive, and the import window says “The import stopped: data was restored from a backup.” Copies of the other browser’s files go in a folder under Orbit’s user data, removed at quit (a copy still being made too) and cleared at startup (D58)"}, - "IM-07": {"built": "yes", "note": "Chromium's History, Firefox's places.sqlite and Safari's History.db (macOS-unverified), read from copies a window of rows at a time, into the chosen profile's history: each page with its title, visit and typed counts and its newest visits (up to 20). History (⌘Y / Ctrl+H) lists them and the command bar suggests and completes them at once; no tab is made and nothing loads. Left out: browsers' own pages, hidden pages, frame loads (Chromium subframes; Firefox embeds, framed links, downloads). History keeps the 50,000 most recent pages: a larger history keeps its newest and the summary counts the rest. 100,000 pages take about 2 s, with main's longest stall about 50–110 ms and none in the windows; the profile's history is written once, as a snapshot"}, - "IM-15": {"built": "yes", "note": "the same browser or file again makes a new \"Imported from (2)\" folder (then (3)…), never merging into or changing the first. History de-duplicates silently: a page already imported gains only the visits it lacked, so its counts aren't doubled. Passwords aren't imported (D52)"}, + "IM-07": {"built": "yes", "feel": true, "note": "Chromium's History, Firefox's places.sqlite and Safari's History.db (macOS-unverified), read from copies a window of rows at a time, into the chosen profile's history: each page with its title, visit and typed counts and its newest visits (up to 20). History (⌘Y / Ctrl+H) lists them and the command bar suggests and completes them at once; no tab is made and nothing loads. Left out: browsers' own pages, hidden pages, frame loads (Chromium subframes; Firefox embeds, framed links, downloads). History keeps the 50,000 most recent pages: a larger history keeps its newest and the summary counts the rest. 100,000 pages take about 2 s, with main's longest stall about 50–110 ms and none in the windows; the profile's history is written once, as a snapshot"}, + "IM-15": {"built": "yes", "feel": true, "note": "the same browser or file again makes a new \"Imported from (2)\" folder (then (3)…), never merging into or changing the first. History de-duplicates silently: a page already imported gains only the visits it lacked, so its counts aren't doubled. Passwords aren't imported (D52)"}, "IM-16": {"built": "yes", "note": "after onboarding's import of a history, the Done step offers the six sites visited most (each by its most-visited page, or its home page when that's a search): the first three as Favorites, the rest as pinned tabs. One click takes one (another takes it back); Dismiss all drops them. They're added unloaded with the import when Start browsing ends onboarding, never with Skip setup (D44). Orbit can't tell web apps from other sites, so it ranks by visits; a suggested pinned tab is named after its site until first opened"}, - "IM-17": {"built": "yes", "note": "Export Pinned Items as Bookmarks… in the File menu (macOS-unverified: Orbit has no menu bar elsewhere), the command bar, and Settings → General → Import and export (with Include Today tabs) writes a standard bookmarks HTML file through the system's save dialog: the Favorites a folder (one per profile when there are several), each Space a folder of its pinned section with its folders nested as they are, a split's tabs in its place; Today's tabs only when chosen, as a Today folder in their Space. Orbit's own pages and private windows' profiles are left out. Copy All Links on a folder is the tabs lane's (it exists)"}, + "IM-17": {"built": "yes", "feel": true, "note": "Export Pinned Items as Bookmarks… in the File menu (macOS-unverified: Orbit has no menu bar elsewhere), the command bar, and Settings → General → Import and export (with Include Today tabs) writes a standard bookmarks HTML file through the system's save dialog: the Favorites a folder (one per profile when there are several), each Space a folder of its pinned section with its folders nested as they are, a split's tabs in its place; Today's tabs only when chosen, as a Today folder in their Space. Orbit's own pages and private windows' profiles are left out. Copy All Links on a folder is the tabs lane's (it exists)"}, "IM-09": {"built": "partial", "note": "A Chromium-family profile's extensions are listed on the types step, from its Preferences and Secure Preferences (read from copies; a name kept only in the manifest is read from it, in its default language): pinned ones first, the browser's own left out. The row is off and says they aren't importable yet; unpacked ones never come over. Not built: installing them fresh from the store with one summary confirmation, pinning the pinned ones, and the ad-blocker de-duplication (blockers are already marked), because the extensions lane's install path isn't on main yet. Firefox add-ons aren't listed (the spec covers Chromium-family sources)"}, "ST-11": {"built": "partial", "note": "Theme mode: Light / Dark / Match system (AP-01). Not built: window material (AP-10, Windows), app icon (AP-09), page defaults (P2)"}, "ST-17": {"built": "yes", "note": "Settings → About: logo, version (a click copies it), build date, Chromium and Electron versions, system, Check for updates, release notes, Copy build info. Licence and Open-source credits open credits.html, built with the app (D43): Orbit's and Electron's licences, a link to the LICENSES.chromium.html Electron ships, and the licence of each npm package whose code ships. Release notes and help need access to the private repository for now"}, diff --git a/scripts/capture/record.mjs b/scripts/capture/record.mjs index 04f9eb8e..94fe3e35 100644 --- a/scripts/capture/record.mjs +++ b/scripts/capture/record.mjs @@ -12,7 +12,8 @@ * server starts looking for a free port, up to 9 above it, so a capture stays inside its own range. * * Scenarios live in scripts/capture/scenarios/.mjs and export - * `default async function (t)`; see helpers in `makeToolkit` below. + * `default async function (t)`; see helpers in `makeToolkit` below. A scenario may also export `env`: + * more environment for Orbit's launch (fixture paths, say), as an object or a function of `{ fx }`. */ import { execFile, spawn } from 'node:child_process' import { mkdirSync, mkdtempSync, renameSync, rmSync } from 'node:fs' @@ -63,7 +64,8 @@ async function startFixtures() { const accents = ['#e5484d', '#3e63dd', '#30a46c', '#f76b15', '#8e4ec6', '#12a594', '#d6409f', '#ffb224'] const esc = (t) => t.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]) const server = createServer((req, res) => { - const raw = (req.url ?? '/').split('/').pop() || 'Home' + const at = new URL(req.url ?? '/', 'http://127.0.0.1') + const raw = at.pathname.split('/').pop() || 'Home' let name = raw try { name = decodeURIComponent(raw) @@ -78,8 +80,9 @@ async function startFixtures() { `

${c}

Local demo content for ${esc(name)}. Nothing here comes from the network.

`, ) .join('') - res.writeHead(200, { 'content-type': 'text/html' }) - res.end(`${esc(name)} + // ?wait=: the page comes that much later (at most 30 s), for a load still under way. + const wait = Math.min(Number(at.searchParams.get('wait')) || 0, 30_000) + const page = `${esc(name)}
${esc(name)}
-

${esc(name)}

A local demo page for Orbit screen captures, served from 127.0.0.1.

${cards}
`) +

${esc(name)}

A local demo page for Orbit screen captures, served from 127.0.0.1.

${cards}
` + setTimeout(() => { + res.writeHead(200, { 'content-type': 'text/html' }) + res.end(page) + }, wait) }) let port = PORT await new Promise((ok, fail) => { @@ -278,6 +285,10 @@ let app let rec let saved = false try { + const scenarioFile = scenario.endsWith('.mjs') + ? resolve(scenario) + : resolve(import.meta.dirname, 'scenarios', `${scenario}.mjs`) + const { default: play, setup, env = {} } = await import(pathToFileURL(scenarioFile).href) log('launching Orbit') app = await electron.launch({ args: [ @@ -286,7 +297,14 @@ try { NO_LOOKUPS, main, ], - env: { ...process.env, ORBIT_E2E: '1', ORBIT_USER_DATA: userData }, + env: { + ...process.env, + ORBIT_E2E: '1', + ORBIT_USER_DATA: userData, + // Import searches no browser on this computer: a scenario that imports names its made-up ones. + ORBIT_IMPORT_PLACES: '[]', + ...(typeof env === 'function' ? await env({ fx }) : env), + }, }) let shell for (let i = 0; i < 200 && !shell; i++) { @@ -317,10 +335,6 @@ try { throw new Error(`not recording: requests would reach the network (${proxies.join(', ')})`) } - const scenarioFile = scenario.endsWith('.mjs') - ? resolve(scenario) - : resolve(import.meta.dirname, 'scenarios', `${scenario}.mjs`) - const { default: play, setup } = await import(pathToFileURL(scenarioFile).href) const toolkit = makeToolkit(app, shell, fx) // Optional unrecorded preparation (Spaces, tabs, history), so the clip starts on the demo itself. if (setup) { diff --git a/scripts/capture/scenarios/ar.mjs b/scripts/capture/scenarios/ar.mjs new file mode 100644 index 00000000..be1da456 --- /dev/null +++ b/scripts/capture/scenarios/ar.mjs @@ -0,0 +1,238 @@ +/** + * Archive (AR-01, AR-02, AR-03, AR-04, AR-05, AR-07, AR-08, AR-11), about 50 s, for the "feels right" + * sign-off (D48). ⌘W on a Today tab and a deleted folder send their tabs to the Archive; the Library + * button opens the Library at the Archive, newest first under day headings; Restore shows on the row + * under the pointer; the source filter and search narrow the list as you click and type; Restore sends + * a tab of another Space back there, and a toast says so; Clear Archive asks first, Cancel keeps + * everything, and the empty Archive says how long entries are kept; Restore Data brings the deleted + * folder back from a sidebar backup; Esc closes the Library. + * + * Each step prints a `[mark] ` line on stderr: its time since the scenario began. + * record.mjs begins it 0.7 s after the recording starts, so a step shows about 0.7 s later in the clip. + */ +import { execFile } from 'node:child_process' + +let t0 = 0 +const mark = (name) => console.error(`[mark] ${name} ${Math.round(performance.now() - t0)}`) + +/** Moves the on-screen pointer (display coordinates) in eased steps. */ +let at = null +async function pointerTo(x, y, steps = 16) { + const from = at ?? { x, y } + for (let i = 1; i <= steps; i++) { + const k = 0.5 - 0.5 * Math.cos((Math.PI * i) / steps) + await new Promise((r) => + execFile( + 'xdotool', + ['mousemove', String(Math.round(from.x + (x - from.x) * k)), String(Math.round(from.y + (y - from.y) * k))], + () => r(), + ), + ) + } + at = { x, y } +} + +/** The pointer to the middle of a locator, in a view placed at `offset` in the window (the window is at 0, 0). */ +async function pointAt(locator, offset = { x: 0, y: 0 }, steps = 16) { + const box = await locator.boundingBox() + if (box) await pointerTo(offset.x + box.x + box.width / 2, offset.y + box.y + box.height / 2, steps) +} + +async function waitFor(t, test, ms = 8000) { + const end = Date.now() + ms + while (Date.now() < end) { + const v = await test() + if (v) return v + await t.sleep(40) + } + throw new Error('timed out') +} + +const MOD = process.platform === 'darwin' ? 'meta' : 'control' + +/** A key pressed in a tab's page, as with the page focused. */ +const press = (t, id, key, mods = []) => + t.app.evaluate( + (_e, [tabId, keyCode, modifiers]) => { + const page = globalThis.__orbit.ctx.tabs.get(tabId).webContents + page.sendInputEvent({ type: 'keyDown', keyCode, modifiers }) + page.sendInputEvent({ type: 'keyUp', keyCode, modifiers }) + }, + [id, key, mods], + ) + +/** A tab of the fixture page `name`, shown (a tab never shown doesn't load) until it has its title. */ +async function open(t, name, spaceId) { + const id = await t.run('tabs.create', { url: t.fx.url(name), spaceId, activate: false }) + await t.run('tabs.activate', { tabId: id }) + await waitFor(t, async () => { + const tab = (await t.state()).tabs[id] + return tab?.title === name && !tab.loading + }) + return id +} + +/** A command typed into the command bar (⌘T), as a person would, and run with Enter. */ +async function command(t, text) { + await t.key('Mod+T') + const bar = await t.overlay() + await waitFor(t, () => bar.getByTestId('command-input').isVisible()) + await t.sleep(250) + await t.type(bar, text, 55) + await waitFor(t, () => bar.getByTestId('command-result').filter({ hasText: text }).first().isVisible()) + await t.sleep(350) + await bar.keyboard.press('Enter') +} + +let tab = '' +let folder = '' +let backup = '' + +/** + * Unrecorded: six days of earlier closes (for the day headings), a tab closed in a second Space, a + * folder in the pinned section with a sidebar backup taken while it was there, and two Today tabs. + */ +export async function setup(t) { + const s = await t.state() + const home = Object.values(s.windows).find((w) => w.kind === 'main').spaceId + // Every tenth seeded entry (the "lantern" pages) closed in a Mini Window, for the source filter. + await t.run('archive.testSeed', { + base: t.fx.url('').replace(/\/$/, ''), + count: 28, + days: 6, + spaceId: home, + prefix: 'Saved', + }) + const travel = await t.run('spaces.create', { name: 'Travel' }) + await t.run('tabs.close', { tabId: await open(t, 'Flight deals', travel) }) + await waitFor(t, async () => (await t.run('archive.query', {})).rows.some((r) => r.title === 'Flight deals')) + await t.run('spaces.switch', { spaceId: home }) + const packing = await open(t, 'Packing list', home) + const hotels = await open(t, 'Hotel shortlist', home) + folder = await t.run('folders.create', { name: 'Trip ideas', items: [packing, hotels] }) + backup = (await t.run('backups.take')).id + await open(t, 'Field notes', home) + tab = await open(t, 'Trail map', home) + await t.sleep(900) +} + +export default async function (t) { + t0 = performance.now() + const { shell } = t + await pointerTo(900, 560, 1) + await t.sleep(500) + + // AR-01: ⌘W on a Today tab sends it to the Archive. + mark('close-tab') + await press(t, tab, 'W', [MOD]) + await waitFor(t, async () => !(await t.state()).tabs[tab]) + await t.sleep(1000) + // AR-01: so does deleting a folder (as from its menu): its tabs go there. + mark('delete-folder') + await t.run('folders.delete', { folderId: folder }) + await t.sleep(1100) + + // AR-02: the Library button, bottom-left, opens the Library at the Archive. + const button = shell.getByTestId('library-button') + await pointAt(button, undefined, 22) + await t.sleep(250) + mark('library') + await button.click() + const lib = await waitFor(t, () => t.app.windows().find((p) => p.url().includes('view=library'))) + await waitFor(t, async () => (await lib.getByTestId('library').getAttribute('data-open')) === 'true') + const view = (await t.run('library.testInfo')).bounds + await t.sleep(1300) + + // AR-03: newest first under day headings; Restore shows on the row under the pointer. + mark('hover') + const rows = lib.getByTestId('archive-row') + await pointAt(rows.nth(0), view, 18) + await t.sleep(700) + await pointAt(rows.nth(1), view, 8) + await t.sleep(500) + await pointAt(rows.nth(3), view, 10) + await t.sleep(700) + // AR-03: the source filter: the tabs closed in a Mini Window, then all again. + mark('filter') + const mini = lib.getByTestId('archive-source-mini') + await pointAt(mini, view, 16) + await mini.click() + await t.sleep(1300) + const all = lib.getByTestId('archive-source-all') + await pointAt(all, view, 8) + await all.click() + await t.sleep(900) + + // AR-04: search narrows the list as you type, by title and address. + mark('search') + const search = lib.getByTestId('archive-search') + await pointAt(search, view, 14) + await search.click() + await t.type(lib, 'garden', 120) + await t.sleep(1500) + for (let i = 0; i < 'garden'.length; i++) { + await lib.keyboard.press('Backspace') + await t.sleep(70) + } + await waitFor(t, async () => (await rows.filter({ hasText: 'Flight deals' }).count()) > 0) + await t.sleep(800) + + // AR-05: Restore on the tab from Travel puts it back in Travel; a toast says so and offers to go there. + mark('restore') + const deals = rows.filter({ hasText: 'Flight deals' }) + await pointAt(deals, view, 16) + await t.sleep(450) + const restore = deals.getByTestId('archive-restore') + await pointAt(restore, view, 8) + await restore.click() + await lib.getByTestId('library-toast').waitFor({ timeout: 8000 }) + await t.sleep(2400) + + // AR-07: Clear Archive asks first, saying it can't be undone; Cancel keeps everything. + mark('clear-ask') + await command(t, 'Clear Archive') + const cancel = lib.getByTestId('archive-clear-cancel') + await cancel.waitFor({ timeout: 8000 }) + await t.sleep(1600) + await pointAt(cancel, view, 14) + mark('clear-cancel') + await cancel.click() + await t.sleep(1100) + // Asked again, and cleared: the empty Archive says so, and how long entries are kept (AR-08). + mark('clear') + await command(t, 'Clear Archive') + const ok = lib.getByTestId('archive-clear-ok') + await ok.waitFor({ timeout: 8000 }) + await t.sleep(900) + await pointAt(ok, view, 12) + await ok.click() + await lib.getByTestId('archive-empty').waitFor({ timeout: 8000 }) + await t.sleep(2300) + + // AR-11: Restore Data lists the sidebar's backups; the one from before the folder went brings it back. + mark('restore-data') + await command(t, 'Restore Data') + const row = lib.locator(`[data-testid="backup-row"][data-id="${backup}"]`) + await row.waitFor({ timeout: 8000 }) + await t.sleep(1200) + await pointAt(row, view, 16) + await row.click() + await t.sleep(500) + const again = row.getByTestId('backup-restore') + await pointAt(again, view, 8) + await again.click() + const yes = lib.getByTestId('backup-confirm-ok') + await yes.waitFor({ timeout: 8000 }) + await t.sleep(1300) + await pointAt(yes, view, 10) + mark('restore-ok') + await yes.click() + await waitFor(t, async () => (await t.state()).folders[folder]) + await t.sleep(2300) + + // AR-02: Esc closes the Library. + mark('close-library') + await lib.keyboard.press('Escape') + await t.sleep(1500) + mark('end') +} diff --git a/scripts/capture/scenarios/im.mjs b/scripts/capture/scenarios/im.mjs new file mode 100644 index 00000000..ea114cd1 --- /dev/null +++ b/scripts/capture/scenarios/im.mjs @@ -0,0 +1,265 @@ +/** + * Import (IM-04, IM-06, IM-07, IM-12, IM-13, IM-15), about 65 s, for the "feels right" sign-off (D48). + * The command bar's Import from Another Browser: Chrome's Work profile; its bookmarks chosen in their + * tree, with a count that follows each change and Select none / all; a new Space to receive them; the + * import, its summary, and Done, which shows the pinned folder. The history came too: a familiar site + * completes in the command bar at once. A pasted list of links becomes a pinned folder the person names; + * the same list pasted on the sidebar opens the import at it, and makes a second folder, " (2)". A + * bookmarks file chosen with From a file… goes the same way. + * + * The browsers are the made-up ones under tests/fixtures/import, never one on this computer, and the + * system's file picker is stood in for (as the e2e tests do), so nothing waits for a person. + * + * Each step prints a `[mark] ` line on stderr: its time since the scenario began. + * record.mjs begins it 0.7 s after the recording starts, so a step shows about 0.7 s later in the clip. + */ +import { execFile } from 'node:child_process' +import { resolve } from 'node:path' + +let t0 = 0 +const mark = (name) => console.error(`[mark] ${name} ${Math.round(performance.now() - t0)}`) + +const FIXTURES = resolve(import.meta.dirname, '..', '..', '..', 'tests', 'fixtures', 'import') +export const env = { + ORBIT_IMPORT_PLACES: JSON.stringify([ + { browser: 'chrome', dir: resolve(FIXTURES, 'chrome') }, + { browser: 'firefox', dir: resolve(FIXTURES, 'firefox') }, + ]), +} + +/** Moves the on-screen pointer (display coordinates) in eased steps. */ +let at = null +async function pointerTo(x, y, steps = 16) { + const from = at ?? { x, y } + for (let i = 1; i <= steps; i++) { + const k = 0.5 - 0.5 * Math.cos((Math.PI * i) / steps) + await new Promise((r) => + execFile( + 'xdotool', + ['mousemove', String(Math.round(from.x + (x - from.x) * k)), String(Math.round(from.y + (y - from.y) * k))], + () => r(), + ), + ) + } + at = { x, y } +} + +/** The pointer to the middle of a locator, in a page whose content starts at `offset` on the display. */ +async function pointAt(locator, offset = { x: 0, y: 0 }, steps = 16) { + const box = await locator.boundingBox() + if (box) await pointerTo(offset.x + box.x + box.width / 2, offset.y + box.y + box.height / 2, steps) +} + +async function waitFor(t, test, ms = 8000) { + const end = Date.now() + ms + while (Date.now() < end) { + const v = await test() + if (v) return v + await t.sleep(40) + } + throw new Error('timed out') +} + +/** The import window's page, drawn, and where its content is on the display. */ +async function importWindow(t) { + const page = await waitFor(t, () => t.app.windows().find((p) => p.url().includes('view=import') && !p.isClosed())) + await page.getByTestId('import-flow').waitFor({ timeout: 8000 }) + const offset = await t.app.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows().find( + (w) => !w.isDestroyed() && w.webContents.getURL().includes('view=import'), + ) + return win ? win.getContentBounds() : { x: 0, y: 0 } + }) + return { page, offset } +} + +/** Points at a control of the import window and clicks it. */ +async function click(page, offset, testId, steps = 12) { + const control = page.getByTestId(testId) + await pointAt(control, offset, steps) + await control.click() +} + +/** On through the steps (with a look at each) until the import has run, then Done, which closes the window. */ +async function finish(t, page, offset) { + for (let i = 0; i < 4; i++) { + if ((await page.getByTestId('import-flow').getAttribute('data-step')) === 'progress') break + await click(page, offset, 'import-next', 10) + await t.sleep(900) + } + await waitFor( + t, + async () => (await page.getByTestId('import-progress').getAttribute('data-phase')) === 'done', + 30_000, + ) + await t.sleep(1700) + mark('done') + const closed = page.waitForEvent('close') + await click(page, offset, 'import-done').catch((err) => { + if (!page.isClosed()) throw err + }) + await closed +} + +let links = '' + +/** Unrecorded: a tab, so the page area isn't empty; the list of links a person copied elsewhere. */ +export async function setup(t) { + const s = await t.state() + const home = Object.values(s.windows).find((w) => w.kind === 'main').spaceId + const tab = await t.run('tabs.create', { url: t.fx.url('Field notes'), spaceId: home, activate: false }) + await t.run('tabs.activate', { tabId: tab }) + await waitFor(t, async () => (await t.state()).tabs[tab]?.title === 'Field notes') + links = [ + `Morning paper — ${t.fx.url('Morning paper')}`, + `Trail map — ${t.fx.url('Trail map')}`, + `${t.fx.url('Bread baking')}`, + `Hotel shortlist — ${t.fx.url('Hotel shortlist')}`, + `Packing list — ${t.fx.url('Packing list')}`, + ].join('\n') + await t.sleep(600) +} + +export default async function (t) { + t0 = performance.now() + const { shell } = t + await pointerTo(900, 520, 1) + await t.sleep(400) + + // The way in: the command bar's Import from Another Browser. + mark('open') + await t.key('Mod+T') + const bar = await t.overlay() + await waitFor(t, () => bar.getByTestId('command-input').isVisible()) + await t.sleep(250) + await t.type(bar, 'Import from', 80) + const action = bar.getByTestId('command-result').filter({ hasText: 'Import from Another Browser' }).first() + await waitFor(t, () => action.isVisible()) + await t.sleep(450) + await action.click() + let { page, offset } = await importWindow(t) + await waitFor(t, () => page.getByTestId('import-source-chrome').isVisible()) + await t.sleep(1000) + + // Chrome, then only its Work profile. + mark('chrome') + await click(page, offset, 'import-source-chrome', 18) + await t.sleep(450) + await click(page, offset, 'import-next') + await page.getByTestId('import-profiles').waitFor({ timeout: 8000 }) + await t.sleep(800) + for (const row of await page.getByTestId('import-profiles').locator('label').all()) { + const name = await row.locator('span').last().innerText() + const box = row.getByRole('checkbox') + if ((name === 'Work') !== (await box.isChecked())) { + await pointAt(box, offset, 12) + await box.click() + await t.sleep(400) + } + } + await click(page, offset, 'import-next') + await page.getByTestId('import-step-types').waitFor({ timeout: 8000 }) + await waitFor(t, async () => (await page.getByTestId('import-inspecting').count()) === 0) + await t.sleep(1500) + await click(page, offset, 'import-next') + + // IM-04: the profile's bookmark tree, all chosen; the count follows each change; Select none, then all. + mark('bookmarks') + await page.getByTestId('import-count').waitFor({ timeout: 8000 }) + await t.sleep(1000) + const news = page.getByRole('checkbox', { name: 'Import News' }) + await pointAt(news, offset, 16) + await news.click() + await t.sleep(1000) + await click(page, offset, 'import-select-none') + await t.sleep(900) + await click(page, offset, 'import-select-all', 8) + await t.sleep(1000) + await click(page, offset, 'import-next') + + // IM-06: where they go: a new Space, named here. + mark('target') + const space = page.getByTestId('import-target-space') + await space.waitFor({ timeout: 8000 }) + await t.sleep(900) + await pointAt(space, offset, 14) + await space.selectOption('new') + const named = page.getByTestId('import-new-space') + await named.waitFor({ timeout: 8000 }) + await pointAt(named, offset, 10) + await named.click() + await page.keyboard.press(`${t.mod}+A`) + await t.type(page, 'From Chrome', 90) + await t.sleep(600) + mark('import') + await finish(t, page, offset) + await t.sleep(1800) + + // IM-07: the history came over too: a familiar site completes in the command bar at once. + mark('history') + await t.key('Mod+T') + await waitFor(t, () => bar.getByTestId('command-input').isVisible()) + await t.sleep(300) + await t.type(bar, 'news', 160) + await t.sleep(1700) + await bar.keyboard.press('Escape') + await t.sleep(800) + + // IM-12: a pasted list of links becomes one pinned folder, named by the person, in the order pasted. + mark('paste') + await t.run('import.open', {}) + ;({ page, offset } = await importWindow(t)) + await t.sleep(900) + await click(page, offset, 'import-paste', 18) + const text = page.getByTestId('import-links-text') + await text.waitFor({ timeout: 8000 }) + await t.app.evaluate(({ clipboard }, s) => clipboard.writeText(s), links) + await pointAt(text, offset, 10) + await text.click() + await t.sleep(400) + await page.keyboard.press(`${t.mod}+V`) + await waitFor(t, async () => (await page.getByTestId('import-links-count').innerText()) === '5 links') + await t.sleep(1000) + const folder = page.getByTestId('import-folder-name') + await pointAt(folder, offset, 10) + await folder.click() + await page.keyboard.press(`${t.mod}+A`) + await t.type(page, 'Weekend reading', 90) + await t.sleep(600) + await finish(t, page, offset) + await t.sleep(1500) + + // IM-12, IM-15: the same list pasted on the sidebar opens the import at it; it makes a second folder. + mark('paste-sidebar') + await shell.getByTestId('sidebar-scroll').evaluate((el) => el.focus()) + await t.key('Mod+V') + ;({ page, offset } = await importWindow(t)) + await page.getByTestId('import-step-links').waitFor({ timeout: 8000 }) + await t.sleep(1000) + const again = page.getByTestId('import-folder-name') + await pointAt(again, offset, 12) + await again.click() + await page.keyboard.press(`${t.mod}+A`) + await t.type(page, 'Weekend reading', 70) + await t.sleep(500) + await finish(t, page, offset) + await t.sleep(1500) + + // IM-13: a bookmarks file, chosen with From a file…. + mark('file') + await t.app.evaluate( + ({ dialog }, picked) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [picked] }) + }, + resolve(FIXTURES, 'files', 'bookmarks.htm'), + ) + await t.run('import.open', {}) + ;({ page, offset } = await importWindow(t)) + await t.sleep(900) + await click(page, offset, 'import-from-file', 18) + await page.getByTestId('import-count').waitFor({ timeout: 8000 }) + await t.sleep(1400) + await finish(t, page, offset) + await t.sleep(1800) + mark('end') +} diff --git a/scripts/capture/scenarios/nv.mjs b/scripts/capture/scenarios/nv.mjs new file mode 100644 index 00000000..f00ed543 --- /dev/null +++ b/scripts/capture/scenarios/nv.mjs @@ -0,0 +1,246 @@ +/** + * Navigation (NV-01, NV-03, NV-04, NV-05, NV-06, NV-11, NV-15, NV-17, NV-19), about 40 s, for the "feels + * right" sign-off (D48). Back and Forward from their buttons; ⌘R; a slow address typed and stopped with + * Esc; find in page; zoom with its indicator; Copy URL's toast; a site asking for the location; a mailto: + * link that asks first; and a private window. + * + * Each step prints a `[mark] ` line on stderr: its time since the scenario began. + * record.mjs begins it 0.7 s after the recording starts, so a step shows about 0.7 s later in the clip. + */ +import { execFile } from 'node:child_process' + +let t0 = 0 +const mark = (name) => console.error(`[mark] ${name} ${Math.round(performance.now() - t0)}`) + +/** A Mail app for mailto: links, as the e2e tests give one (ORBIT_E2E_APPS): asked about, never started. */ +const MAIL_ICON = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%232a7de1'/%3E%3Cpath d='M7 11h18v11H7z' fill='%23fff'/%3E%3Cpath d='M7 11l9 7 9-7' fill='none' stroke='%232a7de1' stroke-width='2'/%3E%3C/svg%3E" +export const env = { ORBIT_E2E_APPS: JSON.stringify({ mailto: { name: 'Mail', icon: MAIL_ICON } }) } + +/** Moves the on-screen pointer (display coordinates) in eased steps. */ +let at = null +async function pointerTo(x, y, steps = 16) { + const from = at ?? { x, y } + for (let i = 1; i <= steps; i++) { + const k = 0.5 - 0.5 * Math.cos((Math.PI * i) / steps) + await new Promise((r) => + execFile( + 'xdotool', + ['mousemove', String(Math.round(from.x + (x - from.x) * k)), String(Math.round(from.y + (y - from.y) * k))], + () => r(), + ), + ) + } + at = { x, y } +} + +/** The pointer to the middle of a locator, in a view placed at `offset` in the window (the window is at 0, 0). */ +async function pointAt(locator, offset = { x: 0, y: 0 }, steps = 16) { + const box = await locator.boundingBox() + if (box) await pointerTo(offset.x + box.x + box.width / 2, offset.y + box.y + box.height / 2, steps) +} + +async function waitFor(t, test, ms = 8000) { + const end = Date.now() + ms + while (Date.now() < end) { + const v = await test() + if (v) return v + await t.sleep(40) + } + throw new Error('timed out') +} + +let tab = '' +const MOD = process.platform === 'darwin' ? 'meta' : 'control' + +/** A key pressed in the tab's page, as with the page focused. */ +const press = (t, key, mods = []) => + t.app.evaluate( + (_e, [id, keyCode, modifiers]) => { + const page = globalThis.__orbit.ctx.tabs.get(id).webContents + page.sendInputEvent({ type: 'keyDown', keyCode, modifiers }) + page.sendInputEvent({ type: 'keyUp', keyCode, modifiers }) + }, + [tab, key, mods], + ) + +const tabNow = async (t) => (await t.state()).tabs[tab] +/** Where the tab's page sits in the window. */ +const tabBounds = (t) => t.app.evaluate((_e, id) => globalThis.__orbit.ctx.tabs.get(id).getBounds(), tab) +/** The tab's page in Playwright, once it shows an address that starts with `url`. */ +const pageAt = (t, url) => waitFor(t, () => t.app.windows().find((p) => p.url().startsWith(url))) + +/** Unrecorded: a tab that has been to two pages, so Back and Forward have somewhere to go. */ +export async function setup(t) { + const s = await t.state() + const win = Object.values(s.windows).find((w) => w.kind === 'main') + tab = await t.run('tabs.create', { url: t.fx.url('Field notes'), spaceId: win.spaceId, activate: false }) + await t.run('tabs.activate', { tabId: tab }) + await waitFor(t, async () => (await tabNow(t))?.loading === false) + await t.sleep(500) + await t.run('tabs.navigate', { tabId: tab, url: t.fx.url('Trail map') }) + await waitFor(t, async () => { + const now = await tabNow(t) + return now?.url === t.fx.url('Trail map') && now.loading === false + }) + await t.sleep(600) +} + +export default async function (t) { + t0 = performance.now() + const { shell } = t + await pointerTo(860, 520, 1) + await t.sleep(500) + + // NV-01: Back and Forward, from the buttons by the address. + mark('back') + const back = shell.getByTestId('nav-back') + await pointAt(back, undefined, 20) + await back.click() + await waitFor(t, async () => (await tabNow(t))?.url === t.fx.url('Field notes')) + await t.sleep(900) + mark('forward') + const forward = shell.getByTestId('nav-forward') + await pointAt(forward, undefined, 8) + await forward.click() + await waitFor(t, async () => (await tabNow(t))?.url === t.fx.url('Trail map')) + await t.sleep(700) + await pointerTo(900, 600, 16) + + // NV-03: ⌘R reloads the page. + mark('reload') + await press(t, 'R', [MOD]) + await t.sleep(1200) + + // NV-04: a slow address, typed: the address shows where it is going at once; Esc stops the load, and + // the address stays at the pending page. + mark('type-slow') + await press(t, 'L', [MOD]) + const bar = await t.overlay() + await t.sleep(350) + await t.type(bar, `${t.fx.url('Slow page')}?wait=15000`, 22) + await t.sleep(250) + await bar.keyboard.press('Enter') + await t.sleep(1800) + mark('stop') + await press(t, 'Escape') + await waitFor(t, async () => (await tabNow(t))?.loading === false) + await t.sleep(1500) + + // NV-05: ⌘F finds as you type, with the count; Enter goes on; Esc closes it. + mark('find') + await press(t, 'F', [MOD]) + const tools = await waitFor(t, () => t.app.windows().find((p) => p.url().includes('view=tools'))) + await waitFor(t, () => tools.getByTestId('find-input').isVisible()) + await t.sleep(300) + await t.type(tools, 'demo', 120) + await t.sleep(800) + await tools.keyboard.press('Enter') + await t.sleep(550) + await tools.keyboard.press('Enter') + await t.sleep(800) + await tools.keyboard.press('Escape') + await t.sleep(900) + + // NV-06: ⌘+ twice zooms in by steps, with the indicator; ⌘0 goes back to 100%. + mark('zoom') + await press(t, '=', [MOD]) + await t.sleep(450) + await press(t, '=', [MOD]) + await t.sleep(1700) + mark('zoom-reset') + await press(t, '0', [MOD]) + await t.sleep(2600) + + // NV-05 again, once the zoom indicator has gone: the find bar comes in the same corner. + mark('find-again') + await press(t, 'F', [MOD]) + await t.sleep(1300) + await tools.keyboard.press('Escape') + await t.sleep(900) + + // NV-17: ⌘⇧C copies the address without its tracking parameters, and a toast says so. + const reading = t.fx.url('Reading list') + await t.run('tabs.navigate', { tabId: tab, url: `${reading}?utm_source=newsletter&id=7` }) + await waitFor(t, async () => { + const now = await tabNow(t) + return now?.url.startsWith(reading) && now.loading === false + }) + await t.sleep(900) + mark('copy') + await press(t, 'C', [MOD, 'shift']) + const copied = await waitFor(t, async () => { + const text = await t.app.evaluate(({ clipboard }) => clipboard.readText()) + return text.startsWith(reading) ? text : null + }) + console.error(`[copied] ${copied}`) + await t.sleep(1800) + + // NV-15: the site asks for the location; the prompt hangs under the address; Never allow. + const page = await pageAt(t, reading) + await page.evaluate(() => { + const b = document.createElement('button') + b.id = 'where' + b.textContent = 'Find a shop near me' + b.style.cssText = + 'font:600 15px system-ui;padding:10px 16px;border-radius:10px;border:0;background:#1d1d1f;color:#fff;margin:0 16px 24px 0' + b.onclick = () => + navigator.geolocation.getCurrentPosition( + () => {}, + () => {}, + ) + const a = document.createElement('a') + a.id = 'mail' + a.href = 'mailto:hello@example.com?subject=Hello' + a.textContent = 'Email the team' + a.style.cssText = 'font:600 15px system-ui;color:#3e63dd' + const row = document.createElement('div') + row.append(b, a) + document.querySelector('.lead').after(row) + }) + await t.sleep(700) + const view = await tabBounds(t) + mark('ask-location') + const where = page.locator('#where') + await pointAt(where, view, 18) + await where.click() + const prompt = shell.getByTestId('permission-prompt') + await prompt.waitFor({ timeout: 8000 }) + await t.sleep(1300) + const never = prompt.getByTestId('permission-block') + await pointAt(never, undefined, 16) + await t.sleep(250) + mark('never-allow') + await never.click() + await t.sleep(1200) + + // NV-19: a mailto: link asks first, in a dialog over the page; Esc (Cancel) leaves the page as it was. + mark('mailto') + const mail = page.locator('#mail') + await pointAt(mail, view, 18) + await mail.click() + const dialog = await waitFor(t, async () => { + for (const p of t.app.windows()) { + if (!p.url().includes('view=dialog')) continue + if (await p.getByTestId('page-dialog-cancel').isVisible()) return p + } + return null + }) + await t.sleep(1800) + mark('mailto-cancel') + await dialog.keyboard.press('Escape') + await t.sleep(1200) + + // NV-11: ⌘⇧N opens a private window: dark, labelled Private. + mark('private') + await press(t, 'N', [MOD, 'shift']) + const priv = await waitFor(t, async () => Object.values((await t.state()).windows).find((w) => w.kind === 'private')) + await t.sleep(2600) + mark('private-close') + await t.app.evaluate(async (_e, id) => { + await new Promise((r) => setImmediate(r)) + globalThis.__orbit.ctx.windows.get(id)?.close() + }, priv.id) + await t.sleep(1200) + mark('end') +} From 027c7fdfc1edd61ae5a99f982a62fb9b6487b5d0 Mon Sep 17 00:00:00 2001 From: Siya Date: Sun, 11 Oct 2026 16:39:08 +0000 Subject: [PATCH 2/3] feel: sign off 7 rows of Settings and private windows (D48, D79, D83) Sign-off 3 checked the 10 built rows of Settings, updates and appearance that no lane's brief still changes, from a new 60 fps capture scenario. The record is reviews/feel-signoff-3.md in the lead's context. - Mark ST-01, ST-02, ST-04, ST-05, ST-06, ST-09 and AP-05 as feeling right. FEATURES.md now counts 104. - Hold ST-17, UP-10 and UP-12 for the finish-settings lane (D83). - record.mjs: the fixture server answers /feed.json, an update feed that a scenario sets with fx.offer(). Until it offers a version, the release is 0.0.0, older than any build, so every check stays on this computer. - st.mjs: the new scenario, about 60 s. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 16 +- docs/feature-status.json | 14 +- scripts/capture/record.mjs | 25 ++- scripts/capture/scenarios/st.mjs | 319 +++++++++++++++++++++++++++++++ 4 files changed, 358 insertions(+), 16 deletions(-) create mode 100644 scripts/capture/scenarios/st.mjs diff --git a/FEATURES.md b/FEATURES.md index 2b42c98f..ccf500b3 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,7 +4,7 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 472 spec’d · 276 built (77 partial) · 356 tested · 97 feel right. 13 more are out of scope and not counted. +**Totals:** 472 spec’d · 276 built (77 partial) · 356 tested · 104 feel right. 13 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| @@ -416,7 +416,7 @@ Spec: `spec/appearance.md` | AP-02 | Theme colours carry through the whole interface | P1 | – | ✅ e2e | – | | | AP-03 | Restore the default (neutral) theme | P1 | – | ✅ unit | – | | | AP-04 | Dark mode quality | P1 | – | ✅ unit | – | | -| AP-05 | Private-window appearance | P1 | ✅ | ✅ e2e | – | 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 | +| AP-05 | Private-window appearance | P1 | ✅ | ✅ e2e | ✅ | 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 | | AP-06 | Inactive-window dimming | P1 | – | – | – | | | AP-07 | Optional top toolbar (look and placement) | P1 | – | – | – | | | AP-08 | Page-colour-aware chrome | P2 | – | – | – | | @@ -535,15 +535,15 @@ Spec: `spec/settings.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| ST-01 | Settings window | P0 | ✅ | ✅ e2e+unit | – | own window (⌘, / Ctrl+,): opens or focuses one; remembers section and size; takes the opener Space's colours; sections come from a registry (src/renderer/src/settings/registry.ts); search filters rows | -| ST-02 | Settings reachable and searchable from the command bar | P1 | ✅ | ✅ e2e | – | sections and common settings are command-bar actions (settings.*) that open Settings at the row; the keys lane adds Shortcuts | +| ST-01 | Settings window | P0 | ✅ | ✅ e2e+unit | ✅ | own window (⌘, / Ctrl+,): opens or focuses one; remembers section and size; takes the opener Space's colours; sections come from a registry (src/renderer/src/settings/registry.ts); search filters rows | +| ST-02 | Settings reachable and searchable from the command bar | P1 | ✅ | ✅ e2e | ✅ | sections and common settings are command-bar actions (settings.*) that open Settings at the row; the keys lane adds Shortcuts | | ST-03 | General section | P1 | 🟡 partial | ✅ e2e | – | default browser row, app-menu item and command-bar action (only an installed build can register; e2e uses a stub); Mini Window archive timing; Import… opens the import flow (IM-01); Updates group: automatic checks switch, Check Now and the update feed address. Not built: UP-04's install-while-idle, hover-preview toggles (OT-26, P2), Open PDFs (P2) | -| ST-04 | Profiles section (list, rename, delete) | P1 | ✅ | ✅ e2e | – | list with Default first, rename any profile, New Profile…, delete with confirmation (off while a Space uses it, and says why) | -| ST-05 | Per-profile: auto-archive timing | P1 | ✅ | ✅ e2e+unit | – | 12 hours (default), 24 hours, 7 days, 30 days per profile; no Never; other profiles follow Default until set | -| ST-06 | Per-profile: default search engine and custom engines | P0 | ✅ | ✅ e2e+unit | – | per-profile default engine (8 built-ins), custom engines with keyword and %s URL, Make default from the row menu; no extension override in Electron, so that row does not exist | +| ST-04 | Profiles section (list, rename, delete) | P1 | ✅ | ✅ e2e | ✅ | list with Default first, rename any profile, New Profile…, delete with confirmation (off while a Space uses it, and says why) | +| ST-05 | Per-profile: auto-archive timing | P1 | ✅ | ✅ e2e+unit | ✅ | 12 hours (default), 24 hours, 7 days, 30 days per profile; no Never; other profiles follow Default until set | +| ST-06 | Per-profile: default search engine and custom engines | P0 | ✅ | ✅ e2e+unit | ✅ | per-profile default engine (8 built-ins), custom engines with keyword and %s URL, Make default from the row menu; no extension override in Electron, so that row does not exist | | ST-07 | Per-profile: download location, new-document app, data shortcuts | P1 | – | – | – | | | ST-08 | Shortcuts section (remapping) | P1 | 🟡 partial | ✅ e2e+unit | – | Settings → Shortcuts: every action Orbit has (the action table, as main registers it) in groups with a search by name, search words or keys; click a key, press the new one (a modifier first except F1–F12; system and reserved keys refused, with the reason, and on macOS ⌃ with a lone letter, which moves the caret in text fields, D60); + adds another (up to 5), ⌫ while listening removes one, Esc cancels; a key another action has asks: Swap (when replacing) or Remove from the other; right-click: Reset to Default, Remove Shortcut, Prefer Websites / Prefer Orbit (KS-02); Reset All asks first; Go to Tab: Include Favorites in ordering and ⌘9 last or ninth item; actions with no keys (Duplicate Tab…) show Record Shortcut; keys the system keeps are marked (KS-03); Settings search finds rows; stored per device; Exit Full Screen (Esc in full screen, NV-20) has its row with no keys of its own, preferring websites by default, and its priority sets Esc's | -| ST-09 | Links section | P1 | ✅ | ✅ e2e+unit | – | links from other apps open in a Mini Window (default), the most recent Space or one Space; routing rules (address contains / is exactly → a Space; first match wins; add, edit, reorder, remove); Peek options | +| ST-09 | Links section | P1 | ✅ | ✅ e2e+unit | ✅ | links from other apps open in a Mini Window (default), the most recent Space or one Space; routing rules (address contains / is exactly → a Space; first match wins; add, edit, reorder, remove); Peek options | | ST-10 | Advanced section | P1 | 🟡 partial | ✅ e2e+unit | – | tab sleep timer (TB-16), picture in picture, graphics acceleration; no site customizations, full-URL toggle, sounds or haptics, or edge-drag settings because those features don't exist yet | | ST-11 | Appearance section | P1 | 🟡 partial | ✅ e2e | – | Theme mode: Light / Dark / Match system (AP-01). Not built: window material (AP-10, Windows), app icon (AP-09), page defaults (P2) | | ST-12 | Privacy & security, and clearing browsing data | P1 | 🟡 partial | ✅ e2e+unit | – | privacy promise (Orbit sends nothing); Clear browsing data per profile (⌘⇧⌫ / Ctrl+Shift+Delete): history by time range; cookies and site data, and the cache, always from all time (Electron has no range), so they start ticked only for All time; Secure DNS (Automatic, Off, Cloudflare, Google, Quad9); graphics acceleration lives in Advanced. No HTTPS-first or Safe Browsing (not in Electron), no per-site clearing here | diff --git a/docs/feature-status.json b/docs/feature-status.json index de507924..42aee48a 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -215,7 +215,7 @@ "FL-23": {"built": "yes", "note": "after 150 ms, a 48x4 theme-accent pill sweeping on a 1.2 s cycle (a pulse with reduced motion); on load end it fills (80 ms) and fades (150 ms), gone 180 ms after; never moves page content"}, "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", "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"}, + "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-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"}, @@ -295,17 +295,17 @@ "DL-12": {"built": "yes", "note": "finished files carry the OS mark of a file from the internet (the macOS quarantine attribute; on Windows a Zone.Identifier with the page and file address, no passwords, and only the zone for a private window's download; Linux has none). Programs, installers, scripts and disk images wait under their partial name with Keep / Discard in the sidebar row, the Library and the flyout, through a restart too: everywhere exe, msi, dmg, pkg, deb, rpm, AppImage, .desktop launchers, sh/bash/zsh/py/pl/rb scripts, iso/img/vhd/vhdx, jar, jnlp, apk…; on Windows also its own script, shortcut and system types (js, vbs, wsf, hta, lnk, url, scf, settingcontent-ms, chm, reg, inf, msc, dll…). Keep puts the file in place and marks it, Discard deletes it. Every such file is held, whatever the site, as there is no reputation service. Gap, by the spec's own rule (D54): Safe Browsing, which needs a reputation service; none is free to use (Google's is non-commercial, Web Risk is paid)"}, "DL-13": {"built": "partial", "note": "PDFs (the engine's viewer), raster pictures, plain text, audio and video open in a tab (only types that run no script there, D53: a downloaded SVG opens in its app); a site that sends the file as an attachment still downloads it (DL-01); the PDF viewer's download button saves through the usual download flow (row, Library, DL-02 flight). Missing: offering Orbit as Windows' app for local .pdf files (Settings → General, P2), which finish-settings builds (D54): the installer's file association and opening files passed on the command line"}, "DL-14": {"built": "yes", "note": "Windows/Linux: Ctrl+J opens the downloads flyout at the top left, beside the sidebar (Orbit has no top toolbar): the 12 newest with live progress, Keep/Discard, Resume/Retry/Cancel and Show in Folder; a click opens a file, rows drag out (DL-06), Show All opens the Library's Downloads section; Esc, Ctrl+J again or a click outside closes it. macOS: ⌘⇧J and ⌥⌘L open the Library's Downloads section (KS-12). The command bar has a \"Downloads\" action"}, - "ST-01": {"built": "yes", "note": "own window (⌘, / Ctrl+,): opens or focuses one; remembers section and size; takes the opener Space's colours; sections come from a registry (src/renderer/src/settings/registry.ts); search filters rows"}, - "ST-02": {"built": "yes", "note": "sections and common settings are command-bar actions (settings.*) that open Settings at the row; the keys lane adds Shortcuts"}, + "ST-01": {"built": "yes", "feel": true, "note": "own window (⌘, / Ctrl+,): opens or focuses one; remembers section and size; takes the opener Space's colours; sections come from a registry (src/renderer/src/settings/registry.ts); search filters rows"}, + "ST-02": {"built": "yes", "feel": true, "note": "sections and common settings are command-bar actions (settings.*) that open Settings at the row; the keys lane adds Shortcuts"}, "ST-03": {"built": "partial", "note": "default browser row, app-menu item and command-bar action (only an installed build can register; e2e uses a stub); Mini Window archive timing; Import… opens the import flow (IM-01); Updates group: automatic checks switch, Check Now and the update feed address. Not built: UP-04's install-while-idle, hover-preview toggles (OT-26, P2), Open PDFs (P2)"}, - "ST-04": {"built": "yes", "note": "list with Default first, rename any profile, New Profile…, delete with confirmation (off while a Space uses it, and says why)"}, - "ST-05": {"built": "yes", "note": "12 hours (default), 24 hours, 7 days, 30 days per profile; no Never; other profiles follow Default until set"}, - "ST-06": {"built": "yes", "note": "per-profile default engine (8 built-ins), custom engines with keyword and %s URL, Make default from the row menu; no extension override in Electron, so that row does not exist"}, + "ST-04": {"built": "yes", "feel": true, "note": "list with Default first, rename any profile, New Profile…, delete with confirmation (off while a Space uses it, and says why)"}, + "ST-05": {"built": "yes", "feel": true, "note": "12 hours (default), 24 hours, 7 days, 30 days per profile; no Never; other profiles follow Default until set"}, + "ST-06": {"built": "yes", "feel": true, "note": "per-profile default engine (8 built-ins), custom engines with keyword and %s URL, Make default from the row menu; no extension override in Electron, so that row does not exist"}, "ST-20": {"built": "partial", "note": "per-profile rows carry the profile's name, the download location (DL-07) too; passwords and site permissions don't exist yet"}, "AP-01": {"built": "yes", "note": "Light / Dark / Match system in Settings and the Space theme picker; about 200 ms cross-fade, no flash"}, "OB-02": {"built": "yes", "note": "audit and e2e: no account UI anywhere, onboarding included; a first run sends nothing off the computer, with or without onboarding, except Chromium's spell-check dictionary download (see tests)"}, "ST-08": {"built": "partial", "note": "Settings → Shortcuts: every action Orbit has (the action table, as main registers it) in groups with a search by name, search words or keys; click a key, press the new one (a modifier first except F1–F12; system and reserved keys refused, with the reason, and on macOS ⌃ with a lone letter, which moves the caret in text fields, D60); + adds another (up to 5), ⌫ while listening removes one, Esc cancels; a key another action has asks: Swap (when replacing) or Remove from the other; right-click: Reset to Default, Remove Shortcut, Prefer Websites / Prefer Orbit (KS-02); Reset All asks first; Go to Tab: Include Favorites in ordering and ⌘9 last or ninth item; actions with no keys (Duplicate Tab…) show Record Shortcut; keys the system keeps are marked (KS-03); Settings search finds rows; stored per device; Exit Full Screen (Esc in full screen, NV-20) has its row with no keys of its own, preferring websites by default, and its priority sets Esc's"}, - "ST-09": {"built": "yes", "note": "links from other apps open in a Mini Window (default), the most recent Space or one Space; routing rules (address contains / is exactly → a Space; first match wins; add, edit, reorder, remove); Peek options"}, + "ST-09": {"built": "yes", "feel": true, "note": "links from other apps open in a Mini Window (default), the most recent Space or one Space; routing rules (address contains / is exactly → a Space; first match wins; add, edit, reorder, remove); Peek options"}, "ST-10": {"built": "partial", "note": "tab sleep timer (TB-16), picture in picture, graphics acceleration; no site customizations, full-URL toggle, sounds or haptics, or edge-drag settings because those features don't exist yet"}, "ST-12": {"built": "partial", "note": "privacy promise (Orbit sends nothing); Clear browsing data per profile (⌘⇧⌫ / Ctrl+Shift+Delete): history by time range; cookies and site data, and the cache, always from all time (Electron has no range), so they start ticked only for All time; Secure DNS (Automatic, Off, Cloudflare, Google, Quad9); graphics acceleration lives in Advanced. No HTTPS-first or Safe Browsing (not in Electron), no per-site clearing here"}, "OB-01": {"built": "yes", "note": "a window of its own before any browser window on a first run (gated by settings.onboarded; e2e runs skip it unless ORBIT_ONBOARDING=1): Orbit's logo with the moon in motion (still with reduced motion), a welcome line, Get started; steps slide in (about 400 ms, none with reduced motion); Enter continues, Esc goes back; each step's title takes focus; progress dots are labelled. No sounds or music (optional), no haptics (Electron has no API), no first-window shimmer"}, diff --git a/scripts/capture/record.mjs b/scripts/capture/record.mjs index 94fe3e35..d71d8561 100644 --- a/scripts/capture/record.mjs +++ b/scripts/capture/record.mjs @@ -63,8 +63,25 @@ const log = (...m) => console.error(`[capture ${new Date().toISOString().slice(1 async function startFixtures() { const accents = ['#e5484d', '#3e63dd', '#30a46c', '#f76b15', '#8e4ec6', '#12a594', '#d6409f', '#ffb224'] const esc = (t) => t.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]) + // What /feed.json offers: set by a scenario with fx.offer({ version, summary, security }). + let offer = null const server = createServer((req, res) => { const at = new URL(req.url ?? '/', 'http://127.0.0.1') + // /feed.json: an update feed (UP-01). Before a scenario offers a version, its release is 0.0.0, older + // than any build. The release's page is one of these pages. + if (at.pathname === '/feed.json') { + const { version = '0.0.0', summary, security = false } = offer ?? {} + res.writeHead(200, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + version, + url: `http://${req.headers.host}/${encodeURIComponent(`Orbit ${version}`)}`, + summary, + security, + }), + ) + return + } const raw = at.pathname.split('/').pop() || 'Home' let name = raw try { @@ -107,7 +124,13 @@ main{padding:40px;max-width:1100px}h1{font-size:40px;margin:8px 0 6px;letter-spa ) server.listen(port, '127.0.0.1', ok) }) - return { url: (name) => `http://127.0.0.1:${port}/${encodeURIComponent(name)}`, close: () => server.close() } + return { + url: (name) => `http://127.0.0.1:${port}/${encodeURIComponent(name)}`, + offer: (release) => { + offer = release + }, + close: () => server.close(), + } } function startRecorder(file) { diff --git a/scripts/capture/scenarios/st.mjs b/scripts/capture/scenarios/st.mjs new file mode 100644 index 00000000..0a1b90bd --- /dev/null +++ b/scripts/capture/scenarios/st.mjs @@ -0,0 +1,319 @@ +/** + * Settings and updates (ST-01, ST-02, ST-04, ST-05, ST-06, ST-09, ST-17, UP-10, UP-12, AP-05), about 60 s, + * for the "feels right" sign-off (D48). ⌘, opens the one Settings window. Profiles: Default renamed in + * place, and Work, whose Delete is off while a Space uses it, and says why. Archive timing per profile: + * Work follows Default until set. The default search engine, which the command bar then names. The + * command bar's Link Settings brings the same Settings window back, at Links: where links from other apps + * open, and Peek's options. About: the version and engine, and Check Now finding a newer version. ⌘W + * closes Settings only. The sidebar's update pill opens on hover, and opens the release's page in a tab; + * a security release says so. A private window: near-black, with hover readable on it. Last, ⌘, opens + * Settings at the section it showed last. + * + * The update feed is the fixture server's /feed.json, set by command before the recording (it stands in + * for typing its address into General → Update feed), so every check stays on this computer. Until the + * scenario offers a version there, Orbit is up to date. + * + * Each step prints a `[mark] ` line on stderr: its time since the scenario began. + * record.mjs begins it 0.7 s after the recording starts, so a step shows about 0.7 s later in the clip. + */ +import { execFile } from 'node:child_process' + +let t0 = 0 +const mark = (name) => console.error(`[mark] ${name} ${Math.round(performance.now() - t0)}`) + +/** Moves the on-screen pointer (display coordinates) in eased steps. */ +let at = null +async function pointerTo(x, y, steps = 16) { + const from = at ?? { x, y } + for (let i = 1; i <= steps; i++) { + const k = 0.5 - 0.5 * Math.cos((Math.PI * i) / steps) + await new Promise((r) => + execFile( + 'xdotool', + ['mousemove', String(Math.round(from.x + (x - from.x) * k)), String(Math.round(from.y + (y - from.y) * k))], + () => r(), + ), + ) + } + at = { x, y } +} + +/** The pointer to the middle of a locator, in a page whose content starts at `offset` on the display. */ +async function pointAt(locator, offset = { x: 0, y: 0 }, steps = 16) { + const box = await locator.boundingBox() + if (box) await pointerTo(offset.x + box.x + box.width / 2, offset.y + box.y + box.height / 2, steps) +} + +async function waitFor(t, test, ms = 8000) { + const end = Date.now() + ms + while (Date.now() < end) { + const v = await test() + if (v) return v + await t.sleep(40) + } + throw new Error('timed out') +} + +/** The Settings window's page, drawn, and where its content is on the display. */ +async function settingsWindow(t) { + const page = await waitFor(t, () => t.app.windows().find((p) => p.url().includes('view=settings') && !p.isClosed())) + await page.getByTestId('settings').waitFor({ timeout: 8000 }) + const offset = await t.app.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows().find( + (w) => !w.isDestroyed() && w.webContents.getURL().includes('view=settings'), + ) + return win ? win.getContentBounds() : { x: 0, y: 0 } + }) + return { page, offset } +} + +/** How many Settings windows exist (ST-01: one at most). */ +const settingsCount = (t) => + t.app.evaluate( + ({ BrowserWindow }) => + BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed() && w.webContents.getURL().includes('view=settings')) + .length, + ) + +/** Points at a control of a window's page, brought into its view first, and clicks it. */ +async function click(control, offset, steps = 12) { + await control.scrollIntoViewIfNeeded() + await pointAt(control, offset, steps) + await control.click() +} + +/** Points at a select and picks an option by its label. */ +async function choose(select, offset, label, steps = 12) { + await select.scrollIntoViewIfNeeded() + await pointAt(select, offset, steps) + await select.selectOption({ label }) +} + +/** Brings the browser window to the front, as a click on it would with a window manager. */ +const focusBrowser = (t, id) => + t.app.evaluate((_e, w) => { + globalThis.__orbit.ctx.windows.get(w)?.focus() + }, id) + +/** A click where the on-screen pointer is, as a person's: it moves the keyboard to what it lands on. */ +const xClick = () => new Promise((r) => execFile('xdotool', ['click', '1'], () => r())) + +/** The page of one window's view (`shell`, `overlay`…). */ +const viewOf = (t, view, windowId) => + waitFor(t, async () => { + for (const p of t.app.windows()) { + if (p.isClosed() || !p.url().includes(`view=${view}`)) continue + if ((await p.evaluate(() => globalThis.orbit?.windowId).catch(() => '')) === windowId) return p + } + }) + +let mainId = '' +let work = '' +let next = '' +let security = '' + +/** + * Unrecorded: a tab, so the page area isn't empty; a Client Space that uses a Work profile; the update + * feed, checked once: up to date. + */ +export async function setup(t) { + const s = await t.state() + const main = Object.values(s.windows).find((w) => w.kind === 'main') + mainId = main.id + const tab = await t.run('tabs.create', { url: t.fx.url('Field notes'), spaceId: main.spaceId, activate: false }) + await t.run('tabs.activate', { tabId: tab }) + await waitFor(t, async () => (await t.state()).tabs[tab]?.title === 'Field notes') + const client = await t.run('spaces.create', { name: 'Client', activate: false }) + work = await t.run('profiles.create', { name: 'Work', assignSpaceId: client }) + // Two releases newer than this build: a plain one, then a security one. + const { version } = await t.run('settings.buildInfo') + const [major, minor] = version.split(/[.-]/).map(Number) + next = `${major}.${minor + 1}.0` + security = `${major}.${minor + 1}.1` + await t.run('updates.setFeed', { url: t.fx.url('feed.json') }) + await waitFor(t, async () => (await t.state()).updates?.state === 'current') + await t.sleep(600) +} + +export default async function (t) { + t0 = performance.now() + const { shell } = t + await pointerTo(760, 470, 1) + await t.sleep(400) + + // ST-01: ⌘, opens Settings, a window of its own. + mark('open') + await t.key('Mod+Comma') + let { page, offset } = await settingsWindow(t) + await t.sleep(1400) + + // ST-04: Profiles, Default first. Default is renamed in place, with no Save button. + mark('profiles') + await click(page.getByTestId('settings-tab-profiles'), offset, 18) + await page.getByTestId('profile-list').waitFor({ timeout: 8000 }) + await t.sleep(1000) + const name = page.getByTestId('profile-name') + await click(name, offset, 14) + await page.keyboard.press(`${t.mod}+A`) + await t.type(page, 'Personal', 110) + await page.keyboard.press('Enter') + await t.sleep(1000) + // Work is used by the Client Space: its Delete is off, and says why. + mark('work') + await click(page.getByTestId(`profile-${work}`), offset, 14) + await t.sleep(700) + const del = page.getByTestId('profile-delete') + await del.scrollIntoViewIfNeeded() + await t.sleep(300) + await pointAt(del, offset, 14) + await t.sleep(1600) + + // ST-05: archive timing per profile. Default to 7 days; Work follows Default until set. + mark('archive') + await click(page.getByTestId('profile-list').getByRole('option').first(), offset, 16) + await t.sleep(600) + await choose(page.getByTestId('archive-after-p-default'), offset, '7 days', 14) + await t.sleep(1000) + await click(page.getByTestId(`profile-${work}`), offset, 14) + await t.sleep(600) + await pointAt(page.getByTestId(`archive-after-${work}`), offset, 14) + await t.sleep(1500) + + // ST-06: the default search engine, which the command bar then names. + mark('search') + await click(page.getByTestId('settings-tab-search'), offset, 16) + await t.sleep(900) + await choose(page.getByTestId('search-engine-p-default'), offset, 'DuckDuckGo', 14) + await t.sleep(1000) + await pointerTo(150, 420, 18) + await focusBrowser(t, mainId) + await t.sleep(700) + mark('search-bar') + await t.key('Mod+T') + const bar = await t.overlay() + await waitFor(t, () => bar.getByTestId('command-input').isVisible()) + await t.sleep(300) + await t.type(bar, 'tide tables', 110) + await waitFor(t, async () => (await bar.getByTestId('command-result').first().innerText()).includes('DuckDuckGo')) + await t.sleep(1500) + await bar.keyboard.press('Escape') + await t.sleep(800) + + // ST-02, ST-01: the command bar's Link Settings brings the same Settings window back, at Links. + mark('link-settings') + await t.key('Mod+T') + await waitFor(t, () => bar.getByTestId('command-input').isVisible()) + await t.sleep(300) + await t.type(bar, 'Link settings', 90) + const action = bar.getByTestId('command-result').first() + await waitFor(t, async () => (await action.innerText()).includes('Link Settings')) + await t.sleep(700) + await bar.keyboard.press('Enter') + ;({ page, offset } = await settingsWindow(t)) + await waitFor(t, async () => (await page.getByTestId('settings-content').getAttribute('data-section')) === 'links') + console.error(`[check] settings windows: ${await settingsCount(t)}`) + await t.sleep(1300) + + // ST-09: links from other apps open in the most recent Space; Peek's shift-click off. + mark('links') + await choose(page.getByTestId('external-links'), offset, 'The most recent Space', 16) + await t.sleep(1000) + await click(page.getByTestId('peek-shift-click'), offset, 14) + await t.sleep(1300) + + // ST-17: About: the version and engine; Check Now finds a newer version (UP-10). + mark('about') + await click(page.getByTestId('settings-tab-about'), offset, 18) + await page.getByTestId('about-version').waitFor({ timeout: 8000 }) + await t.sleep(1500) + t.fx.offer({ version: next, summary: 'Faster Spaces and a tidier Library.' }) + mark('check') + await click(page.getByTestId('updates-check'), offset, 16) + await waitFor(t, async () => (await page.getByTestId('updates-status').getAttribute('data-state')) === 'available') + await t.sleep(1800) + + // ST-01: ⌘W in Settings closes Settings only. + mark('close') + const tabsBefore = Object.keys((await t.state()).tabs).length + const closed = page.waitForEvent('close') + await page.keyboard.press(`${t.mod}+W`).catch((err) => { + if (!page.isClosed()) throw err + }) + await closed + console.error(`[check] tabs before ⌘W: ${tabsBefore}, after: ${Object.keys((await t.state()).tabs).length}`) + await t.sleep(1200) + + // UP-10: the sidebar's pill opens on hover; a click opens the release's page in a tab. + mark('banner') + const pill = shell.getByTestId('update-banner-open') + await pill.waitFor({ timeout: 8000 }) + await pointAt(pill, undefined, 22) + await t.sleep(1600) + await pill.click() + await waitFor(t, async () => + Object.values((await t.state()).tabs).some((tab) => tab.title === `Orbit ${next}` && tab.loading === false), + ) + await t.sleep(1200) + // A click on the release's page, which takes the keyboard from the pill. + await pointerTo(900, 560, 18) + await xClick() + await t.sleep(1200) + + // UP-12: a security release says so. + mark('security') + t.fx.offer({ version: security, security: true }) + await t.run('updates.check') + await waitFor(t, async () => (await shell.getByTestId('update-banner').getAttribute('data-kind')) === 'security') + await t.sleep(1600) + await pointAt(pill, undefined, 20) + await t.sleep(1800) + await pointerTo(760, 470, 18) + await t.sleep(500) + + // AP-05: a private window: near-black, labelled Private, with hover readable on black. + mark('private') + await t.key('Mod+Shift+N') + const priv = await waitFor(t, async () => Object.values((await t.state()).windows).find((w) => w.kind === 'private')) + const privShell = await viewOf(t, 'shell', priv.id) + const privBar = await viewOf(t, 'overlay', priv.id) + const privAt = await t.app.evaluate((_e, w) => globalThis.__orbit.ctx.windows.get(w)?.getContentBounds(), priv.id) + // Its command bar is open, as in any empty window: two pages, typed there. + for (const [i, name] of ['Recipes', 'Atlas'].entries()) { + if (i > 0) await privShell.keyboard.press(`${t.mod}+T`) + await waitFor(t, () => privBar.getByTestId('command-input').isVisible()) + await t.sleep(700) + await t.type(privBar, t.fx.url(name).replace('http://', ''), 45) + await t.sleep(400) + await privBar.keyboard.press('Enter') + await waitFor(t, async () => + Object.values((await t.state()).tabs).some((tab) => tab.spaceId === priv.spaceId && tab.title === name), + ) + await t.sleep(900) + } + mark('private-hover') + const rows = privShell.getByTestId('tab-row') + await waitFor(t, async () => (await rows.count()) >= 2) + await pointAt(rows.nth(0), privAt, 20) + await t.sleep(900) + await pointAt(rows.nth(1), privAt, 10) + await t.sleep(1100) + mark('private-close') + await t.app.evaluate(async (_e, id) => { + await new Promise((r) => setImmediate(r)) + globalThis.__orbit.ctx.windows.get(id)?.close() + }, priv.id) + await t.sleep(1200) + + // ST-01: Settings opens again at the section it showed last. + mark('reopen') + await t.key('Mod+Comma') + ;({ page } = await settingsWindow(t)) + await t.sleep(1800) + const gone = page.waitForEvent('close') + await page.keyboard.press(`${t.mod}+W`).catch((err) => { + if (!page.isClosed()) throw err + }) + await gone + await t.sleep(900) + mark('end') +} From d3d6d3695cde7d3e72affa9a81886f063a3ca5ed Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:09:19 +0000 Subject: [PATCH 3/3] feel: sign off 11 rows of downloads and DevTools (D48, D79, D84) Sign-off 4: the lead read a 60 fps capture of downloads and DevTools frame by frame. Eleven rows feel right: DL-01, DL-02, DL-05, DL-14, DL-15, DL-16, DL-17 (on its tests), DT-01, DT-02, DT-03 and DT-14. DL-03 and DL-10 are held (reviews/feel-signoff-4.md in the lead's context; D84 routes them). The capture's fixture server now serves download pages (/files/) and files (/dl/): a one-page PDF, a PNG and filler, with a rate, an ETag and ranges. ?cut stops a file at a quarter and keeps failing until the scenario repairs it. A new scenario, dl, records the downloads and DevTools steps. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 24 +- docs/feature-status.json | 22 +- scripts/capture/record.mjs | 168 ++++++++++++- scripts/capture/scenarios/dl.mjs | 394 +++++++++++++++++++++++++++++++ 4 files changed, 578 insertions(+), 30 deletions(-) create mode 100644 scripts/capture/scenarios/dl.mjs diff --git a/FEATURES.md b/FEATURES.md index ccf500b3..96863322 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,7 +4,7 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 472 spec’d · 276 built (77 partial) · 356 tested · 104 feel right. 13 more are out of scope and not counted. +**Totals:** 472 spec’d · 276 built (77 partial) · 356 tested · 115 feel right. 13 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| @@ -489,9 +489,9 @@ Spec: `spec/devtools.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| DT-01 | Open / close DevTools | P0 | ✅ | ✅ e2e+unit | – | ⌥⌘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 | -| DT-02 | Inspect element (context menu and picker) | P0 | ✅ | ✅ e2e+unit | – | right-click → Inspect (last item) opens DevTools with that element selected, or just selects it when they're open. Picker: ⌥⌘C on macOS, no default key on Windows/Linux, Inspect Element in the command bar. ⇧⌘C stays Copy URL; inside DevTools it is DevTools' own picker. Remap: give the devtools.inspect action ⇧⌘C (and take it from tab.copyUrl) in src/shared/actions.ts, or in Settings → Shortcuts once remapping (ST-08) lands | -| DT-03 | DevTools docking, size and focus | P1 | ✅ | ✅ e2e | – | docked right/left/bottom or undocked from DevTools' dock menu; the engine keeps the side per profile across restarts. Each tab (pane, Peek, Mini Window) has its own DevTools, shown and hidden with its page; a page shown again takes the docked size last chosen. Undocked: the open key brings them to the front (checked on Linux; macOS unverified), and closes them when they have the keyboard. No windows added, however many tabs have DevTools. What Orbit puts over a page (find bar, zoom, link pill, loading indicator, permission prompt) goes over the page's own area, never over docked DevTools (e2e for the find bar and link pill, docked right, bottom and left) | +| DT-01 | Open / close DevTools | P0 | ✅ | ✅ e2e+unit | ✅ | ⌥⌘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 | +| DT-02 | Inspect element (context menu and picker) | P0 | ✅ | ✅ e2e+unit | ✅ | right-click → Inspect (last item) opens DevTools with that element selected, or just selects it when they're open. Picker: ⌥⌘C on macOS, no default key on Windows/Linux, Inspect Element in the command bar. ⇧⌘C stays Copy URL; inside DevTools it is DevTools' own picker. Remap: give the devtools.inspect action ⇧⌘C (and take it from tab.copyUrl) in src/shared/actions.ts, or in Settings → Shortcuts once remapping (ST-08) lands | +| DT-03 | DevTools docking, size and focus | P1 | ✅ | ✅ e2e | ✅ | docked right/left/bottom or undocked from DevTools' dock menu; the engine keeps the side per profile across restarts. Each tab (pane, Peek, Mini Window) has its own DevTools, shown and hidden with its page; a page shown again takes the docked size last chosen. Undocked: the open key brings them to the front (checked on Linux; macOS unverified), and closes them when they have the keyboard. No windows added, however many tabs have DevTools. What Orbit puts over a page (find bar, zoom, link pill, loading indicator, permission prompt) goes over the page's own area, never over docked DevTools (e2e for the find bar and link pill, docked right, bottom and left) | | DT-04 | View page source | P1 | ✅ | ✅ e2e | – | ⌥⌘U (Ctrl+U), the command bar's View Source, the page menu's View Page Source and View → Developer → View Source (macOS menu) open the page's own address as view-source: in a new tab, shown at once: the engine's source view, read-only, coloured, with line numbers; a Peek's page opens its own source in a new tab of its window, and a Mini Window's in a main window (one showing its Space if there is one), brought to the front; a source view has no source of its own to open. Only the page's own address goes, so a web page can't make Orbit open a file: URL this way (D49) | | DT-05 | Developer mode turns on automatically for local development URLs | P1 | – | – | – | | | DT-06 | Turn Developer mode on/off for any site | P1 | – | – | – | | @@ -502,7 +502,7 @@ Spec: `spec/devtools.md` | DT-11 | Task manager (per-tab memory/CPU) | P1 | – | – | – | | | DT-12 | Record a performance trace (local) | P2 | – | – | – | | | DT-13 | Internal pages for engine-level settings | P1 | 🟡 partial | ✅ e2e+unit | – | orbit:/// opens as a normal tab when typed in the command bar or address field, or from another internal page's link; orbit://pages/ lists them; orbit://version/ shows the build info About shows (Orbit, build day, Electron, Chromium, V8, Node, OS) with Copy build info. orbit://history/ is History (AR-10, pages lane). They follow the app's state (light/dark) through the same bridge as Orbit's UI, which the one web page preload (src/preload/page.ts) gives an orbit: page's main frame only (D40, D53). A web page can't navigate, frame, redirect to, open, load or fetch them, and gets no bridge: Orbit opens one for no page but another internal page on any route (the page menu, Split View's item included, ⌥-click, new tabs, windows and pop-ups, a pop-up's own, blocked pop-ups, a drop), D53. Each page runs only the commands listed for it and sends no events, and its document runs only its own scripts (script-src 'self'), D53. Infrastructure documented for reuse (D39) in src/main/devtools/internal-pages.ts and src/renderer/src/internal/registry.ts: a page is a *.internal-page.tsx module, and lists the commands it runs in INTERNAL_PAGE_COMMANDS. Missing: the pages other lanes add as they build them (D54): extensions EX-07, site settings OT-07/ST-13, privacy and clearing data and system options ST-12, languages ST-14, about UP-07; orbit://pages/ lists each one added | -| DT-14 | Hard reload and cache-bypassing reload | P1 | ✅ | ✅ e2e | – | ⇧⌘R (Ctrl+Shift+R) runs the navigation lane's nav.hardReload action (the e2e checks that action ran): page and subresources refetched (no-cache). A crashed page hands the keyboard to the window, so ⇧⌘R brings it back. Stopping a load (Esc in the page, double-clicking Reload) is NV-04, built with the sidebar's Reload/Stop button; its e2e tests cover both | +| DT-14 | Hard reload and cache-bypassing reload | P1 | ✅ | ✅ e2e | ✅ | ⇧⌘R (Ctrl+Shift+R) runs the navigation lane's nav.hardReload action (the e2e checks that action ran): page and subresources refetched (no-cache). A crashed page hands the keyboard to the window, so ⇧⌘R brings it back. Stopping a load (Esc in the page, double-clicking Reload) is NV-04, built with the sidebar's Reload/Stop button; its e2e tests cover both | ## Downloads and the Library @@ -510,11 +510,11 @@ Spec: `spec/downloads.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| DL-01 | Downloading a file (baseline) | P0 | ✅ | ✅ e2e+unit | – | 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 | -| DL-02 | Download start animation | P1 | ✅ | ✅ e2e | – | a file icon flies from the page's edge into the Library button (≈0.5 s curved path, ease-out) and the button bounces; one flight per download; reduce motion: a fade on the button. Sidebar hidden: a "Downloading " toast (and "Downloaded"/"Couldn't download"). A Mini Window's downloads show in its main window | +| DL-01 | Downloading a file (baseline) | P0 | ✅ | ✅ e2e+unit | ✅ | 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 | +| DL-02 | Download start animation | P1 | ✅ | ✅ e2e | ✅ | a file icon flies from the page's edge into the Library button (≈0.5 s curved path, ease-out) and the button bounces; one flight per download; reduce motion: a fade on the button. Sidebar hidden: a "Downloading " toast (and "Downloaded"/"Couldn't download"). A Mini Window's downloads show in its main window | | DL-03 | In-progress indicator at the bottom of the sidebar | P1 | ✅ | ✅ e2e+unit | – | row above the bottom bar: name truncated in the middle, progress bar, bytes of total, time left (smoothed), ✕ cancels and deletes the partial file; several: newest + "+N", hover or "+N" lists each with its own ✕; done for 4 s, click opens; failed: Resume or Retry, ✕ dismisses; no downloads, no room taken. Right-click: the download's menu | | DL-04 | Open a finished download, or show it in its folder | P1 | 🟡 partial | ✅ e2e+unit | – | Open (in an Orbit tab only types that run no script there, D53: PDF, raster images, plain text, audio, video; anything else, SVG and HTML included, in the system app), Show in Finder / Show in Folder, Copy File (pastes as the file in file managers), Copy Download Link, Remove from List, Delete File; these act on whatever file is at the download's path, also one edited since (D56). Only with no file there (moved away or deleted), or once a later download saved its file there, the row shows "File Missing" and Download Again. The same menu on the sidebar row, the Library's Downloads rows (right-click or ⋯), the recent-files popover and the flyout. Missing: Open with…, which finish-downloads builds (D54) | -| DL-05 | Recent files on hover over the Library icon | P1 | ✅ | ✅ e2e+unit | – | hovering the Library button 300 ms shows a popover above it, inside the sidebar: the 8 newest downloads (and OS screenshots when chosen), each with its picture or kind icon, name, size and age; a click opens one, dragging drops the file (DL-06), right-click shows its menu; it goes 200 ms after the pointer leaves. Right-click the button: Recent Files with Downloads and Screenshots checkboxes (Downloads on, Screenshots off at first) and Show Screenshots in Library (DL-11). No Captures choice: Orbit has no capture tool (OT-03) | +| DL-05 | Recent files on hover over the Library icon | P1 | ✅ | ✅ e2e+unit | ✅ | hovering the Library button 300 ms shows a popover above it, inside the sidebar: the 8 newest downloads (and OS screenshots when chosen), each with its picture or kind icon, name, size and age; a click opens one, dragging drops the file (DL-06), right-click shows its menu; it goes 200 ms after the pointer leaves. Right-click the button: Recent Files with Downloads and Screenshots checkboxes (Downloads on, Screenshots off at first) and Show Screenshots in Library (DL-11). No Captures choice: Orbit has no capture tool (OT-03) | | DL-06 | Drag a download out (and into web pages) | P1 | 🟡 partial | ✅ e2e | – | every download drags out as the real file (the OS drag, with a file icon): the sidebar row, the recent-files popover, the Library's Downloads rows and Media tiles, and the Ctrl+J flyout (which steps aside once the drag ends). Tests stop at the OS drag (Xvfb can't drop). Not verified by hand: dropping into the page under the flyout, and dragging while Orbit is in the background on macOS (an inactive window takes the first click unless it accepts the first mouse, a window-wide choice). Missing: dropping into a canvas (EZ-05), which the canvases lane builds | | DL-07 | Download location setting (per profile) | P1 | ✅ | ✅ e2e+unit | – | Settings → Profiles → [profile] → Download location: a dropdown with the profile's folder (its full path under it), Other… (the OS folder picker) and Ask every time; per profile, a new one starts with the OS Downloads folder. Ask every time shows the OS save dialog for each download, opening in the folder the last one went to (kept across restarts; a private window's choice isn't kept); the file downloads meanwhile under a hidden name where the dialog opens and moves into place when complete (a copy across disks); it shows in the sidebar and the lists once placed, and Cancel drops it. A chosen folder is always used, never the one last used with Ask every time; a folder that can't be written to (a drive that's gone) asks with the save dialog and says why, never saving elsewhere silently. Tests answer the dialogs through test hooks | | DL-08 | Remove, clear and delete downloads | P1 | 🟡 partial | ✅ e2e+unit | – | Remove from List keeps the file; Delete File moves it to the Trash/Recycle Bin (never a permanent delete; stays listed and says so when it can't), only while it is the file downloaded (same size and date, D56): one edited since, or another at its path, stays where it is with the download listed, and a toast says it changed after it was downloaded; Clear all, in the Library's Downloads section, takes finished downloads off the list (files stay; the partial file of a failed one goes) and keeps running, paused, held (DL-12) and resumable ones. Missing: the "Download history" choice in Clear browsing data, which finish-settings builds (ST-12, D54); it can call downloads.clear with the profile | @@ -523,10 +523,10 @@ Spec: `spec/downloads.md` | DL-11 | Library → Media section | P2 | ✅ | ✅ e2e+unit | – | Library → Media: a grid of the pictures and videos downloaded in the profile (those Orbit can show), newest first by day; video tiles play muted and looping while on screen (still with reduced motion); a click opens a larger preview in a Peek (⌘/Ctrl-click: a tab), tiles drag out as the file, right-click shows the file's menu. Right-click the Library button → Show Screenshots in Library adds the OS screenshots (macOS: the system's screenshot folder, else the Desktop; Windows: Pictures\Screenshots; Linux: Pictures/Screenshots and screenshot-named files in Pictures). Out of scope (D54): in-browser captures, which need OT-03, a feature with no spec row | | DL-12 | Download safety | P1 | ✅ | ✅ e2e+unit | – | finished files carry the OS mark of a file from the internet (the macOS quarantine attribute; on Windows a Zone.Identifier with the page and file address, no passwords, and only the zone for a private window's download; Linux has none). Programs, installers, scripts and disk images wait under their partial name with Keep / Discard in the sidebar row, the Library and the flyout, through a restart too: everywhere exe, msi, dmg, pkg, deb, rpm, AppImage, .desktop launchers, sh/bash/zsh/py/pl/rb scripts, iso/img/vhd/vhdx, jar, jnlp, apk…; on Windows also its own script, shortcut and system types (js, vbs, wsf, hta, lnk, url, scf, settingcontent-ms, chm, reg, inf, msc, dll…). Keep puts the file in place and marks it, Discard deletes it. Every such file is held, whatever the site, as there is no reputation service. Gap, by the spec's own rule (D54): Safe Browsing, which needs a reputation service; none is free to use (Google's is non-commercial, Web Risk is paid) | | DL-13 | Files the browser can show inline | P1 | 🟡 partial | ✅ e2e+unit | – | PDFs (the engine's viewer), raster pictures, plain text, audio and video open in a tab (only types that run no script there, D53: a downloaded SVG opens in its app); a site that sends the file as an attachment still downloads it (DL-01); the PDF viewer's download button saves through the usual download flow (row, Library, DL-02 flight). Missing: offering Orbit as Windows' app for local .pdf files (Settings → General, P2), which finish-settings builds (D54): the installer's file association and opening files passed on the command line | -| DL-14 | Downloads keyboard shortcut and menu | P1 | ✅ | ✅ e2e+unit | – | Windows/Linux: Ctrl+J opens the downloads flyout at the top left, beside the sidebar (Orbit has no top toolbar): the 12 newest with live progress, Keep/Discard, Resume/Retry/Cancel and Show in Folder; a click opens a file, rows drag out (DL-06), Show All opens the Library's Downloads section; Esc, Ctrl+J again or a click outside closes it. macOS: ⌘⇧J and ⌥⌘L open the Library's Downloads section (KS-12). The command bar has a "Downloads" action | -| DL-15 | Pause, resume and retry | P1 | ✅ | ✅ e2e+unit | – | Pause/Resume from the row's menu (and a Resume button on a paused row); a cut-off download shows Resume when the server sent an ETag or Last-Modified (continues with a range request), else Retry (starts again); one cut off by quitting resumes after a restart from its partial file (kept under a hidden second name while the engine shuts down). Downloads never block the tab that started them | -| DL-16 | Downloads in private windows, Little windows and Peek | P2 | ✅ | ✅ e2e+unit | – | Peek and Mini Window downloads go to the same list as any other. A private window's downloads save to disk like any other but are in no saved list (not the downloads list, nor the state file or its backup), and their Windows Zone.Identifier names no address. A private window shows only its own session's downloads (sidebar row, Ctrl+J flyout), and no other window shows them; their start, toasts and the files Orbit opens stay in its windows, and they change nothing kept (the Library's section, the Dock's notification names no file). They stay listed while a private window is open, its pages closed or not; when the last one closes (the session ends), they leave every list: finished files stay on disk, one still coming is saved and then leaves, a paused or stopped one and a program held back (DL-12) are discarded. Tested with real private windows | -| DL-17 | Dock / taskbar progress | P2 | ✅ | ✅ e2e+unit | – | While downloads run or are paused, their overall progress shows on the macOS Dock icon and on the windows' taskbar buttons: bytes received of those with a known size, the can't-tell bar when no size is known, Windows' paused colour when every one is paused; it goes when none runs. A download that finishes while no Orbit window has focus bounces the Dock icon once (macOS) and shows a "Download complete" notification naming the file (a private window's isn't named). Tests read what Orbit asks of the OS through test hooks; the OS's own drawing isn't checked on Linux | +| DL-14 | Downloads keyboard shortcut and menu | P1 | ✅ | ✅ e2e+unit | ✅ | Windows/Linux: Ctrl+J opens the downloads flyout at the top left, beside the sidebar (Orbit has no top toolbar): the 12 newest with live progress, Keep/Discard, Resume/Retry/Cancel and Show in Folder; a click opens a file, rows drag out (DL-06), Show All opens the Library's Downloads section; Esc, Ctrl+J again or a click outside closes it. macOS: ⌘⇧J and ⌥⌘L open the Library's Downloads section (KS-12). The command bar has a "Downloads" action | +| DL-15 | Pause, resume and retry | P1 | ✅ | ✅ e2e+unit | ✅ | Pause/Resume from the row's menu (and a Resume button on a paused row); a cut-off download shows Resume when the server sent an ETag or Last-Modified (continues with a range request), else Retry (starts again); one cut off by quitting resumes after a restart from its partial file (kept under a hidden second name while the engine shuts down). Downloads never block the tab that started them | +| DL-16 | Downloads in private windows, Little windows and Peek | P2 | ✅ | ✅ e2e+unit | ✅ | Peek and Mini Window downloads go to the same list as any other. A private window's downloads save to disk like any other but are in no saved list (not the downloads list, nor the state file or its backup), and their Windows Zone.Identifier names no address. A private window shows only its own session's downloads (sidebar row, Ctrl+J flyout), and no other window shows them; their start, toasts and the files Orbit opens stay in its windows, and they change nothing kept (the Library's section, the Dock's notification names no file). They stay listed while a private window is open, its pages closed or not; when the last one closes (the session ends), they leave every list: finished files stay on disk, one still coming is saved and then leaves, a paused or stopped one and a program held back (DL-12) are discarded. Tested with real private windows | +| DL-17 | Dock / taskbar progress | P2 | ✅ | ✅ e2e+unit | ✅ | While downloads run or are paused, their overall progress shows on the macOS Dock icon and on the windows' taskbar buttons: bytes received of those with a known size, the can't-tell bar when no size is known, Windows' paused colour when every one is paused; it goes when none runs. A download that finishes while no Orbit window has focus bounces the Dock icon once (macOS) and shows a "Download complete" notification naming the file (a private window's isn't named). Tests read what Orbit asks of the OS through test hooks; the OS's own drawing isn't checked on Linux | | DL-18 | Smart renaming of downloads | OOS | ⛔ out of scope | – | – | the spec leaves it out | ## Settings / preferences diff --git a/docs/feature-status.json b/docs/feature-status.json index 42aee48a..d655922b 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -273,28 +273,28 @@ "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"}, "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", "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", "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"}, - "DT-02": {"built": "yes", "note": "right-click → Inspect (last item) opens DevTools with that element selected, or just selects it when they're open. Picker: ⌥⌘C on macOS, no default key on Windows/Linux, Inspect Element in the command bar. ⇧⌘C stays Copy URL; inside DevTools it is DevTools' own picker. Remap: give the devtools.inspect action ⇧⌘C (and take it from tab.copyUrl) in src/shared/actions.ts, or in Settings → Shortcuts once remapping (ST-08) lands"}, - "DT-03": {"built": "yes", "note": "docked right/left/bottom or undocked from DevTools' dock menu; the engine keeps the side per profile across restarts. Each tab (pane, Peek, Mini Window) has its own DevTools, shown and hidden with its page; a page shown again takes the docked size last chosen. Undocked: the open key brings them to the front (checked on Linux; macOS unverified), and closes them when they have the keyboard. No windows added, however many tabs have DevTools. What Orbit puts over a page (find bar, zoom, link pill, loading indicator, permission prompt) goes over the page's own area, never over docked DevTools (e2e for the find bar and link pill, docked right, bottom and left)"}, + "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"}, + "DT-02": {"built": "yes", "feel": true, "note": "right-click → Inspect (last item) opens DevTools with that element selected, or just selects it when they're open. Picker: ⌥⌘C on macOS, no default key on Windows/Linux, Inspect Element in the command bar. ⇧⌘C stays Copy URL; inside DevTools it is DevTools' own picker. Remap: give the devtools.inspect action ⇧⌘C (and take it from tab.copyUrl) in src/shared/actions.ts, or in Settings → Shortcuts once remapping (ST-08) lands"}, + "DT-03": {"built": "yes", "feel": true, "note": "docked right/left/bottom or undocked from DevTools' dock menu; the engine keeps the side per profile across restarts. Each tab (pane, Peek, Mini Window) has its own DevTools, shown and hidden with its page; a page shown again takes the docked size last chosen. Undocked: the open key brings them to the front (checked on Linux; macOS unverified), and closes them when they have the keyboard. No windows added, however many tabs have DevTools. What Orbit puts over a page (find bar, zoom, link pill, loading indicator, permission prompt) goes over the page's own area, never over docked DevTools (e2e for the find bar and link pill, docked right, bottom and left)"}, "DT-04": {"built": "yes", "note": "⌥⌘U (Ctrl+U), the command bar's View Source, the page menu's View Page Source and View → Developer → View Source (macOS menu) open the page's own address as view-source: in a new tab, shown at once: the engine's source view, read-only, coloured, with line numbers; a Peek's page opens its own source in a new tab of its window, and a Mini Window's in a main window (one showing its Space if there is one), brought to the front; a source view has no source of its own to open. Only the page's own address goes, so a web page can't make Orbit open a file: URL this way (D49)"}, "DT-13": {"built": "partial", "note": "orbit:/// opens as a normal tab when typed in the command bar or address field, or from another internal page's link; orbit://pages/ lists them; orbit://version/ shows the build info About shows (Orbit, build day, Electron, Chromium, V8, Node, OS) with Copy build info. orbit://history/ is History (AR-10, pages lane). They follow the app's state (light/dark) through the same bridge as Orbit's UI, which the one web page preload (src/preload/page.ts) gives an orbit: page's main frame only (D40, D53). A web page can't navigate, frame, redirect to, open, load or fetch them, and gets no bridge: Orbit opens one for no page but another internal page on any route (the page menu, Split View's item included, ⌥-click, new tabs, windows and pop-ups, a pop-up's own, blocked pop-ups, a drop), D53. Each page runs only the commands listed for it and sends no events, and its document runs only its own scripts (script-src 'self'), D53. Infrastructure documented for reuse (D39) in src/main/devtools/internal-pages.ts and src/renderer/src/internal/registry.ts: a page is a *.internal-page.tsx module, and lists the commands it runs in INTERNAL_PAGE_COMMANDS. Missing: the pages other lanes add as they build them (D54): extensions EX-07, site settings OT-07/ST-13, privacy and clearing data and system options ST-12, languages ST-14, about UP-07; orbit://pages/ lists each one added"}, - "DT-14": {"built": "yes", "note": "⇧⌘R (Ctrl+Shift+R) runs the navigation lane's nav.hardReload action (the e2e checks that action ran): page and subresources refetched (no-cache). A crashed page hands the keyboard to the window, so ⇧⌘R brings it back. Stopping a load (Esc in the page, double-clicking Reload) is NV-04, built with the sidebar's Reload/Stop button; its e2e tests cover both"}, - "DL-02": {"built": "yes", "note": "a file icon flies from the page's edge into the Library button (≈0.5 s curved path, ease-out) and the button bounces; one flight per download; reduce motion: a fade on the button. Sidebar hidden: a \"Downloading \" toast (and \"Downloaded\"/\"Couldn't download\"). A Mini Window's downloads show in its main window"}, + "DT-14": {"built": "yes", "feel": true, "note": "⇧⌘R (Ctrl+Shift+R) runs the navigation lane's nav.hardReload action (the e2e checks that action ran): page and subresources refetched (no-cache). A crashed page hands the keyboard to the window, so ⇧⌘R brings it back. Stopping a load (Esc in the page, double-clicking Reload) is NV-04, built with the sidebar's Reload/Stop button; its e2e tests cover both"}, + "DL-02": {"built": "yes", "feel": true, "note": "a file icon flies from the page's edge into the Library button (≈0.5 s curved path, ease-out) and the button bounces; one flight per download; reduce motion: a fade on the button. Sidebar hidden: a \"Downloading \" toast (and \"Downloaded\"/\"Couldn't download\"). A Mini Window's downloads show in its main window"}, "DL-03": {"built": "yes", "note": "row above the bottom bar: name truncated in the middle, progress bar, bytes of total, time left (smoothed), ✕ cancels and deletes the partial file; several: newest + \"+N\", hover or \"+N\" lists each with its own ✕; done for 4 s, click opens; failed: Resume or Retry, ✕ dismisses; no downloads, no room taken. Right-click: the download's menu"}, "DL-04": {"built": "partial", "note": "Open (in an Orbit tab only types that run no script there, D53: PDF, raster images, plain text, audio, video; anything else, SVG and HTML included, in the system app), Show in Finder / Show in Folder, Copy File (pastes as the file in file managers), Copy Download Link, Remove from List, Delete File; these act on whatever file is at the download's path, also one edited since (D56). Only with no file there (moved away or deleted), or once a later download saved its file there, the row shows \"File Missing\" and Download Again. The same menu on the sidebar row, the Library's Downloads rows (right-click or ⋯), the recent-files popover and the flyout. Missing: Open with…, which finish-downloads builds (D54)"}, "DL-07": {"built": "yes", "note": "Settings → Profiles → [profile] → Download location: a dropdown with the profile's folder (its full path under it), Other… (the OS folder picker) and Ask every time; per profile, a new one starts with the OS Downloads folder. Ask every time shows the OS save dialog for each download, opening in the folder the last one went to (kept across restarts; a private window's choice isn't kept); the file downloads meanwhile under a hidden name where the dialog opens and moves into place when complete (a copy across disks); it shows in the sidebar and the lists once placed, and Cancel drops it. A chosen folder is always used, never the one last used with Ask every time; a folder that can't be written to (a drive that's gone) asks with the save dialog and says why, never saving elsewhere silently. Tests answer the dialogs through test hooks"}, "DL-08": {"built": "partial", "note": "Remove from List keeps the file; Delete File moves it to the Trash/Recycle Bin (never a permanent delete; stays listed and says so when it can't), only while it is the file downloaded (same size and date, D56): one edited since, or another at its path, stays where it is with the download listed, and a toast says it changed after it was downloaded; Clear all, in the Library's Downloads section, takes finished downloads off the list (files stay; the partial file of a failed one goes) and keeps running, paused, held (DL-12) and resumable ones. Missing: the \"Download history\" choice in Clear browsing data, which finish-settings builds (ST-12, D54); it can call downloads.clear with the profile"}, - "DL-15": {"built": "yes", "note": "Pause/Resume from the row's menu (and a Resume button on a paused row); a cut-off download shows Resume when the server sent an ETag or Last-Modified (continues with a range request), else Retry (starts again); one cut off by quitting resumes after a restart from its partial file (kept under a hidden second name while the engine shuts down). Downloads never block the tab that started them"}, - "DL-16": {"built": "yes", "note": "Peek and Mini Window downloads go to the same list as any other. A private window's downloads save to disk like any other but are in no saved list (not the downloads list, nor the state file or its backup), and their Windows Zone.Identifier names no address. A private window shows only its own session's downloads (sidebar row, Ctrl+J flyout), and no other window shows them; their start, toasts and the files Orbit opens stay in its windows, and they change nothing kept (the Library's section, the Dock's notification names no file). They stay listed while a private window is open, its pages closed or not; when the last one closes (the session ends), they leave every list: finished files stay on disk, one still coming is saved and then leaves, a paused or stopped one and a program held back (DL-12) are discarded. Tested with real private windows"}, - "DL-17": {"built": "yes", "note": "While downloads run or are paused, their overall progress shows on the macOS Dock icon and on the windows' taskbar buttons: bytes received of those with a known size, the can't-tell bar when no size is known, Windows' paused colour when every one is paused; it goes when none runs. A download that finishes while no Orbit window has focus bounces the Dock icon once (macOS) and shows a \"Download complete\" notification naming the file (a private window's isn't named). Tests read what Orbit asks of the OS through test hooks; the OS's own drawing isn't checked on Linux"}, - "DL-05": {"built": "yes", "note": "hovering the Library button 300 ms shows a popover above it, inside the sidebar: the 8 newest downloads (and OS screenshots when chosen), each with its picture or kind icon, name, size and age; a click opens one, dragging drops the file (DL-06), right-click shows its menu; it goes 200 ms after the pointer leaves. Right-click the button: Recent Files with Downloads and Screenshots checkboxes (Downloads on, Screenshots off at first) and Show Screenshots in Library (DL-11). No Captures choice: Orbit has no capture tool (OT-03)"}, + "DL-15": {"built": "yes", "feel": true, "note": "Pause/Resume from the row's menu (and a Resume button on a paused row); a cut-off download shows Resume when the server sent an ETag or Last-Modified (continues with a range request), else Retry (starts again); one cut off by quitting resumes after a restart from its partial file (kept under a hidden second name while the engine shuts down). Downloads never block the tab that started them"}, + "DL-16": {"built": "yes", "feel": true, "note": "Peek and Mini Window downloads go to the same list as any other. A private window's downloads save to disk like any other but are in no saved list (not the downloads list, nor the state file or its backup), and their Windows Zone.Identifier names no address. A private window shows only its own session's downloads (sidebar row, Ctrl+J flyout), and no other window shows them; their start, toasts and the files Orbit opens stay in its windows, and they change nothing kept (the Library's section, the Dock's notification names no file). They stay listed while a private window is open, its pages closed or not; when the last one closes (the session ends), they leave every list: finished files stay on disk, one still coming is saved and then leaves, a paused or stopped one and a program held back (DL-12) are discarded. Tested with real private windows"}, + "DL-17": {"built": "yes", "feel": true, "note": "While downloads run or are paused, their overall progress shows on the macOS Dock icon and on the windows' taskbar buttons: bytes received of those with a known size, the can't-tell bar when no size is known, Windows' paused colour when every one is paused; it goes when none runs. A download that finishes while no Orbit window has focus bounces the Dock icon once (macOS) and shows a \"Download complete\" notification naming the file (a private window's isn't named). Tests read what Orbit asks of the OS through test hooks; the OS's own drawing isn't checked on Linux"}, + "DL-05": {"built": "yes", "feel": true, "note": "hovering the Library button 300 ms shows a popover above it, inside the sidebar: the 8 newest downloads (and OS screenshots when chosen), each with its picture or kind icon, name, size and age; a click opens one, dragging drops the file (DL-06), right-click shows its menu; it goes 200 ms after the pointer leaves. Right-click the button: Recent Files with Downloads and Screenshots checkboxes (Downloads on, Screenshots off at first) and Show Screenshots in Library (DL-11). No Captures choice: Orbit has no capture tool (OT-03)"}, "DL-06": {"built": "partial", "note": "every download drags out as the real file (the OS drag, with a file icon): the sidebar row, the recent-files popover, the Library's Downloads rows and Media tiles, and the Ctrl+J flyout (which steps aside once the drag ends). Tests stop at the OS drag (Xvfb can't drop). Not verified by hand: dropping into the page under the flyout, and dragging while Orbit is in the background on macOS (an inactive window takes the first click unless it accepts the first mouse, a window-wide choice). Missing: dropping into a canvas (EZ-05), which the canvases lane builds"}, "DL-10": {"built": "yes", "note": "Library → Downloads: everything downloaded through Orbit in the profile (running ones too, wherever saved; nothing else from the folder), newest first in day groups; a row shows a picture or kind icon, name, size or progress, site and time; search by name (every word, any case); a click previews in a Peek over the Library (PDF, images, text, audio, video; other files open in their app), ⌘/Ctrl-click opens a tab; hover buttons Show in Finder/Folder, Resume, Retry, Cancel, Keep/Discard and ⋯ for the full menu (DL-04, DL-08); rows drag out (DL-06); arrow keys and Enter; a profile switcher when there are several profiles; Clear all. Only the rows on screen are drawn"}, "DL-11": {"built": "yes", "note": "Library → Media: a grid of the pictures and videos downloaded in the profile (those Orbit can show), newest first by day; video tiles play muted and looping while on screen (still with reduced motion); a click opens a larger preview in a Peek (⌘/Ctrl-click: a tab), tiles drag out as the file, right-click shows the file's menu. Right-click the Library button → Show Screenshots in Library adds the OS screenshots (macOS: the system's screenshot folder, else the Desktop; Windows: Pictures\\Screenshots; Linux: Pictures/Screenshots and screenshot-named files in Pictures). Out of scope (D54): in-browser captures, which need OT-03, a feature with no spec row"}, "DL-12": {"built": "yes", "note": "finished files carry the OS mark of a file from the internet (the macOS quarantine attribute; on Windows a Zone.Identifier with the page and file address, no passwords, and only the zone for a private window's download; Linux has none). Programs, installers, scripts and disk images wait under their partial name with Keep / Discard in the sidebar row, the Library and the flyout, through a restart too: everywhere exe, msi, dmg, pkg, deb, rpm, AppImage, .desktop launchers, sh/bash/zsh/py/pl/rb scripts, iso/img/vhd/vhdx, jar, jnlp, apk…; on Windows also its own script, shortcut and system types (js, vbs, wsf, hta, lnk, url, scf, settingcontent-ms, chm, reg, inf, msc, dll…). Keep puts the file in place and marks it, Discard deletes it. Every such file is held, whatever the site, as there is no reputation service. Gap, by the spec's own rule (D54): Safe Browsing, which needs a reputation service; none is free to use (Google's is non-commercial, Web Risk is paid)"}, "DL-13": {"built": "partial", "note": "PDFs (the engine's viewer), raster pictures, plain text, audio and video open in a tab (only types that run no script there, D53: a downloaded SVG opens in its app); a site that sends the file as an attachment still downloads it (DL-01); the PDF viewer's download button saves through the usual download flow (row, Library, DL-02 flight). Missing: offering Orbit as Windows' app for local .pdf files (Settings → General, P2), which finish-settings builds (D54): the installer's file association and opening files passed on the command line"}, - "DL-14": {"built": "yes", "note": "Windows/Linux: Ctrl+J opens the downloads flyout at the top left, beside the sidebar (Orbit has no top toolbar): the 12 newest with live progress, Keep/Discard, Resume/Retry/Cancel and Show in Folder; a click opens a file, rows drag out (DL-06), Show All opens the Library's Downloads section; Esc, Ctrl+J again or a click outside closes it. macOS: ⌘⇧J and ⌥⌘L open the Library's Downloads section (KS-12). The command bar has a \"Downloads\" action"}, + "DL-14": {"built": "yes", "feel": true, "note": "Windows/Linux: Ctrl+J opens the downloads flyout at the top left, beside the sidebar (Orbit has no top toolbar): the 12 newest with live progress, Keep/Discard, Resume/Retry/Cancel and Show in Folder; a click opens a file, rows drag out (DL-06), Show All opens the Library's Downloads section; Esc, Ctrl+J again or a click outside closes it. macOS: ⌘⇧J and ⌥⌘L open the Library's Downloads section (KS-12). The command bar has a \"Downloads\" action"}, "ST-01": {"built": "yes", "feel": true, "note": "own window (⌘, / Ctrl+,): opens or focuses one; remembers section and size; takes the opener Space's colours; sections come from a registry (src/renderer/src/settings/registry.ts); search filters rows"}, "ST-02": {"built": "yes", "feel": true, "note": "sections and common settings are command-bar actions (settings.*) that open Settings at the row; the keys lane adds Shortcuts"}, "ST-03": {"built": "partial", "note": "default browser row, app-menu item and command-bar action (only an installed build can register; e2e uses a stub); Mini Window archive timing; Import… opens the import flow (IM-01); Updates group: automatic checks switch, Check Now and the update feed address. Not built: UP-04's install-while-idle, hover-preview toggles (OT-26, P2), Open PDFs (P2)"}, diff --git a/scripts/capture/record.mjs b/scripts/capture/record.mjs index d71d8561..3b9358bc 100644 --- a/scripts/capture/record.mjs +++ b/scripts/capture/record.mjs @@ -21,6 +21,7 @@ import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +import { crc32, deflateSync } from 'node:zlib' import { _electron as electron } from '@playwright/test' const argv = process.argv.slice(2) @@ -65,8 +66,14 @@ async function startFixtures() { const esc = (t) => t.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]) // What /feed.json offers: set by a scenario with fx.offer({ version, summary, security }). let offer = null + // The `cut` files that broke off (`broken`, until a scenario calls fx.repair(name)) or were repaired. + const cuts = new Map() const server = createServer((req, res) => { const at = new URL(req.url ?? '/', 'http://127.0.0.1') + if (at.pathname.startsWith('/dl/')) { + sendFile(req, res, at, cuts) + return + } // /feed.json: an update feed (UP-01). Before a scenario offers a version, its release is 0.0.0, older // than any build. The release's page is one of these pages. if (at.pathname === '/feed.json') { @@ -91,12 +98,18 @@ async function startFixtures() { } const accent = accents[[...name].reduce((a, c) => a + c.charCodeAt(0), 0) % accents.length] const icon = `${esc(name[0] ?? '?')}` - const cards = ['Overview', 'Recent', 'Shared with you', 'Starred', 'Activity', 'Ideas'] - .map( - (c, i) => - `

${c}

Local demo content for ${esc(name)}. Nothing here comes from the network.

`, - ) - .join('') + // /files/: a page of download links (FILES) in place of the cards. + const cards = at.pathname.startsWith('/files/') + ? FILES.map( + ([file, query, size]) => + `

${esc(file)}

${size}. Download

`, + ).join('') + : ['Overview', 'Recent', 'Shared with you', 'Starred', 'Activity', 'Ideas'] + .map( + (c, i) => + `

${c}

Local demo content for ${esc(name)}. Nothing here comes from the network.

`, + ) + .join('') // ?wait=: the page comes that much later (at most 30 s), for a load still under way. const wait = Math.min(Number(at.searchParams.get('wait')) || 0, 30_000) const page = `${esc(name)} @@ -110,6 +123,7 @@ main{padding:40px;max-width:1100px}h1{font-size:40px;margin:8px 0 6px;letter-spa .lead{color:#555;max-width:60ch;margin:0 0 28px}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px} .card{background:#fff;border:1px solid #eee;border-radius:14px;padding:18px}.card h3{margin:10px 0 4px;font-size:16px} .card p{margin:0;color:#666;font-size:14px}.dot{width:36px;height:36px;border-radius:10px;background:${accent}} +a.file{color:inherit;text-decoration:none}a.file:hover{border-color:${accent}}
${esc(name)}

${esc(name)}

A local demo page for Orbit screen captures, served from 127.0.0.1.

${cards}
` setTimeout(() => { @@ -129,8 +143,148 @@ main{padding:40px;max-width:1100px}h1{font-size:40px;margin:8px 0 6px;letter-spa offer: (release) => { offer = release }, - close: () => server.close(), + repair: (file) => { + cuts.set(file, 'repaired') + }, + close: () => { + server.closeAllConnections() + server.close() + }, + } +} + +const MB = 1024 * 1024 +/** The files a /files/ page links to: name, the /dl/ query, and the size it shows. */ +const FILES = [ + ['Survey report.pdf', '', '1 page'], + ['Site photo.png', '', '480 × 320'], + ['Field recordings.zip', `size=${24 * MB}&rate=${1.5 * MB}`, '24 MB'], + ['Station log.csv', `size=${6 * MB}&rate=${MB}&cut`, '6 MB'], +] + +/** + * /dl/?size=&rate=&cut: a file sent as an attachment (DL-01), with an ETag and byte ranges, so a + * paused or cut-off download goes on where it stopped (DL-15). A .pdf is a one-page PDF and a .png a + * picture, so a preview shows them; any other file is `size` bytes of filler. With `rate`, it comes at + * that many bytes a second. With `cut`, the first request breaks off after a quarter, and every request + * after it breaks too (the engine tries again by itself a few times) until the scenario repairs it. + */ +function sendFile(req, res, at, cuts) { + let name = at.pathname.slice('/dl/'.length) + try { + name = decodeURIComponent(name) + } catch { + // A malformed escape: the name as it came. + } + const ext = name.split('.').pop() + const size = Math.min(Number(at.searchParams.get('size')) || 64 * 1024, 64 * MB) + const data = ext === 'pdf' ? pdfFile(name.replace(/\.pdf$/, '')) : ext === 'png' ? pngFile() : filler(size) + const from = Number(/^bytes=(\d+)-/.exec(req.headers.range ?? '')?.[1] ?? 0) + if (cuts.get(name) === 'broken') { + res.destroy() + return + } + const cut = at.searchParams.has('cut') && !cuts.has(name) + const rest = from > 0 && from < data.length ? data.subarray(from) : data + res.writeHead(rest === data ? 200 : 206, { + 'content-type': 'application/octet-stream', + 'content-disposition': `attachment; filename="${name}"`, + etag: `"${name}-${data.length}"`, + 'accept-ranges': 'bytes', + 'content-length': String(rest.length), + ...(rest === data ? {} : { 'content-range': `bytes ${from}-${data.length - 1}/${data.length}` }), + }) + // The first request of a `cut` file stops a quarter in, and from then on every request breaks. + const stop = cut ? Math.floor(data.length / 4) : rest.length + if (cut) cuts.set(name, 'broken') + const rate = Number(at.searchParams.get('rate')) || 0 + if (!rate && !cut) { + res.end(rest) + return + } + const step = rate ? Math.max(1, Math.round(rate / 20)) : stop + let sent = 0 + const timer = setInterval(() => { + const next = Math.min(stop, sent + step) + res.write(rest.subarray(sent, next)) + sent = next + if (sent < stop) return + clearInterval(timer) + if (cut) setTimeout(() => res.destroy(), 100) + else res.end() + }, 50) + res.on('close', () => clearInterval(timer)) +} + +function filler(size) { + const out = Buffer.alloc(size) + for (let i = 0; i < size; i++) out[i] = i % 251 + return out +} + +/** A one-page PDF with a title and two lines of text. */ +function pdfFile(title) { + const text = (size, y, s) => `BT /F1 ${size} Tf 56 ${y} Td (${s.replace(/[()\\]/g, '\\$&')}) Tj ET` + const stream = [ + text(28, 760, title), + text(13, 726, 'North ridge transect, plots 1 to 14.'), + text(13, 706, 'A local file for Orbit screen captures.'), + ].join('\n') + const objects = [ + '<>', + '<>', + '<>>>>>', + `<>\nstream\n${stream}\nendstream`, + '<>', + ] + let out = '%PDF-1.4\n' + const offsets = objects.map((o, i) => { + const offset = out.length + out += `${i + 1} 0 obj${o}endobj\n` + return offset + }) + const xref = out.length + out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n` + out += offsets.map((o) => `${String(o).padStart(10, '0')} 00000 n \n`).join('') + out += `trailer<>\nstartxref\n${xref}\n%%EOF\n` + return Buffer.from(out, 'latin1') +} + +/** A 480 × 320 picture: a ridge under a sky, with a sun. */ +function pngFile(w = 480, h = 320) { + const raw = Buffer.alloc((w * 3 + 1) * h) + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const ridge = h * 0.62 + Math.sin(x / 55) * 18 + Math.sin(x / 23) * 6 + const sun = (x - w * 0.72) ** 2 + (y - h * 0.3) ** 2 < 30 ** 2 + const k = y > ridge ? (y - ridge) / (h - ridge) : y / ridge + const rgb = + y > ridge ? [48 - 20 * k, 140 - 50 * k, 90 - 30 * k] : sun ? [255, 214, 120] : [140 + 90 * k, 190 + 50 * k, 245] + raw.set( + rgb.map((c) => Math.round(c)), + y * (w * 3 + 1) + 1 + x * 3, + ) + } + } + const chunk = (type, body) => { + const head = Buffer.alloc(4) + head.writeUInt32BE(body.length) + const typed = Buffer.concat([Buffer.from(type, 'latin1'), body]) + const crc = Buffer.alloc(4) + crc.writeUInt32BE(crc32(typed)) + return Buffer.concat([head, typed, crc]) } + const header = Buffer.alloc(13) + header.writeUInt32BE(w, 0) + header.writeUInt32BE(h, 4) + header[8] = 8 // bits per channel + header[9] = 2 // RGB + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', header), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]) } function startRecorder(file) { diff --git a/scripts/capture/scenarios/dl.mjs b/scripts/capture/scenarios/dl.mjs new file mode 100644 index 00000000..a9ac81ce --- /dev/null +++ b/scripts/capture/scenarios/dl.mjs @@ -0,0 +1,394 @@ +/** + * Downloads and DevTools (DL-01, DL-02, DL-05, DL-10, DL-15, DL-16, DT-01, DT-02, DT-03, DT-14), about + * 80 s, for the "feels right" sign-off (D48). Links on a page download: each file flies into the Library + * button, one flight per file. Hovering the button shows the recent files. A download cut off offers + * Resume and goes on where it stopped. The Library's Downloads: a running download paused and resumed, + * search by name, and a PDF and a picture previewed in a Peek. A private window's download is listed in + * that window only, and leaves with it. DevTools: F12 docks them in the page's view and closes them; the + * console key; docked at the bottom, with the find bar on the page's own area; each tab with its own; the + * element picker from the command bar; ⇧⌘R reloads. + * + * Every file comes from the fixture server's /dl/ (record.mjs). Test runs never start another app, and a + * download's menu never shows (ORBIT_E2E): the scenario picks Pause through the downloads test hook, + * as the e2e tests do. DevTools' dock menu is DevTools' own: the scenario calls what it does. + * + * Each step prints a `[mark] ` line on stderr: its time since the scenario began. + * record.mjs begins it 0.7 s after the recording starts, so a step shows about 0.7 s later in the clip. + */ +import { execFile } from 'node:child_process' + +let t0 = 0 +const mark = (name) => console.error(`[mark] ${name} ${Math.round(performance.now() - t0)}`) + +/** Moves the on-screen pointer (display coordinates) in eased steps. */ +let at = null +async function pointerTo(x, y, steps = 16) { + const from = at ?? { x, y } + for (let i = 1; i <= steps; i++) { + const k = 0.5 - 0.5 * Math.cos((Math.PI * i) / steps) + await new Promise((r) => + execFile( + 'xdotool', + ['mousemove', String(Math.round(from.x + (x - from.x) * k)), String(Math.round(from.y + (y - from.y) * k))], + () => r(), + ), + ) + } + at = { x, y } +} + +/** The pointer to the middle of a locator, in a view placed at `offset` on the display. */ +async function pointAt(locator, offset = { x: 0, y: 0 }, steps = 16) { + const box = await locator.boundingBox() + if (box) await pointerTo(offset.x + box.x + box.width / 2, offset.y + box.y + box.height / 2, steps) +} + +async function waitFor(t, test, ms = 8000) { + const end = Date.now() + ms + while (Date.now() < end) { + const v = await test() + if (v) return v + await t.sleep(40) + } + throw new Error('timed out') +} + +/** A click where the on-screen pointer is, as a person's. */ +const xClick = () => new Promise((r) => execFile('xdotool', ['click', '1'], () => r())) + +/** Points at a control of a view placed at `offset` and clicks it. */ +async function click(control, offset, steps = 14) { + await pointAt(control, offset, steps) + await control.click() +} + +const MOD = process.platform === 'darwin' ? 'meta' : 'control' + +/** A key pressed in a tab's page, as with the page focused. */ +const press = (t, tabId, keyCode, modifiers = []) => + t.app.evaluate( + (_e, [id, key, mods]) => { + const page = globalThis.__orbit.ctx.tabs.get(id).webContents + page.sendInputEvent({ type: 'keyDown', keyCode: key, modifiers: mods }) + page.sendInputEvent({ type: 'keyUp', keyCode: key, modifiers: mods }) + }, + [tabId, keyCode, modifiers], + ) + +/** Where a tab's page sits in its window. */ +const tabBounds = (t, tabId) => t.app.evaluate((_e, id) => globalThis.__orbit.ctx.tabs.get(id).getBounds(), tabId) + +/** The pointer to a file's link on a tab's page, in a window whose content starts at `offset`; then a click. */ +async function clickFile(t, tabId, file, offset = { x: 0, y: 0 }, steps = 18) { + const view = await tabBounds(t, tabId) + const box = await t.app.evaluate( + (_e, [id, f]) => + globalThis.__orbit.ctx.tabs + .get(id) + .webContents.executeJavaScript( + `(() => { const r = document.querySelector('[data-file="${f}"]').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 } })()`, + ), + [tabId, file], + ) + await pointerTo(offset.x + view.x + box.x, offset.y + view.y + box.y, steps) + await t.sleep(250) + await xClick() +} + +const downloads = (t, profileId) => t.run('downloads.list', profileId ? { profileId } : {}) +const download = async (t, file) => (await downloads(t)).find((d) => d.filename === file) + +/** The window's view of a kind (`library`, `tools`, `overlay`…), by its page's address. */ +const viewPage = (t, view) => + waitFor(t, () => t.app.windows().find((p) => !p.isClosed() && p.url().includes(`view=${view}`))) + +/** The page of one window's view (`shell`, `overlay`…). */ +const viewOf = (t, view, windowId) => + waitFor(t, async () => { + for (const p of t.app.windows()) { + if (p.isClosed() || !p.url().includes(`view=${view}`)) continue + if ((await p.evaluate(() => globalThis.orbit?.windowId).catch(() => '')) === windowId) return p + } + }) + +/** Runs a script in a tab's DevTools, while they are open. */ +const inDevtools = (t, tabId, code) => + t.app.evaluate( + (_e, [id, script]) => { + const page = globalThis.__orbit.ctx.tabs.get(id)?.webContents + return page?.isDevToolsOpened() ? page.devToolsWebContents.executeJavaScript(script) : null + }, + [tabId, code], + ) + +let mainId = '' +let notes = '' +let kit = '' +let filesUrl = '' + +/** Unrecorded: a Field notes tab (slow to answer, so a reload shows) and the Field kit page of files, shown. */ +export async function setup(t) { + const s = await t.state() + const main = Object.values(s.windows).find((w) => w.kind === 'main') + mainId = main.id + // A tab loads once it is shown: each is shown in turn. + notes = await t.run('tabs.create', { + url: `${t.fx.url('Field notes')}?wait=700`, + spaceId: main.spaceId, + activate: false, + }) + await t.run('tabs.activate', { tabId: notes }) + await waitFor(t, async () => (await t.state()).tabs[notes]?.title === 'Field notes') + filesUrl = new URL('files/Field%20kit', t.fx.url('')).href + kit = await t.run('tabs.create', { url: filesUrl, spaceId: main.spaceId, activate: false }) + await t.run('tabs.activate', { tabId: kit }) + await waitFor(t, async () => (await t.state()).tabs[kit]?.title === 'Field kit') + await t.sleep(600) +} + +export default async function (t) { + t0 = performance.now() + const { shell } = t + await pointerTo(760, 470, 1) + await t.sleep(400) + + // DL-01, DL-02: a link downloads its file, which flies into the Library button. + mark('download') + await clickFile(t, kit, 'Survey report.pdf') + await waitFor(t, async () => (await download(t, 'Survey report.pdf'))?.state === 'completed') + await t.sleep(1500) + + // DL-02: one flight per download; the long one keeps going. + mark('two') + await clickFile(t, kit, 'Site photo.png', undefined, 12) + await clickFile(t, kit, 'Field recordings.zip', undefined, 10) + await waitFor(t, async () => (await download(t, 'Field recordings.zip'))?.state === 'progressing') + await t.sleep(1600) + + // DL-05: hovering the Library button shows the recent files; they go when the pointer leaves. + mark('recent') + const button = shell.getByTestId('library-button') + await pointAt(button, undefined, 22) + await button.hover() + const recent = shell.getByTestId('recent-files') + await recent.waitFor({ timeout: 4000 }) + await t.sleep(1300) + const first = recent.getByTestId('recent-file').first() + await pointAt(first, undefined, 10) + await first.hover() + await t.sleep(1200) + await pointerTo(760, 470, 18) + await shell.mouse.move(760, 470) + await t.sleep(1000) + + // DL-15: a download cut off offers Resume, and goes on where it stopped. + mark('cut') + await clickFile(t, kit, 'Station log.csv', undefined, 16) + await waitFor(t, async () => (await download(t, 'Station log.csv'))?.state === 'interrupted', 20_000) + await t.sleep(1400) + t.fx.repair('Station log.csv') + const cutRow = shell.locator('[data-testid="download-row"][title="Station log.csv"]') + await cutRow.waitFor({ timeout: 4000 }) + await pointAt(cutRow, undefined, 18) + await cutRow.hover() + await t.sleep(500) + await click(cutRow.getByTestId('download-resume'), undefined, 8) + await waitFor(t, async () => (await download(t, 'Station log.csv'))?.state === 'progressing') + await t.sleep(1200) + + // DL-10: the Library's Downloads, newest first, the running ones with their progress. + mark('library') + await click(button, undefined, 18) + const lib = await viewPage(t, 'library') + await lib.getByTestId('library').waitFor({ timeout: 8000 }) + const libAt = await t.app.evaluate((_e, w) => globalThis.__orbit.ctx.windows.pageArea(w), mainId) + const nav = lib.locator('[data-testid="library-nav-item"][data-section-id="downloads"]') + if ((await lib.getByTestId('library-section').getAttribute('data-section-id')) !== 'downloads') { + await t.sleep(600) + await click(nav, libAt, 16) + } + await lib.getByTestId('downloads-section').waitFor({ timeout: 8000 }) + await t.sleep(1500) + + // DL-15: Pause from the row's menu, then the row's Resume. + mark('pause') + const row = (file) => lib.locator(`[data-testid="library-download"][title="${file}"]`) + const long = row('Field recordings.zip') + await pointAt(long, libAt, 16) + await long.hover() + await t.sleep(500) + await click(long.getByTestId('library-download-more'), libAt, 8) + await t.app.evaluate(() => globalThis.__orbitDownloads.clickMenu('Pause')) + await waitFor(t, async () => (await long.getAttribute('data-state')) === 'paused') + await t.sleep(1800) + await click(long.getByTestId('library-download-resume'), libAt, 6) + await waitFor(t, async () => (await long.getAttribute('data-state')) === 'progressing') + await t.sleep(1300) + + // DL-10: search by name. + mark('search') + const search = lib.getByTestId('downloads-search') + await click(search, libAt, 18) + await t.type(lib, 'photo', 120) + await t.sleep(1400) + await lib.keyboard.press(`${t.mod}+A`) + await lib.keyboard.press('Backspace') + await t.sleep(900) + + // DL-10: a row previews its file in a Peek over the Library: a PDF, then a picture. + mark('preview') + await click(row('Survey report.pdf'), libAt, 18) + await waitFor( + t, + async () => Object.values((await t.state()).windows).find((w) => w.id === mainId)?.overlay.kind === 'peek', + ) + await t.sleep(2400) + await t.run('peek.close') + await t.sleep(900) + await click(row('Site photo.png'), libAt, 12) + await waitFor( + t, + async () => Object.values((await t.state()).windows).find((w) => w.id === mainId)?.overlay.kind === 'peek', + ) + await t.sleep(2200) + await t.run('peek.close') + await t.sleep(900) + await lib.keyboard.press('Escape') + await waitFor(t, async () => (await lib.getByTestId('library').getAttribute('data-open')) === 'false') + await waitFor(t, async () => (await downloads(t)).every((d) => d.state === 'completed'), 30_000) + await t.sleep(900) + + // DL-16: a private window lists its own download only, and it leaves with the window. + mark('private') + await t.key('Mod+Shift+N') + const priv = await waitFor(t, async () => Object.values((await t.state()).windows).find((w) => w.kind === 'private')) + const privShell = await viewOf(t, 'shell', priv.id) + const privBar = await viewOf(t, 'overlay', priv.id) + const privAt = await t.app.evaluate((_e, w) => globalThis.__orbit.ctx.windows.get(w)?.getContentBounds(), priv.id) + await waitFor(t, () => privBar.getByTestId('command-input').isVisible()) + await t.sleep(600) + await t.type(privBar, filesUrl.replace('http://', ''), 40) + await t.sleep(300) + await privBar.keyboard.press('Enter') + const privTab = await waitFor(t, async () => + Object.entries((await t.state()).tabs).find( + ([, tab]) => tab.spaceId === priv.spaceId && tab.title === 'Field kit' && tab.loading === false, + ), + ) + await t.sleep(900) + // A private window has no Library button: its sidebar's row and its Downloads list (Ctrl+J here) show + // its own session's downloads. + mark('private-download') + await clickFile(t, privTab[0], 'Site photo.png', privAt, 18) + await waitFor(t, async () => (await privShell.getByTestId('downloads-tray').getAttribute('data-count')) === '1') + await t.sleep(1500) + const flyoutNames = async (shellPage, overlayPage) => { + await shellPage.keyboard.press(`${t.mod}+J`) + await overlayPage.getByTestId('downloads-flyout').waitFor({ timeout: 4000 }) + return overlayPage.getByTestId('flyout-download-name').allTextContents() + } + console.error(`[check] private window's downloads: ${(await flyoutNames(privShell, privBar)).join(', ')}`) + await t.sleep(2000) + await privShell.keyboard.press(`${t.mod}+J`) + await t.sleep(800) + mark('private-close') + await t.app.evaluate(async (_e, id) => { + await new Promise((r) => setImmediate(r)) + globalThis.__orbit.ctx.windows.get(id)?.close() + }, priv.id) + await t.sleep(1000) + mark('after-private') + const mainBar = await viewOf(t, 'overlay', mainId) + console.error(`[check] main window's downloads after: ${(await flyoutNames(shell, mainBar)).join(', ')}`) + await t.sleep(2000) + await shell.keyboard.press(`${t.mod}+J`) + await t.sleep(800) + + // DT-01: F12 docks DevTools in the page's view, and closes them; the console key opens the Console. + mark('devtools') + const notesRow = shell.getByTestId('tab-row').filter({ hasText: 'Field notes' }) + await click(notesRow, undefined, 22) + await waitFor(t, async () => (await t.state()).tabs[notes]?.loading === false) + await pointerTo(900, 600, 16) + await t.sleep(900) + await t.key('F12') + await waitFor(t, async () => (await t.run('devtools.state', { tabId: notes }))?.open) + await t.sleep(2200) + await t.key('F12') + await waitFor(t, async () => !(await t.run('devtools.state', { tabId: notes }))?.open) + await t.sleep(1000) + await t.key('Shift+Mod+J') + await waitFor(t, async () => (await t.run('devtools.state', { tabId: notes }))?.open) + await t.sleep(2000) + + // DT-03: docked at the bottom (what DevTools' dock menu does); the find bar stays on the page's area. + mark('dock') + await waitFor(t, () => + inDevtools( + t, + notes, + `(async () => { const legacy = await import('./ui/legacy/legacy.js'); legacy.DockController.DockController.instance().setDockSide('bottom'); return true })()`, + ), + ) + await t.sleep(1800) + await press(t, notes, 'F', [MOD]) + const tools = await viewPage(t, 'tools') + await waitFor(t, () => tools.getByTestId('find-input').isVisible()) + await t.sleep(300) + await t.type(tools, 'demo', 120) + await t.sleep(1300) + await tools.keyboard.press('Escape') + await t.sleep(800) + // Each tab has its own DevTools, shown and hidden with its page. + mark('tabs') + const kitRow = shell.getByTestId('tab-row').filter({ hasText: 'Field kit' }) + await click(kitRow, undefined, 18) + await t.sleep(1300) + await click(notesRow, undefined, 10) + await t.sleep(1500) + + // DT-02: the element picker from the command bar; what's under the pointer is picked. + mark('inspect') + await t.key('Mod+T') + const bar = await t.overlay() + await waitFor(t, () => bar.getByTestId('command-input').isVisible()) + await t.sleep(300) + await t.type(bar, 'Inspect element', 90) + await waitFor(t, async () => + (await bar.getByTestId('command-result').first().innerText()).includes('Inspect Element'), + ) + await t.sleep(600) + await bar.keyboard.press('Enter') + await t.sleep(700) + const view = await tabBounds(t, notes) + const card = async (n) => + t.app.evaluate( + (_e, [id, i]) => + globalThis.__orbit.ctx.tabs + .get(id) + .webContents.executeJavaScript( + `(() => { const r = document.querySelectorAll('.card')[${i}].getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 } })()`, + ), + [notes, n], + ) + const one = await card(0) + await pointerTo(view.x + one.x, view.y + one.y, 20) + await t.sleep(1100) + const two = await card(1) + await pointerTo(view.x + two.x, view.y + two.y, 14) + await t.sleep(900) + await xClick() + await t.sleep(1800) + + // DT-14: ⇧⌘R reloads the page, bypassing the cache. + mark('hard-reload') + await t.key('Shift+Mod+R') + await waitFor(t, async () => (await t.state()).tabs[notes]?.loading === true, 3000).catch(() => {}) + await waitFor(t, async () => (await t.state()).tabs[notes]?.loading === false) + await t.sleep(1600) + mark('close-devtools') + await t.key('F12') + await waitFor(t, async () => !(await t.run('devtools.state', { tabId: notes }))?.open) + await t.sleep(1200) + mark('end') +}